From d5338274d4c449974dcff3d3d61a702f17b21c84 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 12 Jun 2026 00:00:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=ED=85=8C=EC=8A=A4=ED=8C=85=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 --- .../com/goldenchart/dto/BacktestResponse.java | 4 ++ .../service/BacktestingService.java | 7 +- .../src/components/BacktestHistoryPage.tsx | 72 ++++++++++--------- .../src/components/StrategyEditorPage.tsx | 55 +++++++------- .../backtest/BacktestAnalysisChart.tsx | 6 +- .../backtest/BacktestExecutionList.tsx | 5 +- frontend/src/utils/backendApi.ts | 3 + frontend/src/utils/backtestReportModel.ts | 4 +- frontend/src/utils/backtestRunTimeframe.ts | 50 +++++++++++++ frontend/src/utils/backtestUiUtils.ts | 17 ++++- 10 files changed, 151 insertions(+), 72 deletions(-) create mode 100644 frontend/src/utils/backtestRunTimeframe.ts diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java index bba7a2d..6cbef19 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java @@ -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 { diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 919b9aa..c3f68a2 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -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) diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index bebc4ee..b83e912 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -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('backtest'); const [records, setRecords] = useState([]); @@ -102,7 +97,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { // ── 빠른 백테스팅 실행 ────────────────────────────────────────────────────── const [runMarket, setRunMarket] = useState('KRW-BTC'); - const [runTimeframe, setRunTimeframe] = useState('1D'); + const [runTimeframe, setRunTimeframe] = useState(BACKTEST_STRATEGY_TIMEFRAME); const [runStrategyId, setRunStrategyId] = useState(null); const [runLoading, setRunLoading] = useState(false); const [runError, setRunError] = useState(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) { diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 90c5c15..0bbe843 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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('1D'); + const [qbTimeframe, setQbTimeframe] = useState(BACKTEST_STRATEGY_TIMEFRAME); const [qbLoading, setQbLoading] = useState(false); const [qbError, setQbError] = useState(null); const [qbMarketOpen, setQbMarketOpen] = useState(false); const [qbMarketQ, setQbMarketQ] = useState(''); const qbMarketBtnRef = useRef(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 diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index db07b83..fb07f4e 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -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 = ({ 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)) { diff --git a/frontend/src/components/backtest/BacktestExecutionList.tsx b/frontend/src/components/backtest/BacktestExecutionList.tsx index 08a9010..559a293 100644 --- a/frontend/src/components/backtest/BacktestExecutionList.tsx +++ b/frontend/src/components/backtest/BacktestExecutionList.tsx @@ -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({
{ko} - {strategy} · {formatTimeframeKo(r.timeframe)} + {strategy} · {formatTimeframeKo(resolveBacktestRecordExecTimeframe(r))} {fmtListTimestamp(r.createdAt)}
diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 6c209cf..31af7c9 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1209,6 +1209,9 @@ export interface BacktestResponse { stats: BacktestStats; analysis?: BacktestAnalysis; resultId?: number; + /** 실행에 사용된 시간봉 */ + timeframe?: string; + symbol?: string; } /** DB 저장 이력 레코드 */ diff --git a/frontend/src/utils/backtestReportModel.ts b/frontend/src/utils/backtestReportModel.ts index 8c48228..9f39164 100644 --- a/frontend/src/utils/backtestReportModel.ts +++ b/frontend/src/utils/backtestReportModel.ts @@ -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', diff --git a/frontend/src/utils/backtestRunTimeframe.ts b/frontend/src/utils/backtestRunTimeframe.ts new file mode 100644 index 0000000..2fe4a40 --- /dev/null +++ b/frontend/src/utils/backtestRunTimeframe.ts @@ -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, + ]; +} diff --git a/frontend/src/utils/backtestUiUtils.ts b/frontend/src/utils/backtestUiUtils.ts index 92cbefa..c986245 100644 --- a/frontend/src/utils/backtestUiUtils.ts +++ b/frontend/src/utils/backtestUiUtils.ts @@ -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 = { '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 = { candlestick: '캔들스틱', bar: '바',