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

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
@@ -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;