fix: 백테스트 시간봉·캔들 수 불일치로 시그널 누락 문제 해결

- 전략 DSL 시간봉(5m)과 실행 시간봉(3m) 불일치 시 자동 동기화
- 업비트 캔들 200봉 제한 → 페이지네이션으로 800봉까지 조회
- 백테스트 차트는 실행 타임프레임 기준으로 표시 (시그널·지표 정렬)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-11 18:16:53 +09:00
parent 44c46eee1b
commit b34bdda073
4 changed files with 70 additions and 12 deletions
@@ -119,8 +119,40 @@ public class HistoricalDataService {
// ── 업비트 REST 프록시 ──────────────────────────────────────────────────── // ── 업비트 REST 프록시 ────────────────────────────────────────────────────
private static final int UPBIT_PAGE_SIZE = 200;
/** 업비트 REST — count 초과 시 200봉 단위 페이지네이션 (백테스트 800봉 등) */
private List<UpbitCandleRaw> fetchFromUpbit(String market, String candleType, private List<UpbitCandleRaw> fetchFromUpbit(String market, String candleType,
String to, int count) { String to, int count) {
TreeMap<Long, UpbitCandleRaw> byTime = new TreeMap<>();
String cursorTo = to;
while (byTime.size() < count) {
int pageSize = Math.min(count - byTime.size(), UPBIT_PAGE_SIZE);
List<UpbitCandleRaw> page = fetchFromUpbitOnce(market, candleType, cursorTo, pageSize);
if (page.isEmpty()) break;
int before = byTime.size();
for (UpbitCandleRaw bar : page) byTime.putIfAbsent(bar.time, bar);
if (byTime.size() == before) break;
long oldest = byTime.firstKey();
cursorTo = Instant.ofEpochSecond(oldest)
.atZone(java.time.ZoneOffset.UTC)
.format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
if (page.size() < pageSize) break;
}
List<UpbitCandleRaw> merged = new ArrayList<>(byTime.values());
if (merged.size() > count) {
return merged.subList(merged.size() - count, merged.size());
}
return merged;
}
private List<UpbitCandleRaw> fetchFromUpbitOnce(String market, String candleType,
String to, int count) {
String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1"); String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1");
String url = upbitBaseUrl + "/v1/candles/" + path String url = upbitBaseUrl + "/v1/candles/" + path
+ "?market=" + market + "?market=" + market
@@ -142,11 +174,10 @@ public class HistoricalDataService {
objectMapper.readValue(json, new TypeReference<>() {}); objectMapper.readValue(json, new TypeReference<>() {});
// 업비트 응답은 최신→과거 순 → 시간 오름차순으로 뒤집기 // 업비트 응답은 최신→과거 순 → 시간 오름차순으로 뒤집기
List<UpbitCandleRaw> list = raw.stream() return raw.stream()
.map(this::parseUpbitCandle) .map(this::parseUpbitCandle)
.sorted(Comparator.comparingLong(c -> c.time)) .sorted(Comparator.comparingLong(c -> c.time))
.collect(Collectors.toList()); .collect(Collectors.toList());
return list;
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -51,6 +51,7 @@ import { getKoreanName } from '../utils/marketNameCache';
import { repairUtf8Mojibake } from '../utils/textEncoding'; import { repairUtf8Mojibake } from '../utils/textEncoding';
import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyNameResolver'; import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyNameResolver';
import { markStrategyIdsMissingUnlessValid } from '../utils/strategyMissingCache'; import { markStrategyIdsMissingUnlessValid } from '../utils/strategyMissingCache';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
const LEFT_KEY = 'btd-left-width'; const LEFT_KEY = 'btd-left-width';
const RIGHT_KEY = 'btd-right-width'; const RIGHT_KEY = 'btd-right-width';
@@ -213,6 +214,15 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
useEffect(() => { void fetchAll(); }, [fetchAll]); 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(() => { useEffect(() => {
const onTradesChanged = () => { void refreshLiveTrades(); }; const onTradesChanged = () => { void refreshLiveTrades(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
@@ -234,12 +244,14 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
setRunLoading(true); setRunLoading(true);
setRunError(null); setRunError(null);
try { try {
const strategy = strategies.find(s => s.id === runStrategyId);
const execTimeframe = strategy ? resolveStrategyPrimaryTimeframe(strategy) : runTimeframe;
const [settings, indicatorParams, barsRes] = await Promise.all([ const [settings, indicatorParams, barsRes] = await Promise.all([
loadBacktestSettings(), loadBacktestSettings(),
loadIndicatorSettings(), loadIndicatorSettings(),
fetch(`${API_BASE}/candles/history?${new URLSearchParams({ fetch(`${API_BASE}/candles/history?${new URLSearchParams({
market: runMarket, market: runMarket,
type: timeframeToCandleType(runTimeframe), type: timeframeToCandleType(execTimeframe),
count: '800', count: '800',
})}`), })}`),
]); ]);
@@ -250,11 +262,10 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
if (bars.length < 5) { if (bars.length < 5) {
throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.'); throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.');
} }
const strategy = strategies.find(s => s.id === runStrategyId);
const result = await runBacktest({ const result = await runBacktest({
strategyId: runStrategyId, strategyId: runStrategyId,
bars, bars,
timeframe: runTimeframe, timeframe: execTimeframe,
settings, settings,
indicatorParams, indicatorParams,
symbol: runMarket, symbol: runMarket,
+21 -6
View File
@@ -5,7 +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, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, API_BASE, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { timeframeToCandleType } from '../utils/chartCandleType';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import type { LogicNode } from '../utils/strategyTypes'; import type { LogicNode } from '../utils/strategyTypes';
import { import {
@@ -86,8 +88,6 @@ import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/useP
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'; import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { MarketSearchPanel } from './MarketSearchPanel'; import { MarketSearchPanel } from './MarketSearchPanel';
import { runBacktest, loadBacktestSettings, loadIndicatorSettings, API_BASE } from '../utils/backendApi';
import { timeframeToCandleType } from '../utils/chartCandleType';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
import type { Timeframe } from '../types'; import type { Timeframe } from '../types';
import '../styles/strategyEditor.css'; import '../styles/strategyEditor.css';
@@ -207,6 +207,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [qbMarketOpen, setQbMarketOpen] = useState(false); const [qbMarketOpen, setQbMarketOpen] = useState(false);
const [qbMarketQ, setQbMarketQ] = useState(''); const [qbMarketQ, setQbMarketQ] = useState('');
const qbMarketBtnRef = useRef<HTMLButtonElement>(null); 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 [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT)); const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth); const leftWidthRef = useRef(leftWidth);
@@ -981,12 +992,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
setQbError(null); setQbError(null);
try { try {
const selectedStrategy = strategies.find(s => s.id === selectedId); const selectedStrategy = strategies.find(s => s.id === selectedId);
const execTimeframe = resolveStrategyPrimaryTimeframe({
buyCondition: buyCondition,
sellCondition: sellCondition,
} as ApiStrategyDto);
const [settings, indicatorParams, barsRes] = await Promise.all([ const [settings, indicatorParams, barsRes] = await Promise.all([
loadBacktestSettings(), loadBacktestSettings(),
loadIndicatorSettings(), loadIndicatorSettings(),
fetch(`${API_BASE}/candles/history?${new URLSearchParams({ fetch(`${API_BASE}/candles/history?${new URLSearchParams({
market: qbMarket, market: qbMarket,
type: timeframeToCandleType(qbTimeframe), type: timeframeToCandleType(execTimeframe),
count: '800', count: '800',
})}`), })}`),
]); ]);
@@ -1000,7 +1015,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const result = await runBacktest({ const result = await runBacktest({
strategyId: selectedId, strategyId: selectedId,
bars, bars,
timeframe: qbTimeframe, timeframe: execTimeframe,
settings, settings,
indicatorParams, indicatorParams,
symbol: qbMarket, symbol: qbMarket,
@@ -1016,7 +1031,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
} finally { } finally {
setQbLoading(false); setQbLoading(false);
} }
}, [selectedId, strategies, qbMarket, qbTimeframe, onNavigateToBacktest]); }, [selectedId, strategies, qbMarket, buyCondition, sellCondition, onNavigateToBacktest]);
const operators = [ const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, { type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
@@ -145,7 +145,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`; const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
const chartTimeframe = useMemo((): Timeframe => { const chartTimeframe = useMemo((): Timeframe => {
const tf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframeRaw; // 백테스트 결과: 실행 타임프레임 우선 (전략 DSL TF와 실행 TF 불일치 시 차트·시그널 어긋남 방지)
const tf = timeframeRaw || (strategy ? resolveStrategyPrimaryTimeframe(strategy) : '1m');
return normalizeChartTimeframe(tf) as Timeframe; return normalizeChartTimeframe(tf) as Timeframe;
}, [strategy, timeframeRaw]); }, [strategy, timeframeRaw]);