diff --git a/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java b/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java index c665c26..cca16cf 100644 --- a/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java +++ b/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java @@ -119,8 +119,40 @@ public class HistoricalDataService { // ── 업비트 REST 프록시 ──────────────────────────────────────────────────── + private static final int UPBIT_PAGE_SIZE = 200; + + /** 업비트 REST — count 초과 시 200봉 단위 페이지네이션 (백테스트 800봉 등) */ private List fetchFromUpbit(String market, String candleType, String to, int count) { + TreeMap byTime = new TreeMap<>(); + String cursorTo = to; + + while (byTime.size() < count) { + int pageSize = Math.min(count - byTime.size(), UPBIT_PAGE_SIZE); + List 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 merged = new ArrayList<>(byTime.values()); + if (merged.size() > count) { + return merged.subList(merged.size() - count, merged.size()); + } + return merged; + } + + private List 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 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(); diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index a91444f..bebc4ee 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -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, diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 8600936..2454b55 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(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' }, diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index ff87fe0..db07b83 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -145,7 +145,8 @@ const BacktestAnalysisChart: React.FC = ({ 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]);