diff --git a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java index 06a8f9c..ec786e0 100644 --- a/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java +++ b/backend/src/main/java/com/goldenchart/controller/LiveConditionController.java @@ -48,6 +48,8 @@ public class LiveConditionController { body.getMarket(), body.getStrategyId(), body.getBarTimeSec(), - body.getIndicatorParams())); + body.getIndicatorParams(), + body.getBars(), + body.getTimeframe())); } } diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java index 7286949..ba55ce7 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionEvaluateRequest.java @@ -2,6 +2,7 @@ package com.goldenchart.dto; import lombok.Data; +import java.util.List; import java.util.Map; /** 전략 평가 — 특정 봉 + 지표 파라미터 오버라이드 조건 평가 */ @@ -12,4 +13,8 @@ public class LiveConditionEvaluateRequest { private Long barTimeSec; /** indicatorType → param map (DB 저장값 위에 덮어씀) */ private Map> indicatorParams; + /** 차트에 표시된 캔들 — 지정 시 Ta4jStorage 대신 동일 bars 로 평가 (백테스트·차트와 일치) */ + private List bars; + /** bars 의 시간봉 (1m, 3m, …) */ + private String timeframe; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 478d1f2..6fa0151 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionStatusDto; +import com.goldenchart.dto.OhlcvBar; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.storage.Ta4jStorage; @@ -68,6 +69,16 @@ public class LiveConditionStatusService { String market, long strategyId, Long barTimeSec, Map> indicatorParamsOverride) { + return evaluate(userId, deviceId, market, strategyId, barTimeSec, indicatorParamsOverride, null, null); + } + + @Transactional(readOnly = true) + public LiveConditionStatusDto evaluate(Long userId, String deviceId, + String market, long strategyId, + Long barTimeSec, + Map> indicatorParamsOverride, + List chartBars, + String chartTimeframe) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) { return empty(market, strategyId); @@ -89,6 +100,11 @@ public class LiveConditionStatusService { return empty(market, strategyId); } + if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) { + return evaluateOnChartBars(strategy, market, strategyId, pending, chartBars, chartTimeframe, + barTimeSec, params, visual); + } + // 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보 int warmupBars = barTimeSec != null ? 500 : 60; Set timeframes = new LinkedHashSet<>(); @@ -191,6 +207,137 @@ public class LiveConditionStatusService { return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); } + /** + * 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지. + */ + private LiveConditionStatusDto evaluateOnChartBars( + GcStrategy strategy, + String market, + long strategyId, + List pending, + List chartBars, + String chartTimeframe, + Long barTimeSec, + Map> params, + Map> visual) { + 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 empty(market, strategyId); + } + + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, primaryTf, buyDsl, sellDsl); + + List rows = new ArrayList<>(); + int met = 0; + int evaluable = 0; + Integer primaryEvalIndex = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); + + for (PendingCond p : pending) { + String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); + BarSeries series = resolveChartSeries(tf, primaryTf, primarySeries, seriesOverrides, market); + if (series == null || series.isEmpty()) { + rows.add(toRowUnevaluated(p, visual)); + continue; + } + int index = OhlcvBarSeriesSupport.resolveBarIndex(series, barTimeSec); + try { + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("type", "CONDITION"); + wrapper.set("condition", p.condition()); + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + series, params, visual, market, ta4jStorage, false, seriesOverrides); + Rule rule = adapter.toRule(wrapper, ctx); + boolean satisfied = rule.isSatisfied(index, null); + Double current = adapter.readConditionFieldValue( + p.condition(), series, params, index, true); + Double target = readTargetNumeric(p.condition(), visual); + if (target == null) { + target = adapter.readConditionFieldValue( + p.condition(), series, params, index, false); + } + rows.add(toRow(p, current, target, satisfied, series, params, index)); + evaluable++; + if (satisfied) met++; + } catch (Exception e) { + log.debug("[LiveCondition:chart] eval fail {} {}: {}", market, p.id(), e.getMessage()); + rows.add(toRowUnevaluated(p, visual)); + } + } + + int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; + Boolean overallEntryMet = evaluateOverallRuleOnChart( + buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + Boolean overallExitMet = evaluateOverallRuleOnChart( + sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + + return LiveConditionStatusDto.builder() + .market(market) + .strategyId(strategyId) + .timeframe(primaryTf) + .matchRate(matchRate) + .overallEntryMet(overallEntryMet) + .overallExitMet(overallExitMet) + .rows(rows) + .updatedAt(System.currentTimeMillis()) + .barTimeSec(barTimeSec) + .evalBarIndex(primaryEvalIndex) + .build(); + } catch (Exception e) { + log.warn("[LiveCondition:chart] evaluate fail market={}: {}", market, e.getMessage()); + return empty(market, strategyId); + } + } + + private BarSeries resolveChartSeries(String tf, String primaryTf, BarSeries primarySeries, + Map overrides, String market) { + String normalized = OhlcvBarSeriesSupport.normalizeTf(tf); + if (overrides.containsKey(normalized)) { + return overrides.get(normalized); + } + if (normalized.equals(primaryTf)) { + return primarySeries; + } + if (ta4jStorage.exists(market, normalized)) { + return ta4jStorage.getOrCreate(market, normalized); + } + return primarySeries; + } + + private Boolean evaluateOverallRuleOnChart(JsonNode dsl, BarSeries primarySeries, + Map seriesOverrides, + String market, + Map> params, + Map> visual, + Long barTimeSec) { + if (dsl == null || dsl.isNull()) return null; + try { + String primaryTf = extractPrimaryTimeframe(dsl); + BarSeries series = resolveChartSeries(primaryTf, OhlcvBarSeriesSupport.normalizeTf(primaryTf), + primarySeries, seriesOverrides, market); + if (series == null || series.isEmpty()) return null; + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + series, params, visual, market, ta4jStorage, false, seriesOverrides); + Rule rule = adapter.toRule(dsl, ctx); + int index = OhlcvBarSeriesSupport.resolveBarIndex(series, barTimeSec); + return rule.isSatisfied(index, null); + } catch (Exception e) { + log.debug("[LiveCondition:chart] overall rule fail market={}: {}", market, e.getMessage()); + return null; + } + } + private LiveConditionStatusDto empty(String market, long strategyId) { return LiveConditionStatusDto.builder() .market(market) diff --git a/backend/src/main/java/com/goldenchart/service/OhlcvBarSeriesSupport.java b/backend/src/main/java/com/goldenchart/service/OhlcvBarSeriesSupport.java new file mode 100644 index 0000000..a7f8418 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/OhlcvBarSeriesSupport.java @@ -0,0 +1,184 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.dto.OhlcvBar; +import lombok.extern.slf4j.Slf4j; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilderFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 차트·백테스트 공통 — OHLCV → Ta4j BarSeries 및 멀티 TF overrides. + */ +@Slf4j +final class OhlcvBarSeriesSupport { + + private OhlcvBarSeriesSupport() {} + + static BarSeries buildSeries(List bars, String timeframe) { + BarSeries series = new BaseBarSeriesBuilder() + .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Duration period = timeframeToDuration(timeframe); + for (OhlcvBar b : bars) { + Instant endInst = Instant.ofEpochSecond(b.getTime()).plus(period); + factory.createBarBuilder(series) + .timePeriod(period).endTime(endInst) + .openPrice(b.getOpen()).highPrice(b.getHigh()) + .lowPrice(b.getLow()).closePrice(b.getClose()) + .volume(b.getVolume()).add(); + } + return series; + } + + static Map buildSeriesOverrides(BarSeries primarySeries, String primaryTf, + JsonNode buyDsl, JsonNode sellDsl) { + Set requiredTfs = new LinkedHashSet<>(); + collectTimeframesFromDsl(buyDsl, requiredTfs); + collectTimeframesFromDsl(sellDsl, requiredTfs); + requiredTfs.remove(primaryTf); + + Map overrides = new LinkedHashMap<>(); + overrides.put(primaryTf, primarySeries); + + for (String tf : requiredTfs) { + Duration targetDur = timeframeToDuration(tf); + Duration primaryDur = timeframeToDuration(primaryTf); + if (targetDur.compareTo(primaryDur) <= 0) continue; + + BarSeries aggregated = aggregateSeries(primarySeries, primaryDur, targetDur, tf); + if (aggregated.getBarCount() > 0) { + overrides.put(tf, aggregated); + } + } + return overrides; + } + + static 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; + } + + static long barOpenEpochSec(BarSeries series, int index) { + var bar = series.getBar(index); + return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + } + + static String normalizeTf(String tf) { + if (tf == null) return "1m"; + return switch (tf.toLowerCase()) { + case "1", "1m" -> "1m"; + case "3", "3m" -> "3m"; + case "5", "5m" -> "5m"; + case "10", "10m" -> "10m"; + case "15", "15m" -> "15m"; + case "30", "30m" -> "30m"; + case "60", "1h" -> "1h"; + case "240", "4h" -> "4h"; + case "1d", "d" -> "1d"; + case "1w", "w" -> "1w"; + default -> tf; + }; + } + + private static Duration timeframeToDuration(String tf) { + if (tf == null) return Duration.ofMinutes(1); + return switch (normalizeTf(tf)) { + case "1m" -> Duration.ofMinutes(1); + case "3m" -> Duration.ofMinutes(3); + case "5m" -> Duration.ofMinutes(5); + case "10m" -> Duration.ofMinutes(10); + case "15m" -> Duration.ofMinutes(15); + case "30m" -> Duration.ofMinutes(30); + case "1h" -> Duration.ofHours(1); + case "4h" -> Duration.ofHours(4); + case "1d" -> Duration.ofDays(1); + case "1w" -> Duration.ofDays(7); + default -> Duration.ofMinutes(1); + }; + } + + private static void collectTimeframesFromDsl(JsonNode node, Set result) { + if (node == null || node.isNull()) return; + String type = node.path("type").asText(""); + if ("TIMEFRAME".equals(type)) { + String ct = node.path("candleType").asText(""); + if (!ct.isBlank()) result.add(normalizeTf(ct)); + } + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode child : children) collectTimeframesFromDsl(child, result); + } + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) collectTimeframesFromDsl(child, result); + JsonNode cond = node.path("condition"); + if (!cond.isMissingNode() && !cond.isNull()) { + String leftCt = cond.path("leftCandleType").asText(""); + String rightCt = cond.path("rightCandleType").asText(""); + if (!leftCt.isBlank()) result.add(normalizeTf(leftCt)); + if (!rightCt.isBlank()) result.add(normalizeTf(rightCt)); + } + } + + private static BarSeries aggregateSeries(BarSeries primary, Duration primaryDur, + Duration targetDur, String targetTf) { + long primarySecs = primaryDur.getSeconds(); + long targetSecs = targetDur.getSeconds(); + + BarSeries result = new BaseBarSeriesBuilder() + .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + + Map> grouped = new LinkedHashMap<>(); + for (int i = primary.getBeginIndex(); i <= primary.getEndIndex(); i++) { + Bar bar = primary.getBar(i); + long barStartSec = bar.getEndTime().getEpochSecond() - primarySecs; + long bucketStart = (barStartSec / targetSecs) * targetSecs; + grouped.computeIfAbsent(bucketStart, k -> new ArrayList<>()).add(bar); + } + + for (Map.Entry> entry : grouped.entrySet()) { + List bars = entry.getValue(); + if (bars.isEmpty()) continue; + + Instant bucketEnd = Instant.ofEpochSecond(entry.getKey()).plus(targetDur); + double open = bars.get(0).getOpenPrice().doubleValue(); + double high = bars.stream().mapToDouble(b -> b.getHighPrice().doubleValue()).max().orElse(open); + double low = bars.stream().mapToDouble(b -> b.getLowPrice().doubleValue()).min().orElse(open); + double close = bars.get(bars.size() - 1).getClosePrice().doubleValue(); + double volume = bars.stream().mapToDouble(b -> b.getVolume().doubleValue()).sum(); + + try { + factory.createBarBuilder(result) + .timePeriod(targetDur).endTime(bucketEnd) + .openPrice(open).highPrice(high).lowPrice(low) + .closePrice(close).volume(volume).add(); + } catch (Exception e) { + log.debug("[OhlcvBarSeries] aggregate fail tf={} bucket={}: {}", targetTf, entry.getKey(), e.getMessage()); + } + } + return result; + } +} diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 8a77798..fa186dd 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -222,6 +222,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { selectedStrategy, selectedBarTimeSec, appliedIndicatorParams, + bars, + chartTimeframe, ); if (session !== evalSessionRef.current) return; setSnapshot(snap ?? undefined); @@ -240,7 +242,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { selectedStrategyId, selectedStrategy, selectedBarTimeSec, - bars.length, + selectedBarIndex, + bars, + chartTimeframe, appliedIndicatorParams, ]); diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx index 150b6a3..b61b0f3 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -55,6 +55,7 @@ const SideCard: React.FC = ({ side, metrics, overallMet, signalAc diff --git a/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx b/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx index 66699f7..3cf6a57 100644 --- a/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx +++ b/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx @@ -4,6 +4,8 @@ import { buildConditionMetrics, computeMatchRate, getTrafficLightState, + resolveSideHeadlineMatchRate, + resolveSideTrafficState, type ConditionMetric, } from '../../utils/virtualSignalMetrics'; import VirtualSignalEqualizer from './VirtualSignalEqualizer'; @@ -15,6 +17,8 @@ interface Props { title?: React.ReactNode; /** 백엔드 집계 matchRate — 미지정 시 metrics 로컬 계산 */ backendMatchRate?: number | null; + /** DSL 전체 Rule 충족 — 헤드라인 일치율·신호등 우선 */ + overallMet?: boolean | null; className?: string; panelClassName?: string; } @@ -23,16 +27,21 @@ const VirtualSignalMatchVisual: React.FC = ({ metrics, title, backendMatchRate, + overallMet, className = '', panelClassName = '', }) => { const matchRate = useMemo( - () => computeMatchRate(metrics, backendMatchRate), - [metrics, backendMatchRate], + () => (overallMet != null + ? resolveSideHeadlineMatchRate(metrics, overallMet) + : computeMatchRate(metrics, backendMatchRate)), + [metrics, backendMatchRate, overallMet], ); const trafficState = useMemo( - () => getTrafficLightState(matchRate, metrics), - [matchRate, metrics], + () => (overallMet != null + ? resolveSideTrafficState(metrics, overallMet) + : getTrafficLightState(matchRate, metrics)), + [matchRate, metrics, overallMet], ); const { visualRef, lightRef } = useSignalVisualRailHeight([ metrics.length, diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 5f1d98c..9759f42 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1472,18 +1472,41 @@ export async function fetchLiveConditionStatus( strategyId: number, barTimeSec?: number | null, indicatorParams?: Record> | null, + chartBars?: Array<{ + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + }> | null, + chartTimeframe?: string | null, ): Promise { - if (indicatorParams && Object.keys(indicatorParams).length > 0) { + const normalizedBarTime = barTimeSec != null && Number.isFinite(barTimeSec) + ? Math.floor(barTimeSec) + : null; + const useChartBars = chartBars != null && chartBars.length > 0 && chartTimeframe; + + if (useChartBars || (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, + barTimeSec: normalizedBarTime, + indicatorParams: indicatorParams ?? undefined, + bars: useChartBars + ? chartBars.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })) + : undefined, + timeframe: useChartBars ? chartTimeframe : undefined, }), }); } @@ -1491,8 +1514,8 @@ export async function fetchLiveConditionStatus( market, strategyId: String(strategyId), }); - if (barTimeSec != null && Number.isFinite(barTimeSec)) { - params.set('barTimeSec', String(Math.floor(barTimeSec))); + if (normalizedBarTime != null) { + params.set('barTimeSec', String(normalizedBarTime)); } return request(`/strategy/live-conditions?${params}`, { cache: 'no-store', diff --git a/frontend/src/utils/strategyEvaluationSnapshot.ts b/frontend/src/utils/strategyEvaluationSnapshot.ts index 29ac5f0..cd9b50f 100644 --- a/frontend/src/utils/strategyEvaluationSnapshot.ts +++ b/frontend/src/utils/strategyEvaluationSnapshot.ts @@ -13,12 +13,16 @@ import { normalizeConditionRow } from './virtualSignalMetrics'; import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots'; import { mergeRows } from './strategyEvaluationMerge'; +import type { OHLCVBar } from '../types'; + export async function fetchEvaluationSnapshotAtBar( market: string, strategyId: number, strategy: StrategyDto | undefined, barTimeSec: number | null, indicatorParams?: Record> | null, + chartBars?: OHLCVBar[] | null, + chartTimeframe?: string | null, ): Promise { const normalizedMarket = normalizeMarketCode(market); const hydrated = strategy ? hydrateStrategyDto(strategy) : undefined; @@ -37,6 +41,8 @@ export async function fetchEvaluationSnapshotAtBar( strategyId, barTimeSec, indicatorParams, + chartBars, + chartTimeframe, ); } catch { status = null; diff --git a/frontend/src/utils/virtualSignalMetrics.ts b/frontend/src/utils/virtualSignalMetrics.ts index d56cc39..1c7a6ae 100644 --- a/frontend/src/utils/virtualSignalMetrics.ts +++ b/frontend/src/utils/virtualSignalMetrics.ts @@ -126,6 +126,25 @@ export function getTrafficLightState(matchRate: number, metrics: ConditionMetric return 'red'; } +/** side 카드 헤드라인 — DSL 전체 Rule 충족 시 100% (리프 비율과 구분) */ +export function resolveSideHeadlineMatchRate( + metrics: ConditionMetric[], + overallMet?: boolean | null, +): number { + if (overallMet === true) return 100; + return computeMatchRate(metrics); +} + +export function resolveSideTrafficState( + metrics: ConditionMetric[], + overallMet?: boolean | null, +): TrafficLightState { + const rate = resolveSideHeadlineMatchRate(metrics, overallMet); + if (overallMet === true) return 'blue'; + if (overallMet === false && rate === 0) return 'red'; + return getTrafficLightState(rate, metrics); +} + export function formatUpdatedTime(ts: number | undefined): string { if (!ts) return '—'; return new Date(ts).toLocaleTimeString('ko-KR', {