매매 시그널 디테일 팝업 기능 적용

This commit is contained in:
Macbook
2026-06-10 21:13:53 +09:00
parent bf5554d375
commit 0ff1992b64
19 changed files with 1507 additions and 58 deletions
+3 -1
View File
@@ -30,6 +30,7 @@ export interface AppPopupProps {
headerExtra?: React.ReactNode;
footer?: React.ReactNode;
initialPosition?: { x: number; y: number };
bodyRef?: React.Ref<HTMLDivElement>;
}
export const AppPopup: React.FC<AppPopupProps> = ({
@@ -53,6 +54,7 @@ export const AppPopup: React.FC<AppPopupProps> = ({
headerExtra,
footer,
initialPosition,
bodyRef,
}) => {
const accentGlow = accentColor === APP_POPUP_ACCENT
? APP_POPUP_ACCENT_GLOW
@@ -130,7 +132,7 @@ export const AppPopup: React.FC<AppPopupProps> = ({
</button>
</div>
<div className={bodyClassName}>{children}</div>
<div ref={bodyRef} className={bodyClassName}>{children}</div>
{footer}
</div>
</div>
@@ -16,19 +16,22 @@ interface Props {
/** compact: 스택 팝업, full: 목록/모달 요약 */
variant?: 'compact' | 'full';
showHeadline?: boolean;
/** list: 라벨·값 세로 목록, matrix: n×n 정보 카드 그리드 */
layout?: 'list' | 'matrix';
}
export const TradeSignalDetailBody: React.FC<Props> = ({
item,
variant = 'compact',
showHeadline = true,
layout = 'list',
}) => {
useTradeAlertTimeFormat();
const isBuy = item.signalType === 'BUY';
const rows = buildSignalDetailRows(item);
return (
<div className={`tsd-body tsd-body--${variant}`}>
<div className={`tsd-body tsd-body--${variant}${layout === 'matrix' ? ' tsd-body--matrix' : ''}`}>
{showHeadline && (
<div className="tsd-headline">
<span className={`tsd-direction ${isBuy ? 'tsd-direction--buy' : 'tsd-direction--sell'}`}>
@@ -37,15 +40,35 @@ export const TradeSignalDetailBody: React.FC<Props> = ({
<span className="tsd-headline-price">{formatSignalPrice(item.price)}</span>
</div>
)}
<p className="tsd-summary">{getSignalHeadline(item)}</p>
<dl className="tsd-detail-grid">
{rows.map(row => (
<React.Fragment key={row.label}>
<dt className="tsd-dt">{row.label}</dt>
<dd className={`tsd-dd${row.highlight ? ' tsd-dd--highlight' : ''}`}>{row.value}</dd>
</React.Fragment>
))}
</dl>
{layout !== 'matrix' && (
<p className="tsd-summary">{getSignalHeadline(item)}</p>
)}
{layout === 'matrix' ? (
<div className="tsd-detail-matrix" role="list">
{rows.map(row => (
<div key={row.label} className="tsd-detail-cell" role="listitem">
<span className="tsd-detail-cell-label">{row.label}</span>
<span
className={[
'tsd-detail-cell-value',
row.highlight ? 'tsd-detail-cell-value--highlight' : '',
].filter(Boolean).join(' ')}
>
{row.value}
</span>
</div>
))}
</div>
) : (
<dl className="tsd-detail-grid">
{rows.map(row => (
<React.Fragment key={row.label}>
<dt className="tsd-dt">{row.label}</dt>
<dd className={`tsd-dd${row.highlight ? ' tsd-dd--highlight' : ''}`}>{row.value}</dd>
</React.Fragment>
))}
</dl>
)}
</div>
);
};
+46 -3
View File
@@ -64,6 +64,7 @@ import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKe
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers';
interface TradingChartProps {
bars: OHLCVBar[];
@@ -182,6 +183,12 @@ interface TradingChartProps {
resolveInitialDisplayCount?: (plotWidth: number) => number;
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
skipViewportRecovery?: boolean;
/** true — 캔들 숨김, 보조지표 pane 선형 그래프만 표시 (시그널 상세 하단 차트 등) */
auxIndicatorOnly?: boolean;
/** 지정 시 초기 visible range 대신 시그널 봉 시간축 중앙 정렬 */
centerOnSignalTime?: number;
/** centerOnSignalTime 사용 시 화면에 보일 봉 수 (미지정 시 전체) */
centerVisibleBarCount?: number;
}
const TradingChart: React.FC<TradingChartProps> = ({
@@ -237,6 +244,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
onOpenCustom1OverlaySettings,
resolveInitialDisplayCount,
skipViewportRecovery = false,
auxIndicatorOnly = false,
centerOnSignalTime,
centerVisibleBarCount,
}) => {
const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
const showOverlayControlsOnly = !!(
@@ -324,6 +334,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
const chartVisibleRef = useRef(chartVisible);
const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
const skipViewportRecoveryRef = useRef(skipViewportRecovery);
const auxIndicatorOnlyRef = useRef(auxIndicatorOnly);
const centerOnSignalTimeRef = useRef(centerOnSignalTime);
const centerVisibleBarCountRef = useRef(centerVisibleBarCount);
const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null);
useEffect(() => {
@@ -334,6 +347,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
skipViewportRecoveryRef.current = skipViewportRecovery;
}, [skipViewportRecovery]);
useEffect(() => {
auxIndicatorOnlyRef.current = auxIndicatorOnly;
}, [auxIndicatorOnly]);
useEffect(() => {
centerOnSignalTimeRef.current = centerOnSignalTime;
}, [centerOnSignalTime]);
useEffect(() => {
centerVisibleBarCountRef.current = centerVisibleBarCount;
}, [centerVisibleBarCount]);
const applySnapshotViewport = useCallback((mgr: ChartManager) => {
const t = centerOnSignalTimeRef.current;
if (t != null && Number.isFinite(t)) {
centerChartOnSignalBar(mgr, t, centerVisibleBarCountRef.current);
} else {
mgr.fitContent();
}
}, []);
const chartTimeFormat = useChartTimeFormat();
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
@@ -523,7 +557,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
mgr.applyCandleOnlyLayout(true, wrapperH);
required = wrapperH;
} else {
if (mgr.isCandleOnlyLayout()) {
if (auxIndicatorOnlyRef.current) {
mgr.applyAuxIndicatorOnlyLayout(true);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperH);
}
// 사용자 드래그 비율 우선, 없으면 prop 기반 비율
@@ -587,9 +623,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
applyPaneLayout(mgr);
requestAnimationFrame(() => {
try { mgr.autoScale(); } catch { /* ok */ }
applyInitialVisibleRange(mgr);
if (centerOnSignalTimeRef.current != null && Number.isFinite(centerOnSignalTimeRef.current)) {
applySnapshotViewport(mgr);
} else {
applyInitialVisibleRange(mgr);
}
});
}, [applyPaneLayout, applyInitialVisibleRange]);
}, [applyPaneLayout, applyInitialVisibleRange, applySnapshotViewport]);
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
useEffect(() => {
@@ -647,6 +687,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
} else if (auxIndicatorOnlyRef.current) {
mgr.applyAuxIndicatorOnlyLayout(true);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
}
@@ -960,6 +1002,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true);
onManagerReady(mgr);
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
@@ -10,8 +10,10 @@ import {
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
import {
buildLiveTradeTimeline,
toTimelineSignalDetail,
type LiveTimelineEvent,
type LiveTimelineFilter,
type TimelineSignalDetailSource,
} from '../../utils/liveTradeTimeline';
import {
LIVE_TIMELINE_VIEW_OPTIONS,
@@ -20,6 +22,7 @@ import {
type LiveTimelineViewMode,
} from './LiveTradeTimelineViews';
import TimelineAxisView from './TimelineAxisView';
import TimelineSignalDetailModal from './TimelineSignalDetailModal';
import type { Timeframe } from '../../types';
export const ARP_TIMELINE_VIEW_STORAGE_KEY = 'arp-timeline-view-mode';
@@ -54,6 +57,12 @@ const LiveTradeTimelinePanel: React.FC<Props> = ({
const [events, setEvents] = useState<LiveTimelineEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<LiveTimelineViewMode>(loadSavedTimelineViewMode);
const [signalDetail, setSignalDetail] = useState<TimelineSignalDetailSource | null>(null);
const handleSignalDetail = useCallback((ev: LiveTimelineEvent) => {
const detail = toTimelineSignalDetail(ev);
if (detail) setSignalDetail(detail);
}, []);
const fetchTimeline = useCallback(async () => {
if (!active) return;
@@ -168,15 +177,34 @@ const LiveTradeTimelinePanel: React.FC<Props> = ({
)}
{events.length > 0 && viewMode === 'list' && (
<TimelineListView events={events} timeframe={chartTimeframe} />
<TimelineListView
events={events}
timeframe={chartTimeframe}
onSignalDetail={handleSignalDetail}
/>
)}
{events.length > 0 && viewMode === 'split' && (
<TimelineSplitView events={events} timeframe={chartTimeframe} />
<TimelineSplitView
events={events}
timeframe={chartTimeframe}
onSignalDetail={handleSignalDetail}
/>
)}
{events.length > 0 && viewMode === 'axis' && (
<TimelineAxisView events={events} timeframe={chartTimeframe} />
<TimelineAxisView
events={events}
timeframe={chartTimeframe}
onSignalDetail={handleSignalDetail}
/>
)}
</div>
{signalDetail && (
<TimelineSignalDetailModal
source={signalDetail}
onClose={() => setSignalDetail(null)}
/>
)}
</div>
);
};
@@ -10,6 +10,7 @@ import {
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import TimelineBarTimeBox, { eventToBarTimeSec } from './TimelineBarTimeBox';
import TimelineSignalCardShell from './TimelineSignalCardShell';
import type { Timeframe } from '../../types';
export type LiveTimelineViewMode = 'list' | 'split' | 'axis';
@@ -70,9 +71,10 @@ export const TimelineEventBody: React.FC<EventBodyProps> = ({ ev, compact = fals
interface ListViewProps {
events: LiveTimelineEvent[];
timeframe: Timeframe;
onSignalDetail?: (ev: LiveTimelineEvent) => void;
}
export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe }) => (
export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe, onSignalDetail }) => (
<div className="btd-timeline arp-timeline-scroll arp-timeline-scroll--list" aria-label="거래 이력 타임라인">
<div className="btd-timeline-line" aria-hidden />
{events.map(ev => {
@@ -97,9 +99,13 @@ export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe })
variant="compact"
className="arp-timeline-bar-time--list-node"
/>
<div className="btd-timeline-card">
<TimelineSignalCardShell
ev={ev}
className="btd-timeline-card"
onSignalDetail={onSignalDetail}
>
<TimelineEventBody ev={ev} />
</div>
</TimelineSignalCardShell>
</article>
);
})}
@@ -109,12 +115,14 @@ export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe })
interface SplitViewProps {
events: LiveTimelineEvent[];
timeframe: Timeframe;
onSignalDetail?: (ev: LiveTimelineEvent) => void;
}
export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe }) => (
export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe, onSignalDetail }) => (
<div className="arp-timeline-split-scroll" aria-label="좌우 분할 거래 이력 타임라인">
<div className="arp-timeline-split-axis" aria-hidden />
{events.map(ev => {
<div className="arp-timeline-split-track">
<div className="arp-timeline-split-axis" aria-hidden />
{events.map(ev => {
const isBuy = ev.side === 'BUY';
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
@@ -127,9 +135,13 @@ export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe
].join(' ');
const card = (
<article className={cardCls}>
<TimelineSignalCardShell
ev={ev}
className={cardCls}
onSignalDetail={onSignalDetail}
>
<TimelineEventBody ev={ev} compact />
</article>
</TimelineSignalCardShell>
);
return (
@@ -171,5 +183,6 @@ export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe
</div>
);
})}
</div>
</div>
);
);
@@ -9,6 +9,7 @@ import {
} from '../../utils/liveTradeTimeline';
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import TimelineSignalDetailButton from './TimelineSignalDetailButton';
function kindClass(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return 'signal';
@@ -25,9 +26,10 @@ function kindIcon(kind: LiveTimelineEvent['kind']): string {
interface Props {
ev: LiveTimelineEvent;
side: 'top' | 'bottom';
onSignalDetail?: (ev: LiveTimelineEvent) => void;
}
const TimelineAxisEventCard: React.FC<Props> = ({ ev, side }) => {
const TimelineAxisEventCard: React.FC<Props> = ({ ev, side, onSignalDetail }) => {
const { koreanName } = resolveVirtualTargetNames(ev.symbol);
const kCls = kindClass(ev.kind);
const isSell = ev.side === 'SELL';
@@ -39,8 +41,17 @@ const TimelineAxisEventCard: React.FC<Props> = ({ ev, side }) => {
`arp-timeline-axis-card--${side}`,
`arp-timeline-axis-card--${kCls}`,
isSell ? 'arp-timeline-axis-card--sell' : 'arp-timeline-axis-card--buy',
].join(' ')}
ev.kind === 'SIGNAL' && ev.signalMeta ? 'arp-timeline-signal-card-wrap' : '',
].filter(Boolean).join(' ')}
>
{ev.kind === 'SIGNAL' && ev.signalMeta && onSignalDetail && (
<TimelineSignalDetailButton
onClick={e => {
e.stopPropagation();
onSignalDetail(ev);
}}
/>
)}
<div className="arp-timeline-axis-card-icon" aria-hidden>
{kindIcon(ev.kind)}
</div>
@@ -17,9 +17,10 @@ const SEGMENT_COLORS = ['c0', 'c1', 'c2', 'c3', 'c4'] as const;
interface Props {
events: LiveTimelineEvent[];
timeframe: Timeframe;
onSignalDetail?: (ev: LiveTimelineEvent) => void;
}
const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
const TimelineAxisView: React.FC<Props> = ({ events, timeframe, onSignalDetail }) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
useHorizontalWheelScroll(scrollRef);
@@ -54,11 +55,18 @@ const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
<div className="arp-timeline-axis-cols">
{slots.map(slot => (
<div key={`top-${slot.barTimeSec}`} className="arp-timeline-axis-col">
{slot.sells.map(ev => (
{slot.sells.map((ev, sellIdx) => (
<div key={ev.id} className="arp-timeline-axis-col-stack">
<TimelineAxisEventCard ev={ev} side="top" />
<TimelineAxisEventCard ev={ev} side="top" onSignalDetail={onSignalDetail} />
<div
className="arp-timeline-axis-connector arp-timeline-axis-connector--down arp-timeline-axis-connector--sell"
className={[
'arp-timeline-axis-connector',
'arp-timeline-axis-connector--down',
'arp-timeline-axis-connector--sell',
sellIdx === slot.sells.length - 1
? 'arp-timeline-axis-connector--to-rail'
: '',
].filter(Boolean).join(' ')}
aria-hidden
/>
</div>
@@ -91,13 +99,20 @@ const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
<div className="arp-timeline-axis-cols">
{slots.map(slot => (
<div key={`bottom-${slot.barTimeSec}`} className="arp-timeline-axis-col">
{slot.buys.map(ev => (
{slot.buys.map((ev, buyIdx) => (
<div key={ev.id} className="arp-timeline-axis-col-stack">
<div
className="arp-timeline-axis-connector arp-timeline-axis-connector--up arp-timeline-axis-connector--buy"
className={[
'arp-timeline-axis-connector',
'arp-timeline-axis-connector--up',
'arp-timeline-axis-connector--buy',
buyIdx === slot.buys.length - 1
? 'arp-timeline-axis-connector--to-rail'
: '',
].filter(Boolean).join(' ')}
aria-hidden
/>
<TimelineAxisEventCard ev={ev} side="bottom" />
<TimelineAxisEventCard ev={ev} side="bottom" onSignalDetail={onSignalDetail} />
</div>
))}
</div>
@@ -0,0 +1,46 @@
/**
* 타임라인 카드 — 매매 시그널일 때 상세 버튼 포함 래퍼
*/
import React from 'react';
import type { LiveTimelineEvent } from '../../utils/liveTradeTimeline';
import TimelineSignalDetailButton from './TimelineSignalDetailButton';
interface Props {
ev: LiveTimelineEvent;
className: string;
onSignalDetail?: (ev: LiveTimelineEvent) => void;
children: React.ReactNode;
tag?: 'article' | 'div';
}
const TimelineSignalCardShell: React.FC<Props> = ({
ev,
className,
onSignalDetail,
children,
tag = 'article',
}) => {
const Tag = tag;
const showDetail = ev.kind === 'SIGNAL' && ev.signalMeta && onSignalDetail;
return (
<Tag
className={[
className,
showDetail ? 'arp-timeline-signal-card-wrap' : '',
].filter(Boolean).join(' ')}
>
{showDetail && (
<TimelineSignalDetailButton
onClick={e => {
e.stopPropagation();
onSignalDetail(ev);
}}
/>
)}
{children}
</Tag>
);
};
export default TimelineSignalCardShell;
@@ -0,0 +1,35 @@
/**
* 타임라인 매매 시그널 카드 — 우측 상단 상세 아이콘
*/
import React from 'react';
const IcSignalDetail = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3 3v18h18" />
<rect x="7" y="10" width="3" height="7" rx="0.5" />
<rect x="12" y="7" width="3" height="10" rx="0.5" />
<rect x="17" y="12" width="3" height="5" rx="0.5" />
</svg>
);
interface Props {
onClick: (e: React.MouseEvent) => void;
title?: string;
}
const TimelineSignalDetailButton: React.FC<Props> = ({
onClick,
title = '시그널 상세 (차트 · 조건)',
}) => (
<button
type="button"
className="arp-timeline-signal-detail-btn"
title={title}
aria-label={title}
onClick={onClick}
>
<IcSignalDetail />
</button>
);
export default TimelineSignalDetailButton;
@@ -0,0 +1,186 @@
/**
* 타임라인 매매 시그널 상세 팝업 — 스냅샷 차트 + 조건 일치 설명
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppPopup } from '../AppPopup';
import TradeSignalDetailBody from '../TradeSignalDetailBody';
import StrategyConditionView from '../tradeNotification/StrategyConditionView';
import TimelineSignalSnapshotChart from './TimelineSignalSnapshotChart';
import type { TimelineSignalDetailSource } from '../../utils/liveTradeTimeline';
import { loadStrategy } from '../../utils/backendApi';
import type { StrategyDto } from '../../utils/backendApi';
import {
formatCandleTypeKo,
formatSignalTime,
getExecutionLabel,
getSignalTypeKo,
} from '../../utils/tradeSignalDisplay';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../../utils/tradeSignalColors';
import type { Theme } from '../../types';
import { nodeToText } from '../../utils/strategyEditorShared';
import type { LogicNode } from '../../utils/strategyTypes';
import { useCenterElementInScrollContainer } from '../../hooks/useCenterElementInScrollContainer';
interface Props {
source: TimelineSignalDetailSource;
onClose: () => void;
theme?: Theme;
}
function collectConditionTexts(root: LogicNode | null | undefined): string[] {
if (!root) return [];
const out: string[] = [];
const walk = (node: LogicNode) => {
if (node.type === 'CONDITION') {
const text = nodeToText(node).trim();
if (text) out.push(text);
return;
}
for (const child of node.children ?? []) walk(child);
};
walk(root);
return out;
}
const TimelineSignalDetailModal: React.FC<Props> = ({
source,
onClose,
theme = 'dark',
}) => {
const isBuy = source.side === 'BUY';
const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR;
const [strategy, setStrategy] = useState<StrategyDto | null>(null);
const [stratLoading, setStratLoading] = useState(false);
const [chartsReady, setChartsReady] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
const chartSectionRef = useRef<HTMLElement>(null);
const handleChartsReady = useCallback(() => {
setChartsReady(true);
}, []);
useCenterElementInScrollContainer(
bodyRef,
chartSectionRef,
true,
[source, chartsReady],
);
const displayItem = useMemo(() => ({
market: source.symbol,
signalType: source.side,
price: source.price,
candleTime: source.signalMeta.candleTime,
strategyName: source.strategyName,
strategyId: source.signalMeta.strategyId,
executionType: source.signalMeta.executionType,
candleType: source.signalMeta.candleType,
receivedAt: source.ts,
}), [source]);
useEffect(() => {
setChartsReady(false);
}, [source]);
useEffect(() => {
const sid = source.signalMeta.strategyId;
if (!sid) {
setStrategy(null);
return;
}
let cancelled = false;
setStratLoading(true);
void loadStrategy(sid)
.then(s => { if (!cancelled) setStrategy(s); })
.catch(() => { if (!cancelled) setStrategy(null); })
.finally(() => { if (!cancelled) setStratLoading(false); });
return () => { cancelled = true; };
}, [source.signalMeta.strategyId]);
const conditionTexts = useMemo(() => {
if (!strategy) return [];
const root = isBuy ? strategy.buyCondition : strategy.sellCondition;
return collectConditionTexts(root as LogicNode | null | undefined);
}, [strategy, isBuy]);
const execLabel = getExecutionLabel(
source.signalMeta.executionType,
source.signalMeta.candleType,
);
return (
<AppPopup
onClose={onClose}
title="Signal Detail"
titleKo="매매 시그널 상세"
accentColor={accentColor}
width={920}
maxWidth="96vw"
shellClassName="arp-timeline-signal-detail-popup"
bodyClassName="app-popup-body arp-timeline-signal-detail-popup__body"
bodyRef={bodyRef}
centered
>
<div className="arp-timeline-signal-detail">
<section className="arp-timeline-signal-detail-summary">
<TradeSignalDetailBody
item={displayItem}
variant="full"
showHeadline={false}
layout="matrix"
/>
</section>
<section ref={chartSectionRef} className="arp-timeline-signal-detail-chart">
<TimelineSignalSnapshotChart
source={source}
theme={theme}
onChartsReady={handleChartsReady}
/>
</section>
<section className="arp-timeline-signal-detail-reason">
<header className="arp-timeline-signal-detail-reason-head">
<h3> </h3>
<p>
{formatSignalTime(source.signalMeta.candleTime)} · {formatCandleTypeKo(source.signalMeta.candleType)}
{' '} {getSignalTypeKo(source.side)} .
{' '}({execLabel})
</p>
</header>
{stratLoading && (
<p className="arp-timeline-signal-detail-muted"> </p>
)}
{!stratLoading && strategy && (
<>
{conditionTexts.length > 0 && (
<ul className="arp-timeline-signal-detail-checklist">
{conditionTexts.map(text => (
<li key={text}>
<span className="arp-timeline-signal-detail-check" aria-hidden></span>
<span>{text}</span>
</li>
))}
</ul>
)}
<div className="arp-timeline-signal-detail-strategy">
<StrategyConditionView strategy={strategy} signalType={source.side} />
</div>
</>
)}
{!stratLoading && !strategy && (
<p className="arp-timeline-signal-detail-muted">
.
{source.strategyName ? ` (전략: ${source.strategyName})` : ''}
</p>
)}
</section>
</div>
</AppPopup>
);
};
export default TimelineSignalDetailModal;
@@ -0,0 +1,408 @@
/**
* 타임라인 매매 시그널 상세 — 발생 시점 ±5봉 스냅샷 차트
* (상단 캔들+오버레이 · 하단 전략 조건 보조지표 분리)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import TradingChart from '../TradingChart';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme } from '../../types';
import { loadSignalSnapshotCandles, SIGNAL_SNAPSHOT_VISIBLE_BARS } from '../../utils/analysisChartData';
import type { TimelineSignalDetailSource } from '../../utils/liveTradeTimeline';
import { loadStrategyForNotification, type StrategyDto } from '../../utils/backendApi';
import type { ChartManager } from '../../utils/ChartManager';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useAppSettings } from '../../hooks/useAppSettings';
import { getIndicatorDef, formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
import { buildChartIndicatorConfig } from '../../utils/indicatorPaneMerge';
import { mergeGlobalDefaultsOntoChartIndicators } from '../../utils/indicatorMainConfig';
import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators';
import {
buildStrategyChartIndicators,
candleTypeToTimeframe,
ensurePaperChartOverlays,
} from '../../utils/strategyToChartIndicators';
import {
chartPaneFlexRatio,
countNonOverlayIndicatorPanes,
} from '../../utils/strategyOscillatorSeries';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { ensureWorkspaceChartIndicators } from '../../utils/workspaceChartIndicatorsCache';
import {
applyNotificationSignalMarkers,
type TradeSignalChartMarker,
} from '../../utils/tradeSignalChartMarkers';
import { useLinkedChartLogicalRange } from '../../hooks/useLinkedChartLogicalRange';
interface Props {
source: TimelineSignalDetailSource;
theme?: Theme;
onChartsReady?: () => void;
}
const noop = () => {};
let _indSeq = 0;
function newIndId() {
_indSeq += 1;
return `tsd_${_indSeq}_${Date.now()}`;
}
const ALL_TF_VISIBLE = {
'1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true,
'1h': true, '4h': true, '1D': true, '1W': true, '1M': true,
};
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
function splitChartIndicators(indicators: IndicatorConfig[]) {
const overlay: IndicatorConfig[] = [];
const aux: IndicatorConfig[] = [];
for (const ind of indicators) {
if (isOverlayType(ind.type)) overlay.push(ind);
else aux.push(ind);
}
return { overlay, aux };
}
interface SnapshotPaneProps {
bars: OHLCVBar[];
market: string;
chartTimeframe: ReturnType<typeof candleTypeToTimeframe>;
theme: Theme;
indicators: IndicatorConfig[];
chartKey: string;
showMarker?: boolean;
signalMarker?: TradeSignalChartMarker;
signalCandleTime?: number;
/** true — 캔들 숨김, 보조지표 선형 pane 만 표시 */
indicatorOnly?: boolean;
onReady?: () => void;
chartLinkIndex?: number;
registerLinkedChartManager?: (index: number, mgr: ChartManager | null) => void;
}
const SignalSnapshotChartPane: React.FC<SnapshotPaneProps> = ({
bars,
market,
chartTimeframe,
theme,
indicators,
chartKey,
showMarker = false,
signalMarker,
signalCandleTime,
indicatorOnly = false,
onReady,
chartLinkIndex = 0,
registerLinkedChartManager,
}) => {
const { defaults: appDefaults } = useAppSettings();
const managerRef = useRef<ChartManager | null>(null);
const signalMarkerRef = useRef(signalMarker);
signalMarkerRef.current = signalMarker;
const auxPaneCount = useMemo(
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
[indicators],
);
const paneAreaRatio = useMemo(() => {
if (indicatorOnly) return null;
if (auxPaneCount <= 0) return null;
const flex = chartPaneFlexRatio(auxPaneCount);
return { candle: flex.candle, aux: flex.aux };
}, [auxPaneCount, indicatorOnly]);
const applyMarkers = useCallback((attempt = 0) => {
const mgr = managerRef.current;
if (!mgr?.hasMainSeries()) {
if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1));
return;
}
if (showMarker && signalMarkerRef.current) {
applyNotificationSignalMarkers(mgr, signalMarkerRef.current, true);
} else {
mgr.clearBacktestMarkers();
}
onReady?.();
}, [showMarker, onReady]);
const onManagerReady = useCallback((mgr: ChartManager) => {
managerRef.current = mgr;
registerLinkedChartManager?.(chartLinkIndex, mgr);
applyMarkers();
}, [applyMarkers, chartLinkIndex, registerLinkedChartManager]);
const onCandlesReady = useCallback(() => {
const mgr = managerRef.current;
if (mgr) registerLinkedChartManager?.(chartLinkIndex, mgr);
applyMarkers();
}, [applyMarkers, chartLinkIndex, registerLinkedChartManager]);
useEffect(() => () => {
registerLinkedChartManager?.(chartLinkIndex, null);
}, [chartLinkIndex, registerLinkedChartManager]);
useEffect(() => {
applyMarkers();
}, [bars, indicators, applyMarkers]);
const resolveInitialDisplayCount = useCallback(
() => SIGNAL_SNAPSHOT_VISIBLE_BARS,
[],
);
return (
<TradingChart
key={chartKey}
bars={bars}
barsMarket={market}
market={market}
timeframe={chartTimeframe}
chartType={'candlestick' as ChartType}
theme={theme}
mode="chart"
indicators={indicators}
drawingTool="cursor"
drawings={[]}
logScale={false}
onCrosshair={noop}
onManagerReady={onManagerReady}
onCandlesReady={onCandlesReady}
onAddDrawing={noop}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
volumeVisible={false}
paneSeparatorOptions={appDefaults.chartPaneSeparator}
paneLayoutClamp
showPaneResizeHandle={!indicatorOnly && auxPaneCount > 0}
paneAreaRatio={paneAreaRatio}
showPaneLegend
crosshairInfoVisible
indicatorAreaPriceLabelsEnabled={appDefaults.chartSeriesPriceLabels !== false}
candleAreaPriceLabelsEnabled={false}
seriesPriceLabelsEnabled={appDefaults.chartSeriesPriceLabels !== false}
showHoverToolbar={false}
showChartRightToolbar={false}
showCandlePaneControls={false}
skipViewportRecovery
auxIndicatorOnly={indicatorOnly}
centerOnSignalTime={signalCandleTime}
centerVisibleBarCount={SIGNAL_SNAPSHOT_VISIBLE_BARS}
resolveInitialDisplayCount={resolveInitialDisplayCount}
/>
);
};
const TimelineSignalSnapshotChart: React.FC<Props> = ({
source,
theme = 'dark',
onChartsReady,
}) => {
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [strategyLoading, setStrategyLoading] = useState(false);
const [strategy, setStrategy] = useState<StrategyDto | null>(null);
const [wsIndicators, setWsIndicators] = useState<IndicatorConfig[]>([]);
const paneReadyRef = useRef({ candle: false, aux: false });
const notifyChartsReady = useCallback(() => {
requestAnimationFrame(() => onChartsReady?.());
}, [onChartsReady]);
const market = source.symbol.startsWith('KRW-') ? source.symbol : `KRW-${source.symbol}`;
const chartTimeframe = candleTypeToTimeframe(source.signalMeta.candleType);
const candleTimeSec = source.signalMeta.candleTime;
const signalMarker = useMemo((): TradeSignalChartMarker => ({
candleTime: candleTimeSec,
signalType: source.side,
price: source.price,
}), [candleTimeSec, source.side, source.price]);
useEffect(() => {
let cancelled = false;
void ensureWorkspaceChartIndicators().then(inds => {
if (!cancelled) setWsIndicators(inds);
});
return () => { cancelled = true; };
}, [settingsRevision]);
useEffect(() => {
const strategyId = source.signalMeta.strategyId;
if (!strategyId) {
setStrategy(null);
setStrategyLoading(false);
return;
}
let cancelled = false;
setStrategyLoading(true);
void loadStrategyForNotification(strategyId).then(s => {
if (!cancelled) {
setStrategy(s ?? null);
setStrategyLoading(false);
}
});
return () => { cancelled = true; };
}, [source.signalMeta.strategyId]);
const { candleIndicators, auxIndicators } = useMemo(() => {
if (source.signalMeta.strategyId && strategyLoading) {
return { candleIndicators: [] as IndicatorConfig[], auxIndicators: [] as IndicatorConfig[] };
}
let strategyBuilt: IndicatorConfig[] = [];
if (strategy) {
strategyBuilt = buildStrategyChartIndicators(
strategy,
getParams,
getVisualConfig,
wsIndicators,
source.side,
);
strategyBuilt = strategyBuilt.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
} else {
const sma = buildChartIndicatorConfig('SMA', newIndId(), getParams, getVisualConfig, {
timeframeVisibility: ALL_TF_VISIBLE,
});
if (sma) strategyBuilt = [sma];
}
const merged = mergeGlobalDefaultsOntoChartIndicators(
strategyBuilt,
getParams,
getVisualConfig,
wsIndicators,
);
const withWorkspace = buildNotificationChartIndicators(merged, wsIndicators);
const { overlay, aux } = splitChartIndicators(withWorkspace);
return {
candleIndicators: ensurePaperChartOverlays(overlay, getParams, getVisualConfig),
auxIndicators: aux,
};
}, [
strategy,
source.signalMeta.strategyId,
source.side,
strategyLoading,
getParams,
getVisualConfig,
chartTimeframe,
wsIndicators,
]);
const auxLabels = useMemo(
() => auxIndicators.map(ind => formatIndicatorDisplayLabel(ind.type)),
[auxIndicators],
);
const hasAuxChart = auxIndicators.length > 0;
const { registerManager: registerLinkedChartManager } = useLinkedChartLogicalRange(2);
const markPaneReady = useCallback((pane: 'candle' | 'aux') => {
paneReadyRef.current[pane] = true;
const needAux = auxIndicators.length > 0;
if (paneReadyRef.current.candle && (!needAux || paneReadyRef.current.aux)) {
notifyChartsReady();
}
}, [notifyChartsReady, auxIndicators.length]);
useEffect(() => {
paneReadyRef.current = { candle: false, aux: false };
}, [bars, candleIndicators, auxIndicators, candleTimeSec]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void loadSignalSnapshotCandles(market, chartTimeframe, candleTimeSec)
.then(data => {
if (cancelled) return;
setBars(data);
})
.catch(e => {
if (cancelled) return;
setError(e instanceof Error ? e.message : '차트 로드 실패');
setBars([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [market, chartTimeframe, candleTimeSec]);
const chartReady = !loading && !error && bars.length > 0
&& !(source.signalMeta.strategyId && strategyLoading);
return (
<div className="arp-timeline-signal-chart">
<div className="arp-timeline-signal-chart-head">
<span> ±5</span>
<span>{bars.length > 0 ? `±5 표시 · ${bars.length}` : ''}</span>
</div>
<div className="arp-timeline-signal-chart-stack">
{(loading || strategyLoading) && (
<div className="arp-timeline-signal-chart-loading"> </div>
)}
{error && !loading && (
<div className="arp-timeline-signal-chart-error">{error}</div>
)}
{chartReady && (
<>
<section className="arp-timeline-signal-chart-pane arp-timeline-signal-chart-pane--candle">
<header className="arp-timeline-signal-chart-pane-head">
<span className="arp-timeline-signal-chart-pane-label"></span>
</header>
<div className="arp-timeline-signal-chart-pane-body">
<SignalSnapshotChartPane
bars={bars}
market={market}
chartTimeframe={chartTimeframe}
theme={theme}
indicators={candleIndicators}
chartKey={`candle-${market}-${chartTimeframe}-${candleTimeSec}-${candleIndicators.map(i => i.id).join(',')}`}
showMarker
signalMarker={signalMarker}
signalCandleTime={candleTimeSec}
chartLinkIndex={0}
registerLinkedChartManager={hasAuxChart ? registerLinkedChartManager : undefined}
onReady={() => markPaneReady('candle')}
/>
</div>
</section>
{auxIndicators.length > 0 && (
<section className="arp-timeline-signal-chart-pane arp-timeline-signal-chart-pane--aux">
<header className="arp-timeline-signal-chart-pane-head">
<span className="arp-timeline-signal-chart-pane-label"> </span>
<span className="arp-timeline-signal-chart-pane-meta">
{auxLabels.join(' · ')}
</span>
</header>
<div className="arp-timeline-signal-chart-pane-body arp-timeline-signal-chart-pane-body--aux">
<SignalSnapshotChartPane
bars={bars}
market={market}
chartTimeframe={chartTimeframe}
theme={theme}
indicators={auxIndicators}
chartKey={`aux-${market}-${chartTimeframe}-${candleTimeSec}-${auxIndicators.map(i => i.id).join(',')}`}
signalCandleTime={candleTimeSec}
indicatorOnly
chartLinkIndex={1}
registerLinkedChartManager={registerLinkedChartManager}
onReady={() => markPaneReady('aux')}
/>
</div>
</section>
)}
</>
)}
</div>
</div>
);
};
export default TimelineSignalSnapshotChart;
@@ -0,0 +1,37 @@
import { useLayoutEffect, type RefObject } from 'react';
/** 스크롤 컨테이너 안에서 target 요소가 뷰포트 중앙(세로)에 오도록 scrollTop 조정 */
export function useCenterElementInScrollContainer(
scrollRef: RefObject<HTMLElement | null>,
targetRef: RefObject<HTMLElement | null>,
enabled: boolean,
deps: unknown[] = [],
): void {
useLayoutEffect(() => {
if (!enabled) return;
const scroll = scrollRef.current;
const target = targetRef.current;
if (!scroll || !target) return;
const center = () => {
const scrollRect = scroll.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const targetMidInContent =
scroll.scrollTop + (targetRect.top - scrollRect.top) + targetRect.height / 2;
const targetTop = targetMidInContent - scroll.clientHeight / 2;
scroll.scrollTop = Math.max(0, Math.min(
targetTop,
scroll.scrollHeight - scroll.clientHeight,
));
};
center();
requestAnimationFrame(center);
const ro = new ResizeObserver(() => center());
ro.observe(scroll);
ro.observe(target);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, scrollRef, targetRef, ...deps]);
}
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef } from 'react';
import type { ChartManager } from '../utils/ChartManager';
type LogicalRange = { from: number; to: number };
/** 분리된 TradingChart 인스턴스 — 가로 시간축(visible logical range) 동기화 */
export function useLinkedChartLogicalRange(chartCount = 2) {
const managersRef = useRef<(ChartManager | null)[]>(Array(chartCount).fill(null));
const unsubsRef = useRef<Array<Array<() => void>>>(
Array.from({ length: chartCount }, () => []),
);
const isSyncingRef = useRef(false);
const clearUnsubs = useCallback((index: number) => {
for (const u of unsubsRef.current[index] ?? []) {
try { u(); } catch { /* ok */ }
}
unsubsRef.current[index] = [];
}, []);
const syncPeers = useCallback((sourceIndex: number, range: LogicalRange) => {
if (isSyncingRef.current) return;
isSyncingRef.current = true;
for (let i = 0; i < managersRef.current.length; i++) {
if (i === sourceIndex) continue;
const peer = managersRef.current[i];
if (!peer?.hasMainSeries() || peer.getRawBarsLength() < 2) continue;
peer.applyVisibleLogicalRange(range.from, range.to);
}
requestAnimationFrame(() => { isSyncingRef.current = false; });
}, []);
const attachRangeSync = useCallback((index: number, mgr: ChartManager) => {
clearUnsubs(index);
const onRange = (r: LogicalRange | null) => {
if (!r || isSyncingRef.current) return;
syncPeers(index, r);
};
unsubsRef.current[index].push(mgr.subscribeVisibleLogicalRange(onRange));
// 커스텀 패닝 등 — LWC logical 이벤트가 누락될 수 있어 viewport 알림에도 연결
unsubsRef.current[index].push(mgr.subscribeViewport(() => {
if (isSyncingRef.current) return;
const r = mgr.getVisibleLogicalRange();
if (r) syncPeers(index, r);
}));
}, [clearUnsubs, syncPeers]);
const registerManager = useCallback((index: number, mgr: ChartManager | null) => {
if (index < 0 || index >= managersRef.current.length) return;
clearUnsubs(index);
managersRef.current[index] = mgr;
if (!mgr) return;
attachRangeSync(index, mgr);
// 후행 차트 마운트 시 — 이미 준비된 peer 시간축에 맞춤
for (let i = 0; i < managersRef.current.length; i++) {
if (i === index) continue;
const peer = managersRef.current[i];
if (!peer?.hasMainSeries() || peer.getRawBarsLength() < 2) continue;
const peerRange = peer.getVisibleLogicalRange();
if (!peerRange) continue;
isSyncingRef.current = true;
mgr.applyVisibleLogicalRange(peerRange.from, peerRange.to);
requestAnimationFrame(() => { isSyncingRef.current = false; });
return;
}
}, [attachRangeSync, clearUnsubs]);
useEffect(() => () => {
for (let i = 0; i < unsubsRef.current.length; i++) {
clearUnsubs(i);
}
}, [clearUnsubs]);
return { registerManager };
}
+299 -17
View File
@@ -2353,19 +2353,20 @@
padding: 8px 12px 16px;
}
.arp-timeline-split-track {
position: relative;
min-height: 100%;
}
.arp-timeline-split-axis {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 3px;
width: 2px;
transform: translateX(-50%);
background: linear-gradient(
180deg,
rgba(201, 162, 39, 0.75),
rgba(201, 162, 39, 0.15)
);
border-radius: 2px;
background: rgba(201, 162, 39, 0.58);
border-radius: 1px;
pointer-events: none;
z-index: 0;
}
@@ -2389,6 +2390,7 @@
}
.arp-timeline-split-col--center {
position: relative;
display: flex;
justify-content: center;
align-items: center;
@@ -2581,12 +2583,12 @@
}
.arp-timeline-axis-zone--top {
padding-bottom: 12px;
padding-bottom: 0;
min-height: 148px;
}
.arp-timeline-axis-zone--bottom {
padding-top: 12px;
padding-top: 0;
min-height: 148px;
}
@@ -2643,14 +2645,6 @@
margin-bottom: 4px;
}
.arp-timeline-axis-zone--top .arp-timeline-axis-col-stack {
flex-direction: column-reverse;
}
.arp-timeline-axis-zone--bottom .arp-timeline-axis-col-stack {
flex-direction: column;
}
.arp-timeline-axis-connector {
width: 0;
border-left: 2px dashed rgba(255, 255, 255, 0.34);
@@ -2666,6 +2660,10 @@
margin-bottom: 6px;
}
.arp-timeline-axis-connector--to-rail {
min-height: 44px;
}
.arp-timeline-axis-connector--sell {
border-left-color: rgba(239, 68, 68, 0.4);
}
@@ -2818,3 +2816,287 @@
min-width: 120px;
}
}
/* ── 매매 시그널 상세 (타임라인 카드 버튼 · 팝업) ── */
.arp-timeline-signal-card-wrap {
position: relative;
}
.arp-timeline-signal-detail-btn {
position: absolute;
top: 6px;
right: 6px;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
background: color-mix(in srgb, var(--bg2) 88%, transparent);
color: color-mix(in srgb, var(--text) 78%, transparent);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.arp-timeline-signal-detail-btn:hover {
background: color-mix(in srgb, #c9a227 18%, var(--bg2));
border-color: color-mix(in srgb, #c9a227 45%, transparent);
color: #c9a227;
}
.arp-timeline-signal-card-wrap.btd-timeline-card,
.arp-timeline-signal-card-wrap.arp-timeline-split-card,
.arp-timeline-signal-card-wrap.arp-timeline-axis-card {
padding-top: 28px;
}
.arp-timeline-signal-detail-popup .app-popup-body.arp-timeline-signal-detail-popup__body {
max-height: min(88vh, 860px);
overflow-y: auto;
padding-top: 6px;
}
.arp-timeline-signal-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.arp-timeline-signal-detail-summary {
padding: 0 2px;
}
.arp-timeline-signal-detail-summary .tsd-body {
padding: 0;
border-bottom: none;
}
.arp-timeline-signal-detail-summary .tsd-detail-matrix {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.arp-timeline-signal-detail-summary .tsd-detail-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 56px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
background: color-mix(in srgb, var(--bg2) 72%, transparent);
}
.arp-timeline-signal-detail-summary .tsd-detail-cell-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--text) 52%, transparent);
}
.arp-timeline-signal-detail-summary .tsd-detail-cell-value {
font-size: 12px;
line-height: 1.35;
color: color-mix(in srgb, var(--text) 90%, transparent);
word-break: break-word;
}
.arp-timeline-signal-detail-summary .tsd-detail-cell-value--highlight {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
@media (max-width: 820px) {
.arp-timeline-signal-detail-summary .tsd-detail-matrix {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.arp-timeline-signal-detail-summary .tsd-detail-matrix {
grid-template-columns: 1fr;
}
}
.arp-timeline-signal-detail-chart {
min-height: 0;
}
.arp-timeline-signal-chart {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--bg1) 92%, transparent);
}
.arp-timeline-signal-chart-head {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
font-size: 10px;
font-weight: 700;
color: color-mix(in srgb, var(--text) 62%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
}
.arp-timeline-signal-chart-body {
position: relative;
min-height: 320px;
height: min(52vh, 420px);
}
.arp-timeline-signal-chart-stack {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 320px;
}
.arp-timeline-signal-chart-pane {
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
border-radius: 8px;
overflow: hidden;
background: color-mix(in srgb, var(--bg1) 94%, transparent);
}
.arp-timeline-signal-chart-pane--candle .arp-timeline-signal-chart-pane-body {
min-height: 240px;
height: min(38vh, 300px);
}
.arp-timeline-signal-chart-pane--aux .arp-timeline-signal-chart-pane-body {
min-height: 180px;
height: min(28vh, 220px);
}
.arp-timeline-signal-chart-pane-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
background: color-mix(in srgb, var(--bg2) 55%, transparent);
}
.arp-timeline-signal-chart-pane-label {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.arp-timeline-signal-chart-pane-meta {
font-size: 9px;
font-weight: 600;
color: color-mix(in srgb, var(--text) 52%, transparent);
text-align: right;
}
.arp-timeline-signal-chart-pane-body {
position: relative;
min-height: 0;
}
.arp-timeline-signal-chart-pane-body .tv-chart-wrap {
height: 100% !important;
}
.arp-timeline-signal-chart-pane-body--aux .tv-chart-wrap {
min-height: 100%;
}
.arp-timeline-signal-chart-loading,
.arp-timeline-signal-chart-error {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: color-mix(in srgb, var(--text) 55%, transparent);
background: color-mix(in srgb, var(--bg1) 72%, transparent);
}
.arp-timeline-signal-chart-error {
color: #ef4444;
}
.arp-timeline-signal-detail-reason {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
background: color-mix(in srgb, var(--bg2) 70%, transparent);
}
.arp-timeline-signal-detail-reason-head h3 {
margin: 0 0 4px;
font-size: 12px;
font-weight: 800;
}
.arp-timeline-signal-detail-reason-head p {
margin: 0;
font-size: 11px;
line-height: 1.5;
color: color-mix(in srgb, var(--text) 72%, transparent);
}
.arp-timeline-signal-detail-muted {
margin: 0;
font-size: 11px;
color: color-mix(in srgb, var(--text) 55%, transparent);
}
.arp-timeline-signal-detail-checklist {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.arp-timeline-signal-detail-checklist li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 11px;
line-height: 1.45;
color: color-mix(in srgb, var(--text) 86%, transparent);
}
.arp-timeline-signal-detail-check {
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 800;
color: #22c55e;
background: color-mix(in srgb, #22c55e 16%, transparent);
}
.arp-timeline-signal-detail-strategy .scv-root {
max-height: 280px;
overflow-y: auto;
}
+80 -2
View File
@@ -344,6 +344,9 @@ export class ChartManager {
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
private _paneAreaRatio: { candle: number; aux: number } | null = null;
/** 보조지표 전용 패널 — 캔들 pane 최소화·선형 지표만 표시 (시그널 상세 등) */
private _auxIndicatorOnlyLayout = false;
constructor(
container: HTMLElement,
theme: Theme,
@@ -502,14 +505,22 @@ export class ChartManager {
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
} else if (panesInit[0]) {
} else if (panesInit[0] && !this._auxIndicatorOnlyLayout) {
panesInit[0].setStretchFactor(1);
}
this._reapplyAllPatternMarkers();
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
this._applyDefaultVisibleRange(200, 0);
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
try { this.chart.timeScale().fitContent(); } catch { /* ok */ }
} else {
this._applyDefaultVisibleRange(200, 0);
}
if (this._auxIndicatorOnlyLayout) {
this.syncChartOverlayVisibility();
}
}
/**
@@ -4692,6 +4703,24 @@ export class ChartManager {
}
}
/**
* 보조지표 전용 레이아웃 — 캔들·오버레이 숨김, 하단 pane 선형 지표만 표시.
*/
applyAuxIndicatorOnlyLayout(enabled: boolean): void {
this._auxIndicatorOnlyLayout = enabled;
if (enabled) {
this.setChartOverlayVisibility({
ma: false,
bollinger: false,
ichimoku: false,
candle: false,
});
} else {
this.setChartOverlayVisibility({ ...DEFAULT_CHART_OVERLAY_VISIBILITY });
}
this.resetPaneHeights();
}
private _volumeFrac(H: number): number {
if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0;
return Math.min(Math.max(60 / H, 0.04), 0.12);
@@ -4808,6 +4837,51 @@ export class ChartManager {
return H;
}
/** 보조지표 전용 — pane 0(캔들) 최소화, 보조 pane 균등 확장 */
private _resetPaneHeightsAuxIndicatorOnly(availableHeight?: number): number {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
const ORPHAN_STRETCH = 0.0001;
const IND_EQUAL_WEIGHT = 1;
const H = (availableHeight && availableHeight > 0)
? availableHeight
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const activeIndPanes = this._activeIndicatorPaneIndices();
const volumeShown = this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
try {
this.volumeSeries?.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
for (let i = 0; i < panes.length; i++) {
const paneIndex = panes[i].paneIndex();
if (paneIndex === 0) {
panes[i].setStretchFactor(ORPHAN_STRETCH);
} else if (paneIndex === 1) {
if (volumeShown) {
panes[i].setStretchFactor(ORPHAN_STRETCH);
} else if (activeIndPanes.has(1)) {
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
} else if (activeIndPanes.has(paneIndex)) {
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
}
this.syncChartOverlayVisibility();
this._restoreSubPaneIndicatorVisibility();
this._scheduleIndicatorLastUpdate();
return H;
}
resetPaneHeights(availableHeight?: number): number {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
@@ -4820,6 +4894,10 @@ export class ChartManager {
return this._resetPaneHeightsCandleFullscreen(availableHeight);
}
if (this._auxIndicatorOnlyLayout) {
return this._resetPaneHeightsAuxIndicatorOnly(availableHeight);
}
const H = (availableHeight && availableHeight > 0)
? availableHeight
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
+54
View File
@@ -51,3 +51,57 @@ export async function loadAnalysisCandles(
return data.sort((a, b) => a.time - b.time);
}
/** 시그널 발생 봉 기준 좌우 N개 캔들 (기본 ±5) */
export const SIGNAL_SNAPSHOT_CONTEXT_BARS = 5;
/** 화면에 표시할 봉 수 (±context) */
export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1;
/** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */
const SIGNAL_SNAPSHOT_WARMUP_BARS = 50;
function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number {
if (bars.length === 0) return -1;
let bestIdx = 0;
let bestDist = Math.abs(bars[0].time - candleTimeSec);
for (let i = 1; i < bars.length; i++) {
const dist = Math.abs(bars[i].time - candleTimeSec);
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
return bestIdx;
}
export async function loadSignalSnapshotCandles(
market: string,
timeframe: Timeframe,
candleTimeSec: number,
contextBars = SIGNAL_SNAPSHOT_CONTEXT_BARS,
): Promise<OHLCVBar[]> {
const barSec = timeframeToBarSeconds(timeframe);
const normalizedCandle = normalizeEpochSec(candleTimeSec);
const wantVisible = contextBars * 2 + 1;
const toTimeSec = normalizedCandle + barSec * (contextBars + 2);
const fetchCount = Math.max(wantVisible + SIGNAL_SNAPSHOT_WARMUP_BARS + 10, 60);
const raw = await loadAnalysisCandles(market, timeframe, toTimeSec, fetchCount);
if (raw.length === 0) return raw;
let idx = raw.findIndex(b => b.time === normalizedCandle);
if (idx < 0) idx = findNearestBarIndex(raw, normalizedCandle);
if (idx < 0) return raw.slice(-wantVisible);
const start = Math.max(0, idx - contextBars - SIGNAL_SNAPSHOT_WARMUP_BARS);
const end = Math.min(raw.length, idx + contextBars + 1);
return raw.slice(start, end);
}
function timeframeToBarSeconds(timeframe: string): number {
const map: Record<string, number> = {
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
};
return map[timeframe] ?? 180;
}
+41
View File
@@ -9,6 +9,14 @@ import type {
export type LiveTimelineEventKind = 'SIGNAL' | 'ORDER_PENDING' | 'FILL';
export interface LiveTimelineSignalMeta {
signalId: number;
candleTime: number;
candleType: string;
executionType: string;
strategyId?: number | null;
}
export interface LiveTimelineFilter {
symbol?: string;
strategyId?: number | null;
@@ -25,6 +33,8 @@ export interface LiveTimelineEvent {
strategyName?: string | null;
sourceLabel?: string;
detail?: string;
/** kind === 'SIGNAL' 일 때만 채워짐 */
signalMeta?: LiveTimelineSignalMeta;
}
export const LIVE_TIMELINE_KIND_LABEL: Record<LiveTimelineEventKind, string> = {
@@ -79,6 +89,13 @@ export function buildLiveTradeTimeline(
price: s.price,
strategyName: s.strategyName,
detail: [s.candleType, s.executionType].filter(Boolean).join(' · ') || undefined,
signalMeta: {
signalId: s.id,
candleTime: s.candleTime > 1e12 ? Math.floor(s.candleTime / 1000) : s.candleTime,
candleType: s.candleType,
executionType: s.executionType,
strategyId: s.strategyId,
},
});
}
@@ -230,3 +247,27 @@ export function formatTimelineTime(ts: number): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 타임라인 SIGNAL 이벤트 → 상세 팝업용 메타 */
export interface TimelineSignalDetailSource {
side: 'BUY' | 'SELL';
price: number;
symbol: string;
strategyName?: string | null;
ts: number;
signalMeta: LiveTimelineSignalMeta;
}
export function toTimelineSignalDetail(
ev: LiveTimelineEvent,
): TimelineSignalDetailSource | null {
if (ev.kind !== 'SIGNAL' || !ev.signalMeta) return null;
return {
side: ev.side,
price: ev.price,
symbol: ev.symbol,
strategyName: ev.strategyName,
ts: ev.ts,
signalMeta: ev.signalMeta,
};
}
@@ -145,11 +145,15 @@ function walkIndicatorRefs(
node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen));
}
function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] {
function collectIndicatorRefs(strategy: StrategyDto, side?: 'BUY' | 'SELL'): IndicatorRef[] {
const out: IndicatorRef[] = [];
const seen = new Set<string>();
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
if (!side || side === 'BUY') {
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
}
if (!side || side === 'SELL') {
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
}
return out;
}
@@ -240,10 +244,11 @@ export function buildStrategyChartIndicators(
getParams: GetParams,
getVisual: GetVisual,
workspaceIndicators?: IndicatorConfig[],
side?: 'BUY' | 'SELL',
): IndicatorConfig[] {
if (!strategy) return [];
const refs = collectIndicatorRefs(strategy);
const refs = collectIndicatorRefs(strategy, side);
const byType = new Map<string, IndicatorRef>();
for (const ref of refs) {
@@ -54,6 +54,67 @@ export function buildNotificationSignalMarkers(
return [{ time, type: marker.signalType, price: marker.price }];
}
/** 스냅샷 차트 — 시그널 발생 봉이 시간축 중앙에 오도록 visible logical range 설정 */
export function centerChartOnSignalBar(
mgr: ChartManager,
candleTimeSec: number,
visibleBars?: number,
): void {
const bars = mgr.getRawBars();
if (bars.length < 2) {
mgr.fitContent();
return;
}
const normalized = normalizeEpochSec(candleTimeSec);
const barTime = resolveSignalMarkerBarTime(mgr, normalized);
if (barTime == null) {
mgr.fitContent();
return;
}
let idx = bars.findIndex(b => b.time === barTime);
if (idx < 0) {
let bestIdx = 0;
let bestDist = Math.abs(bars[0].time - barTime);
for (let i = 1; i < bars.length; i++) {
const dist = Math.abs(bars[i].time - barTime);
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
idx = bestIdx;
}
const totalBars = bars.length;
const span = visibleBars != null && visibleBars > 0
? visibleBars
: totalBars;
if (visibleBars == null && totalBars <= 21) {
mgr.fitContent();
return;
}
const halfSpan = span / 2;
let from = idx - halfSpan + 0.5;
let to = idx + halfSpan - 0.5;
if (from < 0) {
to -= from;
from = 0;
}
if (to > totalBars - 1) {
from -= to - (totalBars - 1);
to = totalBars - 1;
}
from = Math.max(0, from);
to = Math.min(totalBars - 1, to);
mgr.applyVisibleLogicalRange(from, to);
}
export function applyNotificationSignalMarkers(
mgr: ChartManager,
marker: TradeSignalChartMarker | undefined,