매매 시그널 디테일 팝업 기능 적용
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user