전략평가 오류 수정

This commit is contained in:
Macbook
2026-06-16 00:36:00 +09:00
parent 133e3d0413
commit e93164deda
6 changed files with 191 additions and 27 deletions
@@ -1,12 +1,15 @@
package com.goldenchart.controller; package com.goldenchart.controller;
import com.goldenchart.dto.BacktestResponse;
import com.goldenchart.dto.LiveConditionEvaluateRequest; import com.goldenchart.dto.LiveConditionEvaluateRequest;
import com.goldenchart.dto.LiveConditionScanSignalsRequest;
import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.service.LiveConditionStatusService; import com.goldenchart.service.LiveConditionStatusService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -52,4 +55,27 @@ public class LiveConditionController {
body.getBars(), body.getBars(),
body.getTimeframe())); body.getTimeframe()));
} }
/** 전략 평가 — 차트 마커용 시그널 스캔 (조건 패널과 동일 Rule) */
@PostMapping("/scan-signals")
public ResponseEntity<List<BacktestResponse.Signal>> scanSignals(
@RequestBody LiveConditionScanSignalsRequest body,
@RequestHeader Map<String, String> 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()));
}
} }
@@ -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<String, Map<String, Object>> indicatorParams;
/** 차트에 표시된 캔들 */
private List<OhlcvBar> bars;
/** bars 의 시간봉 (1m, 3m, …) */
private String timeframe;
/** 평가·표시 구간 봉 수 — null 이면 전체 bars */
private Integer evaluationBarCount;
}
@@ -3,6 +3,7 @@ package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.goldenchart.dto.BacktestResponse;
import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionRowDto;
import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.dto.OhlcvBar; import com.goldenchart.dto.OhlcvBar;
@@ -642,4 +643,92 @@ public class LiveConditionStatusService {
return merged; return merged;
} }
/**
* 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다.
* {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와
* 동일 Rule 엔진을 사용하므로 조건 패널 "충족" 과 차트 마커가 일치한다.
*/
@Transactional(readOnly = true)
public List<BacktestResponse.Signal> scanSignalsOnChartBars(
long userId,
String deviceId,
String market,
long strategyId,
List<OhlcvBar> chartBars,
String chartTimeframe,
Map<String, Map<String, Object>> indicatorParamsOverride,
Integer evaluationBarCount) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty() || chartBars == null || chartBars.isEmpty()
|| chartTimeframe == null || chartTimeframe.isBlank()) {
return List.of();
}
GcStrategy strategy = opt.get();
Map<String, Map<String, Object>> params =
mergeIndicatorParams(
indicatorSettingsService.getAll(userId, deviceId),
indicatorParamsOverride);
Map<String, Map<String, Object>> 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<String, BarSeries> 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<BacktestResponse.Signal> 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();
}
}
} }
@@ -27,6 +27,7 @@ import { getKoreanName } from '../utils/marketNameCache';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import { import {
mergeEvalGetParams, mergeEvalGetParams,
buildEvalParamsFromStrategy,
type EvalIndicatorParams, type EvalIndicatorParams,
} from '../utils/strategyEvaluationParams'; } from '../utils/strategyEvaluationParams';
import { import {
@@ -216,12 +217,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
setEvalLoading(true); setEvalLoading(true);
setEvalError(null); setEvalError(null);
try { try {
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
const snap = await fetchEvaluationSnapshotAtBar( const snap = await fetchEvaluationSnapshotAtBar(
market, market,
selectedStrategyId, selectedStrategyId,
selectedStrategy, selectedStrategy,
selectedBarTimeSec, selectedBarTimeSec,
appliedIndicatorParams, indicatorParams,
bars, bars,
chartTimeframe, chartTimeframe,
); );
@@ -245,7 +247,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
selectedBarIndex, selectedBarIndex,
bars, bars,
chartTimeframe, chartTimeframe,
appliedIndicatorParams, getEvalParams,
]); ]);
useEffect(() => { useEffect(() => {
@@ -14,7 +14,7 @@ import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { getKoreanName } from '../../utils/marketNameCache'; import { getKoreanName } from '../../utils/marketNameCache';
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
import { loadBacktestSettings, runBacktest } from '../../utils/backendApi'; import { fetchLiveConditionScanSignals } from '../../utils/backendApi';
import { import {
BACKTEST_DISPLAY_BAR_COUNT, BACKTEST_DISPLAY_BAR_COUNT,
fetchBacktestCandleBundle, fetchBacktestCandleBundle,
@@ -173,7 +173,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
mgr.setBacktestMarkers(signalsRef.current, true); mgr.setBacktestMarkers(signalsRef.current, true);
}, [strategy]); }, [strategy]);
const runBacktestForBars = useCallback(async (barData: OHLCVBar[]) => { const runSignalScanForBars = useCallback(async (barData: OHLCVBar[]) => {
if (!strategy?.id || barData.length < 10) { if (!strategy?.id || barData.length < 10) {
setSignals([]); setSignals([]);
managerRef.current?.clearBacktestMarkers(); managerRef.current?.clearBacktestMarkers();
@@ -184,10 +184,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
setBacktestRunning(true); setBacktestRunning(true);
setBacktestError(null); setBacktestError(null);
try { try {
const btSettings = await loadBacktestSettings(); const res = await fetchLiveConditionScanSignals(
const res = await runBacktest({ market,
strategyId: strategy.id, strategy.id,
bars: barData.map(b => ({ barData.map(b => ({
time: b.time, time: b.time,
open: b.open, open: b.open,
high: b.high, high: b.high,
@@ -196,24 +196,11 @@ const StrategyEvaluationChart: React.FC<Props> = ({
volume: b.volume, volume: b.volume,
})), })),
timeframe, timeframe,
symbol: market,
strategyName: strategy.name,
settings: {
...btSettings,
// 전략 평가 차트 — 조건 충족 시작 봉마다 매수/매도 마커 (포지션 락 무시)
positionMode: 'SIGNAL_ONLY',
},
indicatorParams, indicatorParams,
evaluationBarCount: evaluationBarCountRef.current, evaluationBarCountRef.current,
}); );
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
if (!res) { setSignals(res);
setSignals([]);
setBacktestError('시그널 계산에 실패했습니다.');
managerRef.current?.clearBacktestMarkers();
return;
}
setSignals(res.signals);
applyMarkers(); applyMarkers();
} catch (e) { } catch (e) {
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
@@ -236,9 +223,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
} }
return; return;
} }
void runBacktestForBars(bars); void runSignalScanForBars(bars);
return () => { ++backtestGenRef.current; }; return () => { ++backtestGenRef.current; };
}, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runBacktestForBars]); }, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runSignalScanForBars]);
useEffect(() => { useEffect(() => {
applyMarkers(); applyMarkers();
@@ -459,7 +446,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
</span> </span>
)} )}
{strategy && signals.length > 0 && !backtestRunning && ( {strategy && signals.length > 0 && !backtestRunning && (
<span className="seval-chart-signal-count" title="백테스트 매매 시그널"> <span className="seval-chart-signal-count" title="조건 충족 매매 시그널">
{signals.length} {signals.length}
</span> </span>
)} )}
+39
View File
@@ -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<string, Record<string, unknown>> | null,
evaluationBarCount?: number | null,
): Promise<BacktestSignal[]> {
if (chartBars.length === 0 || !chartTimeframe) return [];
const list = await request<BacktestSignal[]>('/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 구독용) */ /** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> { export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
if (!hasRegisteredUser()) return ['1m']; if (!hasRegisteredUser()) return ['1m'];