전략평가 화면 수정
This commit is contained in:
@@ -48,6 +48,8 @@ public class LiveConditionController {
|
|||||||
body.getMarket(),
|
body.getMarket(),
|
||||||
body.getStrategyId(),
|
body.getStrategyId(),
|
||||||
body.getBarTimeSec(),
|
body.getBarTimeSec(),
|
||||||
body.getIndicatorParams()));
|
body.getIndicatorParams(),
|
||||||
|
body.getBars(),
|
||||||
|
body.getTimeframe()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.goldenchart.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** 전략 평가 — 특정 봉 + 지표 파라미터 오버라이드 조건 평가 */
|
/** 전략 평가 — 특정 봉 + 지표 파라미터 오버라이드 조건 평가 */
|
||||||
@@ -12,4 +13,8 @@ public class LiveConditionEvaluateRequest {
|
|||||||
private Long barTimeSec;
|
private Long barTimeSec;
|
||||||
/** indicatorType → param map (DB 저장값 위에 덮어씀) */
|
/** indicatorType → param map (DB 저장값 위에 덮어씀) */
|
||||||
private Map<String, Map<String, Object>> indicatorParams;
|
private Map<String, Map<String, Object>> indicatorParams;
|
||||||
|
/** 차트에 표시된 캔들 — 지정 시 Ta4jStorage 대신 동일 bars 로 평가 (백테스트·차트와 일치) */
|
||||||
|
private List<OhlcvBar> bars;
|
||||||
|
/** bars 의 시간봉 (1m, 3m, …) */
|
||||||
|
private String timeframe;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
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.entity.GcStrategy;
|
import com.goldenchart.entity.GcStrategy;
|
||||||
import com.goldenchart.repository.GcStrategyRepository;
|
import com.goldenchart.repository.GcStrategyRepository;
|
||||||
import com.goldenchart.storage.Ta4jStorage;
|
import com.goldenchart.storage.Ta4jStorage;
|
||||||
@@ -68,6 +69,16 @@ public class LiveConditionStatusService {
|
|||||||
String market, long strategyId,
|
String market, long strategyId,
|
||||||
Long barTimeSec,
|
Long barTimeSec,
|
||||||
Map<String, Map<String, Object>> indicatorParamsOverride) {
|
Map<String, Map<String, Object>> 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<String, Map<String, Object>> indicatorParamsOverride,
|
||||||
|
List<OhlcvBar> chartBars,
|
||||||
|
String chartTimeframe) {
|
||||||
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
||||||
if (opt.isEmpty()) {
|
if (opt.isEmpty()) {
|
||||||
return empty(market, strategyId);
|
return empty(market, strategyId);
|
||||||
@@ -89,6 +100,11 @@ public class LiveConditionStatusService {
|
|||||||
return empty(market, strategyId);
|
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 — 과거 봉 평가 시 더 많은 이력 확보
|
// 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보
|
||||||
int warmupBars = barTimeSec != null ? 500 : 60;
|
int warmupBars = barTimeSec != null ? 500 : 60;
|
||||||
Set<String> timeframes = new LinkedHashSet<>();
|
Set<String> timeframes = new LinkedHashSet<>();
|
||||||
@@ -191,6 +207,137 @@ public class LiveConditionStatusService {
|
|||||||
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
|
||||||
|
*/
|
||||||
|
private LiveConditionStatusDto evaluateOnChartBars(
|
||||||
|
GcStrategy strategy,
|
||||||
|
String market,
|
||||||
|
long strategyId,
|
||||||
|
List<PendingCond> pending,
|
||||||
|
List<OhlcvBar> chartBars,
|
||||||
|
String chartTimeframe,
|
||||||
|
Long barTimeSec,
|
||||||
|
Map<String, Map<String, Object>> params,
|
||||||
|
Map<String, Map<String, Object>> 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<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
||||||
|
primarySeries, primaryTf, buyDsl, sellDsl);
|
||||||
|
|
||||||
|
List<LiveConditionRowDto> 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<String, BarSeries> 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<String, BarSeries> seriesOverrides,
|
||||||
|
String market,
|
||||||
|
Map<String, Map<String, Object>> params,
|
||||||
|
Map<String, Map<String, Object>> 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) {
|
private LiveConditionStatusDto empty(String market, long strategyId) {
|
||||||
return LiveConditionStatusDto.builder()
|
return LiveConditionStatusDto.builder()
|
||||||
.market(market)
|
.market(market)
|
||||||
|
|||||||
@@ -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<OhlcvBar> 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<String, BarSeries> buildSeriesOverrides(BarSeries primarySeries, String primaryTf,
|
||||||
|
JsonNode buyDsl, JsonNode sellDsl) {
|
||||||
|
Set<String> requiredTfs = new LinkedHashSet<>();
|
||||||
|
collectTimeframesFromDsl(buyDsl, requiredTfs);
|
||||||
|
collectTimeframesFromDsl(sellDsl, requiredTfs);
|
||||||
|
requiredTfs.remove(primaryTf);
|
||||||
|
|
||||||
|
Map<String, BarSeries> 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<String> 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<Long, List<Bar>> 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<Long, List<Bar>> entry : grouped.entrySet()) {
|
||||||
|
List<Bar> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
selectedStrategy,
|
selectedStrategy,
|
||||||
selectedBarTimeSec,
|
selectedBarTimeSec,
|
||||||
appliedIndicatorParams,
|
appliedIndicatorParams,
|
||||||
|
bars,
|
||||||
|
chartTimeframe,
|
||||||
);
|
);
|
||||||
if (session !== evalSessionRef.current) return;
|
if (session !== evalSessionRef.current) return;
|
||||||
setSnapshot(snap ?? undefined);
|
setSnapshot(snap ?? undefined);
|
||||||
@@ -240,7 +242,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
selectedStrategyId,
|
selectedStrategyId,
|
||||||
selectedStrategy,
|
selectedStrategy,
|
||||||
selectedBarTimeSec,
|
selectedBarTimeSec,
|
||||||
bars.length,
|
selectedBarIndex,
|
||||||
|
bars,
|
||||||
|
chartTimeframe,
|
||||||
appliedIndicatorParams,
|
appliedIndicatorParams,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalAc
|
|||||||
<VirtualSignalMatchVisual
|
<VirtualSignalMatchVisual
|
||||||
metrics={metrics}
|
metrics={metrics}
|
||||||
title="SIGNAL INTELLIGENCE & MATCH RATES"
|
title="SIGNAL INTELLIGENCE & MATCH RATES"
|
||||||
|
overallMet={overallMet}
|
||||||
panelClassName="seval-side-sig-panel"
|
panelClassName="seval-side-sig-panel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
buildConditionMetrics,
|
buildConditionMetrics,
|
||||||
computeMatchRate,
|
computeMatchRate,
|
||||||
getTrafficLightState,
|
getTrafficLightState,
|
||||||
|
resolveSideHeadlineMatchRate,
|
||||||
|
resolveSideTrafficState,
|
||||||
type ConditionMetric,
|
type ConditionMetric,
|
||||||
} from '../../utils/virtualSignalMetrics';
|
} from '../../utils/virtualSignalMetrics';
|
||||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||||
@@ -15,6 +17,8 @@ interface Props {
|
|||||||
title?: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
/** 백엔드 집계 matchRate — 미지정 시 metrics 로컬 계산 */
|
/** 백엔드 집계 matchRate — 미지정 시 metrics 로컬 계산 */
|
||||||
backendMatchRate?: number | null;
|
backendMatchRate?: number | null;
|
||||||
|
/** DSL 전체 Rule 충족 — 헤드라인 일치율·신호등 우선 */
|
||||||
|
overallMet?: boolean | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
}
|
}
|
||||||
@@ -23,16 +27,21 @@ const VirtualSignalMatchVisual: React.FC<Props> = ({
|
|||||||
metrics,
|
metrics,
|
||||||
title,
|
title,
|
||||||
backendMatchRate,
|
backendMatchRate,
|
||||||
|
overallMet,
|
||||||
className = '',
|
className = '',
|
||||||
panelClassName = '',
|
panelClassName = '',
|
||||||
}) => {
|
}) => {
|
||||||
const matchRate = useMemo(
|
const matchRate = useMemo(
|
||||||
() => computeMatchRate(metrics, backendMatchRate),
|
() => (overallMet != null
|
||||||
[metrics, backendMatchRate],
|
? resolveSideHeadlineMatchRate(metrics, overallMet)
|
||||||
|
: computeMatchRate(metrics, backendMatchRate)),
|
||||||
|
[metrics, backendMatchRate, overallMet],
|
||||||
);
|
);
|
||||||
const trafficState = useMemo(
|
const trafficState = useMemo(
|
||||||
() => getTrafficLightState(matchRate, metrics),
|
() => (overallMet != null
|
||||||
[matchRate, metrics],
|
? resolveSideTrafficState(metrics, overallMet)
|
||||||
|
: getTrafficLightState(matchRate, metrics)),
|
||||||
|
[matchRate, metrics, overallMet],
|
||||||
);
|
);
|
||||||
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||||
metrics.length,
|
metrics.length,
|
||||||
|
|||||||
@@ -1472,18 +1472,41 @@ export async function fetchLiveConditionStatus(
|
|||||||
strategyId: number,
|
strategyId: number,
|
||||||
barTimeSec?: number | null,
|
barTimeSec?: number | null,
|
||||||
indicatorParams?: Record<string, Record<string, unknown>> | null,
|
indicatorParams?: Record<string, Record<string, unknown>> | null,
|
||||||
|
chartBars?: Array<{
|
||||||
|
time: number;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
}> | null,
|
||||||
|
chartTimeframe?: string | null,
|
||||||
): Promise<LiveConditionStatusDto | null> {
|
): Promise<LiveConditionStatusDto | null> {
|
||||||
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<LiveConditionStatusDto>('/strategy/live-conditions/evaluate', {
|
return request<LiveConditionStatusDto>('/strategy/live-conditions/evaluate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
market,
|
market,
|
||||||
strategyId,
|
strategyId,
|
||||||
barTimeSec: barTimeSec != null && Number.isFinite(barTimeSec)
|
barTimeSec: normalizedBarTime,
|
||||||
? Math.floor(barTimeSec)
|
indicatorParams: indicatorParams ?? undefined,
|
||||||
: null,
|
bars: useChartBars
|
||||||
indicatorParams,
|
? 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,
|
market,
|
||||||
strategyId: String(strategyId),
|
strategyId: String(strategyId),
|
||||||
});
|
});
|
||||||
if (barTimeSec != null && Number.isFinite(barTimeSec)) {
|
if (normalizedBarTime != null) {
|
||||||
params.set('barTimeSec', String(Math.floor(barTimeSec)));
|
params.set('barTimeSec', String(normalizedBarTime));
|
||||||
}
|
}
|
||||||
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
|
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ import { normalizeConditionRow } from './virtualSignalMetrics';
|
|||||||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||||
import { mergeRows } from './strategyEvaluationMerge';
|
import { mergeRows } from './strategyEvaluationMerge';
|
||||||
|
|
||||||
|
import type { OHLCVBar } from '../types';
|
||||||
|
|
||||||
export async function fetchEvaluationSnapshotAtBar(
|
export async function fetchEvaluationSnapshotAtBar(
|
||||||
market: string,
|
market: string,
|
||||||
strategyId: number,
|
strategyId: number,
|
||||||
strategy: StrategyDto | undefined,
|
strategy: StrategyDto | undefined,
|
||||||
barTimeSec: number | null,
|
barTimeSec: number | null,
|
||||||
indicatorParams?: Record<string, Record<string, unknown>> | null,
|
indicatorParams?: Record<string, Record<string, unknown>> | null,
|
||||||
|
chartBars?: OHLCVBar[] | null,
|
||||||
|
chartTimeframe?: string | null,
|
||||||
): Promise<VirtualIndicatorSnapshot | null> {
|
): Promise<VirtualIndicatorSnapshot | null> {
|
||||||
const normalizedMarket = normalizeMarketCode(market);
|
const normalizedMarket = normalizeMarketCode(market);
|
||||||
const hydrated = strategy ? hydrateStrategyDto(strategy) : undefined;
|
const hydrated = strategy ? hydrateStrategyDto(strategy) : undefined;
|
||||||
@@ -37,6 +41,8 @@ export async function fetchEvaluationSnapshotAtBar(
|
|||||||
strategyId,
|
strategyId,
|
||||||
barTimeSec,
|
barTimeSec,
|
||||||
indicatorParams,
|
indicatorParams,
|
||||||
|
chartBars,
|
||||||
|
chartTimeframe,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
status = null;
|
status = null;
|
||||||
|
|||||||
@@ -126,6 +126,25 @@ export function getTrafficLightState(matchRate: number, metrics: ConditionMetric
|
|||||||
return 'red';
|
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 {
|
export function formatUpdatedTime(ts: number | undefined): string {
|
||||||
if (!ts) return '—';
|
if (!ts) return '—';
|
||||||
return new Date(ts).toLocaleTimeString('ko-KR', {
|
return new Date(ts).toLocaleTimeString('ko-KR', {
|
||||||
|
|||||||
Reference in New Issue
Block a user