전략평가 메뉴 추가

This commit is contained in:
Macbook
2026-06-12 14:39:17 +09:00
parent cb1bde2563
commit ae9266bd28
23 changed files with 1977 additions and 19 deletions
@@ -7,7 +7,7 @@ public final class MenuIds {
private MenuIds() {}
public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "analysis-report", "notifications", "settings",
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "strategy-evaluation", "backtest", "analysis-report", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
"settings_trend-search",
@@ -1,5 +1,6 @@
package com.goldenchart.controller;
import com.goldenchart.dto.LiveConditionEvaluateRequest;
import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.service.LiveConditionStatusService;
import lombok.RequiredArgsConstructor;
@@ -12,6 +13,7 @@ import java.util.Map;
* 가상투자 — 실시간 조건 충족 현황 API.
*
* GET /api/strategy/live-conditions?market=KRW-BTC&amp;strategyId=1
* POST /api/strategy/live-conditions/evaluate — 지표 파라미터 오버라이드 (전략 평가)
*/
@RestController
@RequestMapping("/strategy/live-conditions")
@@ -24,9 +26,28 @@ public class LiveConditionController {
public ResponseEntity<LiveConditionStatusDto> get(
@RequestParam String market,
@RequestParam long strategyId,
@RequestParam(required = false) Long barTimeSec,
@RequestHeader Map<String, String> headers) {
long uid = TradingControllerSupport.requireRegisteredUser(headers);
String deviceId = headers.get("x-device-id");
return ResponseEntity.ok(service.evaluate(uid, deviceId, market, strategyId));
return ResponseEntity.ok(service.evaluate(uid, deviceId, market, strategyId, barTimeSec));
}
@PostMapping("/evaluate")
public ResponseEntity<LiveConditionStatusDto> evaluateAtBar(
@RequestBody LiveConditionEvaluateRequest 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()) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(service.evaluate(
uid,
deviceId,
body.getMarket(),
body.getStrategyId(),
body.getBarTimeSec(),
body.getIndicatorParams()));
}
}
@@ -0,0 +1,15 @@
package com.goldenchart.dto;
import lombok.Data;
import java.util.Map;
/** 전략 평가 — 특정 봉 + 지표 파라미터 오버라이드 조건 평가 */
@Data
public class LiveConditionEvaluateRequest {
private String market;
private long strategyId;
private Long barTimeSec;
/** indicatorType → param map (DB 저장값 위에 덮어씀) */
private Map<String, Map<String, Object>> indicatorParams;
}
@@ -41,4 +41,9 @@ public class LiveConditionStatusDto {
private List<LiveConditionRowDto> rows;
private long updatedAt;
/** 평가 기준 봉 시각(초) — null 이면 최신 봉 */
private Long barTimeSec;
/** barTimeSec 에 대응하는 시리즈 내 bar index */
private Integer evalBarIndex;
}
@@ -53,6 +53,21 @@ public class LiveConditionStatusService {
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId) {
return evaluate(userId, deviceId, market, strategyId, null);
}
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId,
Long barTimeSec) {
return evaluate(userId, deviceId, market, strategyId, barTimeSec, null);
}
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId,
Long barTimeSec,
Map<String, Map<String, Object>> indicatorParamsOverride) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty()) {
return empty(market, strategyId);
@@ -60,7 +75,9 @@ public class LiveConditionStatusService {
GcStrategy strategy = opt.get();
Map<String, Map<String, Object>> params =
indicatorSettingsService.getAll(userId, deviceId);
mergeIndicatorParams(
indicatorSettingsService.getAll(userId, deviceId),
indicatorParamsOverride);
Map<String, Map<String, Object>> visual =
indicatorSettingsService.getAllVisual(userId, deviceId);
@@ -72,18 +89,20 @@ public class LiveConditionStatusService {
return empty(market, strategyId);
}
// 마켓 구독 + 비동기 warm-up 시작 — 현재 봉 유무에 따라 즉시 or 수집 후 평가
// 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보
int warmupBars = barTimeSec != null ? 500 : 60;
Set<String> timeframes = new LinkedHashSet<>();
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
timeframes.add(tf);
subscriptionManager.ensureBarsReadySync(market, tf, 60);
subscriptionManager.ensureBarsReadySync(market, tf, warmupBars);
}
subscriptionManager.ensureBarsReadySync(market, "1m", 60);
subscriptionManager.ensureBarsReadySync(market, "1m", warmupBars);
List<LiveConditionRowDto> rows = new ArrayList<>();
int met = 0;
int evaluable = 0;
Integer primaryEvalIndex = null;
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
@@ -96,7 +115,8 @@ public class LiveConditionStatusService {
rows.add(toRowUnevaluated(p, visual));
continue;
}
int index = series.getEndIndex();
int index = resolveBarIndex(series, barTimeSec);
if (primaryEvalIndex == null) primaryEvalIndex = index;
try {
ObjectNode wrapper = objectMapper.createObjectNode();
wrapper.put("type", "CONDITION");
@@ -128,9 +148,9 @@ public class LiveConditionStatusService {
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
Boolean overallEntryMet = evaluateOverallRule(
strategy.getBuyConditionJson(), market, params, visual);
strategy.getBuyConditionJson(), market, params, visual, barTimeSec);
Boolean overallExitMet = evaluateOverallRule(
strategy.getSellConditionJson(), market, params, visual);
strategy.getSellConditionJson(), market, params, visual, barTimeSec);
return LiveConditionStatusDto.builder()
.market(market)
@@ -141,9 +161,36 @@ public class LiveConditionStatusService {
.overallExitMet(overallExitMet)
.rows(rows)
.updatedAt(System.currentTimeMillis())
.barTimeSec(barTimeSec)
.evalBarIndex(primaryEvalIndex)
.build();
}
/**
* barTimeSec(초)에 가장 가까운 bar index — null 이면 최신 봉.
* 차트·업비트 API 와 동일하게 봉 시작(open) 시각 기준으로 매칭한다.
*/
private 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;
}
/** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */
private static long barOpenEpochSec(BarSeries series, int index) {
var bar = series.getBar(index);
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
}
private LiveConditionStatusDto empty(String market, long strategyId) {
return LiveConditionStatusDto.builder()
.market(market)
@@ -169,7 +216,8 @@ public class LiveConditionStatusService {
*/
private Boolean evaluateOverallRule(String conditionJson, String market,
Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual) {
Map<String, Map<String, Object>> visual,
Long barTimeSec) {
if (conditionJson == null || conditionJson.isBlank()) return null;
try {
com.fasterxml.jackson.databind.JsonNode dsl =
@@ -191,7 +239,7 @@ public class LiveConditionStatusService {
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
return rule.isSatisfied(series.getEndIndex(), null);
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
} catch (Exception e) {
log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage());
return null;
@@ -421,4 +469,24 @@ public class LiveConditionStatusService {
return indicatorType;
}
private static Map<String, Map<String, Object>> mergeIndicatorParams(
Map<String, Map<String, Object>> base,
Map<String, Map<String, Object>> override) {
if (override == null || override.isEmpty()) return base;
if (base == null || base.isEmpty()) return override;
Map<String, Map<String, Object>> merged = new LinkedHashMap<>(base);
override.forEach((type, overrideParams) -> {
Map<String, Object> baseParams = merged.get(type);
if (baseParams == null || baseParams.isEmpty()) {
merged.put(type, overrideParams);
} else {
Map<String, Object> inner = new LinkedHashMap<>(baseParams);
inner.putAll(overrideParams);
merged.put(type, inner);
}
});
return merged;
}
}