diff --git a/frontend/src/App.css b/frontend/src/App.css index 3987022..76a3e9c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1131,7 +1131,6 @@ html.theme-blue { border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85)); background: color-mix(in srgb, var(--bg, #131722) 88%, transparent); overflow: hidden; - will-change: transform; } .candle-pane-time-axis__label { position: absolute; diff --git a/frontend/src/components/CandlePaneTimeAxis.tsx b/frontend/src/components/CandlePaneTimeAxis.tsx index 12fd5a8..f6c615b 100644 --- a/frontend/src/components/CandlePaneTimeAxis.tsx +++ b/frontend/src/components/CandlePaneTimeAxis.tsx @@ -77,22 +77,6 @@ const CandlePaneTimeAxis: React.FC = ({ manager, contai const root = axisRef.current; if (!root || !containerEl.isConnected) return; - const panActive = manager.isCandleAxisPanActive(); - const panOffset = manager.getCandleAxisPanOffsetPx(); - - if (panActive) { - const state = manager.getCandlePaneTimeAxisOverlay(); - if (!state || state.labels.length === 0) { - root.style.display = 'none'; - return; - } - if (panOffset === 0) { - applyOverlayLayout(root, labelPoolRef.current, state); - } - root.style.transform = `translate3d(${panOffset}px, 0, 0)`; - return; - } - root.style.transform = ''; const state = manager.getCandlePaneTimeAxisOverlay(); diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index f1e7362..5e92ee0 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -31,6 +31,9 @@ import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; import BuilderPageShell from './layout/BuilderPageShell'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; +import { loadVirtualSession, loadVirtualTargets } from '../utils/virtualTradingStorage'; +import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy'; +import { formatVirtualTargetOptionLabel } from '../utils/virtualTargetNames'; type HistoryTab = 'open' | 'recent' | 'ledger'; type CenterTab = 'invest' | 'chart'; @@ -95,10 +98,27 @@ const PaperTradingPage: React.FC = ({ const [historyTab, setHistoryTab] = useState('recent'); const [rightTab, setRightTab] = useState('trade'); const [leftTab, setLeftTab] = useState('strategy'); + const [virtualTargets, setVirtualTargets] = useState(() => loadVirtualTargets()); + const [virtualGlobalStrategyId, setVirtualGlobalStrategyId] = useState( + () => loadVirtualSession().globalStrategyId, + ); const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); const orderAnchorRef = useRef(null); + useEffect(() => { + const refreshVirtual = () => { + setVirtualTargets(loadVirtualTargets()); + setVirtualGlobalStrategyId(loadVirtualSession().globalStrategyId); + }; + window.addEventListener('gc_virtual_session_changed', refreshVirtual); + window.addEventListener('storage', refreshVirtual); + return () => { + window.removeEventListener('gc_virtual_session_changed', refreshVirtual); + window.removeEventListener('storage', refreshVirtual); + }; + }, []); + const tickersRef = useRef(tickers); tickersRef.current = tickers; const summaryRef = useRef(summary); @@ -129,6 +149,12 @@ const PaperTradingPage: React.FC = ({ useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]); + useEffect(() => { + if (initialLoading) return; + setVirtualTargets(loadVirtualTargets()); + setVirtualGlobalStrategyId(loadVirtualSession().globalStrategyId); + }, [initialLoading, dataRefreshKey]); + const priceKey = positionPriceKey(summary?.positions, tickers); const lastPriceKeyRef = useRef(''); useEffect(() => { @@ -159,11 +185,33 @@ const PaperTradingPage: React.FC = ({ return coerceFiniteNumber(p?.quantity) ?? 0; }, [s?.positions, selectedMarket]); - const selectedAlloc = useMemo( - () => s?.allocations?.find(a => a.symbol === selectedMarket), - [s?.allocations, selectedMarket], + const investmentTargets = useMemo( + () => s?.allocations ?? [], + [s?.allocations], ); + const selectedAlloc = useMemo( + () => investmentTargets.find(a => a.symbol === selectedMarket), + [investmentTargets, selectedMarket], + ); + + /** 가상매매 투자대상 종목별 전략(없으면 기본 전략) — virtualLiveStrategySync 와 동일 */ + const chartStrategyId = useMemo(() => { + if (!selectedAlloc) return null; + const vt = virtualTargets.find(t => t.market === selectedAlloc.symbol); + if (vt) { + return resolveVirtualTargetStrategyId(vt, virtualGlobalStrategyId); + } + return selectedAlloc.strategyId ?? null; + }, [selectedAlloc, virtualTargets, virtualGlobalStrategyId]); + + useEffect(() => { + if (centerTab !== 'chart' || investmentTargets.length === 0) return; + if (!investmentTargets.some(a => a.symbol === selectedMarket)) { + setSelectedMarket(investmentTargets[0].symbol); + } + }, [centerTab, investmentTargets, selectedMarket]); + const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0; const tradePrice = useMemo( @@ -277,12 +325,40 @@ const PaperTradingPage: React.FC = ({ /> )} centerHead={( -
+
- {coinCode(selectedMarket)} / KRW + {centerTab === 'chart' ? ( +
+ +
+ ) : ( + {coinCode(selectedMarket)} / KRW + )}
)} center={centerTab === 'invest' ? ( @@ -306,7 +382,11 @@ const PaperTradingPage: React.FC = ({
) : (
- +
)} rightTabs={rightTabs} diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 6d24b4a..371544f 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -149,6 +149,8 @@ interface TradingChartProps { paneSeparatorOptions?: ChartPaneSeparatorOptions; /** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */ paneLayoutClamp?: boolean; + /** 캔들+거래량 vs 보조 pane 높이 비율 (aux 0 이면 미적용) */ + paneAreaRatio?: { candle: number; aux: number } | null; /** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */ showPaneLegend?: boolean; /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ @@ -195,6 +197,7 @@ const TradingChart: React.FC = ({ chartVisible = true, paneSeparatorOptions, paneLayoutClamp = false, + paneAreaRatio = null, showPaneLegend = true, crosshairInfoVisible = true, dataLoading = false, @@ -227,6 +230,8 @@ const TradingChart: React.FC = ({ const seriesDblSuppressRef = useRef(false); const toggleCandleOnlyRef = useRef<() => void>(() => {}); const candleOnlyModeRef = useRef(false); + const paneAreaRatioRef = useRef(paneAreaRatio); + paneAreaRatioRef.current = paneAreaRatio; const lastCrosshairPriceRef = useRef(null); const onTradeOrderRequestRef = useRef(onTradeOrderRequest); onTradeOrderRequestRef.current = onTradeOrderRequest; @@ -387,6 +392,12 @@ const TradingChart: React.FC = ({ if (mgr.isCandleOnlyLayout()) { mgr.restoreFromCandleFullscreen(wrapperH); } + const ratio = paneAreaRatioRef.current; + if (ratio && ratio.aux > 0) { + mgr.setPaneAreaRatio(ratio.candle, ratio.aux); + } else { + mgr.setPaneAreaRatio(0, 0); + } required = mgr.resetPaneHeights(wrapperH); } @@ -408,7 +419,7 @@ const TradingChart: React.FC = ({ } catch { /* ok */ } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paneLayoutClamp]); + }, [paneLayoutClamp, paneAreaRatio]); /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */ const normalizeChartViewport = useCallback((mgr: ChartManager) => { @@ -938,8 +949,8 @@ const TradingChart: React.FC = ({ if (!panStateRef.current.active) return; if (panStateRef.current.moved) { suppressClickRef.current = true; + managerRef.current?.notifyViewportChanged(); } - managerRef.current?.endCandleAxisPan(); panStateRef.current.active = false; container.style.cursor = canPanRef.current ? 'grab' : ''; }; diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index d30fbb4..8499399 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -1,23 +1,26 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; import TradingChart from '../TradingChart'; import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; import { loadStrategy } from '../../utils/backendApi'; import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types'; import { loadAnalysisCandles } from '../../utils/analysisChartData'; import type { ChartManager } from '../../utils/ChartManager'; -import { useHistoryLoader } from '../../hooks/useHistoryLoader'; +import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../../hooks/useHistoryLoader'; import { isUpbitMarket } from '../../utils/upbitApi'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useAppSettings } from '../../hooks/useAppSettings'; import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry'; +import { + chartPaneFlexRatio, + countNonOverlayIndicatorPanes, +} from '../../utils/strategyOscillatorSeries'; import { normalizeSmaConfig } from '../../utils/smaConfig'; import { buildVirtualTradingChartIndicators, resolveStrategyPrimaryTimeframe, } from '../../utils/strategyToChartIndicators'; -import BacktestOscillatorPanes from './BacktestOscillatorPanes'; interface Props { symbol: string; @@ -28,6 +31,10 @@ interface Props { signals: BacktestSignal[]; strategyId?: number | null; theme?: Theme; + /** 과거 캔들 추가 로드 후 (차트에 반영된 전체 봉) */ + onHistoryBarsLoaded?: (bars: OHLCVBar[]) => void; + /** 과거 로드·시그널 재계산 중 UI */ + onHistoryLoadingChange?: (loading: boolean) => void; } const noop = () => {}; @@ -59,6 +66,11 @@ function defaultOverlayIndicators( return [cfg]; } +const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; + +/** + * 백테스트·투자관리 차트 — TradingChart 단일 엔진(실시간 차트와 동일 크로스헤어·시간축·우측 가격) + */ const BacktestAnalysisChart: React.FC = ({ symbol, timeframe: timeframeRaw, @@ -68,8 +80,9 @@ const BacktestAnalysisChart: React.FC = ({ signals, strategyId, theme = 'dark', + onHistoryBarsLoaded, + onHistoryLoadingChange, }) => { - const timeframe = normalizeChartTimeframe(timeframeRaw); const { getParams, getVisualConfig } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); const [bars, setBars] = useState([]); @@ -78,10 +91,21 @@ const BacktestAnalysisChart: React.FC = ({ const [chartType] = useState('candlestick'); const [strategy, setStrategy] = useState(); const managerRef = useRef(null); + const logicalRangeUnsubRef = useRef<(() => void) | null>(null); + const viewportUnsubRef = useRef<(() => void) | null>(null); + const loadMoreRefRef = useRef void>>({ current: () => {} }); const signalsRef = useRef(signals); signalsRef.current = signals; + const onHistoryBarsLoadedRef = useRef(onHistoryBarsLoaded); + onHistoryBarsLoadedRef.current = onHistoryBarsLoaded; + const [loadedBarCount, setLoadedBarCount] = useState(null); const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`; + const chartTimeframe = useMemo((): Timeframe => { + const tf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframeRaw; + return normalizeChartTimeframe(tf) as Timeframe; + }, [strategy, timeframeRaw]); + useEffect(() => { if (!strategyId) { setStrategy(undefined); @@ -97,10 +121,21 @@ const BacktestAnalysisChart: React.FC = ({ const indicators = useMemo(() => { if (strategy) { const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); - if (inds.length) return inds.filter(i => i.timeframeVisibility?.[timeframe] !== false); + if (inds.length) return inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false); } return defaultOverlayIndicators(getParams); - }, [strategy, getParams, getVisualConfig, timeframe]); + }, [strategy, getParams, getVisualConfig, chartTimeframe]); + + const auxPaneCount = useMemo( + () => countNonOverlayIndicatorPanes(indicators, isOverlayType), + [indicators], + ); + + const paneAreaRatio = useMemo(() => { + if (auxPaneCount <= 0) return null; + const flex = chartPaneFlexRatio(auxPaneCount); + return { candle: flex.candle, aux: flex.aux }; + }, [auxPaneCount]); const effectiveBarCount = useMemo(() => { const barSec = timeframeBarSeconds(timeframeRaw); @@ -114,7 +149,8 @@ const BacktestAnalysisChart: React.FC = ({ let cancelled = false; setLoading(true); setError(null); - void loadAnalysisCandles(market, timeframe, toTimeSec, effectiveBarCount) + setLoadedBarCount(null); + void loadAnalysisCandles(market, chartTimeframe, toTimeSec, effectiveBarCount) .then(data => { if (cancelled) return; setBars(data); @@ -128,7 +164,7 @@ const BacktestAnalysisChart: React.FC = ({ if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [market, timeframe, toTimeSec, effectiveBarCount]); + }, [market, chartTimeframe, toTimeSec, effectiveBarCount]); const applyMarkers = useCallback((attempt = 0) => { const mgr = managerRef.current; @@ -139,51 +175,127 @@ const BacktestAnalysisChart: React.FC = ({ mgr.setBacktestMarkers(signalsRef.current, true); }, []); + const applyPaneRatio = useCallback((mgr: ChartManager) => { + if (paneAreaRatio && paneAreaRatio.aux > 0) { + mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux); + } else { + mgr.setPaneAreaRatio(0, 0); + } + }, [paneAreaRatio]); + + const handleBarsPrepended = useCallback((_added: number, total: number) => { + setLoadedBarCount(total); + const mgr = managerRef.current; + if (!mgr) return; + onHistoryBarsLoadedRef.current?.(mgr.getRawBars()); + // 왼쪽 끝에 붙어 있으면 연속 로드 + requestAnimationFrame(() => { + const lr = mgr.getVisibleLogicalRange(); + if (lr && lr.from < LOAD_MORE_TRIGGER) { + loadMoreRefRef.current.current(); + } + }); + }, []); + + const { isLoadingMore, loadMoreRef } = useHistoryLoader({ + symbol: market, + timeframe: chartTimeframe, + isUpbit: isUpbitMarket(market), + managerRef, + onBarsPrepended: handleBarsPrepended, + }); + + loadMoreRefRef.current = loadMoreRef; + + const setupHistoryScroll = useCallback((mgr: ChartManager) => { + logicalRangeUnsubRef.current?.(); + viewportUnsubRef.current?.(); + + const tryLoadMore = () => { + if (!mgr.hasMainSeries()) return; + const lr = mgr.getVisibleLogicalRange(); + if (lr && lr.from < LOAD_MORE_TRIGGER) { + loadMoreRefRef.current.current(); + } + }; + + logicalRangeUnsubRef.current = mgr.subscribeVisibleLogicalRange(r => { + if (r && r.from < LOAD_MORE_TRIGGER) { + loadMoreRefRef.current.current(); + } + }); + // 커스텀 패닝은 LWC 논리범위 이벤트를 안 낼 수 있어 뷰포트 알림에도 연결 + viewportUnsubRef.current = mgr.subscribeViewport(tryLoadMore); + requestAnimationFrame(tryLoadMore); + }, []); + + useEffect(() => { + onHistoryLoadingChange?.(isLoadingMore); + }, [isLoadingMore, onHistoryLoadingChange]); + + useEffect(() => () => { + logicalRangeUnsubRef.current?.(); + logicalRangeUnsubRef.current = null; + viewportUnsubRef.current?.(); + viewportUnsubRef.current = null; + }, []); + const onManagerReady = useCallback((mgr: ChartManager) => { managerRef.current = mgr; + setupHistoryScroll(mgr); + setLoadedBarCount(mgr.getRawBarsLength()); + applyPaneRatio(mgr); applyMarkers(); - }, [applyMarkers]); + }, [applyMarkers, applyPaneRatio, setupHistoryScroll]); const onCandlesReady = useCallback(() => { + const mgr = managerRef.current; + if (!mgr) return; + setupHistoryScroll(mgr); + applyPaneRatio(mgr); applyMarkers(); - }, [applyMarkers]); + setLoadedBarCount(mgr.getRawBarsLength()); + }, [applyMarkers, applyPaneRatio, setupHistoryScroll]); useEffect(() => { applyMarkers(); }, [signals, bars, applyMarkers]); - useHistoryLoader({ - symbol: market, - timeframe, - isUpbit: isUpbitMarket(market), - managerRef, - }); + useEffect(() => { + const mgr = managerRef.current; + if (mgr) applyPaneRatio(mgr); + }, [paneAreaRatio, applyPaneRatio]); const symLabel = market.replace(/^KRW-/, ''); - const stratTf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframe; + const stratTf = chartTimeframe; + + const priceLabels = appDefaults.chartSeriesPriceLabels !== false; return (
{symLabel} - {timeframe} + {chartTimeframe} Candle
- {effectiveBarCount.toLocaleString()}봉 + + {(loadedBarCount ?? effectiveBarCount).toLocaleString()}봉 + {isLoadingMore && ' · 과거 로드…'} +
-
+
{loading &&
차트 로딩…
} {error && !loading &&
{error}
} {!loading && !error && bars.length > 0 && ( i.id).join(',')}`} bars={bars} barsMarket={market} market={market} @@ -201,13 +313,20 @@ const BacktestAnalysisChart: React.FC = ({ onAddDrawing={noop} displayTimezone={DEFAULT_DISPLAY_TIMEZONE} paneSeparatorOptions={appDefaults.chartPaneSeparator} + paneLayoutClamp + paneAreaRatio={paneAreaRatio} + showPaneLegend + crosshairInfoVisible + candleAreaPriceLabelsEnabled={priceLabels} + indicatorAreaPriceLabelsEnabled={priceLabels} + seriesPriceLabelsEnabled={priceLabels} + showHoverToolbar /> )} {!loading && !error && bars.length === 0 && (
표시할 캔들 데이터가 없습니다.
)}
- {!loading && bars.length > 0 && }
); diff --git a/frontend/src/components/backtest/StrategyOscillatorPanes.tsx b/frontend/src/components/backtest/StrategyOscillatorPanes.tsx new file mode 100644 index 0000000..5a145b7 --- /dev/null +++ b/frontend/src/components/backtest/StrategyOscillatorPanes.tsx @@ -0,0 +1,96 @@ +import React, { useMemo } from 'react'; +import type { OHLCVBar } from '../../types'; +import type { StrategyDto } from '../../utils/backendApi'; +import { + buildOscillatorPaneData, + collectStrategyOscillatorPanes, + defaultOscillatorPaneSpecs, +} from '../../utils/strategyOscillatorSeries'; + +function spark(values: number[], w: number, h: number, min?: number, max?: number): string { + if (!values.length) return ''; + const lo = min ?? Math.min(...values); + const hi = max ?? Math.max(...values); + const range = hi - lo || 1; + return values.map((v, i) => { + const x = (i / Math.max(values.length - 1, 1)) * w; + const y = h - ((v - lo) / range) * (h - 6) - 3; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); +} + +interface Props { + bars: OHLCVBar[]; + strategy?: StrategyDto; + /** 부모 flex 영역 높이에 맞춰 pane 균등 분배 */ + fillHeight?: boolean; +} + +/** 전략 조건에 사용된 보조지표 하단 pane (없으면 RSI·MACD 기본) */ +const StrategyOscillatorPanes: React.FC = ({ bars, strategy, fillHeight = false }) => { + const panes = useMemo(() => { + const specs = collectStrategyOscillatorPanes(strategy); + const useSpecs = specs.length > 0 ? specs : defaultOscillatorPaneSpecs(); + return buildOscillatorPaneData(bars, useSpecs); + }, [bars, strategy]); + + if (panes.length === 0) return null; + + return ( +
+ {panes.map(pane => { + const last = pane.values[pane.values.length - 1]; + const lo = pane.refLines?.[0]; + const hi = pane.refLines?.[pane.refLines.length - 1]; + const path = spark( + pane.values, + 280, + 52, + lo != null && hi != null && pane.spec.registryType === 'RSI' ? 0 : undefined, + lo != null && hi != null && pane.spec.registryType === 'RSI' ? 100 : undefined, + ); + return ( +
+
+ {pane.spec.label} + + {last != null + ? (pane.spec.registryType === 'RSI' ? last.toFixed(1) : last.toFixed(2)) + : '–'} + +
+ + {pane.refLines?.map((r, i) => { + const min = pane.refLines![0]; + const max = pane.refLines![pane.refLines!.length - 1]; + const y = 52 - ((r - min) / (max - min || 1)) * 44 - 4; + return ( + + ); + })} + {!pane.refLines?.length && ( + + )} + + +
+ ); + })} +
+ ); +}; + +export default StrategyOscillatorPanes; diff --git a/frontend/src/components/paper/PaperAnalysisChart.tsx b/frontend/src/components/paper/PaperAnalysisChart.tsx index a2d6077..7d200c6 100644 --- a/frontend/src/components/paper/PaperAnalysisChart.tsx +++ b/frontend/src/components/paper/PaperAnalysisChart.tsx @@ -1,30 +1,195 @@ -import React, { useState } from 'react'; -import type { Theme, Timeframe } from '../../types'; -import PaperMiniChart from './PaperMiniChart'; -import PaperIndicatorPanel from './PaperIndicatorPanel'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart'; +import { + DEFAULT_BACKTEST_SETTINGS, + loadStrategy, + runBacktest, + type BacktestSignal, + type StrategyDto, +} from '../../utils/backendApi'; +import { loadAnalysisCandles } from '../../utils/analysisChartData'; +import { normalizeChartTimeframe } from '../../utils/backtestUiUtils'; +import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; +import type { OHLCVBar, Theme } from '../../types'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; + +const BAR_COUNT = 300; +const HISTORY_BACKTEST_DEBOUNCE_MS = 450; interface Props { market: string; + strategyId?: number | null; theme?: Theme; } -/** 모의투자 중앙 — 상단 캔들 + 하단 보조지표 (실시간 차트 레이아웃) */ -const PaperAnalysisChart: React.FC = ({ market, theme = 'dark' }) => { - const [timeframe, setTimeframe] = useState('1h'); +/** + * 모의투자 차트 탭 — 전략 백테스트 시그널 + 캔들·보조지표 (백테스팅 분석 차트와 동일) + * 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산. + */ +const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark' }) => { + const { getParams } = useIndicatorSettings(); + const [signals, setSignals] = useState([]); + const [strategy, setStrategy] = useState(); + const [timeframe, setTimeframe] = useState('1h'); + const [running, setRunning] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [error, setError] = useState(null); + const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]); + const backtestGenRef = useRef(0); + const historyDebounceRef = useRef | null>(null); + const strategyRef = useRef(strategy); + const strategyIdRef = useRef(strategyId); + strategyRef.current = strategy; + strategyIdRef.current = strategyId; + + useEffect(() => { + if (!strategyId) { + setStrategy(undefined); + setSignals([]); + setError('가상매매 투자대상에 종목 전략 또는 기본 전략이 설정되어 있지 않습니다.'); + return; + } + let cancelled = false; + setError(null); + void loadStrategy(strategyId).then(s => { + if (!cancelled) setStrategy(s ?? undefined); + }); + return () => { cancelled = true; }; + }, [strategyId]); + + const runSignalsForBars = useCallback(async ( + bars: OHLCVBar[], + tf: string, + strat: StrategyDto, + sid: number, + ) => { + const sym = market.startsWith('KRW-') ? market : `KRW-${market}`; + if (bars.length < 10) { + setSignals([]); + setError('캔들 데이터가 부족합니다.'); + return; + } + const gen = ++backtestGenRef.current; + setRunning(true); + setError(null); + try { + const res = await runBacktest({ + strategyId: sid, + bars: bars.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })), + timeframe: tf, + symbol: sym, + strategyName: strat.name, + settings: DEFAULT_BACKTEST_SETTINGS, + indicatorParams: Object.fromEntries( + ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ + t, + getParams(t) as Record, + ]), + ), + }); + if (gen !== backtestGenRef.current) return; + if (!res) { + setSignals([]); + setError('시그널 계산에 실패했습니다.'); + return; + } + setSignals(res.signals); + } catch (e) { + if (gen !== backtestGenRef.current) return; + setSignals([]); + setError(e instanceof Error ? e.message : '차트 분석 실패'); + } finally { + if (gen === backtestGenRef.current) setRunning(false); + } + }, [market, getParams]); + + useEffect(() => { + if (!strategyId || !strategy) { + setSignals([]); + return; + } + let cancelled = false; + const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy)); + setTimeframe(tf); + setError(null); + + void (async () => { + try { + const sym = market.startsWith('KRW-') ? market : `KRW-${market}`; + const bars = await loadAnalysisCandles(sym, tf, toTimeSec, BAR_COUNT); + if (cancelled) return; + await runSignalsForBars(bars, tf, strategy, strategyId); + } catch (e) { + if (!cancelled) { + setSignals([]); + setError(e instanceof Error ? e.message : '차트 분석 실패'); + setRunning(false); + } + } + })(); + + return () => { + cancelled = true; + ++backtestGenRef.current; + }; + }, [market, strategyId, strategy, toTimeSec, runSignalsForBars]); + + const handleHistoryBarsLoaded = useCallback((bars: OHLCVBar[]) => { + const strat = strategyRef.current; + const sid = strategyIdRef.current; + if (!strat || !sid) return; + const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strat)); + if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current); + historyDebounceRef.current = setTimeout(() => { + historyDebounceRef.current = null; + void runSignalsForBars(bars, tf, strat, sid); + }, HISTORY_BACKTEST_DEBOUNCE_MS); + }, [runSignalsForBars]); + + useEffect(() => () => { + if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current); + }, []); + + if (!strategyId) { + return ( +
+

+ 가상매매 투자대상 목록에서 종목별 전략 또는 기본 전략을 설정하면 차트에 매매 시그널이 표시됩니다. +

+
+ ); + } + + const statusBusy = running || historyLoading; return ( -
-
- -
-
- -
+
+ {statusBusy && ( +
+ {historyLoading && !running ? '과거 캔들 로드 중…' : '전략 시그널 계산 중…'} +
+ )} + {error && !statusBusy && ( +
{error}
+ )} +
); }; diff --git a/frontend/src/hooks/useHistoryLoader.ts b/frontend/src/hooks/useHistoryLoader.ts index 69c99cb..1fa79f9 100644 --- a/frontend/src/hooks/useHistoryLoader.ts +++ b/frontend/src/hooks/useHistoryLoader.ts @@ -18,6 +18,8 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import type { RefObject, MutableRefObject } from 'react'; import type { ChartManager } from '../utils/ChartManager'; +import { toHistoryApiIso } from '../utils/analysisChartData'; +import { timeframeToCandleType } from '../utils/chartCandleType'; import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache'; import type { OHLCVBar, Timeframe } from '../types'; @@ -38,6 +40,8 @@ interface UseHistoryLoaderOptions { timeframe: Timeframe; isUpbit: boolean; managerRef: RefObject; + /** 과거 캔들 prepend 완료 후 (추가된 봉 수, 전체 봉 수) */ + onBarsPrepended?: (addedCount: number, totalBars: number) => void; } interface UseHistoryLoaderReturn { @@ -91,6 +95,7 @@ export function useHistoryLoader({ timeframe, isUpbit, managerRef, + onBarsPrepended, }: UseHistoryLoaderOptions): UseHistoryLoaderReturn { const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -103,6 +108,8 @@ export function useHistoryLoader({ /** false 가 되면 더 이상 API 를 호출하지 않음 */ const hasMoreRef = useRef(true); const debounceTimer = useRef | null>(null); + const onPrependedRef = useRef(onBarsPrepended); + onPrependedRef.current = onBarsPrepended; // 심볼·타임프레임이 바뀌면 상태 초기화 useEffect(() => { @@ -125,10 +132,15 @@ export function useHistoryLoader({ const oldestTime = mgr.getOldestBarTime(); if (oldestTime === null) return; - // Unix 타임스탬프(초) → ISO 8601 UTC ('Z' 없이 전달, 백엔드에서 처리) - const beforeIso = new Date(oldestTime * 1000) - .toISOString() - .replace('.000Z', ''); // '2026-05-20T10:00:00' + // exclusive to — 가장 오래된 봉 시각 그대로 쓰면 중복·0건이 될 수 있어 한 봉 이전으로 + const raw = mgr.getRawBars(); + let beforeSec = oldestTime; + if (raw.length >= 2) { + const step = Math.max(1, (raw[1].time as number) - (raw[0].time as number)); + beforeSec = oldestTime - step; + } + const beforeIso = toHistoryApiIso(beforeSec); + const candleType = timeframeToCandleType(timeframe); // ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단 loadingRef.current = true; @@ -139,8 +151,8 @@ export function useHistoryLoader({ // ── 1. 백엔드 /api/candles/history 우선 시도 ────────────────────────── try { - olderBars = await fetchHistoryFromBackend(symbol, timeframe, beforeIso, LOAD_BATCH); - console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신`); + olderBars = await fetchHistoryFromBackend(symbol, candleType, beforeIso, LOAD_BATCH); + console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신 (${candleType})`); } catch (backendErr) { // ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ─────────────────────── console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr); @@ -153,7 +165,10 @@ export function useHistoryLoader({ hasMoreRef.current = false; // 더 이상 데이터 없음 } else { // ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ────────────────────── - await mgr.prependBars(olderBars); + const added = await mgr.prependBars(olderBars); + if (added > 0) { + onPrependedRef.current?.(added, mgr.getRawBarsLength()); + } } } catch (e) { console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e); diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index a168d82..e467ab0 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -1216,36 +1216,69 @@ min-height: 0; display: flex; flex-direction: column; + overflow: hidden; } .btd-analysis-canvas-wrap { flex: 1; - min-height: 320px; + min-height: 0; position: relative; overflow: hidden; } +.btd-analysis-canvas-wrap--live { + display: flex; + flex-direction: column; +} + +.btd-analysis-canvas-wrap--live .tv-chart-wrap { + flex: 1; + min-height: 0; +} + .btd-analysis-canvas-wrap .tv-chart-wrap { height: 100% !important; - min-height: 320px; + min-height: 0 !important; } .btd-analysis-canvas-wrap .chart-container { - min-height: 320px; + min-height: 0 !important; } -/* ── RSI / MACD pane ── */ +/* ── 보조지표 pane ── */ .btd-osc-stack { flex-shrink: 0; display: flex; flex-direction: column; border-top: 1px solid var(--btd-divider); + overflow: hidden; +} + +.btd-osc-stack--fill { + flex-shrink: 1; + overflow-y: auto; + overflow-x: hidden; } .btd-osc-pane { padding: 6px 10px 4px; border-bottom: 1px solid color-mix(in srgb, var(--btd-divider) 70%, transparent); + flex-shrink: 0; +} + +.btd-osc-pane--fill { + flex: 1 1 0; + min-height: 52px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.btd-osc-pane--fill .btd-osc-svg { + flex: 1 1 auto; + min-height: 36px; + height: auto; } .btd-osc-pane:last-child { border-bottom: none; } diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index 7d1452f..7d0a515 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -72,6 +72,54 @@ max-height: none; } +.ptd-analysis-chart--backtest { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ptd-analysis-chart--backtest .btd-analysis-chart { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.ptd-analysis-chart--backtest .btd-analysis-main { + flex: 1; + min-height: 0; +} + +.ptd-analysis-chart--empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; +} + +.ptd-analysis-chart-status { + position: absolute; + top: 8px; + right: 12px; + z-index: 4; + font-size: 11px; + padding: 4px 10px; + border-radius: 6px; + background: color-mix(in srgb, var(--bg3) 92%, transparent); + border: 1px solid var(--border); + color: var(--text2); +} + +.ptd-analysis-chart-status--err { + color: var(--down, #ef4444); +} + .ptd-ind-stack { flex: 1; min-height: 0; diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index d56e835..b5b6763 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -2805,6 +2805,59 @@ gap: 12px; width: 100%; } + +.ptd-center-head--chart { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 10px; +} + +.ptd-center-head--chart .ptd-tabs--center { + justify-self: start; +} + +.ptd-center-head--chart .ptd-center-head-center { + justify-self: center; + min-width: 0; +} + +.ptd-center-head--chart .bps-center-head-title { + justify-self: end; +} + +.ptd-chart-market-select-wrap { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.ptd-chart-market-select { + min-width: min(220px, 42vw); + max-width: 280px; + padding: 5px 28px 5px 10px; + border-radius: 6px; + border: 1px solid var(--se-border, var(--border)); + background: var(--gc-surface-2, rgba(255, 255, 255, 0.06)); + color: var(--text); + font-size: 12px; + font-weight: 600; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%2394a3b8' d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} + +.ptd-chart-market-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ptd-chart-market-select:focus { + outline: none; + border-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 55%, var(--border)); +} .ptd-tabs--center { flex-shrink: 0; border-bottom: none; diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index acda357..6c6533c 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -264,6 +264,9 @@ export class ChartManager { /** 알림 목록 미니 차트 등 고정 높이 — pane 최소 px·비율 완화 */ private _compactPaneLayout = false; + /** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */ + private _paneAreaRatio: { candle: number; aux: number } | null = null; + constructor( container: HTMLElement, theme: Theme, @@ -2054,10 +2057,6 @@ export class ChartManager { private _viewportLwcHooked = false; private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); }; - /** 캔들 pane 시간축 — 드래그 패닝 중 transform 동기화용 */ - private _candleAxisPanActive = false; - private _candleAxisPanOffsetPx = 0; - private _notifyViewport(): void { for (const cb of this._viewportListeners) { try { cb(); } catch { /* ok */ } @@ -2096,29 +2095,6 @@ export class ChartManager { this._notifyViewport(); } - /** 캔들 pane 시간축 드래그 패닝 종료 — 라벨 위치를 최종 논리 범위로 재정렬 */ - endCandleAxisPan(): void { - if (!this._candleAxisPanActive) return; - this._candleAxisPanActive = false; - this._candleAxisPanOffsetPx = 0; - this._notifyViewport(); - } - - isCandleAxisPanActive(): boolean { - return this._candleAxisPanActive; - } - - getCandleAxisPanOffsetPx(): number { - return this._candleAxisPanOffsetPx; - } - - private _ensureCandleAxisPan(): void { - if (this._candleAxisPanActive) return; - this._candleAxisPanActive = true; - this._candleAxisPanOffsetPx = 0; - this._notifyViewport(); - } - /** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */ private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number { const span = lr.to - lr.from; @@ -3040,9 +3016,6 @@ export class ChartManager { options?: { allowVerticalPan?: boolean }, ): void { if (deltaX !== 0) { - this._ensureCandleAxisPan(); - this._candleAxisPanOffsetPx += deltaX; - const ts = this.chart.timeScale(); const lr = ts.getVisibleLogicalRange(); if (lr) { @@ -3272,6 +3245,11 @@ export class ChartManager { return this.rawBars.length; } + /** 현재 차트에 로드된 캔들 스냅샷 (과거 추가 로드·백테스트 재계산용) */ + getRawBars(): OHLCVBar[] { + return this.rawBars.slice(); + } + /** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */ countBarsBetweenTime(t0: number, t1: number): number { const lo = Math.min(t0, t1); @@ -3296,15 +3274,15 @@ export class ChartManager { * 사용자가 보고 있던 구간이 유지되도록 합니다. * - 모든 보조지표를 전체 데이터 기준으로 재계산합니다. */ - async prependBars(olderBars: OHLCVBar[]): Promise { - if (!this.mainSeries || olderBars.length === 0) return; + async prependBars(olderBars: OHLCVBar[]): Promise { + if (!this.mainSeries || olderBars.length === 0) return 0; const firstExistingTime = this.rawBars[0]?.time as number | undefined; const newBars = firstExistingTime !== undefined ? olderBars.filter(b => (b.time as number) < firstExistingTime) : olderBars; - if (newBars.length === 0) return; + if (newBars.length === 0) return 0; // 현재 가시 논리 범위 저장 (prepend 후 복원용) const logicalRange = this.chart.timeScale().getVisibleLogicalRange(); @@ -3349,6 +3327,7 @@ export class ChartManager { // 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요) await this._refreshAllIndicatorsData(); + return newBars.length; } /** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */ @@ -3628,6 +3607,21 @@ export class ChartManager { * * @param availableHeight 외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용. */ + /** + * 캔들 영역 vs 보조지표 영역 높이 비율 (예: 보조 1개 → 2:1, 2~5개 → 1:1, 6+ → 1:2) + * auxWeight 가 0 이면 비율 해제. + */ + setPaneAreaRatio(candleWeight: number, auxWeight: number): void { + if (auxWeight <= 0) { + this._paneAreaRatio = null; + } else { + this._paneAreaRatio = { + candle: Math.max(0.1, candleWeight), + aux: Math.max(0.1, auxWeight), + }; + } + } + private _volumeFrac(H: number): number { if (!this._volumeVisible || this._candleOnlyLayout) return 0; return Math.min(Math.max(60 / H, 0.04), 0.12); @@ -3750,7 +3744,15 @@ export class ChartManager { ? this._lastLayoutAvailableHeight : this.container.clientHeight); const N = this._activeIndicatorPaneIndices().size; - const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H), H); + const volFrac = this._volumeFrac(H); + let mainFrac: number; + if (this._paneAreaRatio && N > 0) { + const total = this._paneAreaRatio.candle + this._paneAreaRatio.aux; + const candleGroupFrac = this._paneAreaRatio.candle / total; + mainFrac = Math.max(0.12, candleGroupFrac - volFrac); + } else { + mainFrac = this._paneHeightFractions(N, volFrac, H).mainFrac; + } return this._applyPaneStretchFactors(mainFrac, availableHeight); } diff --git a/frontend/src/utils/strategyOscillatorSeries.ts b/frontend/src/utils/strategyOscillatorSeries.ts new file mode 100644 index 0000000..1a21afa --- /dev/null +++ b/frontend/src/utils/strategyOscillatorSeries.ts @@ -0,0 +1,209 @@ +import type { OHLCVBar } from '../types'; +import type { StrategyDto } from './backendApi'; +import { extractVirtualConditions } from './virtualStrategyConditions'; +import { formatIndicatorDisplayLabel } from './indicatorRegistry'; + +const DSL_TO_REGISTRY: Record = { + RSI: 'RSI', + MACD: 'MACD', + MA: 'SMA', + EMA: 'EMA', + BOLLINGER: 'BollingerBands', + STOCHASTIC: 'Stochastic', + WILLIAMS_R: 'WilliamsPercentRange', + CCI: 'CCI', + ADX: 'ADX', + DMI: 'DMI', + OBV: 'OBV', + TRIX: 'TRIX', + VOLUME_OSC: 'VolumeOscillator', + VR: 'VR', + DISPARITY: 'Disparity', + PSYCHOLOGICAL: 'Psychological', + NEW_PSYCHOLOGICAL: 'NewPsychological', + INVEST_PSYCHOLOGICAL: 'InvestPsychological', + ICHIMOKU: 'IchimokuCloud', + ATR: 'ATR', + MFI: 'MFI', +}; + +/** 메인 캔들 pane 오버레이 — 하단 스파크 pane 제외 */ +const OVERLAY_DSL = new Set([ + 'MA', 'EMA', 'BOLLINGER', 'ICHIMOKU', 'DONCHIAN', 'NEW_HIGH', 'NEW_LOW', 'DISPARITY', +]); + +export interface OscillatorPaneSpec { + key: string; + registryType: string; + label: string; + period?: number; +} + +export interface OscillatorPaneData { + spec: OscillatorPaneSpec; + values: number[]; + refLines?: number[]; + color: string; +} + +const PANE_COLORS: Record = { + RSI: '#a78bfa', + MACD: '#38bdf8', + CCI: '#fbbf24', + Stochastic: '#f472b6', + WilliamsPercentRange: '#c084fc', + ADX: '#34d399', + MFI: '#fb923c', + OBV: '#94a3b8', + TRIX: '#22d3ee', + VolumeOscillator: '#86efac', + VR: '#fcd34d', + Psychological: '#e879f9', + NewPsychological: '#e879f9', + InvestPsychological: '#e879f9', +}; + +function computeRsi(closes: number[], period = 14): number[] { + if (closes.length < period + 1) return []; + const out: number[] = []; + let avgGain = 0; + let avgLoss = 0; + for (let i = 1; i <= period; i++) { + const d = closes[i] - closes[i - 1]; + if (d >= 0) avgGain += d; else avgLoss -= d; + } + avgGain /= period; + avgLoss /= period; + for (let i = period; i < closes.length; i++) { + const d = closes[i] - closes[i - 1]; + const gain = d > 0 ? d : 0; + const loss = d < 0 ? -d : 0; + avgGain = (avgGain * (period - 1) + gain) / period; + avgLoss = (avgLoss * (period - 1) + loss) / period; + const rs = avgLoss === 0 ? 100 : avgGain / avgLoss; + out.push(100 - 100 / (1 + rs)); + } + return out; +} + +function computeMacd(closes: number[]): number[] { + const ema = (arr: number[], p: number) => { + const k = 2 / (p + 1); + let v = arr[0]; + return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k)))); + }; + const e12 = ema(closes, 12); + const e26 = ema(closes, 26); + return e12.map((v, i) => v - e26[i]); +} + +function computeCci(bars: OHLCVBar[], period = 20): number[] { + const tp = bars.map(b => (b.high + b.low + b.close) / 3); + const out: number[] = []; + for (let i = period; i < tp.length; i++) { + const slice = tp.slice(i - period, i); + const sma = slice.reduce((a, b) => a + b, 0) / slice.length; + const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length; + out.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md)); + } + return out; +} + +function computeSeries(bars: OHLCVBar[], registryType: string, period?: number): number[] { + const closes = bars.map(b => b.close); + switch (registryType) { + case 'RSI': + return computeRsi(closes, period ?? 14); + case 'MACD': + return computeMacd(closes); + case 'CCI': + return computeCci(bars, period ?? 20); + default: + return computeRsi(closes, 14); + } +} + +function refLinesFor(registryType: string): number[] | undefined { + if (registryType === 'RSI') return [30, 50, 70]; + if (registryType === 'CCI') return [-100, 0, 100]; + if (registryType === 'MACD') return [0]; + return undefined; +} + +export function collectStrategyOscillatorPanes(strategy: StrategyDto | undefined): OscillatorPaneSpec[] { + if (!strategy) return []; + const rows = extractVirtualConditions(strategy); + const seen = new Set(); + const out: OscillatorPaneSpec[] = []; + for (const row of rows) { + if (OVERLAY_DSL.has(row.indicatorType)) continue; + const registryType = DSL_TO_REGISTRY[row.indicatorType] ?? row.indicatorType; + if (OVERLAY_DSL.has(registryType) || registryType === 'SMA' || registryType === 'EMA' + || registryType === 'BollingerBands' || registryType === 'IchimokuCloud') { + continue; + } + if (seen.has(registryType)) continue; + seen.add(registryType); + out.push({ + key: registryType, + registryType, + label: formatIndicatorDisplayLabel(row.indicatorType), + period: undefined, + }); + } + return out; +} + +export function buildOscillatorPaneData( + bars: OHLCVBar[], + specs: OscillatorPaneSpec[], +): OscillatorPaneData[] { + if (bars.length < 20) return []; + const out: OscillatorPaneData[] = []; + for (const spec of specs) { + const values = computeSeries(bars, spec.registryType, spec.period).slice(-80); + if (values.length < 2) continue; + out.push({ + spec, + values, + refLines: refLinesFor(spec.registryType), + color: PANE_COLORS[spec.registryType] ?? '#94a3b8', + }); + } + return out; +} + +export function defaultOscillatorPaneSpecs(): OscillatorPaneSpec[] { + return [ + { key: 'RSI', registryType: 'RSI', label: 'RSI (14)', period: 14 }, + { key: 'MACD', registryType: 'MACD', label: 'MACD (12, 26, 9)' }, + ]; +} + +/** StrategyOscillatorPanes 에 실제로 그려지는 보조 pane 개수 */ +export function countAuxiliaryOscillatorPaneCount(strategy: StrategyDto | undefined): number { + const specs = collectStrategyOscillatorPanes(strategy); + return (specs.length > 0 ? specs : defaultOscillatorPaneSpecs()).length; +} + +/** TradingChart 보조 pane(비오버레이 지표) 개수 */ +export function countNonOverlayIndicatorPanes( + indicators: Array<{ type: string }>, + isOverlay: (type: string) => boolean, +): number { + return indicators.filter(ind => !isOverlay(ind.type)).length; +} + +/** + * 캔들 vs 보조지표 영역 flex 비율 + * - 보조 1개: 2:1 + * - 보조 2~5개: 1:1 + * - 보조 6개+: 1:2 + */ +export function chartPaneFlexRatio(auxPaneCount: number): { candle: number; aux: number } { + const n = Math.max(0, auxPaneCount); + if (n === 0) return { candle: 1, aux: 0 }; + if (n === 1) return { candle: 2, aux: 1 }; + if (n <= 5) return { candle: 1, aux: 1 }; + return { candle: 1, aux: 2 }; +} diff --git a/frontend/src/utils/virtualTargetNames.ts b/frontend/src/utils/virtualTargetNames.ts index b87d75a..2dd8c8e 100644 --- a/frontend/src/utils/virtualTargetNames.ts +++ b/frontend/src/utils/virtualTargetNames.ts @@ -23,3 +23,13 @@ export function resolveVirtualTargetNames( : getKoreanName(market); return { koreanName: ko, englishName: en }; } + +/** 투자대상·차트 종목 선택 — 한글명(영문명) */ +export function formatVirtualTargetOptionLabel( + market: string, + koreanName?: string | null, + englishName?: string | null, +): string { + const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(market, koreanName, englishName); + return `${ko}(${en})`; +}