diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index 810ecf7..cf72b52 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -7,7 +7,7 @@ public final class MenuIds { private MenuIds() {} public static final List ALL = List.of( - "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "analysis-report", "notifications", "settings", + "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "strategy-evaluation", "backtest", "analysis-report", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live", "settings_trend-search", diff --git a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java index 5ac6fb8..06a8f9c 100644 --- a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java +++ b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java @@ -1,5 +1,6 @@ package com.goldenchart.controller; +import com.goldenchart.dto.LiveConditionEvaluateRequest; import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.service.LiveConditionStatusService; import lombok.RequiredArgsConstructor; @@ -12,6 +13,7 @@ import java.util.Map; * 가상투자 — 실시간 조건 충족 현황 API. * * GET /api/strategy/live-conditions?market=KRW-BTC&strategyId=1 + * POST /api/strategy/live-conditions/evaluate — 지표 파라미터 오버라이드 (전략 평가) */ @RestController @RequestMapping("/strategy/live-conditions") @@ -24,9 +26,28 @@ public class LiveConditionController { public ResponseEntity get( @RequestParam String market, @RequestParam long strategyId, + @RequestParam(required = false) Long barTimeSec, @RequestHeader Map headers) { long uid = TradingControllerSupport.requireRegisteredUser(headers); String deviceId = headers.get("x-device-id"); - return ResponseEntity.ok(service.evaluate(uid, deviceId, market, strategyId)); + return ResponseEntity.ok(service.evaluate(uid, deviceId, market, strategyId, barTimeSec)); + } + + @PostMapping("/evaluate") + public ResponseEntity evaluateAtBar( + @RequestBody LiveConditionEvaluateRequest body, + @RequestHeader Map headers) { + long uid = TradingControllerSupport.requireRegisteredUser(headers); + String deviceId = headers.get("x-device-id"); + if (body == null || body.getMarket() == null || body.getMarket().isBlank()) { + return ResponseEntity.badRequest().build(); + } + return ResponseEntity.ok(service.evaluate( + uid, + deviceId, + body.getMarket(), + body.getStrategyId(), + body.getBarTimeSec(), + body.getIndicatorParams())); } } diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java new file mode 100644 index 0000000..7286949 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java @@ -0,0 +1,15 @@ +package com.goldenchart.dto; + +import lombok.Data; + +import java.util.Map; + +/** 전략 평가 — 특정 봉 + 지표 파라미터 오버라이드 조건 평가 */ +@Data +public class LiveConditionEvaluateRequest { + private String market; + private long strategyId; + private Long barTimeSec; + /** indicatorType → param map (DB 저장값 위에 덮어씀) */ + private Map> indicatorParams; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java index e79707f..af363b9 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java @@ -41,4 +41,9 @@ public class LiveConditionStatusDto { private List rows; private long updatedAt; + + /** 평가 기준 봉 시각(초) — null 이면 최신 봉 */ + private Long barTimeSec; + /** barTimeSec 에 대응하는 시리즈 내 bar index */ + private Integer evalBarIndex; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index f9f5f9c..478d1f2 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -53,6 +53,21 @@ public class LiveConditionStatusService { @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, String market, long strategyId) { + return evaluate(userId, deviceId, market, strategyId, null); + } + + @Transactional(readOnly = true) + public LiveConditionStatusDto evaluate(Long userId, String deviceId, + String market, long strategyId, + Long barTimeSec) { + return evaluate(userId, deviceId, market, strategyId, barTimeSec, null); + } + + @Transactional(readOnly = true) + public LiveConditionStatusDto evaluate(Long userId, String deviceId, + String market, long strategyId, + Long barTimeSec, + Map> indicatorParamsOverride) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) { return empty(market, strategyId); @@ -60,7 +75,9 @@ public class LiveConditionStatusService { GcStrategy strategy = opt.get(); Map> params = - indicatorSettingsService.getAll(userId, deviceId); + mergeIndicatorParams( + indicatorSettingsService.getAll(userId, deviceId), + indicatorParamsOverride); Map> visual = indicatorSettingsService.getAllVisual(userId, deviceId); @@ -72,18 +89,20 @@ public class LiveConditionStatusService { return empty(market, strategyId); } - // 마켓 구독 + 비동기 warm-up 시작 — 현재 봉 유무에 따라 즉시 or 수집 후 평가 + // 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보 + int warmupBars = barTimeSec != null ? 500 : 60; Set timeframes = new LinkedHashSet<>(); for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); timeframes.add(tf); - subscriptionManager.ensureBarsReadySync(market, tf, 60); + subscriptionManager.ensureBarsReadySync(market, tf, warmupBars); } - subscriptionManager.ensureBarsReadySync(market, "1m", 60); + subscriptionManager.ensureBarsReadySync(market, "1m", warmupBars); List rows = new ArrayList<>(); int met = 0; int evaluable = 0; + Integer primaryEvalIndex = null; for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); @@ -96,7 +115,8 @@ public class LiveConditionStatusService { rows.add(toRowUnevaluated(p, visual)); continue; } - int index = series.getEndIndex(); + int index = resolveBarIndex(series, barTimeSec); + if (primaryEvalIndex == null) primaryEvalIndex = index; try { ObjectNode wrapper = objectMapper.createObjectNode(); wrapper.put("type", "CONDITION"); @@ -128,9 +148,9 @@ public class LiveConditionStatusService { // [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영 Boolean overallEntryMet = evaluateOverallRule( - strategy.getBuyConditionJson(), market, params, visual); + strategy.getBuyConditionJson(), market, params, visual, barTimeSec); Boolean overallExitMet = evaluateOverallRule( - strategy.getSellConditionJson(), market, params, visual); + strategy.getSellConditionJson(), market, params, visual, barTimeSec); return LiveConditionStatusDto.builder() .market(market) @@ -141,9 +161,36 @@ public class LiveConditionStatusService { .overallExitMet(overallExitMet) .rows(rows) .updatedAt(System.currentTimeMillis()) + .barTimeSec(barTimeSec) + .evalBarIndex(primaryEvalIndex) .build(); } + /** + * barTimeSec(초)에 가장 가까운 bar index — null 이면 최신 봉. + * 차트·업비트 API 와 동일하게 봉 시작(open) 시각 기준으로 매칭한다. + */ + private int resolveBarIndex(BarSeries series, Long barTimeSec) { + if (barTimeSec == null) return series.getEndIndex(); + int bestIdx = series.getEndIndex(); + long bestDist = Long.MAX_VALUE; + for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { + long openSec = barOpenEpochSec(series, i); + long dist = Math.abs(openSec - barTimeSec); + if (dist < bestDist) { + bestDist = dist; + bestIdx = i; + } + } + return bestIdx; + } + + /** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */ + private static long barOpenEpochSec(BarSeries series, int index) { + var bar = series.getBar(index); + return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + } + private LiveConditionStatusDto empty(String market, long strategyId) { return LiveConditionStatusDto.builder() .market(market) @@ -169,7 +216,8 @@ public class LiveConditionStatusService { */ private Boolean evaluateOverallRule(String conditionJson, String market, Map> params, - Map> visual) { + Map> visual, + Long barTimeSec) { if (conditionJson == null || conditionJson.isBlank()) return null; try { com.fasterxml.jackson.databind.JsonNode dsl = @@ -191,7 +239,7 @@ public class LiveConditionStatusService { new StrategyDslToTa4jAdapter.RuleBuildContext( series, params, visual, market, ta4jStorage, false, java.util.Map.of()); org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx); - return rule.isSatisfied(series.getEndIndex(), null); + return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null); } catch (Exception e) { log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage()); return null; @@ -421,4 +469,24 @@ public class LiveConditionStatusService { return indicatorType; } + private static Map> mergeIndicatorParams( + Map> base, + Map> override) { + if (override == null || override.isEmpty()) return base; + if (base == null || base.isEmpty()) return override; + + Map> merged = new LinkedHashMap<>(base); + override.forEach((type, overrideParams) -> { + Map baseParams = merged.get(type); + if (baseParams == null || baseParams.isEmpty()) { + merged.put(type, overrideParams); + } else { + Map inner = new LinkedHashMap<>(baseParams); + inner.putAll(overrideParams); + merged.put(type, inner); + } + }); + return merged; + } + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ac7b8c..368be89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,6 +71,7 @@ const AnalysisReportPage = lazy(() => import('./components/analysisReport/Analys const SettingsPage = lazy(() => import('./components/SettingsPage')); const PaperTradingPage = lazy(() => import('./components/PaperTradingPage')); const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage')); +const StrategyEvaluationPage = lazy(() => import('./components/StrategyEvaluationPage')); const TrendSearchPage = lazy(() => import('./components/TrendSearchPage')); const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage')); @@ -231,6 +232,16 @@ function AppMainContent({ : )} + {menuPage === 'strategy-evaluation' && ( + formalLogin + ? ( + }> + + + ) + : + )} + {menuPage === 'backtest' && ( formalLogin ? ( @@ -505,7 +516,7 @@ function App() { }, [menuPage]); // 지표 설정: 차트·전략·알림 관련 페이지 첫 진입 시 로드 (이후 캐시 유지) - const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings']; + const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'strategy-evaluation', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings']; const [indicatorSettingsEnabled, setIndicatorSettingsEnabled] = useState( () => INDICATOR_SETTINGS_PAGES.includes(menuPage), ); diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx new file mode 100644 index 0000000..9fd49c1 --- /dev/null +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -0,0 +1,370 @@ +/** + * 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석 + */ +import '../styles/strategyEvaluation.css'; +import '../styles/backtestDashboard.css'; +import '../styles/virtualTradingDashboard.css'; +import '../styles/strategyEditorTheme.css'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Theme, Timeframe } from '../types'; +import { + loadStrategies, + loadStrategy, + type StrategyDto, +} from '../utils/backendApi'; +import { hydrateStrategyDto } from '../utils/strategyHydrate'; +import { repairUtf8Mojibake } from '../utils/textEncoding'; +import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; +import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot'; +import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots'; +import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel'; +import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart'; +import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; +import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; +import { getKoreanName } from '../utils/marketNameCache'; +import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; +import { + mergeEvalGetParams, + type EvalIndicatorParams, +} from '../utils/strategyEvaluationParams'; +import { + BACKTEST_STRATEGY_TIMEFRAME, + buildQuickRunTimeframeSelectOptions, + resolveBacktestExecTimeframe, + type BacktestRunTimeframeChoice, +} from '../utils/backtestRunTimeframe'; +import type { OHLCVBar } from '../types'; + +const LEFT_KEY = 'seval-left-width'; +const RIGHT_KEY = 'seval-right-width'; +const LEFT_DEFAULT = 380; +const RIGHT_DEFAULT = 440; + +type LeftTab = 'strategy' | 'settings'; + +function readMinWidth(key: string, fallback: number): number { + return Math.max(readStoredSize(key, fallback), fallback); +} + +interface Props { + theme?: Theme; +} + +export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { + const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings(); + const [leftTab, setLeftTab] = useState('strategy'); + const [strategies, setStrategies] = useState([]); + const [strategySearch, setStrategySearch] = useState(''); + const [selectedStrategyId, setSelectedStrategyId] = useState(null); + const [selectedStrategy, setSelectedStrategy] = useState(null); + + const [market, setMarket] = useState('KRW-BTC'); + const [timeframeChoice, setTimeframeChoice] = useState(BACKTEST_STRATEGY_TIMEFRAME); + + const [bars, setBars] = useState([]); + const [selectedBarIndex, setSelectedBarIndex] = useState(0); + const [snapshot, setSnapshot] = useState(); + const [evalLoading, setEvalLoading] = useState(false); + const [evalError, setEvalError] = useState(null); + const [appliedIndicatorParams, setAppliedIndicatorParams] = useState(null); + const [paramsRevision, setParamsRevision] = useState(0); + + const getEvalParams = useMemo( + () => mergeEvalGetParams(baseGetParams, appliedIndicatorParams), + [baseGetParams, appliedIndicatorParams], + ); + + const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT)); + const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT)); + const leftRef = useRef(leftWidth); + const rightRef = useRef(rightWidth); + leftRef.current = leftWidth; + rightRef.current = rightWidth; + + const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v)); + const onRightSplit = useCallback((e: React.PointerEvent) => { + const startX = e.clientX; + const start = rightRef.current; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.classList.add('btd-splitter--active'); + const splitter = e.currentTarget; + const onMove = (ev: PointerEvent) => { + const delta = ev.clientX - startX; + setRightWidth(Math.min(600, Math.max(360, start - delta))); + }; + const onUp = (ev: PointerEvent) => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + splitter.releasePointerCapture(ev.pointerId); + splitter.classList.remove('btd-splitter--active'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + storeSize(RIGHT_KEY, rightRef.current); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }, []); + + const strategyPrimaryTimeframe = useMemo( + () => (selectedStrategy ? resolveStrategyPrimaryTimeframe(selectedStrategy) : undefined), + [selectedStrategy], + ); + + const chartTimeframe = useMemo((): Timeframe => { + return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m'); + }, [timeframeChoice, strategyPrimaryTimeframe]); + + const tfOptions = useMemo( + () => buildQuickRunTimeframeSelectOptions(strategyPrimaryTimeframe), + [strategyPrimaryTimeframe], + ); + + useEffect(() => { + void loadStrategies().then(list => setStrategies(list ?? [])); + }, []); + + useEffect(() => { + if (!selectedStrategyId) { + setSelectedStrategy(null); + return; + } + let cancelled = false; + void loadStrategy(selectedStrategyId).then(s => { + if (cancelled) return; + setSelectedStrategy(s ? hydrateStrategyDto(s) : null); + }); + return () => { cancelled = true; }; + }, [selectedStrategyId]); + + const filteredStrategies = useMemo(() => { + const q = strategySearch.trim().toLowerCase(); + if (!q) return strategies; + return strategies.filter(s => + (s.name ?? '').toLowerCase().includes(q) + || String(s.id ?? '').includes(q), + ); + }, [strategies, strategySearch]); + + const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; + + const refreshEvaluation = useCallback(async () => { + if (!selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) { + setSnapshot(undefined); + return; + } + setEvalLoading(true); + setEvalError(null); + try { + const snap = await fetchEvaluationSnapshotAtBar( + market, + selectedStrategyId, + selectedStrategy, + selectedBarTimeSec, + appliedIndicatorParams, + ); + setSnapshot(snap ?? undefined); + } catch (e) { + setEvalError(e instanceof Error ? e.message : '조건 평가 실패'); + setSnapshot(undefined); + } finally { + setEvalLoading(false); + } + }, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, appliedIndicatorParams]); + + useEffect(() => { + void refreshEvaluation(); + }, [refreshEvaluation]); + + const handleSelectStrategy = useCallback((s: StrategyDto) => { + if (s.id == null) return; + setSelectedStrategyId(s.id); + setSelectedStrategy(hydrateStrategyDto(s)); + setAppliedIndicatorParams(null); + setParamsRevision(v => v + 1); + setLeftTab('strategy'); + }, []); + + const handleSaveIndicatorParams = useCallback((params: EvalIndicatorParams) => { + setAppliedIndicatorParams(params); + setParamsRevision(v => v + 1); + }, []); + + const handleResetIndicatorParams = useCallback(() => { + setAppliedIndicatorParams(null); + setParamsRevision(v => v + 1); + }, []); + + const strategyLabel = selectedStrategy?.name + ? repairUtf8Mojibake(selectedStrategy.name) + : '전략 미선택'; + + return ( +
+
+
+

전략 평가

+ Strategy Match Analysis +
+
+ +
+ + +
+ +
+
+ +
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 852cad7..68d2e4f 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import { canAccessMenu } from '../utils/permissions'; -export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'analysis-report' | 'notifications' | 'settings'; +export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings'; interface TopMenuBarProps { activePage: MenuPage; @@ -152,6 +152,18 @@ const IcStrategyEditor = () => ( ); +const IcStrategyEvaluation = () => ( + + + + + + + + + +); + const IcPaper = () => ( @@ -191,6 +203,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [ { page: 'virtual', label: '가상매매', icon: }, { page: 'trend-search', label: '추세검색', icon: }, { page: 'strategy-editor', label: '전략편집기', icon: }, + { page: 'strategy-evaluation', label: '전략 평가', icon: }, { page: 'backtest', label: '백테스팅', icon: }, { page: 'analysis-report', label: '분석레포트', icon: }, { page: 'notifications', label: '알림목록', icon: }, diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 6c39b3a..d6573bd 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -1122,7 +1122,7 @@ const TradingChart: React.FC = ({ const onPanPointerDown = (e: PointerEvent) => { if (!canPanRef.current || e.button !== 0) return; const target = e.target as HTMLElement | null; - if (target?.closest('.chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) { + if (target?.closest('[data-no-chart-pan], .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) { return; } const pt = isChartPlotPointer(e.clientX, e.clientY); diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx new file mode 100644 index 0000000..4f61211 --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx @@ -0,0 +1,186 @@ +/** + * 전략 평가 — 캔들 pane 세로 선택 영역 (드래그로 봉 이동) + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import type { ChartManager } from '../../utils/ChartManager'; +import type { OHLCVBar } from '../../types'; +import { findNearestBarIndex } from '../../utils/analysisChartData'; + +interface Props { + manager: ChartManager; + bars: OHLCVBar[]; + selectedBarIndex: number; + onSelectedBarIndexChange: (index: number) => void; +} + +interface BarBand { + left: number; + width: number; + top: number; + height: number; +} + +function resolveBarBand( + manager: ChartManager, + bars: OHLCVBar[], + index: number, +): BarBand | null { + if (index < 0 || index >= bars.length) return null; + + const main = manager.getPaneLayouts().find(l => l.paneIndex === 0); + if (!main || main.height < 8) return null; + + const centerX = manager.timeToX(bars[index].time); + if (centerX == null || !Number.isFinite(centerX)) return null; + + let halfWidth = 7; + if (index > 0) { + const prevX = manager.timeToX(bars[index - 1].time); + if (prevX != null) halfWidth = Math.max(5, Math.abs(centerX - prevX) * 0.42); + } + if (index < bars.length - 1) { + const nextX = manager.timeToX(bars[index + 1].time); + if (nextX != null) halfWidth = Math.max(halfWidth, Math.abs(nextX - centerX) * 0.42); + } + + const plotWidth = manager.getCandlePaneTimeAxisBand()?.plotWidth + ?? Math.max(40, manager.getContainer().clientWidth - 64); + const width = Math.min(Math.max(halfWidth * 2, 10), 48); + const left = Math.max(0, Math.min(plotWidth - width, centerX - width / 2)); + + return { + left, + width, + top: main.topY, + height: main.height, + }; +} + +function indexFromClientX( + manager: ChartManager, + bars: OHLCVBar[], + clientX: number, +): number { + const rect = manager.getContainer().getBoundingClientRect(); + const chartX = clientX - rect.left; + const time = manager.xToTime(chartX); + if (time == null) return -1; + return findNearestBarIndex(bars, time); +} + +const StrategyEvaluationBarSelector: React.FC = ({ + manager, + bars, + selectedBarIndex, + onSelectedBarIndexChange, +}) => { + const [band, setBand] = useState(null); + const [dragIndex, setDragIndex] = useState(null); + const draggingRef = useRef(false); + const displayIndex = dragIndex ?? selectedBarIndex; + + const refreshBand = useCallback(() => { + setBand(resolveBarBand(manager, bars, displayIndex)); + }, [manager, bars, displayIndex]); + + useEffect(() => { + refreshBand(); + const ro = new ResizeObserver(refreshBand); + ro.observe(manager.getContainer()); + + let raf = 0; + const schedule = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(refreshBand); + }; + const unsubLayout = manager.subscribePaneLayout(schedule); + const unsubViewport = manager.subscribeViewport(schedule); + const timers = [0, 80, 200].map(ms => window.setTimeout(refreshBand, ms)); + + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + unsubLayout(); + unsubViewport(); + timers.forEach(clearTimeout); + }; + }, [manager, refreshBand]); + + const finishDrag = useCallback((clientX: number) => { + draggingRef.current = false; + setDragIndex(null); + const idx = indexFromClientX(manager, bars, clientX); + if (idx >= 0 && idx !== selectedBarIndex) { + onSelectedBarIndexChange(idx); + } + }, [manager, bars, selectedBarIndex, onSelectedBarIndexChange]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + + const onMove = (ev: PointerEvent) => { + if (!draggingRef.current) return; + ev.preventDefault(); + ev.stopPropagation(); + const idx = indexFromClientX(manager, bars, ev.clientX); + if (idx >= 0) setDragIndex(idx); + }; + const onUp = (ev: PointerEvent) => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + finishDrag(ev.clientX); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }, [manager, bars, finishDrag]); + + if (!band || bars.length === 0) return null; + + const bar = bars[displayIndex]; + const timeLabel = bar + ? new Date(bar.time * 1000).toLocaleString('ko-KR', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : ''; + + return ReactDOM.createPortal( +
+
+
+
+ {timeLabel && ( +
{timeLabel}
+ )} +
+
, + manager.getContainer(), + ); +}; + +export default StrategyEvaluationBarSelector; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx new file mode 100644 index 0000000..0a376e7 --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -0,0 +1,456 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import TradingChart from '../TradingChart'; +import { MarketSearchPanel } from '../MarketSearchPanel'; +import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types'; +import { loadAnalysisCandles } from '../../utils/analysisChartData'; +import type { ChartManager } from '../../utils/ChartManager'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import { useAppSettings } from '../../hooks/useAppSettings'; +import { + chartPaneFlexRatio, + countNonOverlayIndicatorPanes, +} from '../../utils/strategyOscillatorSeries'; +import { getIndicatorDef } from '../../utils/indicatorRegistry'; +import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; +import { getKoreanName } from '../../utils/marketNameCache'; +import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; +import { loadBacktestSettings, runBacktest } from '../../utils/backendApi'; +import { buildVirtualTradingChartIndicators } from '../../utils/strategyToChartIndicators'; +import { buildEvalParamsFromStrategy } from '../../utils/strategyEvaluationParams'; +import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup'; +import StrategyEvaluationBarSelector from './StrategyEvaluationBarSelector'; +import { + parseBacktestRunTimeframeChoice, + type BacktestRunTimeframeChoice, +} from '../../utils/backtestRunTimeframe'; + +interface Props { + market: string; + timeframe: Timeframe; + timeframeChoice: BacktestRunTimeframeChoice; + tfOptions: { value: string; label: string }[]; + onMarketChange: (market: string) => void; + onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void; + theme?: Theme; + strategy: StrategyDto | null; + selectedBarIndex: number; + onSelectedBarIndexChange: (index: number) => void; + onBarsLoaded?: (bars: OHLCVBar[]) => void; + paramsRevision?: number; + getParamsOverride?: ( + type: string, + defaults?: Record, + ) => Record; + getVisualConfigOverride?: ( + type: string, + plots: import('../../utils/indicatorRegistry').PlotDef[], + hlines: import('../../utils/indicatorRegistry').HLineDef[], + ) => { + plots: import('../../utils/indicatorRegistry').PlotDef[]; + hlines: import('../../utils/indicatorRegistry').HLineDef[]; + cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors; + }; +} + +const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; + +function isKeyboardTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; +} + +const StrategyEvaluationChart: React.FC = ({ + market, + timeframe, + timeframeChoice, + tfOptions, + onMarketChange, + onTimeframeChoiceChange, + theme = 'dark', + strategy, + selectedBarIndex, + onSelectedBarIndexChange, + onBarsLoaded, + paramsRevision = 0, + getParamsOverride, + getVisualConfigOverride, +}) => { + const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings(); + const getParams = getParamsOverride ?? baseGetParams; + const getVisualConfig = getVisualConfigOverride ?? baseGetVisual; + const { defaults: appDefaults } = useAppSettings(); + const [bars, setBars] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [chartMgr, setChartMgr] = useState(null); + const [showMarketSearch, setShowMarketSearch] = useState(false); + const [marketQuery, setMarketQuery] = useState(''); + const [signals, setSignals] = useState([]); + const [backtestRunning, setBacktestRunning] = useState(false); + const [backtestError, setBacktestError] = useState(null); + const marketBtnRef = useRef(null); + const managerRef = useRef(null); + const backtestGenRef = useRef(0); + const signalsRef = useRef(signals); + signalsRef.current = signals; + const chartType: ChartType = 'candlestick'; + + const indicators = useMemo(() => { + if (!strategy) return [] as IndicatorConfig[]; + return buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); + }, [strategy, getParams, getVisualConfig]); + + const auxPaneCount = useMemo( + () => countNonOverlayIndicatorPanes(indicators, isOverlayType), + [indicators], + ); + + const paneAreaRatio = useMemo(() => { + if (auxPaneCount <= 0) return null; + const flex = chartPaneFlexRatio(auxPaneCount); + return { candle: flex.candle, aux: flex.aux }; + }, [auxPaneCount]); + + const indicatorParams = useMemo( + () => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : {}), + [strategy, getParams], + ); + + const applyMarkers = useCallback((attempt = 0) => { + const mgr = managerRef.current; + if (!mgr?.hasMainSeries()) { + if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1)); + return; + } + if (!strategy) { + mgr.clearBacktestMarkers(); + return; + } + mgr.setBacktestMarkers(signalsRef.current, true); + }, [strategy]); + + const runBacktestForBars = useCallback(async (barData: OHLCVBar[]) => { + if (!strategy?.id || barData.length < 10) { + setSignals([]); + managerRef.current?.clearBacktestMarkers(); + return; + } + + const gen = ++backtestGenRef.current; + setBacktestRunning(true); + setBacktestError(null); + try { + const btSettings = await loadBacktestSettings(); + const { evaluationBarCount } = resolveEvaluationFromLoadedBars( + barData.length, + strategy.buyCondition, + strategy.sellCondition, + indicatorParams, + ); + const res = await runBacktest({ + strategyId: strategy.id, + bars: barData.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })), + timeframe, + symbol: market, + strategyName: strategy.name, + settings: btSettings, + indicatorParams, + evaluationBarCount, + }); + if (gen !== backtestGenRef.current) return; + if (!res) { + setSignals([]); + setBacktestError('시그널 계산에 실패했습니다.'); + managerRef.current?.clearBacktestMarkers(); + return; + } + setSignals(res.signals); + applyMarkers(); + } catch (e) { + if (gen !== backtestGenRef.current) return; + setSignals([]); + setBacktestError(e instanceof Error ? e.message : '시그널 계산 실패'); + managerRef.current?.clearBacktestMarkers(); + } finally { + if (gen === backtestGenRef.current) setBacktestRunning(false); + } + }, [strategy, market, timeframe, indicatorParams, applyMarkers]); + + useEffect(() => { + if (!strategy?.id || loading || bars.length < 10) { + if (!strategy?.id) { + ++backtestGenRef.current; + setSignals([]); + setBacktestError(null); + setBacktestRunning(false); + managerRef.current?.clearBacktestMarkers(); + } + return; + } + void runBacktestForBars(bars); + return () => { ++backtestGenRef.current; }; + }, [strategy?.id, bars, loading, runBacktestForBars]); + + useEffect(() => { + applyMarkers(); + }, [signals, applyMarkers]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + const toTimeSec = Math.floor(Date.now() / 1000); + void loadAnalysisCandles(market, timeframe, toTimeSec, 300) + .then(data => { + if (cancelled) return; + setBars(data); + onBarsLoaded?.(data); + if (data.length > 0) { + onSelectedBarIndexChange(data.length - 1); + } + }) + .catch(e => { + if (cancelled) return; + setError(e instanceof Error ? e.message : '차트 로드 실패'); + setBars([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [market, timeframe, onBarsLoaded, onSelectedBarIndexChange]); + + const scrollToBar = useCallback((barIdx: number) => { + const mgr = chartMgr; + if (!mgr || barIdx < 0 || barIdx >= bars.length) return; + const bar = bars[barIdx]; + const barSec = Math.max(60, 180); + mgr.applyVisibleTimeRange(bar.time - barSec * 24, bar.time + barSec * 8); + }, [chartMgr, bars]); + + useEffect(() => { + if (bars.length === 0) return; + scrollToBar(Math.min(Math.max(0, selectedBarIndex), bars.length - 1)); + }, [selectedBarIndex, bars, scrollToBar]); + + const onManagerReady = useCallback((mgr: ChartManager) => { + managerRef.current = mgr; + setChartMgr(mgr); + if (paneAreaRatio && paneAreaRatio.aux > 0) { + mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux); + } + applyMarkers(); + }, [paneAreaRatio, applyMarkers]); + + const onCandlesReady = useCallback(() => { + const mgr = managerRef.current; + if (!mgr) return; + if (paneAreaRatio && paneAreaRatio.aux > 0) { + mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux); + } + applyMarkers(); + }, [paneAreaRatio, applyMarkers]); + + const stepBar = useCallback((delta: number) => { + if (bars.length === 0) return; + const next = Math.min(bars.length - 1, Math.max(0, selectedBarIndex + delta)); + onSelectedBarIndexChange(next); + }, [bars.length, selectedBarIndex, onSelectedBarIndexChange]); + + const goLatest = useCallback(() => { + if (bars.length === 0) return; + onSelectedBarIndexChange(bars.length - 1); + }, [bars.length, onSelectedBarIndexChange]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (isKeyboardTypingTarget(e.target)) return; + if (bars.length === 0) return; + + if (e.key === 'ArrowLeft') { + if (selectedBarIndex <= 0) return; + e.preventDefault(); + stepBar(-1); + return; + } + if (selectedBarIndex >= bars.length - 1) return; + e.preventDefault(); + stepBar(1); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [bars.length, selectedBarIndex, stepBar]); + + const selectedBar = bars[selectedBarIndex] ?? null; + + return ( +
+
+
+ + + {selectedBar && ( + + {new Date(selectedBar.time * 1000).toLocaleString('ko-KR')} + + )} + {strategy && signals.length > 0 && !backtestRunning && ( + + 시그널 {signals.length}건 + + )} + {strategy && backtestRunning && ( + 시그널 계산 중… + )} +
+ 세로 영역 드래그 · ← → 키 · 하단 툴바로 분석 봉 이동 +
+ +
+
+ {loading &&
차트 로딩…
} + {error && !loading &&
{error}
} + {backtestError && !loading && !backtestRunning && !error && ( +
{backtestError}
+ )} + {!loading && !error && bars.length > 0 && ( + <> + i.id).join(',')}`} + bars={bars} + barsMarket={market} + market={market} + timeframe={timeframe} + chartType={chartType} + theme={theme} + mode="chart" + indicators={indicators} + drawingTool="cursor" + drawings={[]} + logScale={false} + onCrosshair={() => {}} + onManagerReady={onManagerReady} + onCandlesReady={onCandlesReady} + onAddDrawing={() => {}} + displayTimezone={DEFAULT_DISPLAY_TIMEZONE} + volumeVisible={false} + paneSeparatorOptions={appDefaults.chartPaneSeparator} + paneLayoutClamp + showPaneResizeHandle={auxPaneCount > 0} + paneAreaRatio={paneAreaRatio} + showPaneLegend + crosshairInfoVisible={false} + showHoverToolbar={false} + showChartRightToolbar={false} + /> + {chartMgr && ( + + )} + + )} + {!loading && !error && bars.length === 0 && ( +
표시할 캔들 데이터가 없습니다.
+ )} +
+
+ +
+ + + {bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'} + + + + +
+ + {showMarketSearch && marketBtnRef.current && ReactDOM.createPortal( + { + onMarketChange(m); + setShowMarketSearch(false); + setMarketQuery(''); + }} + onClose={() => { + setShowMarketSearch(false); + setMarketQuery(''); + }} + anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null} + anchorPlacement="dropdown" + />, + document.body, + )} +
+ ); +}; + +export default StrategyEvaluationChart; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx new file mode 100644 index 0000000..975833b --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSettingsTab.tsx @@ -0,0 +1,187 @@ +/** + * 전략 평가 — 좌측 패널 전략설정 탭 (지표 파라미터 테스트) + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { StrategyDto } from '../../utils/backendApi'; +import { getIndicatorDef } from '../../utils/indicatorRegistry'; +import { getGlobalParamKeys, getPlotParamKeys } from '../../utils/indicatorSettingsLayout'; +import { GlobalParamRow } from '../IndicatorSettingsSections'; +import { collectStrategyRegistryTypes } from '../../utils/strategyToChartIndicators'; +import { + buildEvalParamsFromStrategy, + hasEvalParamsDiff, + type EvalIndicatorParams, +} from '../../utils/strategyEvaluationParams'; +import { repairUtf8Mojibake } from '../../utils/textEncoding'; + +interface Props { + strategy: StrategyDto | null; + getParams: (type: string) => Record; + appliedParams: EvalIndicatorParams | null; + onSave: (params: EvalIndicatorParams) => void; + onReset: () => void; +} + +function getEditableParamKeys( + indicatorType: string, + params: Record, +): string[] { + const def = getIndicatorDef(indicatorType); + const plots = def?.plots ?? []; + const keys = new Set(); + for (const k of getGlobalParamKeys(indicatorType, params, plots)) keys.add(k); + plots.forEach((p, i) => { + for (const k of getPlotParamKeys(indicatorType, p.id, i, plots, params)) keys.add(k); + }); + if (keys.size === 0) { + Object.keys(params).forEach(k => { + if (k !== 'symbolMode' && k !== 'refSymbol') keys.add(k); + }); + } + return [...keys]; +} + +const StrategyEvaluationSettingsTab: React.FC = ({ + strategy, + getParams, + appliedParams, + onSave, + onReset, +}) => { + const [draft, setDraft] = useState({}); + const [expanded, setExpanded] = useState(null); + + const indicatorTypes = useMemo( + () => collectStrategyRegistryTypes(strategy ?? undefined), + [strategy], + ); + + useEffect(() => { + if (!strategy) { + setDraft({}); + setExpanded(null); + return; + } + const baseline = appliedParams ?? buildEvalParamsFromStrategy(strategy, getParams); + setDraft(JSON.parse(JSON.stringify(baseline)) as EvalIndicatorParams); + setExpanded(indicatorTypes[0] ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [strategy?.id, appliedParams, indicatorTypes.join(',')]); + + const dirty = useMemo( + () => hasEvalParamsDiff(draft, appliedParams), + [draft, appliedParams], + ); + + const patchParam = useCallback((type: string, key: string, raw: string | boolean) => { + setDraft(prev => { + const current = prev[type] ?? {}; + const oldVal = current[key]; + const defVal = getIndicatorDef(type)?.defaultParams?.[key]; + let nextVal: number | string | boolean; + if (typeof oldVal === 'number' || typeof defVal === 'number') { + const n = parseFloat(raw as string); + const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number); + nextVal = Number.isNaN(n) ? fallback : n; + } else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') { + nextVal = raw as boolean; + } else { + nextVal = raw as string; + } + return { + ...prev, + [type]: { ...current, [key]: nextVal }, + }; + }); + }, []); + + const resetType = useCallback((type: string) => { + setDraft(prev => ({ + ...prev, + [type]: { ...getParams(type) }, + })); + }, [getParams]); + + if (!strategy) { + return

전략을 먼저 선택하세요

; + } + + if (indicatorTypes.length === 0) { + return

선택한 전략에 설정 가능한 보조지표가 없습니다

; + } + + const strategyName = repairUtf8Mojibake(strategy.name ?? `전략 #${strategy.id}`); + + return ( +
+
+

{strategyName}

+

지표 값을 변경 후 저장하면 차트·일치율이 재계산됩니다

+
+ +
+ {indicatorTypes.map(type => { + const def = getIndicatorDef(type); + const params = draft[type] ?? {}; + const paramKeys = getEditableParamKeys(type, params); + const isOpen = expanded === type; + const label = def?.koreanName ?? def?.shortName ?? type; + + return ( +
+ + {isOpen && ( +
+ {paramKeys.map(key => ( + patchParam(type, k, raw)} + /> + ))} + +
+ )} +
+ ); + })} +
+ +
+ + +
+
+ ); +}; + +export default StrategyEvaluationSettingsTab; diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css new file mode 100644 index 0000000..93daf95 --- /dev/null +++ b/frontend/src/styles/strategyEvaluation.css @@ -0,0 +1,405 @@ +/* ── 전략 평가 — btd-page 레이아웃 위 보조 스타일 ─────────────────────────── */ + +.seval-left-panel { + height: 100%; +} + +.seval-main-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 8px 10px; +} + +.seval-main-content .seval-chart { + flex: 1; + min-height: 0; +} + +.seval-chart { + display: flex; + flex-direction: column; + min-height: 0; +} + +.seval-chart-body { + flex: 1; + min-height: 0; +} + +.seval-chart-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-bottom: 1px solid var(--btd-divider, var(--se-border)); + background: var(--se-header-bg, var(--se-bg-elevated)); +} + +.seval-chart-toolbar-run { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 0; +} + +.seval-chart-bar-time { + font-size: 0.68rem; + color: var(--btd-gold, var(--se-gold)); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--btd-gold, var(--se-gold)) 35%, var(--se-border)); + background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 8%, transparent); + white-space: nowrap; +} + +.seval-chart-hint { + font-size: 0.66rem; + color: var(--se-text-muted); + white-space: nowrap; +} + +/* btd-analysis-canvas-wrap--live: tv-chart-wrap 높이 (backtestDashboard.css) */ + +.seval-chart-signal-count { + font-size: 0.66rem; + font-weight: 600; + color: #3fb950; + white-space: nowrap; +} + +.seval-chart-backtest-busy { + font-size: 0.66rem; + color: var(--se-text-muted); + white-space: nowrap; +} + +.seval-chart-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + color: var(--se-text-muted); +} + +.seval-chart-status--error { + color: #f85149; +} + +.seval-chart-bottom-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--btd-divider, var(--se-border)); + background: var(--se-header-bg, var(--se-bg-elevated)); +} + +.seval-chart-nav--step { + min-width: 88px; +} + +.seval-chart-bottom-divider { + width: 1px; + height: 20px; + background: var(--btd-divider, var(--se-border)); +} + +.seval-chart-count { + font-size: 0.68rem; + color: var(--se-text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── 세로 선택바 ─────────────────────────────────────────────────────────── */ +.seval-bar-selector-layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 24; +} + +.seval-bar-selector { + position: absolute; + pointer-events: auto; + touch-action: none; + cursor: grab; + border-radius: 4px; + overflow: hidden; + background: linear-gradient( + 180deg, + rgba(212, 160, 23, 0.04) 0%, + rgba(255, 193, 37, 0.18) 45%, + rgba(212, 160, 23, 0.22) 50%, + rgba(255, 193, 37, 0.18) 55%, + rgba(212, 160, 23, 0.04) 100% + ); + border: 1px solid rgba(212, 160, 23, 0.28); + box-shadow: + inset 0 0 28px rgba(255, 193, 37, 0.12), + 0 0 18px rgba(212, 160, 23, 0.08); + transition: box-shadow 0.15s, border-color 0.15s; +} + +.seval-bar-selector:hover { + border-color: rgba(212, 160, 23, 0.45); +} + +.seval-bar-selector--dragging { + cursor: grabbing; + border-color: rgba(255, 193, 37, 0.55); +} + +.seval-bar-selector__shine { + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.06) 50%, transparent 100%); + pointer-events: none; +} + +.seval-bar-selector__handle { + position: absolute; + top: 8px; + bottom: 8px; + left: 50%; + width: 2px; + transform: translateX(-50%); + border-radius: 1px; + background: linear-gradient(180deg, rgba(255, 193, 37, 0.15) 0%, rgba(255, 193, 37, 0.65) 50%, rgba(255, 193, 37, 0.15) 100%); + pointer-events: none; +} + +.seval-bar-selector__label { + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 600; + white-space: nowrap; + color: #ffe082; + background: rgba(13, 17, 23, 0.72); + border: 1px solid rgba(212, 160, 23, 0.35); + pointer-events: none; +} + +/* ── 우측 시그널 패널 ───────────────────────────────────────────────────── */ +.seval-right-panel { + display: flex; + flex-direction: column; + min-height: 0; +} + +.seval-signal-head { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 4px 8px; + font-size: 0.72rem; + font-weight: 700; + color: var(--se-text-muted); +} + +.seval-signal-market { + color: var(--se-text); +} + +.seval-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #3fb950; + box-shadow: 0 0 8px #3fb950; +} + +.seval-live-label { + margin-left: auto; + font-size: 0.68rem; + font-weight: 600; + color: #3fb950; +} + +.seval-signal-panel { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 2px; +} + +.seval-eval-error { + margin: 0 4px 8px; + font-size: 0.72rem; + color: #f85149; +} + +.seval-signal-footer { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 4px 4px; + border-top: 1px solid var(--btd-divider, var(--se-border)); + font-size: 0.68rem; + color: var(--se-text-muted); +} + +.seval-strategy-chip { + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--btd-divider, var(--se-border)); + background: var(--btd-surface, var(--se-input-bg)); + color: var(--se-text); +} + +.seval-right-panel .vtd-sig-panel-wrap { + height: auto; +} + +/* ── 전략설정 탭 ─────────────────────────────────────────────────────────── */ +.seval-settings-tab { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.seval-settings-head { + flex-shrink: 0; + padding: 4px 2px 8px; +} + +.seval-settings-strategy { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + color: var(--se-text); +} + +.seval-settings-hint { + margin: 4px 0 0; + font-size: 0.64rem; + line-height: 1.35; + color: var(--se-text-muted); +} + +.seval-settings-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 2px 8px; +} + +.seval-settings-card { + margin-bottom: 6px; + border-radius: 8px; + border: 1px solid var(--btd-divider, var(--se-border)); + background: var(--btd-surface, var(--se-palette-card-bg)); + overflow: hidden; +} + +.seval-settings-card--open { + border-color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 30%, var(--se-border)); +} + +.seval-settings-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 10px; + border: none; + background: transparent; + color: var(--se-text); + cursor: pointer; + text-align: left; +} + +.seval-settings-card-title { + font-size: 0.76rem; + font-weight: 600; +} + +.seval-settings-card-type { + font-size: 0.62rem; + color: var(--se-text-muted); +} + +.seval-settings-card-body { + padding: 0 6px 10px; + border-top: 1px solid var(--btd-divider, var(--se-border)); +} + +.seval-settings-card-body .ism-row { + padding: 6px 4px; +} + +.seval-settings-reset-type { + margin-top: 4px; + padding: 4px 8px; + border: none; + background: transparent; + color: var(--se-text-muted); + font-size: 0.66rem; + cursor: pointer; + text-decoration: underline; +} + +.seval-settings-actions { + flex-shrink: 0; + display: flex; + gap: 8px; + padding: 8px 2px 4px; + border-top: 1px solid var(--btd-divider, var(--se-border)); +} + +.seval-settings-save, +.seval-settings-reset { + flex: 1; + padding: 9px 10px; + border-radius: 8px; + font-size: 0.74rem; + font-weight: 600; + cursor: pointer; +} + +.seval-settings-save { + border: 1px solid color-mix(in srgb, var(--btd-gold, var(--se-gold)) 45%, var(--se-border)); + background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 12%, var(--se-input-bg)); + color: var(--btd-gold, var(--se-gold)); +} + +.seval-settings-save:disabled, +.seval-settings-reset:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.seval-settings-reset { + border: 1px solid var(--btd-divider, var(--se-border)); + background: var(--btd-surface, var(--se-input-bg)); + color: var(--se-text-muted); +} + +@media (max-width: 960px) { + .seval-chart-hint { + display: none; + } +} diff --git a/frontend/src/utils/analysisChartData.ts b/frontend/src/utils/analysisChartData.ts index e560272..b6c2af9 100644 --- a/frontend/src/utils/analysisChartData.ts +++ b/frontend/src/utils/analysisChartData.ts @@ -61,7 +61,7 @@ export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1 /** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */ const SIGNAL_SNAPSHOT_WARMUP_BARS = 50; -function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number { +export function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number { if (bars.length === 0) return -1; let bestIdx = 0; let bestDist = Math.abs(bars[0].time - candleTimeSec); diff --git a/frontend/src/utils/appNavigationPaths.ts b/frontend/src/utils/appNavigationPaths.ts index 6fcbe0d..926e6b7 100644 --- a/frontend/src/utils/appNavigationPaths.ts +++ b/frontend/src/utils/appNavigationPaths.ts @@ -26,6 +26,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [ P('메뉴-추세검색', 'trend', '추세', '검색'), P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'), P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'), + P('메뉴-전략 평가', 'strategy-evaluation', '전략평가', '조건일치', '일치율'), P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'), P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'), P('메뉴-설정', 'settings', '환경설정'), diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index e55c403..5f1d98c 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1460,17 +1460,40 @@ export interface LiveConditionStatusDto { matchRate: number; rows: LiveConditionRowDto[]; updatedAt: number; + barTimeSec?: number | null; + evalBarIndex?: number | null; + overallEntryMet?: boolean | null; + overallExitMet?: boolean | null; } -/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */ +/** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec 지정 봉 */ export async function fetchLiveConditionStatus( market: string, strategyId: number, + barTimeSec?: number | null, + indicatorParams?: Record> | null, ): Promise { + if (indicatorParams && Object.keys(indicatorParams).length > 0) { + return request('/strategy/live-conditions/evaluate', { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ + market, + strategyId, + barTimeSec: barTimeSec != null && Number.isFinite(barTimeSec) + ? Math.floor(barTimeSec) + : null, + indicatorParams, + }), + }); + } const params = new URLSearchParams({ market, strategyId: String(strategyId), }); + if (barTimeSec != null && Number.isFinite(barTimeSec)) { + params.set('barTimeSec', String(Math.floor(barTimeSec))); + } return request(`/strategy/live-conditions?${params}`, { cache: 'no-store', }); diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index 033a796..1decd02 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -12,7 +12,7 @@ export type SettingsCategoryId = | 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin'; export const TOP_MENU_IDS: TopMenuId[] = [ - 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board', + 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board', ]; export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ @@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ ]; export const ALL_MENU_IDS = [ - 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', + 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', ...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const), ] as const; @@ -36,6 +36,7 @@ export const MENU_LABELS: Record = { 'verification-board': '검증게시판', strategy: '투자전략', 'strategy-editor': '전략편집기', + 'strategy-evaluation': '전략 평가', backtest: '백테스팅', 'analysis-report': '분석레포트', notifications: '알림목록', @@ -80,6 +81,10 @@ export function canAccessMenu( if (permissions['strategy-editor'] === true) return true; return permissions.strategy === true; } + if (menuId === 'strategy-evaluation') { + if (permissions['strategy-evaluation'] === true) return true; + return permissions.backtest === true || permissions['strategy-editor'] === true; + } return permissions[menuId] === true; } diff --git a/frontend/src/utils/strategyEvaluationMerge.ts b/frontend/src/utils/strategyEvaluationMerge.ts new file mode 100644 index 0000000..67355b1 --- /dev/null +++ b/frontend/src/utils/strategyEvaluationMerge.ts @@ -0,0 +1,62 @@ +/** live-conditions 행 ↔ 전략 DSL 행 병합 (useVirtualIndicatorSnapshots 와 동일) */ +import type { LiveConditionRowDto } from './backendApi'; +import { formatIndicatorDisplayLabel } from './indicatorRegistry'; +import { normalizeConditionRow } from './virtualSignalMetrics'; +import type { VirtualConditionRow } from './virtualStrategyConditions'; + +function rowFallbackKey(row: Pick): string { + return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`; +} + +function liveFallbackKey(r: LiveConditionRowDto): string { + const plotKey = r.plotKey ?? r.indicatorType; + return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${plotKey}`; +} + +function liveRowToVirtual(r: LiveConditionRowDto): VirtualConditionRow & { currentValue: number | null } { + const plotKey = r.plotKey ?? r.indicatorType; + return normalizeConditionRow({ + id: r.id, + indicatorType: r.indicatorType, + displayName: formatIndicatorDisplayLabel(r.indicatorType), + conditionType: r.conditionType, + conditionLabel: r.conditionLabel, + targetValue: r.targetValue, + timeframe: r.timeframe, + side: r.side, + plotKey, + satisfied: r.satisfied, + thresholdLabel: r.thresholdLabel, + currentValue: r.currentValue, + }); +} + +export function mergeRows( + base: VirtualConditionRow[], + live: LiveConditionRowDto[], +): Array { + const liveById = new Map(); + const liveByKey = new Map(); + for (const r of live) { + liveById.set(r.id, r); + liveByKey.set(liveFallbackKey(r), r); + } + + if (base.length === 0) { + return live.map(liveRowToVirtual); + } + + return base.map(row => { + const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row)); + if (!liveRow) { + return normalizeConditionRow({ ...row, currentValue: null, satisfied: null }); + } + return normalizeConditionRow({ + ...row, + satisfied: liveRow.satisfied, + thresholdLabel: liveRow.thresholdLabel, + currentValue: liveRow.currentValue, + targetValue: liveRow.targetValue ?? row.targetValue, + }); + }); +} diff --git a/frontend/src/utils/strategyEvaluationParams.ts b/frontend/src/utils/strategyEvaluationParams.ts new file mode 100644 index 0000000..b6fb117 --- /dev/null +++ b/frontend/src/utils/strategyEvaluationParams.ts @@ -0,0 +1,37 @@ +/** + * 전략 평가 — 전략에 포함된 지표 파라미터 맵 + */ +import type { StrategyDto } from './backendApi'; +import { collectStrategyRegistryTypes } from './strategyToChartIndicators'; + +export type EvalIndicatorParams = Record>; + +export function buildEvalParamsFromStrategy( + strategy: StrategyDto | null | undefined, + getParams: (type: string) => Record, +): EvalIndicatorParams { + const types = collectStrategyRegistryTypes(strategy); + const out: EvalIndicatorParams = {}; + for (const type of types) { + out[type] = { ...getParams(type) }; + } + return out; +} + +export function mergeEvalGetParams( + baseGetParams: (type: string, defaults?: Record) => Record, + applied: EvalIndicatorParams | null | undefined, +) { + return (type: string, defaults?: Record) => { + const base = baseGetParams(type, defaults); + const override = applied?.[type]; + return override ? { ...base, ...override } : base; + }; +} + +export function hasEvalParamsDiff( + draft: EvalIndicatorParams, + applied: EvalIndicatorParams | null | undefined, +): boolean { + return JSON.stringify(draft) !== JSON.stringify(applied ?? {}); +} diff --git a/frontend/src/utils/strategyEvaluationSnapshot.ts b/frontend/src/utils/strategyEvaluationSnapshot.ts new file mode 100644 index 0000000..3d3eac1 --- /dev/null +++ b/frontend/src/utils/strategyEvaluationSnapshot.ts @@ -0,0 +1,66 @@ +/** + * 전략 평가 — 특정 봉 시점 조건 일치율 스냅샷 + */ +import { + fetchLiveConditionStatus, + type StrategyDto, +} from './backendApi'; +import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin'; +import { hydrateStrategyDto, normalizeMarketCode } from './strategyHydrate'; +import { extractVirtualConditions } from './virtualStrategyConditions'; +import { coerceFiniteNumber } from './safeFormat'; +import { normalizeConditionRow } from './virtualSignalMetrics'; +import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots'; +import { mergeRows } from './strategyEvaluationMerge'; + +export async function fetchEvaluationSnapshotAtBar( + market: string, + strategyId: number, + strategy: StrategyDto | undefined, + barTimeSec: number | null, + indicatorParams?: Record> | null, +): Promise { + const normalizedMarket = normalizeMarketCode(market); + const hydrated = strategy ? hydrateStrategyDto(strategy) : undefined; + const baseRows = hydrated ? extractVirtualConditions(hydrated) : []; + + try { + await pinStrategyEvaluationTimeframes(normalizedMarket, strategyId); + } catch { + /* pin 실패해도 평가 시도 */ + } + + let status = null; + try { + status = await fetchLiveConditionStatus( + normalizedMarket, + strategyId, + barTimeSec, + indicatorParams, + ); + } catch { + status = null; + } + + if (!status) { + if (baseRows.length === 0) return null; + return { + market: normalizedMarket, + strategyId, + timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), + updatedAt: Date.now(), + matchRate: 0, + }; + } + + const rows = mergeRows(baseRows, status.rows ?? []); + return { + market: normalizedMarket, + strategyId, + timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows, + updatedAt: status.updatedAt || Date.now(), + matchRate: coerceFiniteNumber(status.matchRate) ?? 0, + }; +} diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts index 0b94667..cb83891 100644 --- a/frontend/src/utils/strategyToChartIndicators.ts +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -169,6 +169,13 @@ function collectIndicatorRefs(strategy: StrategyDto, side?: 'BUY' | 'SELL'): Ind return out; } +/** 전략 DSL에 포함된 보조지표 registry type 목록 (중복 제거) */ +export function collectStrategyRegistryTypes(strategy: StrategyDto | null | undefined): string[] { + if (!strategy) return []; + const refs = collectIndicatorRefs(strategy); + return [...new Set(refs.map(r => r.registryType))]; +} + function applyTargetHlines( hlines: HLineDef[], target: number | null | undefined, diff --git a/frontend/src/utils/tradingAccess.ts b/frontend/src/utils/tradingAccess.ts index c3c5c60..b79453a 100644 --- a/frontend/src/utils/tradingAccess.ts +++ b/frontend/src/utils/tradingAccess.ts @@ -15,6 +15,7 @@ export const TRADING_MENU_PAGES = new Set([ 'notifications', 'strategy', 'strategy-editor', + 'strategy-evaluation', 'backtest', ]); diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index 2d02750..1aec226 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -1270,15 +1270,34 @@ export interface LiveConditionStatusDto { updatedAt: number; } -/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */ +/** 백엔드 Ta4j 조건 평가 — 실시간 또는 barTimeSec·지표 파라미터 오버라이드 */ export async function fetchLiveConditionStatus( market: string, strategyId: number, + barTimeSec?: number | null, + indicatorParams?: Record> | null, ): Promise { + if (indicatorParams && Object.keys(indicatorParams).length > 0) { + return request('/strategy/live-conditions/evaluate', { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ + market, + strategyId, + barTimeSec: barTimeSec != null && Number.isFinite(barTimeSec) + ? Math.floor(barTimeSec) + : null, + indicatorParams, + }), + }); + } const params = new URLSearchParams({ market, strategyId: String(strategyId), }); + if (barTimeSec != null && Number.isFinite(barTimeSec)) { + params.set('barTimeSec', String(Math.floor(barTimeSec))); + } return request(`/strategy/live-conditions?${params}`, { cache: 'no-store', });