From 24128e6a0a02f25638f4dcca0a140742efd04b47 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 13 Jun 2026 00:00:39 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EB=A7=A4=EB=A7=A4=EC=8B=9C=EA=B7=B8=EB=84=90=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EC=95=88=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEvaluationPage.tsx | 1 + .../StrategyEvaluationChart.tsx | 35 ++++++++------ .../StrategyEvaluationSignalPanel.tsx | 23 +++------ frontend/src/hooks/useStompChartData.ts | 47 ++----------------- frontend/src/styles/strategyEvaluation.css | 29 ++++++------ frontend/src/utils/ChartManager.ts | 5 ++ frontend/src/utils/backtestWarmup.ts | 5 +- 7 files changed, 55 insertions(+), 90 deletions(-) diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index c246a9e..9be27f4 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -424,6 +424,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { onSelectedBarIndexChange={setSelectedBarIndex} onBarsLoaded={setBars} paramsRevision={paramsRevision} + appliedIndicatorParams={appliedIndicatorParams} getParamsOverride={getEvalParams} getVisualConfigOverride={getVisualConfig} evalLoading={evalLoading} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 285c29d..50c0619 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -18,8 +18,8 @@ import { loadBacktestSettings, runBacktest } from '../../utils/backendApi'; import { BACKTEST_DISPLAY_BAR_COUNT, fetchBacktestCandleBundle, - resolveEvaluationFromLoadedBars, } from '../../utils/backtestWarmup'; +import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams'; import { DISPLAY_COUNT } from '../../hooks/useUpbitData'; import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartIndicators'; import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams'; @@ -49,6 +49,8 @@ interface Props { onSelectedBarIndexChange: (index: number) => void; onBarsLoaded?: (bars: OHLCVBar[]) => void; paramsRevision?: number; + /** 사용자가 저장한 지표 파라미터 — null 이면 백테스트도 서버 DB 설정 사용 (live-conditions 와 동일) */ + appliedIndicatorParams?: EvalIndicatorParams | null; getParamsOverride?: ( type: string, defaults?: Record, @@ -94,6 +96,7 @@ const StrategyEvaluationChart: React.FC = ({ onSelectedBarIndexChange, onBarsLoaded, paramsRevision = 0, + appliedIndicatorParams = null, getParamsOverride, getVisualConfigOverride, evalLoading = false, @@ -115,6 +118,7 @@ const StrategyEvaluationChart: React.FC = ({ const marketBtnRef = useRef(null); const managerRef = useRef(null); const backtestGenRef = useRef(0); + const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT); const signalsRef = useRef(signals); signalsRef.current = signals; const chartType: ChartType = 'candlestick'; @@ -135,9 +139,14 @@ const StrategyEvaluationChart: React.FC = ({ return { candle: flex.candle, aux: flex.aux }; }, [auxPaneCount]); + /** 저장된 오버라이드가 있을 때만 백테스트에 전달 — 미설정 시 live-conditions 와 같이 서버 DB 사용 */ const indicatorParams = useMemo( - () => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : {}), - [strategy, getParams], + () => ( + strategy && appliedIndicatorParams + ? buildEvalParamsFromStrategy(strategy, getParams) + : undefined + ), + [strategy, getParams, appliedIndicatorParams], ); const resolveInitialDisplayCount = useCallback( @@ -180,13 +189,6 @@ const StrategyEvaluationChart: React.FC = ({ setBacktestError(null); try { const btSettings = await loadBacktestSettings(); - const { evaluationBarCount } = resolveEvaluationFromLoadedBars( - barData.length, - strategy.buyCondition, - strategy.sellCondition, - indicatorParams, - ); - const evalCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, evaluationBarCount); const res = await runBacktest({ strategyId: strategy.id, bars: barData.map(b => ({ @@ -200,9 +202,13 @@ const StrategyEvaluationChart: React.FC = ({ timeframe, symbol: market, strategyName: strategy.name, - settings: btSettings, + settings: { + ...btSettings, + // 전략 평가 차트 — 조건 충족 시작 봉마다 매수/매도 마커 (포지션 락 무시) + positionMode: 'SIGNAL_ONLY', + }, indicatorParams, - evaluationBarCount: evalCount, + evaluationBarCount: evaluationBarCountRef.current, }); if (gen !== backtestGenRef.current) return; if (!res) { @@ -267,8 +273,9 @@ const StrategyEvaluationChart: React.FC = ({ sellDsl: strategy?.sellCondition, indicatorParams, }) - .then(({ bars: data }) => { + .then(({ bars: data, evaluationBarCount: evalCount }) => { if (cancelled) return; + evaluationBarCountRef.current = evalCount; setBars(data); onBarsLoaded?.(data); if (data.length > 0) { @@ -284,7 +291,7 @@ const StrategyEvaluationChart: React.FC = ({ if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [market, timeframe, strategy?.id, strategy?.buyCondition, strategy?.sellCondition, indicatorParams, onBarsLoaded, onSelectedBarIndexChange]); + }, [market, timeframe, strategy?.id, paramsRevision, onBarsLoaded, onSelectedBarIndexChange]); const scrollToBar = useCallback((barIdx: number) => { const mgr = chartMgr; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx index 3d64be2..e424051 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -25,16 +25,6 @@ interface Props { className?: string; } -const InterpretIcon = () => ( - - - - -); - interface SideCardProps { side: 'buy' | 'sell'; metrics: ConditionMetric[]; @@ -53,9 +43,10 @@ const SideCard: React.FC = ({ barTimeSec, }) => { const [interpretOpen, setInterpretOpen] = useState(false); + const sideLabel = side === 'buy' ? '매수' : '매도'; const title = side === 'buy' ? '매수 조건' : '매도 조건'; const overallLabel = - overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null; + overallMet === true ? '충족' : overallMet === false ? '미충족' : null; const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]); const interpretation = useMemo( @@ -75,7 +66,7 @@ const SideCard: React.FC = ({ >
- {title} + {sideLabel} {overallLabel && ( = ({
diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index 9f95a69..9596ef8 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -9,6 +9,7 @@ import { timeframeToCandleType } from '../utils/chartCandleType'; import { getCandleStart, TF_SECONDS } from '../utils/upbitApi'; import { DISPLAY_COUNT, WARMUP_COUNT, OBV_DEEP_HISTORY } from './useUpbitData'; import type { WsStatus } from './useUpbitData'; +import { fetchBacktestHistoryCandles } from '../utils/backtestWarmup'; import { API_BASE } from '../utils/backendApi'; /** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */ @@ -63,58 +64,16 @@ interface Return { error: string | null; } -async function fetchInitialBars(market: string, candleType: string, count: number): Promise { - const params = new URLSearchParams({ - market, - type: candleType, - count: String(count), - }); - const res = await fetch(`${API_BASE}/candles/history?${params}`); - if (!res.ok) throw new Error(`history ${res.status}`); - const data = (await res.json()) as StompCandlePayload[]; - return data.map(b => ({ - time: b.time, - open: b.open, - high: b.high, - low: b.low, - close: b.close, - volume: b.volume, - })); -} - -async function fetchInitialBarsWithRetry( - market: string, - candleType: string, - count: number, - attempts = 3, -): Promise { - let lastErr: Error | null = null; - for (let i = 0; i < attempts; i++) { - try { - return await fetchInitialBars(market, candleType, count); - } catch (e) { - lastErr = e as Error; - if (i < attempts - 1) { - await new Promise(r => setTimeout(r, 400 * (i + 1))); - } - } - } - throw lastErr ?? new Error('history fetch failed'); -} - /** - * 백엔드 /api/candles/history 에서만 초기 캔들을 로드한다. - * Upbit REST 직접 호출을 하지 않음 — 백엔드가 데이터를 아직 쌓지 못한 경우 - * 빈 배열을 반환하고, 호출부에서 STOMP bootstrap 경로로 처리한다. + * 백엔드 /api/candles/history — 200봉 초과 시 페이지네이션 (워밍업·OBV 누적용) */ async function loadInitialBars( market: string, timeframe: Timeframe, count: number, ): Promise { - const candleType = timeframeToCandleType(timeframe); try { - return await fetchInitialBarsWithRetry(market, candleType, count); + return await fetchBacktestHistoryCandles(market, timeframe, count); } catch { return []; } diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index a03df0f..c129306 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -472,9 +472,11 @@ .seval-side-card-head-main { display: flex; - flex-direction: column; - gap: 4px; + flex-direction: row; + align-items: center; + gap: 8px; min-width: 0; + flex: 1; } .seval-side-card-badge { @@ -1138,38 +1140,37 @@ cursor: not-allowed; } -/* ── 전략 판별 해석 버튼 · 팝업 ─────────────────────────────────────────── */ -.seval-interpret-btn { +/* ── 조건 분석보기 버튼 · 팝업 ─────────────────────────────────────────── */ +.seval-analyze-btn { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; - padding: 0; - border-radius: 8px; + height: 26px; + padding: 0 10px; + border-radius: 6px; border: 1px solid color-mix(in srgb, var(--se-border) 85%, transparent); background: color-mix(in srgb, var(--se-input-bg, #121826) 92%, transparent); color: var(--se-text-muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; cursor: pointer; transition: color 0.15s, border-color 0.15s, background 0.15s; } -.seval-interpret-btn:hover:not(:disabled) { +.seval-analyze-btn:hover:not(:disabled) { color: var(--se-accent, #3f7ef5); border-color: color-mix(in srgb, var(--se-accent, #3f7ef5) 45%, transparent); background: color-mix(in srgb, var(--se-accent, #3f7ef5) 10%, transparent); } -.seval-interpret-btn:disabled { +.seval-analyze-btn:disabled { opacity: 0.35; cursor: not-allowed; } -.seval-interpret-icon { - display: block; -} - .seval-interpret-overlay { z-index: 10999; } diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index f6c9f08..42134d7 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -531,6 +531,11 @@ export class ChartManager { if (this._auxIndicatorOnlyLayout) { this.syncChartOverlayVisibility(); } + + // setData 시 마커 플러그인만 dispose 되고 backtestMarkers 배열은 유지됨 — 재적용 + if (this.backtestMarkers.length > 0 || this.liveStrategyMarkers.length > 0) { + this._reapplyAllPatternMarkers(); + } } /** diff --git a/frontend/src/utils/backtestWarmup.ts b/frontend/src/utils/backtestWarmup.ts index 5d14650..244ec17 100644 --- a/frontend/src/utils/backtestWarmup.ts +++ b/frontend/src/utils/backtestWarmup.ts @@ -139,10 +139,11 @@ export async function fetchBacktestHistoryCandles( const raw = (await res.json()) as OHLCVBar[]; if (raw.length === 0) break; + const before = byTime.size; for (const b of raw) byTime.set(b.time, b); - remaining -= raw.length; + if (byTime.size === before) break; - if (raw.length < batch) break; + remaining -= raw.length; const oldest = raw[0]?.time; if (!oldest || oldest <= 0) break;