백테스팅 워밍업데이터 적용

This commit is contained in:
Macbook
2026-06-12 01:41:34 +09:00
parent 0d338fffe4
commit 74b0ea4ab6
12 changed files with 335 additions and 52 deletions
@@ -32,9 +32,15 @@ public class BacktestRequest {
/** 매도 조건 DSL */ /** 매도 조건 DSL */
private JsonNode sellCondition; private JsonNode sellCondition;
/** OHLCV 캔들 데이터 */ /** OHLCV 캔들 데이터 (선행 워밍업 봉 + 평가 구간, 시간 오름차순) */
private List<OhlcvBar> bars; private List<OhlcvBar> bars;
/**
* 시그널·통계 평가 대상 봉 수 (bars 의 마지막 N개).
* 앞쪽 봉은 MA·RSI 등 지표 워밍업 전용. null 이면 전체 bars 를 평가한다.
*/
private Integer evaluationBarCount;
/** 타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M) */ /** 타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M) */
private String timeframe; private String timeframe;
@@ -117,7 +117,21 @@ public class BacktestingService {
: new BooleanRule(false); : new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg); 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, BacktestRequest req, BacktestSettingsDto cfg,
String strategyName, String strategyName,
JsonNode execBuyDsl, JsonNode execSellDsl, JsonNode execBuyDsl, JsonNode execSellDsl,
Map<String, Map<String, Object>> execParams) { Map<String, Map<String, Object>> execParams,
int evalStartIndex) {
BaseTradingRecord record = new BaseTradingRecord(); BaseTradingRecord record = new BaseTradingRecord();
@@ -177,8 +192,9 @@ public class BacktestingService {
final double[] simGrossLoss = {0.0}; final double[] simGrossLoss = {0.0};
int barCount = series.getBarCount(); 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 closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType());
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
long time = barStartEpoch(series, i); long time = barStartEpoch(series, i);
@@ -611,7 +627,9 @@ public class BacktestingService {
Map<String, Map<String, Object>> execParams) { Map<String, Map<String, Object>> execParams) {
try { try {
List<OhlcvBar> bars = req.getBars(); List<OhlcvBar> 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(); long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime();
Map<String, Object> snapshot = new LinkedHashMap<>(); Map<String, Object> snapshot = new LinkedHashMap<>();
@@ -620,7 +638,14 @@ public class BacktestingService {
snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN"); snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN");
String execTf = normalizeTf(req.getTimeframe()); String execTf = normalizeTf(req.getTimeframe());
snapshot.put("timeframe", execTf); 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 (execParams != null && !execParams.isEmpty()) snapshot.put("indicatorParams", execParams);
if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl); if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl);
if (execSellDsl != null && !execSellDsl.isNull()) snapshot.put("sellCondition", execSellDsl); if (execSellDsl != null && !execSellDsl.isNull()) snapshot.put("sellCondition", execSellDsl);
@@ -631,7 +656,7 @@ public class BacktestingService {
.strategyName(strategyName) .strategyName(strategyName)
.symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN") .symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN")
.timeframe(execTf) .timeframe(execTf)
.barCount(bars.size()) .barCount(evalCount)
.fromTime(fromTime) .fromTime(fromTime)
.toTime(toTime) .toTime(toTime)
.settingsJson(objectMapper.writeValueAsString(cfg)) .settingsJson(objectMapper.writeValueAsString(cfg))
@@ -45,6 +45,9 @@ public class HistoricalDataService {
@Value("${upbit.api.candle-limit:200}") @Value("${upbit.api.candle-limit:200}")
private int candleLimit; private int candleLimit;
/** 백테스트·차트 워밍업 포함 최대 history 요청 봉 수 */
private static final int MAX_HISTORY_FETCH = 800;
@Value("${upbit.api.request-delay-ms:110}") @Value("${upbit.api.request-delay-ms:110}")
private long requestDelayMs; private long requestDelayMs;
@@ -66,7 +69,7 @@ public class HistoricalDataService {
* @return CandleBarDto 리스트 (시간 오름차순) * @return CandleBarDto 리스트 (시간 오름차순)
*/ */
public List<CandleBarDto> getHistory(String market, String candleType, String to, int count) { public List<CandleBarDto> 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 시간 파싱 ─────────────────────────────────────────────────── // ── 1. to 시간 파싱 ───────────────────────────────────────────────────
ZonedDateTime toTime = parseToTime(to); ZonedDateTime toTime = parseToTime(to);
@@ -81,14 +84,14 @@ public class HistoricalDataService {
rawBars = sliceFromMemory(market, candleType, toTime, safeCount); rawBars = sliceFromMemory(market, candleType, toTime, safeCount);
} }
// 인메모리 봉이 최소 임계치(20개) 미만이면 Upbit REST 로 재조회. // 인메모리 봉이 요청 수 미만이면 Upbit REST 로 보충 (백테스트 200+워밍업 등)
// 백엔드 재시작 직후 warm-up 미완료 상태에서 2~3개 봉만 반환되는
// race-condition 을 방지한다.
final int MIN_BARS_THRESHOLD = 20; final int MIN_BARS_THRESHOLD = 20;
if (rawBars.size() < Math.min(MIN_BARS_THRESHOLD, safeCount)) { if (rawBars.size() < safeCount
log.info("[HistoricalDataService] 인메모리 봉 부족({}) → 업비트 REST 프록시: {} / {}", && (rawBars.size() < Math.min(MIN_BARS_THRESHOLD, safeCount) || safeCount > candleLimit)) {
rawBars.size(), market, candleType); log.info("[HistoricalDataService] 봉 부족({}/{}) → 업비트 REST: {} / {}",
List<UpbitCandleRaw> upbitBars = fetchFromUpbit(market, candleType, to, safeCount); rawBars.size(), safeCount, market, candleType);
String toParam = to != null && !to.isBlank() ? to : null;
List<UpbitCandleRaw> upbitBars = fetchFromUpbit(market, candleType, toParam, safeCount);
if (upbitBars.size() > rawBars.size()) { if (upbitBars.size() > rawBars.size()) {
rawBars = upbitBars; rawBars = upbitBars;
} }
+13 -13
View File
@@ -15,14 +15,13 @@ import {
runBacktest, runBacktest,
loadBacktestSettings, loadBacktestSettings,
loadIndicatorSettings, loadIndicatorSettings,
API_BASE,
type BacktestResultRecord, type BacktestResultRecord,
type BacktestAnalysis, type BacktestAnalysis,
type BacktestSignal, type BacktestSignal,
type StrategyDto, type StrategyDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { timeframeToCandleType } from '../utils/chartCandleType';
import type { Theme, Timeframe } from '../types'; import type { Theme, Timeframe } from '../types';
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
import { buildEquityFromSignals } from '../utils/backtestEquity'; import { buildEquityFromSignals } from '../utils/backtestEquity';
import { import {
formatChartTypeKo, formatChartTypeKo,
@@ -30,6 +29,7 @@ import {
normalizeEpochSec, normalizeEpochSec,
normalizeEpochSecOptional, normalizeEpochSecOptional,
resolveBacktestRecordExecTimeframe, resolveBacktestRecordExecTimeframe,
resolveBacktestChartBarCount,
toUpbitMarket, toUpbitMarket,
} from '../utils/backtestUiUtils'; } from '../utils/backtestUiUtils';
import BacktestExecutionList, { type ExecutionListTab } from './backtest/BacktestExecutionList'; import BacktestExecutionList, { type ExecutionListTab } from './backtest/BacktestExecutionList';
@@ -272,19 +272,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
? resolveStrategyPrimaryTimeframe(strategy) ? resolveStrategyPrimaryTimeframe(strategy)
: '1m'; : '1m';
const execTimeframe = resolveBacktestExecTimeframe(runTimeframe, strategyTimeframe); const execTimeframe = resolveBacktestExecTimeframe(runTimeframe, strategyTimeframe);
const [settings, indicatorParams, barsRes] = await Promise.all([ const [settings, indicatorParams] = await Promise.all([
loadBacktestSettings(), loadBacktestSettings(),
loadIndicatorSettings(), loadIndicatorSettings(),
fetch(`${API_BASE}/candles/history?${new URLSearchParams({
market: runMarket,
type: timeframeToCandleType(execTimeframe),
count: '800',
})}`),
]); ]);
if (!barsRes.ok) throw new Error(`캔들 로딩 실패 (${barsRes.status})`); const candleBundle = await fetchBacktestCandleBundle({
const bars = (await barsRes.json()) as Array<{ market: runMarket,
time: number; open: number; high: number; low: number; close: number; volume: number; timeframe: execTimeframe as Timeframe,
}>; buyDsl: strategy?.buyCondition,
sellDsl: strategy?.sellCondition,
indicatorParams,
});
const { bars, evaluationBarCount } = candleBundle;
if (bars.length < 5) { if (bars.length < 5) {
throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.'); throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.');
} }
@@ -296,6 +295,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
indicatorParams, indicatorParams,
symbol: runMarket, symbol: runMarket,
strategyName: strategy?.name, strategyName: strategy?.name,
evaluationBarCount,
}); });
if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); if (!result) throw new Error('백테스팅 실행에 실패했습니다.');
const list = await loadBacktestResults(); const list = await loadBacktestResults();
@@ -360,7 +360,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
timeframe: resolveBacktestRecordExecTimeframe(selectedBacktest), timeframe: resolveBacktestRecordExecTimeframe(selectedBacktest),
toTimeSec: toSec, toTimeSec: toSec,
fromTimeSec: fromSec > 0 ? fromSec : undefined, fromTimeSec: fromSec > 0 ? fromSec : undefined,
barCount: selectedBacktest.barCount || 300, barCount: resolveBacktestChartBarCount(selectedBacktest),
strategyId: selectedBacktest.strategyId, strategyId: selectedBacktest.strategyId,
}; };
} }
+11 -12
View File
@@ -5,9 +5,9 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react'; import { ReactFlowProvider } from '@xyflow/react';
import DraggableModalFrame from './DraggableModalFrame'; import DraggableModalFrame from './DraggableModalFrame';
import type { Theme } from '../types/index'; 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 { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { timeframeToCandleType } from '../utils/chartCandleType';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import type { LogicNode } from '../utils/strategyTypes'; import type { LogicNode } from '../utils/strategyTypes';
import { import {
@@ -1178,19 +1178,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
{ buy: buyEditorState, sell: sellEditorState }, { buy: buyEditorState, sell: sellEditorState },
); );
const execTimeframe = resolveBacktestExecTimeframe(qbTimeframe, strategyTimeframe); const execTimeframe = resolveBacktestExecTimeframe(qbTimeframe, strategyTimeframe);
const [settings, indicatorParams, barsRes] = await Promise.all([ const [settings, indicatorParams] = await Promise.all([
loadBacktestSettings(), loadBacktestSettings(),
loadIndicatorSettings(), 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, evaluationBarCount } = await fetchBacktestCandleBundle({
const bars = (await barsRes.json()) as Array<{ market: qbMarket,
time: number; open: number; high: number; low: number; close: number; volume: number; timeframe: execTimeframe as import('../types').Timeframe,
}>; buyDsl: selectedStrategy?.buyCondition,
sellDsl: selectedStrategy?.sellCondition,
indicatorParams,
});
if (bars.length < 5) { if (bars.length < 5) {
throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.'); throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.');
} }
@@ -1202,6 +1200,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
indicatorParams, indicatorParams,
symbol: qbMarket, symbol: qbMarket,
strategyName: selectedStrategy?.name, strategyName: selectedStrategy?.name,
evaluationBarCount,
}); });
if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); if (!result) throw new Error('백테스팅 실행에 실패했습니다.');
if (result.resultId) { if (result.resultId) {
@@ -14,7 +14,7 @@ import {
} from '../utils/backendApi'; } from '../utils/backendApi';
import { prefetchNotificationStrategies, resolveNotificationStrategy } from '../utils/resolveNotificationStrategy'; import { prefetchNotificationStrategies, resolveNotificationStrategy } from '../utils/resolveNotificationStrategy';
import { normalizeMarketCode } from '../utils/strategyHydrate'; import { normalizeMarketCode } from '../utils/strategyHydrate';
import { loadAnalysisCandles } from '../utils/analysisChartData'; import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators'; import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators';
import { normalizeEpochSec } from '../utils/backtestUiUtils'; import { normalizeEpochSec } from '../utils/backtestUiUtils';
@@ -70,7 +70,6 @@ import { getKoreanName } from '../utils/marketNameCache';
type RightTab = 'trade' | 'orderbook'; type RightTab = 'trade' | 'orderbook';
const TNL_RIGHT_OPEN_KEY = 'tnl-right-open'; const TNL_RIGHT_OPEN_KEY = 'tnl-right-open';
const REPORT_BAR_COUNT = 300;
const REPORT_INDICATOR_TYPES = [ const REPORT_INDICATOR_TYPES = [
'RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological', 'RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological',
] as const; ] as const;
@@ -298,7 +297,17 @@ export const TradeNotificationListPage: React.FC<Props> = ({
const market = normalizeMarketCode(item.market); const market = normalizeMarketCode(item.market);
const timeframe = candleTypeToTimeframe(item.candleType ?? '1m'); const timeframe = candleTypeToTimeframe(item.candleType ?? '1m');
const toTimeSec = normalizeEpochSec(item.candleTime); 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<string, unknown>]),
);
const { bars, evaluationBarCount } = await fetchBacktestCandleBundle({
market,
timeframe,
toTimeSec,
buyDsl: strategy.buyCondition,
sellDsl: strategy.sellCondition,
indicatorParams,
});
if (gen !== reportGenRef.current) return; if (gen !== reportGenRef.current) return;
if (bars.length < 10) { if (bars.length < 10) {
window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.'); window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.');
@@ -321,9 +330,8 @@ export const TradeNotificationListPage: React.FC<Props> = ({
symbol: market, symbol: market,
strategyName, strategyName,
settings: btSettings, settings: btSettings,
indicatorParams: Object.fromEntries( indicatorParams,
REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]), evaluationBarCount,
),
}); });
if (gen !== reportGenRef.current) return; if (gen !== reportGenRef.current) return;
if (!res) { if (!res) {
@@ -10,6 +10,7 @@ import {
import { loadAnalysisCandles } from '../../utils/analysisChartData'; import { loadAnalysisCandles } from '../../utils/analysisChartData';
import { normalizeChartTimeframe } from '../../utils/backtestUiUtils'; import { normalizeChartTimeframe } from '../../utils/backtestUiUtils';
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
import type { OHLCVBar, Theme } from '../../types'; import type { OHLCVBar, Theme } from '../../types';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { import {
@@ -83,6 +84,18 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
setError(null); setError(null);
try { try {
const btSettings = await loadBacktestSettings(); 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<string, unknown>,
]),
);
const { evaluationBarCount } = resolveEvaluationFromLoadedBars(
bars.length,
strat.buyCondition,
strat.sellCondition,
indicatorParams,
);
const res = await runBacktest({ const res = await runBacktest({
strategyId: sid, strategyId: sid,
bars: bars.map(b => ({ bars: bars.map(b => ({
@@ -97,12 +110,8 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
symbol: sym, symbol: sym,
strategyName: strat.name, strategyName: strat.name,
settings: btSettings, settings: btSettings,
indicatorParams: Object.fromEntries( indicatorParams,
['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ evaluationBarCount,
t,
getParams(t) as Record<string, unknown>,
]),
),
}); });
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
if (!res) { if (!res) {
+2
View File
@@ -52,6 +52,7 @@ export function useBacktest() {
settings?: BacktestSettingsDto; settings?: BacktestSettingsDto;
symbol?: string; symbol?: string;
strategyName?: string; strategyName?: string;
evaluationBarCount?: number;
}) => { }) => {
const runGen = runGenRef.current; const runGen = runGenRef.current;
setRunning(true); setRunning(true);
@@ -74,6 +75,7 @@ export function useBacktest() {
settings: opts.settings, settings: opts.settings,
symbol: opts.symbol, symbol: opts.symbol,
strategyName: opts.strategyName, strategyName: opts.strategyName,
evaluationBarCount: opts.evaluationBarCount,
}; };
const res = await runBacktest(req); const res = await runBacktest(req);
if (runGen !== runGenRef.current) return null; if (runGen !== runGenRef.current) return null;
+2
View File
@@ -1317,6 +1317,8 @@ export interface BacktestRequest {
symbol?: string; symbol?: string;
strategyName?: string; strategyName?: string;
deviceId?: string; deviceId?: string;
/** bars 마지막 N봉만 시그널 평가 (앞쪽은 지표 워밍업) */
evaluationBarCount?: number;
} }
/** 백테스팅 실행 */ /** 백테스팅 실행 */
+16
View File
@@ -1,6 +1,7 @@
import type { ChartType, Timeframe } from '../types'; import type { ChartType, Timeframe } from '../types';
import type { BacktestResultRecord } from './backendApi'; import type { BacktestResultRecord } from './backendApi';
import { formatUpbitKrwPrice } from './safeFormat'; import { formatUpbitKrwPrice } from './safeFormat';
import { INDICATOR_WARMUP } from './upbitApi';
export function toUpbitMarket(symbol: string): string { export function toUpbitMarket(symbol: string): string {
return symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`; return symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
@@ -40,6 +41,21 @@ export function resolveBacktestRecordExecTimeframe(record: BacktestResultRecord)
return '1m'; 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<ChartType, string> = { const CHART_TYPE_KO: Record<ChartType, string> = {
candlestick: '캔들스틱', candlestick: '캔들스틱',
bar: '바', bar: '바',
+212
View File
@@ -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<number>): 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<number>): void {
if (!node || typeof node !== 'object') return;
const n = node as Record<string, unknown>;
const cond = n.condition;
if (cond && typeof cond === 'object') {
const c = cond as Record<string, unknown>;
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<string, Record<string, unknown>> | 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<string, Record<string, unknown>>,
): number {
const periods = new Set<number>();
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<OHLCVBar[]> {
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<number, OHLCVBar>();
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<string, Record<string, unknown>>;
toTimeSec?: number;
}): Promise<BacktestCandleBundle> {
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<string, Record<string, unknown>>,
): { 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,
};
}
+1
View File
@@ -1140,6 +1140,7 @@ export interface BacktestRequest {
symbol?: string; symbol?: string;
strategyName?: string; strategyName?: string;
deviceId?: string; deviceId?: string;
evaluationBarCount?: number;
} }
/** 백테스팅 실행 */ /** 백테스팅 실행 */