diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java b/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java index 1d4e247..1ea9510 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java @@ -32,9 +32,15 @@ public class BacktestRequest { /** 매도 조건 DSL */ private JsonNode sellCondition; - /** OHLCV 캔들 데이터 */ + /** OHLCV 캔들 데이터 (선행 워밍업 봉 + 평가 구간, 시간 오름차순) */ private List bars; + /** + * 시그널·통계 평가 대상 봉 수 (bars 의 마지막 N개). + * 앞쪽 봉은 MA·RSI 등 지표 워밍업 전용. null 이면 전체 bars 를 평가한다. + */ + private Integer evaluationBarCount; + /** 타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M) */ private String timeframe; diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index c3f68a2..cd29f68 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -117,7 +117,21 @@ public class BacktestingService { : new BooleanRule(false); Rule exitRule = buildExitRule(baseExitRule, series, cfg); - return runBacktest(series, entryRule, exitRule, req, cfg, strategyName, buyDsl, sellDsl, params); + int evalStart = resolveEvalStartIndex(req, n); + if (evalStart > 0) { + log.info("[Backtest] 지표 워밍업 {}봉, 평가 구간 {}봉 (index {}..{})", + evalStart, n - evalStart, evalStart, n - 1); + } + + return runBacktest(series, entryRule, exitRule, req, cfg, strategyName, buyDsl, sellDsl, params, evalStart); + } + + /** bars 앞쪽 워밍업 구간을 건너뛰고 마지막 evaluationBarCount 봉만 평가 */ + private static int resolveEvalStartIndex(BacktestRequest req, int totalBars) { + if (req.getEvaluationBarCount() == null || totalBars <= 0) return 0; + int evalCount = req.getEvaluationBarCount(); + if (evalCount <= 0 || evalCount >= totalBars) return 0; + return totalBars - evalCount; } // ── 청산 규칙 합성 ──────────────────────────────────────────────────────── @@ -147,7 +161,8 @@ public class BacktestingService { BacktestRequest req, BacktestSettingsDto cfg, String strategyName, JsonNode execBuyDsl, JsonNode execSellDsl, - Map> execParams) { + Map> execParams, + int evalStartIndex) { BaseTradingRecord record = new BaseTradingRecord(); @@ -177,8 +192,9 @@ public class BacktestingService { final double[] simGrossLoss = {0.0}; int barCount = series.getBarCount(); + int loopStart = Math.max(0, Math.min(evalStartIndex, barCount)); - for (int i = 0; i < barCount; i++) { + for (int i = loopStart; i < barCount; i++) { double closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType()); double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); long time = barStartEpoch(series, i); @@ -611,7 +627,9 @@ public class BacktestingService { Map> execParams) { try { List bars = req.getBars(); - long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime(); + int evalStart = resolveEvalStartIndex(req, bars.size()); + int evalCount = bars.isEmpty() ? 0 : bars.size() - evalStart; + long fromTime = bars.isEmpty() ? 0 : bars.get(evalStart).getTime(); long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime(); Map snapshot = new LinkedHashMap<>(); @@ -620,7 +638,14 @@ public class BacktestingService { snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN"); String execTf = normalizeTf(req.getTimeframe()); snapshot.put("timeframe", execTf); - snapshot.put("barCount", bars.size()); + snapshot.put("barCount", evalCount); + if (evalStart > 0) { + snapshot.put("warmupBarCount", evalStart); + snapshot.put("totalBarsFetched", bars.size()); + } + if (req.getEvaluationBarCount() != null) { + snapshot.put("evaluationBarCount", req.getEvaluationBarCount()); + } if (execParams != null && !execParams.isEmpty()) snapshot.put("indicatorParams", execParams); if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl); if (execSellDsl != null && !execSellDsl.isNull()) snapshot.put("sellCondition", execSellDsl); @@ -631,7 +656,7 @@ public class BacktestingService { .strategyName(strategyName) .symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN") .timeframe(execTf) - .barCount(bars.size()) + .barCount(evalCount) .fromTime(fromTime) .toTime(toTime) .settingsJson(objectMapper.writeValueAsString(cfg)) diff --git a/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java b/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java index cca16cf..5d00499 100644 --- a/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java +++ b/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java @@ -45,6 +45,9 @@ public class HistoricalDataService { @Value("${upbit.api.candle-limit:200}") private int candleLimit; + /** 백테스트·차트 워밍업 포함 최대 history 요청 봉 수 */ + private static final int MAX_HISTORY_FETCH = 800; + @Value("${upbit.api.request-delay-ms:110}") private long requestDelayMs; @@ -66,7 +69,7 @@ public class HistoricalDataService { * @return CandleBarDto 리스트 (시간 오름차순) */ public List getHistory(String market, String candleType, String to, int count) { - int safeCount = Math.min(count, candleLimit); + int safeCount = Math.min(Math.max(count, 1), MAX_HISTORY_FETCH); // ── 1. to 시간 파싱 ─────────────────────────────────────────────────── ZonedDateTime toTime = parseToTime(to); @@ -81,14 +84,14 @@ public class HistoricalDataService { rawBars = sliceFromMemory(market, candleType, toTime, safeCount); } - // 인메모리 봉이 최소 임계치(20개) 미만이면 Upbit REST 로 재조회. - // 백엔드 재시작 직후 warm-up 미완료 상태에서 2~3개 봉만 반환되는 - // race-condition 을 방지한다. + // 인메모리 봉이 요청 수 미만이면 Upbit REST 로 보충 (백테스트 200+워밍업 등) final int MIN_BARS_THRESHOLD = 20; - if (rawBars.size() < Math.min(MIN_BARS_THRESHOLD, safeCount)) { - log.info("[HistoricalDataService] 인메모리 봉 부족({}) → 업비트 REST 프록시: {} / {}", - rawBars.size(), market, candleType); - List upbitBars = fetchFromUpbit(market, candleType, to, safeCount); + if (rawBars.size() < safeCount + && (rawBars.size() < Math.min(MIN_BARS_THRESHOLD, safeCount) || safeCount > candleLimit)) { + log.info("[HistoricalDataService] 봉 부족({}/{}) → 업비트 REST: {} / {}", + rawBars.size(), safeCount, market, candleType); + String toParam = to != null && !to.isBlank() ? to : null; + List upbitBars = fetchFromUpbit(market, candleType, toParam, safeCount); if (upbitBars.size() > rawBars.size()) { rawBars = upbitBars; } diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index f2859e3..3881d67 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -15,14 +15,13 @@ import { runBacktest, loadBacktestSettings, loadIndicatorSettings, - API_BASE, type BacktestResultRecord, type BacktestAnalysis, type BacktestSignal, type StrategyDto, } from '../utils/backendApi'; -import { timeframeToCandleType } from '../utils/chartCandleType'; import type { Theme, Timeframe } from '../types'; +import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; import { buildEquityFromSignals } from '../utils/backtestEquity'; import { formatChartTypeKo, @@ -30,6 +29,7 @@ import { normalizeEpochSec, normalizeEpochSecOptional, resolveBacktestRecordExecTimeframe, + resolveBacktestChartBarCount, toUpbitMarket, } from '../utils/backtestUiUtils'; import BacktestExecutionList, { type ExecutionListTab } from './backtest/BacktestExecutionList'; @@ -272,19 +272,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { ? resolveStrategyPrimaryTimeframe(strategy) : '1m'; const execTimeframe = resolveBacktestExecTimeframe(runTimeframe, strategyTimeframe); - const [settings, indicatorParams, barsRes] = await Promise.all([ + const [settings, indicatorParams] = await Promise.all([ loadBacktestSettings(), loadIndicatorSettings(), - fetch(`${API_BASE}/candles/history?${new URLSearchParams({ - market: runMarket, - type: timeframeToCandleType(execTimeframe), - count: '800', - })}`), ]); - if (!barsRes.ok) throw new Error(`캔들 로딩 실패 (${barsRes.status})`); - const bars = (await barsRes.json()) as Array<{ - time: number; open: number; high: number; low: number; close: number; volume: number; - }>; + const candleBundle = await fetchBacktestCandleBundle({ + market: runMarket, + timeframe: execTimeframe as Timeframe, + buyDsl: strategy?.buyCondition, + sellDsl: strategy?.sellCondition, + indicatorParams, + }); + const { bars, evaluationBarCount } = candleBundle; if (bars.length < 5) { throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.'); } @@ -296,6 +295,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { indicatorParams, symbol: runMarket, strategyName: strategy?.name, + evaluationBarCount, }); if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); const list = await loadBacktestResults(); @@ -360,7 +360,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { timeframe: resolveBacktestRecordExecTimeframe(selectedBacktest), toTimeSec: toSec, fromTimeSec: fromSec > 0 ? fromSec : undefined, - barCount: selectedBacktest.barCount || 300, + barCount: resolveBacktestChartBarCount(selectedBacktest), strategyId: selectedBacktest.strategyId, }; } diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index f9d6a8b..7eb9926 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -5,9 +5,9 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { ReactFlowProvider } from '@xyflow/react'; import DraggableModalFrame from './DraggableModalFrame'; import type { Theme } from '../types/index'; -import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, API_BASE, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; -import { timeframeToCandleType } from '../utils/chartCandleType'; +import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; +import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import type { LogicNode } from '../utils/strategyTypes'; import { @@ -1178,19 +1178,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop { buy: buyEditorState, sell: sellEditorState }, ); const execTimeframe = resolveBacktestExecTimeframe(qbTimeframe, strategyTimeframe); - const [settings, indicatorParams, barsRes] = await Promise.all([ + const [settings, indicatorParams] = await Promise.all([ loadBacktestSettings(), loadIndicatorSettings(), - fetch(`${API_BASE}/candles/history?${new URLSearchParams({ - market: qbMarket, - type: timeframeToCandleType(execTimeframe), - count: '800', - })}`), ]); - if (!barsRes.ok) throw new Error(`캔들 로딩 실패 (${barsRes.status})`); - const bars = (await barsRes.json()) as Array<{ - time: number; open: number; high: number; low: number; close: number; volume: number; - }>; + const { bars, evaluationBarCount } = await fetchBacktestCandleBundle({ + market: qbMarket, + timeframe: execTimeframe as import('../types').Timeframe, + buyDsl: selectedStrategy?.buyCondition, + sellDsl: selectedStrategy?.sellCondition, + indicatorParams, + }); if (bars.length < 5) { throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.'); } @@ -1202,6 +1200,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop indicatorParams, symbol: qbMarket, strategyName: selectedStrategy?.name, + evaluationBarCount, }); if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); if (result.resultId) { diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index 4e99d29..de95873 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -14,7 +14,7 @@ import { } from '../utils/backendApi'; import { prefetchNotificationStrategies, resolveNotificationStrategy } from '../utils/resolveNotificationStrategy'; import { normalizeMarketCode } from '../utils/strategyHydrate'; -import { loadAnalysisCandles } from '../utils/analysisChartData'; +import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators'; import { normalizeEpochSec } from '../utils/backtestUiUtils'; @@ -70,7 +70,6 @@ import { getKoreanName } from '../utils/marketNameCache'; type RightTab = 'trade' | 'orderbook'; const TNL_RIGHT_OPEN_KEY = 'tnl-right-open'; -const REPORT_BAR_COUNT = 300; const REPORT_INDICATOR_TYPES = [ 'RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological', ] as const; @@ -298,7 +297,17 @@ export const TradeNotificationListPage: React.FC = ({ const market = normalizeMarketCode(item.market); const timeframe = candleTypeToTimeframe(item.candleType ?? '1m'); const toTimeSec = normalizeEpochSec(item.candleTime); - const bars = await loadAnalysisCandles(market, timeframe, toTimeSec, REPORT_BAR_COUNT); + const indicatorParams = Object.fromEntries( + REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record]), + ); + const { bars, evaluationBarCount } = await fetchBacktestCandleBundle({ + market, + timeframe, + toTimeSec, + buyDsl: strategy.buyCondition, + sellDsl: strategy.sellCondition, + indicatorParams, + }); if (gen !== reportGenRef.current) return; if (bars.length < 10) { window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.'); @@ -321,9 +330,8 @@ export const TradeNotificationListPage: React.FC = ({ symbol: market, strategyName, settings: btSettings, - indicatorParams: Object.fromEntries( - REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record]), - ), + indicatorParams, + evaluationBarCount, }); if (gen !== reportGenRef.current) return; if (!res) { diff --git a/frontend/src/components/paper/PaperAnalysisChart.tsx b/frontend/src/components/paper/PaperAnalysisChart.tsx index 113e201..24cadeb 100644 --- a/frontend/src/components/paper/PaperAnalysisChart.tsx +++ b/frontend/src/components/paper/PaperAnalysisChart.tsx @@ -10,6 +10,7 @@ import { import { loadAnalysisCandles } from '../../utils/analysisChartData'; import { normalizeChartTimeframe } from '../../utils/backtestUiUtils'; import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; +import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup'; import type { OHLCVBar, Theme } from '../../types'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { @@ -83,6 +84,18 @@ const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark setError(null); try { const btSettings = await loadBacktestSettings(); + const indicatorParams = Object.fromEntries( + ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ + t, + getParams(t) as Record, + ]), + ); + const { evaluationBarCount } = resolveEvaluationFromLoadedBars( + bars.length, + strat.buyCondition, + strat.sellCondition, + indicatorParams, + ); const res = await runBacktest({ strategyId: sid, bars: bars.map(b => ({ @@ -97,12 +110,8 @@ const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark symbol: sym, strategyName: strat.name, settings: btSettings, - indicatorParams: Object.fromEntries( - ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ - t, - getParams(t) as Record, - ]), - ), + indicatorParams, + evaluationBarCount, }); if (gen !== backtestGenRef.current) return; if (!res) { diff --git a/frontend/src/hooks/useBacktest.ts b/frontend/src/hooks/useBacktest.ts index d7340ee..341a4b4 100644 --- a/frontend/src/hooks/useBacktest.ts +++ b/frontend/src/hooks/useBacktest.ts @@ -52,6 +52,7 @@ export function useBacktest() { settings?: BacktestSettingsDto; symbol?: string; strategyName?: string; + evaluationBarCount?: number; }) => { const runGen = runGenRef.current; setRunning(true); @@ -74,6 +75,7 @@ export function useBacktest() { settings: opts.settings, symbol: opts.symbol, strategyName: opts.strategyName, + evaluationBarCount: opts.evaluationBarCount, }; const res = await runBacktest(req); if (runGen !== runGenRef.current) return null; diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index df2b470..d11268b 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1317,6 +1317,8 @@ export interface BacktestRequest { symbol?: string; strategyName?: string; deviceId?: string; + /** bars 마지막 N봉만 시그널 평가 (앞쪽은 지표 워밍업) */ + evaluationBarCount?: number; } /** 백테스팅 실행 */ diff --git a/frontend/src/utils/backtestUiUtils.ts b/frontend/src/utils/backtestUiUtils.ts index c986245..5e78de2 100644 --- a/frontend/src/utils/backtestUiUtils.ts +++ b/frontend/src/utils/backtestUiUtils.ts @@ -1,6 +1,7 @@ import type { ChartType, Timeframe } from '../types'; import type { BacktestResultRecord } from './backendApi'; import { formatUpbitKrwPrice } from './safeFormat'; +import { INDICATOR_WARMUP } from './upbitApi'; export function toUpbitMarket(symbol: string): string { return symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`; @@ -40,6 +41,21 @@ export function resolveBacktestRecordExecTimeframe(record: BacktestResultRecord) return '1m'; } +/** 백테스트 결과 차트 로드 봉 수 — 평가 구간 + 지표 워밍업 (차트 SMA와 백테스트 엔진 정렬) */ +export function resolveBacktestChartBarCount(record: BacktestResultRecord): number { + const evalCount = record.barCount > 0 ? record.barCount : 200; + let warmup = INDICATOR_WARMUP; + if (record.executionSnapshotJson) { + try { + const snap = JSON.parse(record.executionSnapshotJson) as { warmupBarCount?: number }; + if (typeof snap.warmupBarCount === 'number' && snap.warmupBarCount > 0) { + warmup = snap.warmupBarCount; + } + } catch { /* ignore */ } + } + return evalCount + warmup; +} + const CHART_TYPE_KO: Record = { candlestick: '캔들스틱', bar: '바', diff --git a/frontend/src/utils/backtestWarmup.ts b/frontend/src/utils/backtestWarmup.ts new file mode 100644 index 0000000..5d14650 --- /dev/null +++ b/frontend/src/utils/backtestWarmup.ts @@ -0,0 +1,212 @@ +/** + * 백테스팅 워밍업 — 차트(표시 200 + 워밍업)와 동일하게 지표 수렴 후 평가 구간만 시그널 생성 + */ +import type { OHLCVBar, Timeframe } from '../types'; +import { API_BASE } from './backendApi'; +import { timeframeToCandleType } from './chartCandleType'; +import { toHistoryApiIso } from './analysisChartData'; +import { INDICATOR_WARMUP } from './upbitApi'; + +/** 차트 초기 표시·백테스트 평가 구간 기본 봉 수 (워밍업 제외) */ +export const BACKTEST_DISPLAY_BAR_COUNT = 200; + +const HISTORY_PAGE_SIZE = 200; +const MAX_BACKTEST_FETCH = 800; + +const MA_FIELD_RE = /^MA(\d+)$/; +const EMA_FIELD_RE = /^EMA(\d+)$/; + +function collectPeriodFromField(field: unknown, out: Set): void { + if (typeof field !== 'string') return; + const ma = MA_FIELD_RE.exec(field); + if (ma) { + out.add(parseInt(ma[1], 10)); + return; + } + const ema = EMA_FIELD_RE.exec(field); + if (ema) out.add(parseInt(ema[1], 10)); +} + +/** 전략 DSL 트리에서 MA/EMA 기간 추출 */ +function walkStrategyDsl(node: unknown, out: Set): void { + if (!node || typeof node !== 'object') return; + const n = node as Record; + + const cond = n.condition; + if (cond && typeof cond === 'object') { + const c = cond as Record; + collectPeriodFromField(c.leftField, out); + collectPeriodFromField(c.rightField, out); + const p = Number(c.period ?? c.leftPeriod ?? c.rightPeriod); + if (Number.isFinite(p) && p > 0) out.add(Math.floor(p)); + } + + for (const ch of (n.children as unknown[]) ?? []) walkStrategyDsl(ch, out); +} + +function maxFromIndicatorParams( + params: Record> | undefined, +): number { + if (!params) return 0; + let max = 0; + + const sma = params.SMA ?? params.sma; + if (sma) { + for (let i = 1; i <= 11; i++) { + const v = Number(sma[`period${i}`]); + if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v)); + } + } + + const ichimoku = params.IchimokuCloud ?? params.ICHIMOKU; + if (ichimoku) { + for (const key of ['basePeriods', 'laggingSpan2Periods', 'conversionPeriods', 'displacement']) { + const v = Number(ichimoku[key]); + if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v)); + } + } + + const macd = params.MACD ?? params.macd; + if (macd) { + for (const key of ['fastLength', 'slowLength', 'signalLength']) { + const v = Number(macd[key]); + if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v)); + } + const slow = Number(macd.slowLength ?? 26); + const sig = Number(macd.signalLength ?? 9); + if (Number.isFinite(slow) && Number.isFinite(sig)) { + max = Math.max(max, Math.floor(slow + sig)); + } + } + + for (const key of ['RSI', 'CCI', 'ADX', 'Stochastic']) { + const block = params[key]; + if (!block) continue; + const len = Number(block.length ?? block.period); + if (Number.isFinite(len) && len > 0) max = Math.max(max, Math.floor(len)); + } + + return max; +} + +/** + * 전략·지표 설정 기준 워밍업 봉 수. + * 차트 INDICATOR_WARMUP(100)과 동일한 안전 마진을 포함한다. + */ +export function computeBacktestWarmupBars( + buyDsl?: unknown, + sellDsl?: unknown, + indicatorParams?: Record>, +): number { + const periods = new Set(); + walkStrategyDsl(buyDsl, periods); + walkStrategyDsl(sellDsl, periods); + let maxPeriod = periods.size > 0 ? Math.max(...periods) : 0; + maxPeriod = Math.max(maxPeriod, maxFromIndicatorParams(indicatorParams)); + // Ta4j SMA unstable + cross 1봉 여유 + const strategyNeed = maxPeriod > 0 ? maxPeriod + 5 : 0; + return Math.max(INDICATOR_WARMUP, strategyNeed); +} + +/** 백엔드 history API — count>200 페이지네이션 */ +export async function fetchBacktestHistoryCandles( + market: string, + timeframe: Timeframe, + totalCount: number, + toTimeSec?: number, +): Promise { + const type = timeframeToCandleType(timeframe); + const want = Math.min(Math.max(totalCount, 1), MAX_BACKTEST_FETCH); + let remaining = want; + let toIso: string | undefined = toTimeSec && toTimeSec > 0 + ? toHistoryApiIso(toTimeSec) + : undefined; + + const byTime = new Map(); + + while (remaining > 0) { + const batch = Math.min(remaining, HISTORY_PAGE_SIZE); + const params = new URLSearchParams({ + market, + type, + count: String(batch), + }); + if (toIso) params.set('to', toIso); + + const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`); + if (!res.ok) throw new Error(`캔들 로딩 실패 (${res.status})`); + + const raw = (await res.json()) as OHLCVBar[]; + if (raw.length === 0) break; + + for (const b of raw) byTime.set(b.time, b); + remaining -= raw.length; + + if (raw.length < batch) break; + + const oldest = raw[0]?.time; + if (!oldest || oldest <= 0) break; + toIso = toHistoryApiIso(oldest); + + if (remaining > 0) { + await new Promise(r => setTimeout(r, 110)); + } + } + + const sorted = [...byTime.values()].sort((a, b) => a.time - b.time); + if (sorted.length <= want) return sorted; + return sorted.slice(sorted.length - want); +} + +export interface BacktestCandleBundle { + bars: OHLCVBar[]; + evaluationBarCount: number; + warmupBarCount: number; +} + +/** + * 표시 구간(displayCount) + 워밍업을 포함한 캔들 묶음. + * 백엔드 evaluationBarCount 로 마지막 displayCount 봉만 시그널 평가. + */ +export async function fetchBacktestCandleBundle(opts: { + market: string; + timeframe: Timeframe; + displayCount?: number; + buyDsl?: unknown; + sellDsl?: unknown; + indicatorParams?: Record>; + toTimeSec?: number; +}): Promise { + const displayCount = opts.displayCount ?? BACKTEST_DISPLAY_BAR_COUNT; + const warmupBarCount = computeBacktestWarmupBars( + opts.buyDsl, + opts.sellDsl, + opts.indicatorParams, + ); + const total = displayCount + warmupBarCount; + const bars = await fetchBacktestHistoryCandles( + opts.market, + opts.timeframe, + total, + opts.toTimeSec, + ); + const evaluationBarCount = Math.min(displayCount, bars.length); + return { bars, evaluationBarCount, warmupBarCount }; +} + +/** 이미 로드된 캔들(차트 rawBars)에 대한 평가·워밍업 구간 계산 */ +export function resolveEvaluationFromLoadedBars( + barCount: number, + buyDsl?: unknown, + sellDsl?: unknown, + indicatorParams?: Record>, +): { evaluationBarCount: number; warmupBarCount: number } { + const warmupBarCount = computeBacktestWarmupBars(buyDsl, sellDsl, indicatorParams); + if (barCount <= warmupBarCount) { + return { evaluationBarCount: Math.max(1, barCount), warmupBarCount: 0 }; + } + return { + evaluationBarCount: barCount - warmupBarCount, + warmupBarCount, + }; +} diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index 357c544..2d02750 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -1140,6 +1140,7 @@ export interface BacktestRequest { symbol?: string; strategyName?: string; deviceId?: string; + evaluationBarCount?: number; } /** 백테스팅 실행 */