백테스팅 수정

This commit is contained in:
Macbook
2026-06-12 00:00:06 +09:00
parent eb6cf894da
commit d5338274d4
10 changed files with 151 additions and 72 deletions
@@ -26,6 +26,10 @@ public class BacktestResponse {
private BacktestAnalysisDto analysis;
/** DB 저장 후 부여된 결과 ID */
private Long resultId;
/** 실행에 사용된 시간봉 (요청 bars 기준) */
private String timeframe;
/** 실행 종목 */
private String symbol;
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class Signal {
@@ -299,6 +299,8 @@ public class BacktestingService {
.stats(stats)
.analysis(analysis)
.resultId(resultId)
.timeframe(normalizeTf(req.getTimeframe()))
.symbol(req.getSymbol())
.build();
}
@@ -616,7 +618,8 @@ public class BacktestingService {
snapshot.put("strategyId", req.getStrategyId());
snapshot.put("strategyName", strategyName);
snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN");
snapshot.put("timeframe", req.getTimeframe());
String execTf = normalizeTf(req.getTimeframe());
snapshot.put("timeframe", execTf);
snapshot.put("barCount", bars.size());
if (execParams != null && !execParams.isEmpty()) snapshot.put("indicatorParams", execParams);
if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl);
@@ -627,7 +630,7 @@ public class BacktestingService {
.strategyId(req.getStrategyId())
.strategyName(strategyName)
.symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN")
.timeframe(req.getTimeframe())
.timeframe(execTf)
.barCount(bars.size())
.fromTime(fromTime)
.toTime(toTime)
+40 -32
View File
@@ -28,6 +28,7 @@ import {
formatTimeframeKo,
normalizeEpochSec,
normalizeEpochSecOptional,
resolveBacktestRecordExecTimeframe,
toUpbitMarket,
} from '../utils/backtestUiUtils';
import BacktestExecutionList, { type ExecutionListTab } from './backtest/BacktestExecutionList';
@@ -52,6 +53,13 @@ import { repairUtf8Mojibake } from '../utils/textEncoding';
import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyNameResolver';
import { markStrategyIdsMissingUnlessValid } from '../utils/strategyMissingCache';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
import {
BACKTEST_STRATEGY_TIMEFRAME,
buildQuickRunTimeframeSelectOptions,
parseBacktestRunTimeframeChoice,
resolveBacktestExecTimeframe,
type BacktestRunTimeframeChoice,
} from '../utils/backtestRunTimeframe';
const LEFT_KEY = 'btd-left-width';
const RIGHT_KEY = 'btd-right-width';
@@ -77,19 +85,6 @@ interface Props {
theme?: Theme;
}
const QUICK_RUN_TIMEFRAMES: { value: Timeframe; label: string }[] = [
{ value: '1m', label: '1분' },
{ value: '3m', label: '3분' },
{ value: '5m', label: '5분' },
{ value: '10m', label: '10분' },
{ value: '15m', label: '15분' },
{ value: '30m', label: '30분' },
{ value: '1h', label: '1시간' },
{ value: '4h', label: '4시간' },
{ value: '1D', label: '일봉' },
{ value: '1W', label: '주봉' },
];
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
const [tab, setTab] = useState<ExecutionListTab>('backtest');
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
@@ -102,7 +97,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
// ── 빠른 백테스팅 실행 ──────────────────────────────────────────────────────
const [runMarket, setRunMarket] = useState<string>('KRW-BTC');
const [runTimeframe, setRunTimeframe] = useState<Timeframe>('1D');
const [runTimeframe, setRunTimeframe] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
const [runStrategyId, setRunStrategyId] = useState<number | null>(null);
const [runLoading, setRunLoading] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
@@ -214,15 +209,6 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
useEffect(() => { void fetchAll(); }, [fetchAll]);
// 전략 선택 시 DSL 기준 시간봉으로 자동 동기화 (예: 전략 4=5m 조건인데 3m 실행 방지)
useEffect(() => {
if (!runStrategyId) return;
const strat = strategies.find(s => s.id === runStrategyId);
if (!strat) return;
const tf = resolveStrategyPrimaryTimeframe(strat);
setRunTimeframe(prev => (prev === tf ? prev : tf));
}, [runStrategyId, strategies]);
useEffect(() => {
const onTradesChanged = () => { void refreshLiveTrades(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
@@ -245,7 +231,10 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
setRunError(null);
try {
const strategy = strategies.find(s => s.id === runStrategyId);
const execTimeframe = strategy ? resolveStrategyPrimaryTimeframe(strategy) : runTimeframe;
const strategyTimeframe = strategy
? resolveStrategyPrimaryTimeframe(strategy)
: '1m';
const execTimeframe = resolveBacktestExecTimeframe(runTimeframe, strategyTimeframe);
const [settings, indicatorParams, barsRes] = await Promise.all([
loadBacktestSettings(),
loadIndicatorSettings(),
@@ -272,15 +261,34 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
strategyName: strategy?.name,
});
if (!result) throw new Error('백테스팅 실행에 실패했습니다.');
// 결과 목록 갱신 후 새 결과 자동 선택
await fetchAll();
const list = await loadBacktestResults();
setRecords(list);
if (result.resultId) {
const fresh = list.find(r => r.id === result.resultId) ?? list[0] ?? null;
setSelectedBacktest(fresh);
} else {
setSelectedBacktest(list[0] ?? null);
}
setTab('backtest');
} catch (e) {
setRunError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.');
} finally {
setRunLoading(false);
}
}, [runMarket, runTimeframe, runStrategyId, strategies, fetchAll]);
}, [runMarket, runTimeframe, runStrategyId, strategies]);
const runStrategy = useMemo(
() => (runStrategyId ? strategies.find(s => s.id === runStrategyId) : undefined),
[runStrategyId, strategies],
);
const runStrategyTimeframe = useMemo(
() => (runStrategy ? resolveStrategyPrimaryTimeframe(runStrategy) : undefined),
[runStrategy],
);
const runTimeframeOptions = useMemo(
() => buildQuickRunTimeframeSelectOptions(runStrategyTimeframe),
[runStrategyTimeframe],
);
const backtestDetail = selectedBacktest ? parseDetail(selectedBacktest) : null;
@@ -312,7 +320,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
const fromSec = normalizeEpochSecOptional(selectedBacktest.fromTime);
return {
symbol: selectedBacktest.symbol,
timeframe: selectedBacktest.timeframe,
timeframe: resolveBacktestRecordExecTimeframe(selectedBacktest),
toTimeSec: toSec,
fromTimeSec: fromSec > 0 ? fromSec : undefined,
barCount: selectedBacktest.barCount || 300,
@@ -341,7 +349,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
ko: getKoreanName(market),
strategy: selectedBacktest.strategyName || '전략 없음',
metaParts: [
formatTimeframeKo(selectedBacktest.timeframe),
formatTimeframeKo(resolveBacktestRecordExecTimeframe(selectedBacktest)),
formatChartTypeKo('candlestick'),
],
};
@@ -452,11 +460,11 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
<select
className="btd-run-select"
value={runTimeframe}
onChange={e => setRunTimeframe(e.target.value as Timeframe)}
onChange={e => setRunTimeframe(parseBacktestRunTimeframeChoice(e.target.value))}
disabled={runLoading}
title="타임프레임"
title="백테스팅 실행 시간봉"
>
{QUICK_RUN_TIMEFRAMES.map(tf => (
{runTimeframeOptions.map(tf => (
<option key={tf.value} value={tf.value}>{tf.label}</option>
))}
</select>
+26 -29
View File
@@ -105,22 +105,16 @@ import ReactDOM from 'react-dom';
import { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache';
import type { Timeframe } from '../types';
import {
BACKTEST_STRATEGY_TIMEFRAME,
buildQuickRunTimeframeSelectOptions,
parseBacktestRunTimeframeChoice,
resolveBacktestExecTimeframe,
type BacktestRunTimeframeChoice,
} from '../utils/backtestRunTimeframe';
import '../styles/strategyEditor.css';
import '../styles/strategyEditorTheme.css';
const QUICK_RUN_TIMEFRAMES: { value: Timeframe; label: string }[] = [
{ value: '1m', label: '1분' },
{ value: '3m', label: '3분' },
{ value: '5m', label: '5분' },
{ value: '10m', label: '10분' },
{ value: '15m', label: '15분' },
{ value: '30m', label: '30분' },
{ value: '1h', label: '1시간' },
{ value: '4h', label: '4시간' },
{ value: '1D', label: '일봉' },
{ value: '1W', label: '주봉' },
];
const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
const LEFT_PANEL_MIN = 220;
@@ -222,23 +216,13 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
// ── 빠른 백테스팅 실행 ────────────────────────────────────────────────────
const [qbMarket, setQbMarket] = useState('KRW-BTC');
const [qbTimeframe, setQbTimeframe] = useState<Timeframe>('1D');
const [qbTimeframe, setQbTimeframe] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
const [qbLoading, setQbLoading] = useState(false);
const [qbError, setQbError] = useState<string | null>(null);
const [qbMarketOpen, setQbMarketOpen] = useState(false);
const [qbMarketQ, setQbMarketQ] = useState('');
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
// 전략 선택/조건 변경 시 DSL 기준 시간봉으로 빠른 실행 TF 동기화
useEffect(() => {
if (selectedId == null) return;
const tf = resolveStrategyPrimaryTimeframe({
buyCondition: buyCondition,
sellCondition: sellCondition,
} as ApiStrategyDto);
setQbTimeframe(prev => (prev === tf ? prev : tf));
}, [selectedId, buyCondition, sellCondition]);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
@@ -1189,10 +1173,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
setQbError(null);
try {
const selectedStrategy = strategies.find(s => s.id === selectedId);
const execTimeframe = resolveStrategyPrimaryTimeframe({
const strategyTimeframe = resolveStrategyPrimaryTimeframe({
buyCondition: buyCondition,
sellCondition: sellCondition,
} as ApiStrategyDto);
const execTimeframe = resolveBacktestExecTimeframe(qbTimeframe, strategyTimeframe);
const [settings, indicatorParams, barsRes] = await Promise.all([
loadBacktestSettings(),
loadIndicatorSettings(),
@@ -1228,7 +1213,19 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
} finally {
setQbLoading(false);
}
}, [selectedId, strategies, qbMarket, buyCondition, sellCondition, onNavigateToBacktest]);
}, [selectedId, strategies, qbMarket, qbTimeframe, buyCondition, sellCondition, onNavigateToBacktest]);
const qbStrategyTimeframe = useMemo(
() => resolveStrategyPrimaryTimeframe({
buyCondition: buyCondition,
sellCondition: sellCondition,
} as ApiStrategyDto),
[buyCondition, sellCondition],
);
const qbTimeframeOptions = useMemo(
() => buildQuickRunTimeframeSelectOptions(qbStrategyTimeframe),
[qbStrategyTimeframe],
);
const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
@@ -1303,11 +1300,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
<select
className="se-qr-select"
value={qbTimeframe}
onChange={e => setQbTimeframe(e.target.value as Timeframe)}
onChange={e => setQbTimeframe(parseBacktestRunTimeframeChoice(e.target.value))}
disabled={qbLoading}
title="타임프레임"
title="백테스팅 실행 시간봉"
>
{QUICK_RUN_TIMEFRAMES.map(tf => (
{qbTimeframeOptions.map(tf => (
<option key={tf.value} value={tf.value}>{tf.label}</option>
))}
</select>
@@ -35,7 +35,6 @@ import {
import {
buildVirtualTradingChartIndicators,
ensurePaperChartOverlays,
resolveStrategyPrimaryTimeframe,
} from '../../utils/strategyToChartIndicators';
import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility';
@@ -145,10 +144,9 @@ const BacktestAnalysisChart: React.FC<Props> = ({
const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
const chartTimeframe = useMemo((): Timeframe => {
// 백테스트 결과: 실행 타임프레임 우선 (전략 DSL TF와 실행 TF 불일치 시 차트·시그널 어긋남 방지)
const tf = timeframeRaw || (strategy ? resolveStrategyPrimaryTimeframe(strategy) : '1m');
const tf = timeframeRaw?.trim() ? timeframeRaw : '1m';
return normalizeChartTimeframe(tf) as Timeframe;
}, [strategy, timeframeRaw]);
}, [timeframeRaw]);
useEffect(() => {
if (!strategyId || isStrategyMissingOnServer(strategyId)) {
@@ -11,6 +11,7 @@ import {
formatTimeframeKo,
pct,
pctAbs,
resolveBacktestRecordExecTimeframe,
toUpbitMarket,
} from '../../utils/backtestUiUtils';
import { getKoreanName } from '../../utils/marketNameCache';
@@ -88,7 +89,7 @@ export default function BacktestExecutionList({
const ko = getKoreanName(market).toLowerCase();
const code = r.symbol.replace(/^KRW-/i, '').toLowerCase();
const strategy = (r.strategyName ?? '').toLowerCase();
const tf = r.timeframe?.toLowerCase() ?? '';
const tf = resolveBacktestRecordExecTimeframe(r).toLowerCase();
return ko.includes(normalizedQuery)
|| code.includes(normalizedQuery)
|| strategy.includes(normalizedQuery)
@@ -200,7 +201,7 @@ export default function BacktestExecutionList({
<div className="btd-history-card-row">
<div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span>
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(r.timeframe)}</span>
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(resolveBacktestRecordExecTimeframe(r))}</span>
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
</div>
<div className="btd-history-card-side">
+3
View File
@@ -1209,6 +1209,9 @@ export interface BacktestResponse {
stats: BacktestStats;
analysis?: BacktestAnalysis;
resultId?: number;
/** 실행에 사용된 시간봉 */
timeframe?: string;
symbol?: string;
}
/** DB 저장 이력 레코드 */
+2 -2
View File
@@ -7,7 +7,7 @@ import type {
import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal';
import type { LiveExecutionItem } from './liveExecutionGroups';
import { buildAnalysisFromPaperTrades } from './paperMetrics';
import { fmtListTimestamp, toUpbitMarket } from './backtestUiUtils';
import { fmtListTimestamp, resolveBacktestRecordExecTimeframe, toUpbitMarket } from './backtestUiUtils';
import { getKoreanName } from './marketNameCache';
import { repairUtf8Mojibake } from './textEncoding';
@@ -89,7 +89,7 @@ export function buildBacktestReportModel(
strategyName,
symbol: record.symbol,
symbolKo: repairUtf8Mojibake(getKoreanName(market)),
timeframe: record.timeframe,
timeframe: resolveBacktestRecordExecTimeframe(record),
barCount: record.barCount || 300,
createdAt: record.createdAt ? fmtListTimestamp(record.createdAt) : undefined,
reportKind: 'backtest',
@@ -0,0 +1,50 @@
import type { Timeframe } from '../types';
import { formatTimeframeKo } from './backtestUiUtils';
/** 빠른 백테스팅 — 전략 DSL에 저장된 시간봉 사용 */
export const BACKTEST_STRATEGY_TIMEFRAME = '__strategy__' as const;
export type BacktestRunTimeframeChoice = Timeframe | typeof BACKTEST_STRATEGY_TIMEFRAME;
export const QUICK_RUN_TIMEFRAME_ITEMS: { value: Timeframe; label: string }[] = [
{ value: '1m', label: '1분' },
{ value: '3m', label: '3분' },
{ value: '5m', label: '5분' },
{ value: '10m', label: '10분' },
{ value: '15m', label: '15분' },
{ value: '30m', label: '30분' },
{ value: '1h', label: '1시간' },
{ value: '4h', label: '4시간' },
{ value: '1D', label: '일봉' },
{ value: '1W', label: '주봉' },
];
export function isBacktestStrategyTimeframeChoice(
value: string,
): value is typeof BACKTEST_STRATEGY_TIMEFRAME {
return value === BACKTEST_STRATEGY_TIMEFRAME;
}
export function parseBacktestRunTimeframeChoice(value: string): BacktestRunTimeframeChoice {
if (isBacktestStrategyTimeframeChoice(value)) return BACKTEST_STRATEGY_TIMEFRAME;
return value as Timeframe;
}
/** UI 선택 → 실제 실행 시간봉 */
export function resolveBacktestExecTimeframe(
choice: BacktestRunTimeframeChoice,
strategyTimeframe: Timeframe,
): Timeframe {
if (choice === BACKTEST_STRATEGY_TIMEFRAME) return strategyTimeframe;
return choice;
}
export function buildQuickRunTimeframeSelectOptions(strategyTimeframe?: Timeframe) {
const stratLabel = strategyTimeframe
? `전략 시간봉 (${formatTimeframeKo(strategyTimeframe)})`
: '전략 시간봉';
return [
{ value: BACKTEST_STRATEGY_TIMEFRAME, label: stratLabel },
...QUICK_RUN_TIMEFRAME_ITEMS,
];
}
+16 -1
View File
@@ -1,4 +1,5 @@
import type { ChartType, Timeframe } from '../types';
import type { BacktestResultRecord } from './backendApi';
import { formatUpbitKrwPrice } from './safeFormat';
export function toUpbitMarket(symbol: string): string {
@@ -19,12 +20,26 @@ const TIMEFRAME_KO: Record<Timeframe, string> = {
'1M': '1월',
};
/** 차트·목록용 시간봉 한글 라벨 */
/** 차트·목록용 한글 라벨 */
export function formatTimeframeKo(raw: string | undefined): string {
const tf = normalizeChartTimeframe(raw);
return TIMEFRAME_KO[tf] ?? tf;
}
/** 백테스트 결과 — 실제 실행 시간봉 (스냅샷 우선, 전략 DSL과 무관) */
export function resolveBacktestRecordExecTimeframe(record: BacktestResultRecord): string {
if (record.executionSnapshotJson) {
try {
const snap = JSON.parse(record.executionSnapshotJson) as { timeframe?: string };
const fromSnap = snap?.timeframe?.trim();
if (fromSnap) return fromSnap;
} catch { /* ignore */ }
}
const fromCol = record.timeframe?.trim();
if (fromCol && fromCol !== 'unknown') return fromCol;
return '1m';
}
const CHART_TYPE_KO: Record<ChartType, string> = {
candlestick: '캔들스틱',
bar: '바',