diff --git a/frontend/src/components/AppPopup.tsx b/frontend/src/components/AppPopup.tsx index a401c4d..a87fd94 100644 --- a/frontend/src/components/AppPopup.tsx +++ b/frontend/src/components/AppPopup.tsx @@ -30,6 +30,7 @@ export interface AppPopupProps { headerExtra?: React.ReactNode; footer?: React.ReactNode; initialPosition?: { x: number; y: number }; + bodyRef?: React.Ref; } export const AppPopup: React.FC = ({ @@ -53,6 +54,7 @@ export const AppPopup: React.FC = ({ headerExtra, footer, initialPosition, + bodyRef, }) => { const accentGlow = accentColor === APP_POPUP_ACCENT ? APP_POPUP_ACCENT_GLOW @@ -130,7 +132,7 @@ export const AppPopup: React.FC = ({ ✕ -
{children}
+
{children}
{footer} diff --git a/frontend/src/components/TradeSignalDetailBody.tsx b/frontend/src/components/TradeSignalDetailBody.tsx index 5c9bb2a..62cb2be 100644 --- a/frontend/src/components/TradeSignalDetailBody.tsx +++ b/frontend/src/components/TradeSignalDetailBody.tsx @@ -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 = ({ item, variant = 'compact', showHeadline = true, + layout = 'list', }) => { useTradeAlertTimeFormat(); const isBuy = item.signalType === 'BUY'; const rows = buildSignalDetailRows(item); return ( -
+
{showHeadline && (
@@ -37,15 +40,35 @@ export const TradeSignalDetailBody: React.FC = ({ {formatSignalPrice(item.price)}
)} -

{getSignalHeadline(item)}

-
- {rows.map(row => ( - -
{row.label}
-
{row.value}
-
- ))} -
+ {layout !== 'matrix' && ( +

{getSignalHeadline(item)}

+ )} + {layout === 'matrix' ? ( +
+ {rows.map(row => ( +
+ {row.label} + + {row.value} + +
+ ))} +
+ ) : ( +
+ {rows.map(row => ( + +
{row.label}
+
{row.value}
+
+ ))} +
+ )}
); }; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 0a36560..2b93e21 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -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 = ({ @@ -237,6 +244,9 @@ const TradingChart: React.FC = ({ onOpenCustom1OverlaySettings, resolveInitialDisplayCount, skipViewportRecovery = false, + auxIndicatorOnly = false, + centerOnSignalTime, + centerVisibleBarCount, }) => { const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend; const showOverlayControlsOnly = !!( @@ -324,6 +334,9 @@ const TradingChart: React.FC = ({ 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 = ({ 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(null); /** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */ @@ -523,7 +557,9 @@ const TradingChart: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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
+ + {signalDetail && ( + setSignalDetail(null)} + /> + )} ); }; diff --git a/frontend/src/components/analysisReport/LiveTradeTimelineViews.tsx b/frontend/src/components/analysisReport/LiveTradeTimelineViews.tsx index a9b3b91..035a72d 100644 --- a/frontend/src/components/analysisReport/LiveTradeTimelineViews.tsx +++ b/frontend/src/components/analysisReport/LiveTradeTimelineViews.tsx @@ -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 = ({ ev, compact = fals interface ListViewProps { events: LiveTimelineEvent[]; timeframe: Timeframe; + onSignalDetail?: (ev: LiveTimelineEvent) => void; } -export const TimelineListView: React.FC = ({ events, timeframe }) => ( +export const TimelineListView: React.FC = ({ events, timeframe, onSignalDetail }) => (
{events.map(ev => { @@ -97,9 +99,13 @@ export const TimelineListView: React.FC = ({ events, timeframe }) variant="compact" className="arp-timeline-bar-time--list-node" /> -
+ -
+ ); })} @@ -109,12 +115,14 @@ export const TimelineListView: React.FC = ({ events, timeframe }) interface SplitViewProps { events: LiveTimelineEvent[]; timeframe: Timeframe; + onSignalDetail?: (ev: LiveTimelineEvent) => void; } -export const TimelineSplitView: React.FC = ({ events, timeframe }) => ( +export const TimelineSplitView: React.FC = ({ events, timeframe, onSignalDetail }) => (
-
- {events.map(ev => { +
+
+ {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 = ({ events, timeframe ].join(' '); const card = ( -
+ -
+ ); return ( @@ -171,5 +183,6 @@ export const TimelineSplitView: React.FC = ({ events, timeframe
); })} +
-); +); \ No newline at end of file diff --git a/frontend/src/components/analysisReport/TimelineAxisEventCard.tsx b/frontend/src/components/analysisReport/TimelineAxisEventCard.tsx index fee0412..7fe2ef5 100644 --- a/frontend/src/components/analysisReport/TimelineAxisEventCard.tsx +++ b/frontend/src/components/analysisReport/TimelineAxisEventCard.tsx @@ -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 = ({ ev, side }) => { +const TimelineAxisEventCard: React.FC = ({ 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 = ({ 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 && ( + { + e.stopPropagation(); + onSignalDetail(ev); + }} + /> + )}
{kindIcon(ev.kind)}
diff --git a/frontend/src/components/analysisReport/TimelineAxisView.tsx b/frontend/src/components/analysisReport/TimelineAxisView.tsx index 6bdd85c..8ee6267 100644 --- a/frontend/src/components/analysisReport/TimelineAxisView.tsx +++ b/frontend/src/components/analysisReport/TimelineAxisView.tsx @@ -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 = ({ events, timeframe }) => { +const TimelineAxisView: React.FC = ({ events, timeframe, onSignalDetail }) => { const scrollRef = useRef(null); const trackRef = useRef(null); useHorizontalWheelScroll(scrollRef); @@ -54,11 +55,18 @@ const TimelineAxisView: React.FC = ({ events, timeframe }) => {
{slots.map(slot => (
- {slot.sells.map(ev => ( + {slot.sells.map((ev, sellIdx) => (
- +
@@ -91,13 +99,20 @@ const TimelineAxisView: React.FC = ({ events, timeframe }) => {
{slots.map(slot => (
- {slot.buys.map(ev => ( + {slot.buys.map((ev, buyIdx) => (
- +
))}
diff --git a/frontend/src/components/analysisReport/TimelineSignalCardShell.tsx b/frontend/src/components/analysisReport/TimelineSignalCardShell.tsx new file mode 100644 index 0000000..7f2c12f --- /dev/null +++ b/frontend/src/components/analysisReport/TimelineSignalCardShell.tsx @@ -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 = ({ + ev, + className, + onSignalDetail, + children, + tag = 'article', +}) => { + const Tag = tag; + const showDetail = ev.kind === 'SIGNAL' && ev.signalMeta && onSignalDetail; + + return ( + + {showDetail && ( + { + e.stopPropagation(); + onSignalDetail(ev); + }} + /> + )} + {children} + + ); +}; + +export default TimelineSignalCardShell; diff --git a/frontend/src/components/analysisReport/TimelineSignalDetailButton.tsx b/frontend/src/components/analysisReport/TimelineSignalDetailButton.tsx new file mode 100644 index 0000000..aa60116 --- /dev/null +++ b/frontend/src/components/analysisReport/TimelineSignalDetailButton.tsx @@ -0,0 +1,35 @@ +/** + * 타임라인 매매 시그널 카드 — 우측 상단 상세 아이콘 + */ +import React from 'react'; + +const IcSignalDetail = () => ( + + + + + + +); + +interface Props { + onClick: (e: React.MouseEvent) => void; + title?: string; +} + +const TimelineSignalDetailButton: React.FC = ({ + onClick, + title = '시그널 상세 (차트 · 조건)', +}) => ( + +); + +export default TimelineSignalDetailButton; diff --git a/frontend/src/components/analysisReport/TimelineSignalDetailModal.tsx b/frontend/src/components/analysisReport/TimelineSignalDetailModal.tsx new file mode 100644 index 0000000..fa886b9 --- /dev/null +++ b/frontend/src/components/analysisReport/TimelineSignalDetailModal.tsx @@ -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 = ({ + source, + onClose, + theme = 'dark', +}) => { + const isBuy = source.side === 'BUY'; + const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR; + const [strategy, setStrategy] = useState(null); + const [stratLoading, setStratLoading] = useState(false); + const [chartsReady, setChartsReady] = useState(false); + const bodyRef = useRef(null); + const chartSectionRef = useRef(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 ( + +
+
+ +
+ +
+ +
+ +
+
+

조건 일치 사유

+

+ {formatSignalTime(source.signalMeta.candleTime)} · {formatCandleTypeKo(source.signalMeta.candleType)} + {' '}기준 {getSignalTypeKo(source.side)} 시그널이 발생했습니다. + {' '}({execLabel}) +

+
+ + {stratLoading && ( +

전략 조건 불러오는 중…

+ )} + + {!stratLoading && strategy && ( + <> + {conditionTexts.length > 0 && ( +
    + {conditionTexts.map(text => ( +
  • + + {text} +
  • + ))} +
+ )} +
+ +
+ + )} + + {!stratLoading && !strategy && ( +

+ 연결된 전략 정보가 없어 조건 트리를 표시할 수 없습니다. + {source.strategyName ? ` (전략: ${source.strategyName})` : ''} +

+ )} +
+
+
+ ); +}; + +export default TimelineSignalDetailModal; diff --git a/frontend/src/components/analysisReport/TimelineSignalSnapshotChart.tsx b/frontend/src/components/analysisReport/TimelineSignalSnapshotChart.tsx new file mode 100644 index 0000000..0598bd5 --- /dev/null +++ b/frontend/src/components/analysisReport/TimelineSignalSnapshotChart.tsx @@ -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; + 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 = ({ + bars, + market, + chartTimeframe, + theme, + indicators, + chartKey, + showMarker = false, + signalMarker, + signalCandleTime, + indicatorOnly = false, + onReady, + chartLinkIndex = 0, + registerLinkedChartManager, +}) => { + const { defaults: appDefaults } = useAppSettings(); + const managerRef = useRef(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 ( + 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 = ({ + source, + theme = 'dark', + onChartsReady, +}) => { + const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings(); + const [bars, setBars] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [strategyLoading, setStrategyLoading] = useState(false); + const [strategy, setStrategy] = useState(null); + const [wsIndicators, setWsIndicators] = useState([]); + 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 ( +
+
+ 시그널 발생 봉 기준 ±5캔들 + {bars.length > 0 ? `±5 표시 · ${bars.length}봉` : ''} +
+ +
+ {(loading || strategyLoading) && ( +
차트 로딩…
+ )} + {error && !loading && ( +
{error}
+ )} + + {chartReady && ( + <> +
+
+ 캔들 +
+
+ i.id).join(',')}`} + showMarker + signalMarker={signalMarker} + signalCandleTime={candleTimeSec} + chartLinkIndex={0} + registerLinkedChartManager={hasAuxChart ? registerLinkedChartManager : undefined} + onReady={() => markPaneReady('candle')} + /> +
+
+ + {auxIndicators.length > 0 && ( +
+
+ 전략 조건 보조지표 + + {auxLabels.join(' · ')} + +
+
+ i.id).join(',')}`} + signalCandleTime={candleTimeSec} + indicatorOnly + chartLinkIndex={1} + registerLinkedChartManager={registerLinkedChartManager} + onReady={() => markPaneReady('aux')} + /> +
+
+ )} + + )} +
+
+ ); +}; + +export default TimelineSignalSnapshotChart; diff --git a/frontend/src/hooks/useCenterElementInScrollContainer.ts b/frontend/src/hooks/useCenterElementInScrollContainer.ts new file mode 100644 index 0000000..55d8d3d --- /dev/null +++ b/frontend/src/hooks/useCenterElementInScrollContainer.ts @@ -0,0 +1,37 @@ +import { useLayoutEffect, type RefObject } from 'react'; + +/** 스크롤 컨테이너 안에서 target 요소가 뷰포트 중앙(세로)에 오도록 scrollTop 조정 */ +export function useCenterElementInScrollContainer( + scrollRef: RefObject, + targetRef: RefObject, + 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]); +} diff --git a/frontend/src/hooks/useLinkedChartLogicalRange.ts b/frontend/src/hooks/useLinkedChartLogicalRange.ts new file mode 100644 index 0000000..8800c99 --- /dev/null +++ b/frontend/src/hooks/useLinkedChartLogicalRange.ts @@ -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 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 }; +} diff --git a/frontend/src/styles/analysisReportPage.css b/frontend/src/styles/analysisReportPage.css index ad081b9..87542f8 100644 --- a/frontend/src/styles/analysisReportPage.css +++ b/frontend/src/styles/analysisReportPage.css @@ -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; +} diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 96b2477..4d8c3b4 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -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['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['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 diff --git a/frontend/src/utils/analysisChartData.ts b/frontend/src/utils/analysisChartData.ts index 05897c7..e560272 100644 --- a/frontend/src/utils/analysisChartData.ts +++ b/frontend/src/utils/analysisChartData.ts @@ -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 { + 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 = { + '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; +} diff --git a/frontend/src/utils/liveTradeTimeline.ts b/frontend/src/utils/liveTradeTimeline.ts index 473bf15..681f056 100644 --- a/frontend/src/utils/liveTradeTimeline.ts +++ b/frontend/src/utils/liveTradeTimeline.ts @@ -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 = { @@ -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, + }; +} diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts index 0d94c63..f10cf03 100644 --- a/frontend/src/utils/strategyToChartIndicators.ts +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -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(); - 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(); for (const ref of refs) { diff --git a/frontend/src/utils/tradeSignalChartMarkers.ts b/frontend/src/utils/tradeSignalChartMarkers.ts index e6c3d40..54ea950 100644 --- a/frontend/src/utils/tradeSignalChartMarkers.ts +++ b/frontend/src/utils/tradeSignalChartMarkers.ts @@ -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,