전략평가 오류 수정
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
Reference in New Issue
Block a user