지표탭 추가 수정
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
@@ -9,8 +7,9 @@ import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ta4j.core.*;
|
||||
import org.ta4j.core.rules.BooleanRule;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseTradingRecord;
|
||||
import org.ta4j.core.Rule;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -51,17 +50,18 @@ public class LiveStrategyEvaluator {
|
||||
private final GcLiveStrategySettingsRepository settingsRepo;
|
||||
private final GcStrategyRepository strategyRepo;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final StrategyDslToTa4jAdapter adapter;
|
||||
private final IndicatorSettingsService indicatorSettingsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategySignalDeterminer determiner;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
private final StrategyTriggerBranchEvaluator triggerBranchEvaluator;
|
||||
private final StrategyBranchStateCache branchStateCache;
|
||||
|
||||
/**
|
||||
* Strategy 캐시: "market:candleType:strategyId" → Strategy
|
||||
* (entryRule / exitRule 포함)
|
||||
* 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule)
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Strategy> strategyCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, TriggerRules> triggerRulesCache = new ConcurrentHashMap<>();
|
||||
|
||||
private record TriggerRules(Rule entryRule, Rule exitRule) {}
|
||||
|
||||
/**
|
||||
* LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord
|
||||
@@ -157,12 +157,9 @@ public class LiveStrategyEvaluator {
|
||||
if (s.getStrategyId() == null) continue;
|
||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||
|
||||
// ★ Ta4j CachedIndicator 캐시 무효화:
|
||||
// updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는
|
||||
// 이미 캐시된 구 값을 반환한다. 전략을 재빌드하면 새 인디케이터 인스턴스를
|
||||
// 생성(빈 캐시)하므로 getValue(currentIndex) 가 현재 가격으로 재계산된다.
|
||||
// ★ Ta4j CachedIndicator 캐시 무효화 — Rule 트리 재빌드
|
||||
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||||
strategyCache.remove(cacheKey);
|
||||
triggerRulesCache.remove(cacheKey);
|
||||
|
||||
String result = evaluate(market, candleType, s.getStrategyId(),
|
||||
s.getPositionMode(), currentIndex,
|
||||
@@ -205,7 +202,7 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
// CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산
|
||||
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||||
strategyCache.remove(cacheKey);
|
||||
triggerRulesCache.remove(cacheKey);
|
||||
|
||||
String result = evaluate(market, candleType, s.getStrategyId(),
|
||||
s.getPositionMode(), maturedIndex,
|
||||
@@ -222,10 +219,20 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
/** 설정 변경 시 해당 마켓 캐시 무효화 */
|
||||
public void invalidateCache(String market) {
|
||||
strategyCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||
triggerRulesCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||
tradingRecordCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||
positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||
realtimeSignaledIdx.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||
branchStateCache.invalidateMarket(market);
|
||||
}
|
||||
|
||||
/** 전략 DSL 변경 시 Rule·분기 상태 캐시 무효화 */
|
||||
public void invalidateStrategy(long strategyId) {
|
||||
String suffix = ":" + strategyId;
|
||||
triggerRulesCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||
tradingRecordCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||
positionOpenCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||
branchStateCache.invalidateStrategy(strategyId);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
@@ -235,30 +242,27 @@ public class LiveStrategyEvaluator {
|
||||
String deviceId, Long userId) {
|
||||
String cacheKey = market + ":" + candleType + ":" + strategyId;
|
||||
|
||||
// computeIfAbsent 는 device context 전달이 불가하므 수동 분기
|
||||
Strategy strategy = strategyCache.get(cacheKey);
|
||||
if (strategy == null) {
|
||||
strategy = buildStrategy(market, candleType, strategyId, deviceId, userId);
|
||||
if (strategy != null) strategyCache.put(cacheKey, strategy);
|
||||
TriggerRules rules = triggerRulesCache.get(cacheKey);
|
||||
if (rules == null) {
|
||||
rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId);
|
||||
if (rules == null) return "NONE";
|
||||
triggerRulesCache.put(cacheKey, rules);
|
||||
}
|
||||
|
||||
if (strategy == null) return "NONE";
|
||||
|
||||
try {
|
||||
String mode = positionMode != null ? positionMode : "LONG_ONLY";
|
||||
|
||||
if ("SIGNAL_ONLY".equals(mode)) {
|
||||
// 포지션 락 우회 — 순수 Rule 충족 여부만 판단
|
||||
return determiner.determineSignal(strategy, null, index, "SIGNAL_ONLY");
|
||||
return determiner.determineSignalFromRules(
|
||||
rules.entryRule(), rules.exitRule(), null, index, "SIGNAL_ONLY");
|
||||
}
|
||||
|
||||
// LONG_ONLY — TradingRecord 상태를 유지하여 포지션 검증
|
||||
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
||||
cacheKey, k -> new BaseTradingRecord());
|
||||
boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey));
|
||||
String signal = determiner.determineSignal(strategy, record, index, "LONG_ONLY");
|
||||
String signal = determiner.determineSignalFromRules(
|
||||
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
|
||||
|
||||
// 시그널에 따라 TradingRecord 갱신 및 포지션 상태 추적
|
||||
if ("BUY".equals(signal) && !isOpen) {
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
|
||||
@@ -270,21 +274,20 @@ public class LiveStrategyEvaluator {
|
||||
record.exit(index, price, series.numFactory().numOf(1));
|
||||
positionOpenCache.put(cacheKey, false);
|
||||
} else if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||||
// 포지션 상태와 불일치 (이미 매수 중인데 또 BUY 등) → 신호 무시
|
||||
signal = "NONE";
|
||||
}
|
||||
return signal;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage());
|
||||
strategyCache.remove(cacheKey);
|
||||
triggerRulesCache.remove(cacheKey);
|
||||
tradingRecordCache.remove(cacheKey);
|
||||
}
|
||||
return "NONE";
|
||||
}
|
||||
|
||||
private Strategy buildStrategy(String market, String candleType, long strategyId,
|
||||
String deviceId, Long userId) {
|
||||
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
|
||||
String deviceId, Long userId) {
|
||||
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
|
||||
if (strategyOpt.isEmpty()) return null;
|
||||
|
||||
@@ -292,7 +295,6 @@ public class LiveStrategyEvaluator {
|
||||
if (!ta4jStorage.exists(market, candleType)) return null;
|
||||
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||
// 사용자·장치별 지표 파라미터 우선 로드 (없으면 빈 맵 → adapter 기본값 사용)
|
||||
Map<String, Map<String, Object>> indicatorParams =
|
||||
indicatorSettingsService.getAll(userId, deviceId);
|
||||
|
||||
@@ -302,29 +304,18 @@ public class LiveStrategyEvaluator {
|
||||
strategyId, market, barCount);
|
||||
return null;
|
||||
}
|
||||
log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount);
|
||||
|
||||
try {
|
||||
Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams, market);
|
||||
Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams, market);
|
||||
BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule);
|
||||
return builtStrategy;
|
||||
Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
|
||||
strategy.getBuyConditionJson(), market, strategyId, "buy",
|
||||
candleType, indicatorParams, ta4jStorage);
|
||||
Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
|
||||
strategy.getSellConditionJson(), market, strategyId, "sell",
|
||||
candleType, indicatorParams, ta4jStorage);
|
||||
return new TriggerRules(entryRule, exitRule);
|
||||
} catch (Exception e) {
|
||||
log.error("[Evaluator] Strategy 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
|
||||
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Rule buildRule(String conditionJson, BarSeries series,
|
||||
Map<String, Map<String, Object>> params,
|
||||
String market) {
|
||||
if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false);
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(conditionJson);
|
||||
return adapter.toRule(node, series, params, market, ta4jStorage);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage());
|
||||
return new BooleanRule(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 멀티 START(AND) 전략 — 각 분봉 분기의 마지막 마감봉 평가 결과 캐시.
|
||||
* 비트리거 분봉은 자신의 마감 시점에만 갱신된다.
|
||||
*/
|
||||
@Component
|
||||
public class StrategyBranchStateCache {
|
||||
|
||||
public record BranchState(boolean satisfied, int barIndex) {}
|
||||
|
||||
private final ConcurrentHashMap<String, BranchState> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private static String key(String market, long strategyId, String side, String candleType) {
|
||||
return market + ":" + strategyId + ":" + side + ":"
|
||||
+ LiveStrategyTimeframeService.normalize(candleType);
|
||||
}
|
||||
|
||||
public void put(String market, long strategyId, String side, String candleType,
|
||||
boolean satisfied, int barIndex) {
|
||||
cache.put(key(market, strategyId, side, candleType),
|
||||
new BranchState(satisfied, barIndex));
|
||||
}
|
||||
|
||||
public BranchState get(String market, long strategyId, String side, String candleType) {
|
||||
return cache.get(key(market, strategyId, side, candleType));
|
||||
}
|
||||
|
||||
public void invalidateMarket(String market) {
|
||||
cache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||
}
|
||||
|
||||
public void invalidateStrategy(long strategyId) {
|
||||
String prefix = ":" + strategyId + ":";
|
||||
cache.keySet().removeIf(k -> k.contains(prefix));
|
||||
}
|
||||
|
||||
public void invalidateAll() {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class StrategyService {
|
||||
private final GcStrategyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
|
||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +65,7 @@ public class StrategyService {
|
||||
|
||||
GcStrategy saved = repository.save(entity);
|
||||
conditionTimeframes.invalidate(saved.getId());
|
||||
liveStrategyEvaluator.invalidateStrategy(saved.getId());
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ public class StrategyService {
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
conditionTimeframes.invalidate(id);
|
||||
liveStrategyEvaluator.invalidateStrategy(id);
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.rules.BooleanRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* START/TIMEFRAME 분기별 트리거 분봉 마감 평가.
|
||||
*
|
||||
* <ul>
|
||||
* <li>OR / 단일 START: 트리거 분봉 마감 시 해당 분기만 평가</li>
|
||||
* <li>AND + 다중 START: 트리거 분기만 갱신, 나머지는 각자 마감 시 캐시된 상태로 결합</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StrategyTriggerBranchEvaluator {
|
||||
|
||||
private final StrategyDslToTa4jAdapter adapter;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StrategyBranchStateCache branchCache;
|
||||
|
||||
record BranchScope(String combineOp, List<BranchDef> branches) {}
|
||||
|
||||
record BranchDef(String candleType, JsonNode subtree) {}
|
||||
|
||||
/**
|
||||
* 트리거 분봉({@code triggerCandleType}) 마감 시 호출되는 Rule.
|
||||
* {@code index}는 트리거 분봉 BarSeries의 확정봉 인덱스.
|
||||
*/
|
||||
public Rule buildTriggerRule(String conditionJson,
|
||||
String market,
|
||||
long strategyId,
|
||||
String side,
|
||||
String triggerCandleType,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Ta4jStorage storage) {
|
||||
BranchScope scope = parseScope(conditionJson);
|
||||
if (scope.branches().isEmpty()) return new BooleanRule(false);
|
||||
|
||||
String trigger = LiveStrategyTimeframeService.normalize(triggerCandleType);
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
storage.getOrCreate(market, trigger),
|
||||
indicatorParams,
|
||||
market,
|
||||
storage);
|
||||
|
||||
return new TriggerBranchRule(scope, market, strategyId, side, trigger, ctx);
|
||||
}
|
||||
|
||||
BranchScope parseScope(String conditionJson) {
|
||||
if (conditionJson == null || conditionJson.isBlank()) {
|
||||
return new BranchScope("SINGLE", List.of());
|
||||
}
|
||||
try {
|
||||
return parseScopeNode(objectMapper.readTree(conditionJson));
|
||||
} catch (Exception e) {
|
||||
log.warn("[TriggerBranch] JSON 파싱 실패: {}", e.getMessage());
|
||||
return new BranchScope("SINGLE", List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private BranchScope parseScopeNode(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return new BranchScope("SINGLE", List.of());
|
||||
}
|
||||
|
||||
String type = node.path("type").asText("");
|
||||
|
||||
if ("TIMEFRAME".equals(type)) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
|
||||
JsonNode sub = firstChild(node);
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
|
||||
}
|
||||
|
||||
if ("AND".equals(type) || "OR".equals(type)) {
|
||||
JsonNode children = node.path("children");
|
||||
if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) {
|
||||
List<BranchDef> branches = new ArrayList<>();
|
||||
for (JsonNode child : children) {
|
||||
String ct = LiveStrategyTimeframeService.normalize(
|
||||
child.path("candleType").asText("1m"));
|
||||
branches.add(new BranchDef(ct, firstChild(child)));
|
||||
}
|
||||
return new BranchScope(type, branches);
|
||||
}
|
||||
}
|
||||
|
||||
return new BranchScope("SINGLE", List.of(new BranchDef("1m", node)));
|
||||
}
|
||||
|
||||
private static JsonNode firstChild(JsonNode timeframeNode) {
|
||||
JsonNode children = timeframeNode.path("children");
|
||||
if (children.isArray() && !children.isEmpty()) return children.get(0);
|
||||
return timeframeNode.path("child");
|
||||
}
|
||||
|
||||
private static boolean allTimeframeChildren(JsonNode children) {
|
||||
for (JsonNode c : children) {
|
||||
if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private final class TriggerBranchRule implements Rule {
|
||||
private final BranchScope scope;
|
||||
private final String market;
|
||||
private final long strategyId;
|
||||
private final String side;
|
||||
private final String triggerCandleType;
|
||||
private final StrategyDslToTa4jAdapter.RuleBuildContext ctx;
|
||||
|
||||
TriggerBranchRule(BranchScope scope,
|
||||
String market,
|
||||
long strategyId,
|
||||
String side,
|
||||
String triggerCandleType,
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx) {
|
||||
this.scope = scope;
|
||||
this.market = market;
|
||||
this.strategyId = strategyId;
|
||||
this.side = side;
|
||||
this.triggerCandleType = triggerCandleType;
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
Optional<BranchDef> triggerBranch = scope.branches().stream()
|
||||
.filter(b -> b.candleType().equals(triggerCandleType))
|
||||
.findFirst();
|
||||
if (triggerBranch.isEmpty()) return false;
|
||||
|
||||
BarSeries triggerSeries = ctx.resolveSeries(triggerCandleType);
|
||||
if (triggerSeries.isEmpty()) return false;
|
||||
|
||||
int evalIndex = Math.min(index, triggerSeries.getEndIndex());
|
||||
if (evalIndex < 0) return false;
|
||||
|
||||
boolean triggerSat = evaluateBranch(triggerBranch.get(), evalIndex);
|
||||
branchCache.put(market, strategyId, side, triggerCandleType, triggerSat, evalIndex);
|
||||
|
||||
if ("OR".equals(scope.combineOp()) || "SINGLE".equals(scope.combineOp())) {
|
||||
return triggerSat;
|
||||
}
|
||||
|
||||
for (BranchDef branch : scope.branches()) {
|
||||
StrategyBranchStateCache.BranchState state =
|
||||
branchCache.get(market, strategyId, side, branch.candleType());
|
||||
if (state == null || !state.satisfied()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean evaluateBranch(BranchDef branch, int index) {
|
||||
JsonNode subtree = branch.subtree();
|
||||
if (subtree == null || subtree.isNull()) return false;
|
||||
|
||||
BarSeries branchSeries = ctx.resolveSeries(branch.candleType());
|
||||
if (branchSeries.isEmpty()) return false;
|
||||
|
||||
int branchIndex = branch.candleType().equals(triggerCandleType)
|
||||
? index
|
||||
: branchSeries.getEndIndex();
|
||||
if (branchIndex < 0 || branchIndex >= branchSeries.getBarCount()) return false;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage());
|
||||
try {
|
||||
Rule rule = adapter.toRule(subtree, branchCtx);
|
||||
return rule.isSatisfied(branchIndex, null);
|
||||
} catch (Exception e) {
|
||||
log.debug("[TriggerBranch] 분기 평가 실패 {} {}: {}",
|
||||
market, branch.candleType(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user