fix: 백테스트 시간봉·캔들 수 불일치로 시그널 누락 문제 해결
- 전략 DSL 시간봉(5m)과 실행 시간봉(3m) 불일치 시 자동 동기화 - 업비트 캔들 200봉 제한 → 페이지네이션으로 800봉까지 조회 - 백테스트 차트는 실행 타임프레임 기준으로 표시 (시그널·지표 정렬) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -119,8 +119,40 @@ public class HistoricalDataService {
|
||||
|
||||
// ── 업비트 REST 프록시 ────────────────────────────────────────────────────
|
||||
|
||||
private static final int UPBIT_PAGE_SIZE = 200;
|
||||
|
||||
/** 업비트 REST — count 초과 시 200봉 단위 페이지네이션 (백테스트 800봉 등) */
|
||||
private List<UpbitCandleRaw> fetchFromUpbit(String market, String candleType,
|
||||
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 url = upbitBaseUrl + "/v1/candles/" + path
|
||||
+ "?market=" + market
|
||||
@@ -142,11 +174,10 @@ public class HistoricalDataService {
|
||||
objectMapper.readValue(json, new TypeReference<>() {});
|
||||
|
||||
// 업비트 응답은 최신→과거 순 → 시간 오름차순으로 뒤집기
|
||||
List<UpbitCandleRaw> list = raw.stream()
|
||||
return raw.stream()
|
||||
.map(this::parseUpbitCandle)
|
||||
.sorted(Comparator.comparingLong(c -> c.time))
|
||||
.collect(Collectors.toList());
|
||||
return list;
|
||||
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
|
||||
@@ -51,6 +51,7 @@ import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||
import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyNameResolver';
|
||||
import { markStrategyIdsMissingUnlessValid } from '../utils/strategyMissingCache';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
|
||||
|
||||
const LEFT_KEY = 'btd-left-width';
|
||||
const RIGHT_KEY = 'btd-right-width';
|
||||
@@ -213,6 +214,15 @@ 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);
|
||||
@@ -234,12 +244,14 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
setRunLoading(true);
|
||||
setRunError(null);
|
||||
try {
|
||||
const strategy = strategies.find(s => s.id === runStrategyId);
|
||||
const execTimeframe = strategy ? resolveStrategyPrimaryTimeframe(strategy) : runTimeframe;
|
||||
const [settings, indicatorParams, barsRes] = await Promise.all([
|
||||
loadBacktestSettings(),
|
||||
loadIndicatorSettings(),
|
||||
fetch(`${API_BASE}/candles/history?${new URLSearchParams({
|
||||
market: runMarket,
|
||||
type: timeframeToCandleType(runTimeframe),
|
||||
type: timeframeToCandleType(execTimeframe),
|
||||
count: '800',
|
||||
})}`),
|
||||
]);
|
||||
@@ -250,11 +262,10 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
if (bars.length < 5) {
|
||||
throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.');
|
||||
}
|
||||
const strategy = strategies.find(s => s.id === runStrategyId);
|
||||
const result = await runBacktest({
|
||||
strategyId: runStrategyId,
|
||||
bars,
|
||||
timeframe: runTimeframe,
|
||||
timeframe: execTimeframe,
|
||||
settings,
|
||||
indicatorParams,
|
||||
symbol: runMarket,
|
||||
|
||||
@@ -5,7 +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, 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 type { LogicNode } from '../utils/strategyTypes';
|
||||
import {
|
||||
@@ -86,8 +88,6 @@ import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/useP
|
||||
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||
import { runBacktest, loadBacktestSettings, loadIndicatorSettings, API_BASE } from '../utils/backendApi';
|
||||
import { timeframeToCandleType } from '../utils/chartCandleType';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import type { Timeframe } from '../types';
|
||||
import '../styles/strategyEditor.css';
|
||||
@@ -207,6 +207,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
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);
|
||||
@@ -981,12 +992,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
setQbError(null);
|
||||
try {
|
||||
const selectedStrategy = strategies.find(s => s.id === selectedId);
|
||||
const execTimeframe = resolveStrategyPrimaryTimeframe({
|
||||
buyCondition: buyCondition,
|
||||
sellCondition: sellCondition,
|
||||
} as ApiStrategyDto);
|
||||
const [settings, indicatorParams, barsRes] = await Promise.all([
|
||||
loadBacktestSettings(),
|
||||
loadIndicatorSettings(),
|
||||
fetch(`${API_BASE}/candles/history?${new URLSearchParams({
|
||||
market: qbMarket,
|
||||
type: timeframeToCandleType(qbTimeframe),
|
||||
type: timeframeToCandleType(execTimeframe),
|
||||
count: '800',
|
||||
})}`),
|
||||
]);
|
||||
@@ -1000,7 +1015,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const result = await runBacktest({
|
||||
strategyId: selectedId,
|
||||
bars,
|
||||
timeframe: qbTimeframe,
|
||||
timeframe: execTimeframe,
|
||||
settings,
|
||||
indicatorParams,
|
||||
symbol: qbMarket,
|
||||
@@ -1016,7 +1031,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
} finally {
|
||||
setQbLoading(false);
|
||||
}
|
||||
}, [selectedId, strategies, qbMarket, qbTimeframe, onNavigateToBacktest]);
|
||||
}, [selectedId, strategies, qbMarket, buyCondition, sellCondition, onNavigateToBacktest]);
|
||||
|
||||
const operators = [
|
||||
{ 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 chartTimeframe = useMemo((): Timeframe => {
|
||||
const tf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframeRaw;
|
||||
// 백테스트 결과: 실행 타임프레임 우선 (전략 DSL TF와 실행 TF 불일치 시 차트·시그널 어긋남 방지)
|
||||
const tf = timeframeRaw || (strategy ? resolveStrategyPrimaryTimeframe(strategy) : '1m');
|
||||
return normalizeChartTimeframe(tf) as Timeframe;
|
||||
}, [strategy, timeframeRaw]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user