From e93164deda1bab3562ce2fa2aaaa631437598af3 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 16 Jun 2026 00:36:00 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/LiveConditionController.java | 26 ++++++ .../dto/LiveConditionScanSignalsRequest.java | 21 +++++ .../service/LiveConditionStatusService.java | 89 +++++++++++++++++++ .../src/components/StrategyEvaluationPage.tsx | 6 +- .../StrategyEvaluationChart.tsx | 37 +++----- frontend/src/utils/backendApi.ts | 39 ++++++++ 6 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java diff --git a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java index ec786e0..1ce567c 100644 --- a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java +++ b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java @@ -1,12 +1,15 @@ package com.goldenchart.controller; +import com.goldenchart.dto.BacktestResponse; import com.goldenchart.dto.LiveConditionEvaluateRequest; +import com.goldenchart.dto.LiveConditionScanSignalsRequest; import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.service.LiveConditionStatusService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Map; /** @@ -52,4 +55,27 @@ public class LiveConditionController { body.getBars(), body.getTimeframe())); } + + /** 전략 평가 — 차트 마커용 시그널 스캔 (조건 패널과 동일 Rule) */ + @PostMapping("/scan-signals") + public ResponseEntity> scanSignals( + @RequestBody LiveConditionScanSignalsRequest 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() + || body.getBars() == null || body.getBars().isEmpty() + || body.getTimeframe() == null || body.getTimeframe().isBlank()) { + return ResponseEntity.badRequest().build(); + } + return ResponseEntity.ok(service.scanSignalsOnChartBars( + uid, + deviceId, + body.getMarket(), + body.getStrategyId(), + body.getBars(), + body.getTimeframe(), + body.getIndicatorParams(), + body.getEvaluationBarCount())); + } } diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java new file mode 100644 index 0000000..bfe637d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionScanSignalsRequest.java @@ -0,0 +1,21 @@ +package com.goldenchart.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** 전략 평가 — 차트 봉 구간별 매수/매도 시그널 스캔 (live-conditions 와 동일 Rule 엔진) */ +@Data +public class LiveConditionScanSignalsRequest { + private String market; + private long strategyId; + /** indicatorType → param map (DB 저장값 위에 덮어씀) */ + private Map> indicatorParams; + /** 차트에 표시된 캔들 */ + private List bars; + /** bars 의 시간봉 (1m, 3m, …) */ + private String timeframe; + /** 평가·표시 구간 봉 수 — null 이면 전체 bars */ + private Integer evaluationBarCount; +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 190bc9e..9460778 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -3,6 +3,7 @@ package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.goldenchart.dto.BacktestResponse; import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.OhlcvBar; @@ -642,4 +643,92 @@ public class LiveConditionStatusService { return merged; } + /** + * 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다. + * {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와 + * 동일 Rule 엔진을 사용하므로 조건 패널 "충족" 과 차트 마커가 일치한다. + */ + @Transactional(readOnly = true) + public List scanSignalsOnChartBars( + long userId, + String deviceId, + String market, + long strategyId, + List chartBars, + String chartTimeframe, + Map> indicatorParamsOverride, + Integer evaluationBarCount) { + Optional opt = strategyRepo.findById(strategyId); + if (opt.isEmpty() || chartBars == null || chartBars.isEmpty() + || chartTimeframe == null || chartTimeframe.isBlank()) { + return List.of(); + } + GcStrategy strategy = opt.get(); + + Map> params = + mergeIndicatorParams( + indicatorSettingsService.getAll(userId, deviceId), + indicatorParamsOverride); + Map> visual = + indicatorSettingsService.getAllVisual(userId, deviceId); + + try { + JsonNode buyDsl = objectMapper.readTree( + strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"); + JsonNode sellDsl = objectMapper.readTree( + strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"); + + String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf); + if (primarySeries.isEmpty()) { + return List.of(); + } + + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, primaryTf, buyDsl, sellDsl); + + int evalCount = evaluationBarCount != null && evaluationBarCount > 0 + ? evaluationBarCount + : primarySeries.getBarCount(); + int startIdx = Math.max( + primarySeries.getBeginIndex(), + primarySeries.getEndIndex() - evalCount + 1); + + List signals = new ArrayList<>(); + for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) { + long barTimeSec = barOpenEpochSec(primarySeries, i); + double close = primarySeries.getBar(i).getClosePrice().doubleValue(); + + Boolean entryMet = evaluateOverallRuleOnChart( + buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + if (Boolean.TRUE.equals(entryMet)) { + signals.add(BacktestResponse.Signal.builder() + .time(barTimeSec) + .type("BUY") + .price(close) + .barIndex(i) + .quantity(0) + .build()); + } + + Boolean exitMet = evaluateOverallRuleOnChart( + sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + if (Boolean.TRUE.equals(exitMet)) { + signals.add(BacktestResponse.Signal.builder() + .time(barTimeSec) + .type("SELL") + .price(close) + .barIndex(i) + .quantity(0) + .build()); + } + } + return signals; + } catch (Exception e) { + log.warn("[LiveCondition:scan] fail market={} strategy={}: {}", + market, strategyId, e.getMessage()); + return List.of(); + } + } + } diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index af622c7..11b7f54 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -27,6 +27,7 @@ import { getKoreanName } from '../utils/marketNameCache'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { mergeEvalGetParams, + buildEvalParamsFromStrategy, type EvalIndicatorParams, } from '../utils/strategyEvaluationParams'; import { @@ -216,12 +217,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { setEvalLoading(true); setEvalError(null); try { + const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); const snap = await fetchEvaluationSnapshotAtBar( market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, - appliedIndicatorParams, + indicatorParams, bars, chartTimeframe, ); @@ -245,7 +247,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { selectedBarIndex, bars, chartTimeframe, - appliedIndicatorParams, + getEvalParams, ]); useEffect(() => { diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 3375f14..76f7503 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -14,7 +14,7 @@ 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 { fetchLiveConditionScanSignals } from '../../utils/backendApi'; import { BACKTEST_DISPLAY_BAR_COUNT, fetchBacktestCandleBundle, @@ -173,7 +173,7 @@ const StrategyEvaluationChart: React.FC = ({ mgr.setBacktestMarkers(signalsRef.current, true); }, [strategy]); - const runBacktestForBars = useCallback(async (barData: OHLCVBar[]) => { + const runSignalScanForBars = useCallback(async (barData: OHLCVBar[]) => { if (!strategy?.id || barData.length < 10) { setSignals([]); managerRef.current?.clearBacktestMarkers(); @@ -184,10 +184,10 @@ const StrategyEvaluationChart: React.FC = ({ setBacktestRunning(true); setBacktestError(null); try { - const btSettings = await loadBacktestSettings(); - const res = await runBacktest({ - strategyId: strategy.id, - bars: barData.map(b => ({ + const res = await fetchLiveConditionScanSignals( + market, + strategy.id, + barData.map(b => ({ time: b.time, open: b.open, high: b.high, @@ -196,24 +196,11 @@ const StrategyEvaluationChart: React.FC = ({ volume: b.volume, })), timeframe, - symbol: market, - strategyName: strategy.name, - settings: { - ...btSettings, - // 전략 평가 차트 — 조건 충족 시작 봉마다 매수/매도 마커 (포지션 락 무시) - positionMode: 'SIGNAL_ONLY', - }, indicatorParams, - evaluationBarCount: evaluationBarCountRef.current, - }); + evaluationBarCountRef.current, + ); if (gen !== backtestGenRef.current) return; - if (!res) { - setSignals([]); - setBacktestError('시그널 계산에 실패했습니다.'); - managerRef.current?.clearBacktestMarkers(); - return; - } - setSignals(res.signals); + setSignals(res); applyMarkers(); } catch (e) { if (gen !== backtestGenRef.current) return; @@ -236,9 +223,9 @@ const StrategyEvaluationChart: React.FC = ({ } return; } - void runBacktestForBars(bars); + void runSignalScanForBars(bars); return () => { ++backtestGenRef.current; }; - }, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runBacktestForBars]); + }, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runSignalScanForBars]); useEffect(() => { applyMarkers(); @@ -459,7 +446,7 @@ const StrategyEvaluationChart: React.FC = ({ )} {strategy && signals.length > 0 && !backtestRunning && ( - + 시그널 {signals.length}건 )} diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 5c60d2c..fa582bd 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1619,6 +1619,45 @@ export async function fetchLiveConditionStatus( }); } +/** 전략 평가 — 차트 마커용 시그널 스캔 (live-conditions 와 동일 Rule) */ +export async function fetchLiveConditionScanSignals( + market: string, + strategyId: number, + chartBars: Array<{ + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + }>, + chartTimeframe: string, + indicatorParams?: Record> | null, + evaluationBarCount?: number | null, +): Promise { + if (chartBars.length === 0 || !chartTimeframe) return []; + const list = await request('/strategy/live-conditions/scan-signals', { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ + market, + strategyId, + indicatorParams: indicatorParams ?? undefined, + bars: chartBars.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })), + timeframe: chartTimeframe, + evaluationBarCount: evaluationBarCount ?? undefined, + }), + }); + return list ?? []; +} + /** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */ export async function loadStrategyTimeframes(strategyId: number): Promise { if (!hasRegisteredUser()) return ['1m'];