지표탭 추가 수정
This commit is contained in:
@@ -41,4 +41,7 @@ public class CandleBarDto {
|
|||||||
* "BUY" | "SELL" | "NONE" (null 이면 전략 체크 미설정)
|
* "BUY" | "SELL" | "NONE" (null 이면 전략 체크 미설정)
|
||||||
*/
|
*/
|
||||||
private String signal;
|
private String signal;
|
||||||
|
|
||||||
|
/** 시그널이 발생한 평가 분봉 (예: 1m, 3m, 5m) */
|
||||||
|
private String candleType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.goldenchart.service;
|
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.GcLiveStrategySettings;
|
||||||
import com.goldenchart.entity.GcStrategy;
|
import com.goldenchart.entity.GcStrategy;
|
||||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||||
@@ -9,8 +7,9 @@ import com.goldenchart.repository.GcStrategyRepository;
|
|||||||
import com.goldenchart.storage.Ta4jStorage;
|
import com.goldenchart.storage.Ta4jStorage;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.ta4j.core.*;
|
import org.ta4j.core.BarSeries;
|
||||||
import org.ta4j.core.rules.BooleanRule;
|
import org.ta4j.core.BaseTradingRecord;
|
||||||
|
import org.ta4j.core.Rule;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -51,17 +50,18 @@ public class LiveStrategyEvaluator {
|
|||||||
private final GcLiveStrategySettingsRepository settingsRepo;
|
private final GcLiveStrategySettingsRepository settingsRepo;
|
||||||
private final GcStrategyRepository strategyRepo;
|
private final GcStrategyRepository strategyRepo;
|
||||||
private final Ta4jStorage ta4jStorage;
|
private final Ta4jStorage ta4jStorage;
|
||||||
private final StrategyDslToTa4jAdapter adapter;
|
|
||||||
private final IndicatorSettingsService indicatorSettingsService;
|
private final IndicatorSettingsService indicatorSettingsService;
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final StrategySignalDeterminer determiner;
|
private final StrategySignalDeterminer determiner;
|
||||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||||
|
private final StrategyTriggerBranchEvaluator triggerBranchEvaluator;
|
||||||
|
private final StrategyBranchStateCache branchStateCache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strategy 캐시: "market:candleType:strategyId" → Strategy
|
* 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule)
|
||||||
* (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
|
* LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord
|
||||||
@@ -157,12 +157,9 @@ public class LiveStrategyEvaluator {
|
|||||||
if (s.getStrategyId() == null) continue;
|
if (s.getStrategyId() == null) continue;
|
||||||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
|
||||||
|
|
||||||
// ★ Ta4j CachedIndicator 캐시 무효화:
|
// ★ Ta4j CachedIndicator 캐시 무효화 — Rule 트리 재빌드
|
||||||
// updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는
|
|
||||||
// 이미 캐시된 구 값을 반환한다. 전략을 재빌드하면 새 인디케이터 인스턴스를
|
|
||||||
// 생성(빈 캐시)하므로 getValue(currentIndex) 가 현재 가격으로 재계산된다.
|
|
||||||
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||||||
strategyCache.remove(cacheKey);
|
triggerRulesCache.remove(cacheKey);
|
||||||
|
|
||||||
String result = evaluate(market, candleType, s.getStrategyId(),
|
String result = evaluate(market, candleType, s.getStrategyId(),
|
||||||
s.getPositionMode(), currentIndex,
|
s.getPositionMode(), currentIndex,
|
||||||
@@ -205,7 +202,7 @@ public class LiveStrategyEvaluator {
|
|||||||
|
|
||||||
// CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산
|
// CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산
|
||||||
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||||||
strategyCache.remove(cacheKey);
|
triggerRulesCache.remove(cacheKey);
|
||||||
|
|
||||||
String result = evaluate(market, candleType, s.getStrategyId(),
|
String result = evaluate(market, candleType, s.getStrategyId(),
|
||||||
s.getPositionMode(), maturedIndex,
|
s.getPositionMode(), maturedIndex,
|
||||||
@@ -222,10 +219,20 @@ public class LiveStrategyEvaluator {
|
|||||||
|
|
||||||
/** 설정 변경 시 해당 마켓 캐시 무효화 */
|
/** 설정 변경 시 해당 마켓 캐시 무효화 */
|
||||||
public void invalidateCache(String market) {
|
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 + ":"));
|
tradingRecordCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||||
positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||||||
realtimeSignaledIdx.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 ───────────────────────────────────────────────────────────────
|
// ── Private ───────────────────────────────────────────────────────────────
|
||||||
@@ -235,30 +242,27 @@ public class LiveStrategyEvaluator {
|
|||||||
String deviceId, Long userId) {
|
String deviceId, Long userId) {
|
||||||
String cacheKey = market + ":" + candleType + ":" + strategyId;
|
String cacheKey = market + ":" + candleType + ":" + strategyId;
|
||||||
|
|
||||||
// computeIfAbsent 는 device context 전달이 불가하므 수동 분기
|
TriggerRules rules = triggerRulesCache.get(cacheKey);
|
||||||
Strategy strategy = strategyCache.get(cacheKey);
|
if (rules == null) {
|
||||||
if (strategy == null) {
|
rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId);
|
||||||
strategy = buildStrategy(market, candleType, strategyId, deviceId, userId);
|
if (rules == null) return "NONE";
|
||||||
if (strategy != null) strategyCache.put(cacheKey, strategy);
|
triggerRulesCache.put(cacheKey, rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strategy == null) return "NONE";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String mode = positionMode != null ? positionMode : "LONG_ONLY";
|
String mode = positionMode != null ? positionMode : "LONG_ONLY";
|
||||||
|
|
||||||
if ("SIGNAL_ONLY".equals(mode)) {
|
if ("SIGNAL_ONLY".equals(mode)) {
|
||||||
// 포지션 락 우회 — 순수 Rule 충족 여부만 판단
|
return determiner.determineSignalFromRules(
|
||||||
return determiner.determineSignal(strategy, null, index, "SIGNAL_ONLY");
|
rules.entryRule(), rules.exitRule(), null, index, "SIGNAL_ONLY");
|
||||||
}
|
}
|
||||||
|
|
||||||
// LONG_ONLY — TradingRecord 상태를 유지하여 포지션 검증
|
|
||||||
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
||||||
cacheKey, k -> new BaseTradingRecord());
|
cacheKey, k -> new BaseTradingRecord());
|
||||||
boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey));
|
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) {
|
if ("BUY".equals(signal) && !isOpen) {
|
||||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||||
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
|
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
|
||||||
@@ -270,20 +274,19 @@ public class LiveStrategyEvaluator {
|
|||||||
record.exit(index, price, series.numFactory().numOf(1));
|
record.exit(index, price, series.numFactory().numOf(1));
|
||||||
positionOpenCache.put(cacheKey, false);
|
positionOpenCache.put(cacheKey, false);
|
||||||
} else if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
} else if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||||||
// 포지션 상태와 불일치 (이미 매수 중인데 또 BUY 등) → 신호 무시
|
|
||||||
signal = "NONE";
|
signal = "NONE";
|
||||||
}
|
}
|
||||||
return signal;
|
return signal;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage());
|
log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage());
|
||||||
strategyCache.remove(cacheKey);
|
triggerRulesCache.remove(cacheKey);
|
||||||
tradingRecordCache.remove(cacheKey);
|
tradingRecordCache.remove(cacheKey);
|
||||||
}
|
}
|
||||||
return "NONE";
|
return "NONE";
|
||||||
}
|
}
|
||||||
|
|
||||||
private Strategy buildStrategy(String market, String candleType, long strategyId,
|
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
|
||||||
String deviceId, Long userId) {
|
String deviceId, Long userId) {
|
||||||
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
|
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
|
||||||
if (strategyOpt.isEmpty()) return null;
|
if (strategyOpt.isEmpty()) return null;
|
||||||
@@ -292,7 +295,6 @@ public class LiveStrategyEvaluator {
|
|||||||
if (!ta4jStorage.exists(market, candleType)) return null;
|
if (!ta4jStorage.exists(market, candleType)) return null;
|
||||||
|
|
||||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||||
// 사용자·장치별 지표 파라미터 우선 로드 (없으면 빈 맵 → adapter 기본값 사용)
|
|
||||||
Map<String, Map<String, Object>> indicatorParams =
|
Map<String, Map<String, Object>> indicatorParams =
|
||||||
indicatorSettingsService.getAll(userId, deviceId);
|
indicatorSettingsService.getAll(userId, deviceId);
|
||||||
|
|
||||||
@@ -302,29 +304,18 @@ public class LiveStrategyEvaluator {
|
|||||||
strategyId, market, barCount);
|
strategyId, market, barCount);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams, market);
|
Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
|
||||||
Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams, market);
|
strategy.getBuyConditionJson(), market, strategyId, "buy",
|
||||||
BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule);
|
candleType, indicatorParams, ta4jStorage);
|
||||||
return builtStrategy;
|
Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
|
||||||
|
strategy.getSellConditionJson(), market, strategyId, "sell",
|
||||||
|
candleType, indicatorParams, ta4jStorage);
|
||||||
|
return new TriggerRules(entryRule, exitRule);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[Evaluator] Strategy 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
|
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
|
||||||
return null;
|
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 GcStrategyRepository repository;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||||
|
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||||
|
|
||||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ public class StrategyService {
|
|||||||
|
|
||||||
GcStrategy saved = repository.save(entity);
|
GcStrategy saved = repository.save(entity);
|
||||||
conditionTimeframes.invalidate(saved.getId());
|
conditionTimeframes.invalidate(saved.getId());
|
||||||
|
liveStrategyEvaluator.invalidateStrategy(saved.getId());
|
||||||
return toDto(saved);
|
return toDto(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ public class StrategyService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id) {
|
public void delete(Long id) {
|
||||||
conditionTimeframes.invalidate(id);
|
conditionTimeframes.invalidate(id);
|
||||||
|
liveStrategyEvaluator.invalidateStrategy(id);
|
||||||
repository.deleteById(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ public class BarBuilder {
|
|||||||
.close(bar.getClosePrice().doubleValue())
|
.close(bar.getClosePrice().doubleValue())
|
||||||
.volume(bar.getVolume().doubleValue())
|
.volume(bar.getVolume().doubleValue())
|
||||||
.signal(signal)
|
.signal(signal)
|
||||||
|
.candleType(candleType)
|
||||||
.build();
|
.build();
|
||||||
broker.publish(market, candleType, dto);
|
broker.publish(market, candleType, dto);
|
||||||
log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType);
|
log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType);
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ public class LiveStrategyScheduler {
|
|||||||
.close(lastBar.getClosePrice().doubleValue())
|
.close(lastBar.getClosePrice().doubleValue())
|
||||||
.volume(lastBar.getVolume().doubleValue())
|
.volume(lastBar.getVolume().doubleValue())
|
||||||
.signal(signal)
|
.signal(signal)
|
||||||
|
.candleType(candleType)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
broker.publish(market, candleType, dto);
|
broker.publish(market, candleType, dto);
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.goldenchart.storage.Ta4jStorage;
|
||||||
|
import com.goldenchart.trading.pipeline.TradingPipelineProperties;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.ta4j.core.BarSeries;
|
||||||
|
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||||
|
import org.ta4j.core.Rule;
|
||||||
|
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||||
|
import org.ta4j.core.num.DoubleNumFactory;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* START 분기별 트리거 분봉 평가 — OR/AND 분기 격리.
|
||||||
|
*/
|
||||||
|
class StrategyTriggerBranchEvaluatorTest {
|
||||||
|
|
||||||
|
private static final String MARKET = "KRW-BTC";
|
||||||
|
private static final long STRATEGY_ID = 42L;
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private StrategyTriggerBranchEvaluator evaluator;
|
||||||
|
private StrategyBranchStateCache branchCache;
|
||||||
|
private Ta4jStorage storage;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
branchCache = new StrategyBranchStateCache();
|
||||||
|
evaluator = new StrategyTriggerBranchEvaluator(
|
||||||
|
new StrategyDslToTa4jAdapter(), MAPPER, branchCache);
|
||||||
|
TradingPipelineProperties props = new TradingPipelineProperties();
|
||||||
|
props.setEnabled(false);
|
||||||
|
storage = new Ta4jStorage(props);
|
||||||
|
seedStorage(MARKET, "1m", buildSyntheticSeries("1m", 260));
|
||||||
|
seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120));
|
||||||
|
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orScope_1mTrigger_doesNotRequire5mBranch() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "OR",
|
||||||
|
"children": [
|
||||||
|
{ "type": "TIMEFRAME", "candleType": "1m", "children": [{
|
||||||
|
"type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "RSI", "conditionType": "GT",
|
||||||
|
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||||
|
}
|
||||||
|
}]},
|
||||||
|
{ "type": "TIMEFRAME", "candleType": "5m", "children": [{
|
||||||
|
"type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "RSI", "conditionType": "LT",
|
||||||
|
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||||
|
}
|
||||||
|
}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||||
|
"1m", Map.of(), storage);
|
||||||
|
BarSeries s1m = storage.getOrCreate(MARKET, "1m");
|
||||||
|
int idx = s1m.getEndIndex();
|
||||||
|
assertDoesNotThrow(() -> rule1m.isSatisfied(idx, null));
|
||||||
|
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||||
|
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "5m"),
|
||||||
|
"1m 트리거 시 5m 분기는 평가·캐시되지 않아야 함");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void andScope_requiresAllBranchCaches() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "TIMEFRAME", "candleType": "1m", "children": [{
|
||||||
|
"type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "RSI", "conditionType": "GT",
|
||||||
|
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||||
|
}
|
||||||
|
}]},
|
||||||
|
{ "type": "TIMEFRAME", "candleType": "3m", "children": [{
|
||||||
|
"type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "RSI", "conditionType": "GT",
|
||||||
|
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||||
|
}
|
||||||
|
}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "sell",
|
||||||
|
"1m", Map.of(), storage);
|
||||||
|
BarSeries s1m = storage.getOrCreate(MARKET, "1m");
|
||||||
|
int idx1m = s1m.getEndIndex();
|
||||||
|
|
||||||
|
rule1m.isSatisfied(idx1m, null);
|
||||||
|
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "sell", "1m"));
|
||||||
|
assertNull(branchCache.get(MARKET, STRATEGY_ID, "sell", "3m"));
|
||||||
|
|
||||||
|
branchCache.put(MARKET, STRATEGY_ID, "sell", "3m", true, 10);
|
||||||
|
assertTrue(rule1m.isSatisfied(idx1m, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void single3mScope_onlyTriggersOn3mBranch() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "3m",
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION", "condition": {
|
||||||
|
"indicatorType": "RSI", "conditionType": "GT",
|
||||||
|
"leftField": "RSI_VALUE", "rightField": "K_0", "period": 14
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scope = evaluator.parseScope(json);
|
||||||
|
assertEquals("SINGLE", scope.combineOp());
|
||||||
|
assertEquals(1, scope.branches().size());
|
||||||
|
assertEquals("3m", scope.branches().get(0).candleType());
|
||||||
|
|
||||||
|
Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||||
|
"3m", Map.of(), storage);
|
||||||
|
BarSeries s3m = storage.getOrCreate(MARKET, "3m");
|
||||||
|
assertDoesNotThrow(() -> on3m.isSatisfied(s3m.getEndIndex(), null));
|
||||||
|
|
||||||
|
Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
|
||||||
|
"1m", Map.of(), storage);
|
||||||
|
assertFalse(on1m.isSatisfied(storage.getOrCreate(MARKET, "1m").getEndIndex(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BarSeries buildSyntheticSeries(String timeframe, int barCount) {
|
||||||
|
Duration period = switch (timeframe) {
|
||||||
|
case "3m" -> Duration.ofMinutes(3);
|
||||||
|
case "5m" -> Duration.ofMinutes(5);
|
||||||
|
default -> Duration.ofMinutes(1);
|
||||||
|
};
|
||||||
|
BarSeries series = new BaseBarSeriesBuilder()
|
||||||
|
.withName("test-" + timeframe)
|
||||||
|
.withNumFactory(DoubleNumFactory.getInstance())
|
||||||
|
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||||
|
.build();
|
||||||
|
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||||
|
Instant base = Instant.parse("2024-06-01T00:00:00Z");
|
||||||
|
for (int i = 0; i < barCount; i++) {
|
||||||
|
double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4;
|
||||||
|
double close = 50_000_000 + i * 120 + wave * 1000;
|
||||||
|
Instant end = base.plus(period.multipliedBy(i + 1L));
|
||||||
|
factory.createBarBuilder(series)
|
||||||
|
.timePeriod(period)
|
||||||
|
.endTime(end)
|
||||||
|
.openPrice(close - 500).highPrice(close + 800)
|
||||||
|
.lowPrice(close - 800).closePrice(close)
|
||||||
|
.volume(12.5 + i * 0.01)
|
||||||
|
.add();
|
||||||
|
}
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedStorage(String market, String candleType, BarSeries source) {
|
||||||
|
for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) {
|
||||||
|
storage.addBar(market, candleType, source.getBar(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
reorderIndicatorGroup,
|
reorderIndicatorGroup,
|
||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
buildIndicatorConfigsFromTypes,
|
replaceIndicatorConfigsFromTypes,
|
||||||
} from './utils/indicatorPaneMerge';
|
} from './utils/indicatorPaneMerge';
|
||||||
import { useAppSettings } from './hooks/useAppSettings';
|
import { useAppSettings } from './hooks/useAppSettings';
|
||||||
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
||||||
@@ -1368,7 +1368,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
||||||
|
|
||||||
/** 지표 추가 팝업 — 탭 전체 추가 (한 번에 상태 반영) */
|
/** 지표 추가 팝업 — 탭 전체 추가 (기존 보조지표 제거 후 탭 지표만 적용) */
|
||||||
const handleAddIndicators = useCallback((types: string[]) => {
|
const handleAddIndicators = useCallback((types: string[]) => {
|
||||||
if (!types.length) return;
|
if (!types.length) return;
|
||||||
if (layoutDef.count > 1) {
|
if (layoutDef.count > 1) {
|
||||||
@@ -1378,10 +1378,9 @@ function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIndicators(prev => {
|
setIndicators(
|
||||||
const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId);
|
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId),
|
||||||
return added.length > 0 ? [...prev, ...added] : prev;
|
);
|
||||||
});
|
|
||||||
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
reorderIndicatorGroup,
|
reorderIndicatorGroup,
|
||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
buildIndicatorConfigsFromTypes,
|
replaceIndicatorConfigsFromTypes,
|
||||||
} from '../utils/indicatorPaneMerge';
|
} from '../utils/indicatorPaneMerge';
|
||||||
import { saveSlot } from '../utils/backendApi';
|
import { saveSlot } from '../utils/backendApi';
|
||||||
import type {
|
import type {
|
||||||
@@ -86,7 +86,7 @@ function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
|
|||||||
export interface ChartSlotHandle {
|
export interface ChartSlotHandle {
|
||||||
/** 지표 추가 */
|
/** 지표 추가 */
|
||||||
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
||||||
/** 지표 일괄 추가 (탭 전체 추가) */
|
/** 지표 일괄 추가 (탭 전체 추가 — 기존 지표 제거 후 탭 지표만 적용) */
|
||||||
addIndicators: (types: string[]) => void;
|
addIndicators: (types: string[]) => void;
|
||||||
/** 지표 제거 (type 기준) */
|
/** 지표 제거 (type 기준) */
|
||||||
removeIndicatorByType: (type: string) => void;
|
removeIndicatorByType: (type: string) => void;
|
||||||
@@ -273,10 +273,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
},
|
},
|
||||||
addIndicators: (types: string[]) => {
|
addIndicators: (types: string[]) => {
|
||||||
if (!types.length) return;
|
if (!types.length) return;
|
||||||
setIndicators(prev => {
|
setIndicators(
|
||||||
const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId);
|
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId),
|
||||||
return added.length > 0 ? [...prev, ...added] : prev;
|
);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
removeIndicatorByType: (type: string) => {
|
removeIndicatorByType: (type: string) => {
|
||||||
setIndicators(prev => prev.filter(i => i.type !== type));
|
setIndicators(prev => prev.filter(i => i.type !== type));
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
|||||||
candleTime: marker.time,
|
candleTime: marker.time,
|
||||||
strategyName: strategyNameByMarket?.[marker.market] ?? strategyName,
|
strategyName: strategyNameByMarket?.[marker.market] ?? strategyName,
|
||||||
executionType: executionTypeByMarket?.[marker.market] ?? executionType,
|
executionType: executionTypeByMarket?.[marker.market] ?? executionType,
|
||||||
candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m',
|
candleType: marker.candleType ?? '1m',
|
||||||
});
|
});
|
||||||
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
||||||
window.setTimeout(() => { void refreshHistory(); }, 800);
|
window.setTimeout(() => { void refreshHistory(); }, 800);
|
||||||
|
|||||||
@@ -382,17 +382,16 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
const validTypes = addAllContext.types.filter(
|
const validTypes = addAllContext.types.filter(
|
||||||
type => INDICATOR_REGISTRY.some(d => d.type === type),
|
type => INDICATOR_REGISTRY.some(d => d.type === type),
|
||||||
);
|
);
|
||||||
const toAdd = validTypes.filter(type => !isActive(type));
|
if (validTypes.length === 0) {
|
||||||
if (toAdd.length === 0) {
|
window.alert('추가할 지표가 없습니다.');
|
||||||
window.alert('추가할 지표가 없습니다. (이미 모두 차트에 있습니다)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = `${addAllContext.label} 탭에 있는 지표 ${toAdd.length}개를 차트에 추가하시겠습니까?`;
|
const msg = `기존 보조지표를 모두 제거하고 ${addAllContext.label} 탭의 지표 ${validTypes.length}개로 교체하시겠습니까?`;
|
||||||
if (!window.confirm(msg)) return;
|
if (!window.confirm(msg)) return;
|
||||||
if (onAddMany) {
|
if (onAddMany) {
|
||||||
onAddMany(toAdd);
|
onAddMany(validTypes);
|
||||||
} else {
|
} else {
|
||||||
toAdd.forEach(type => onAdd(type));
|
validTypes.forEach(type => onAdd(type));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -569,8 +568,8 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="ind-panel-add-all"
|
className="ind-panel-add-all"
|
||||||
disabled={addAllDisabled}
|
disabled={addAllDisabled}
|
||||||
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '현재 탭의 모든 지표 추가'}
|
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'}
|
||||||
aria-label="현재 탭의 모든 지표 추가"
|
aria-label="현재 탭 지표로 보조지표 교체"
|
||||||
onClick={handleAddAllInTab}
|
onClick={handleAddAllInTab}
|
||||||
>
|
>
|
||||||
<IcPlus />
|
<IcPlus />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface LiveMarker {
|
|||||||
signal: 'BUY' | 'SELL';
|
signal: 'BUY' | 'SELL';
|
||||||
price: number;
|
price: number;
|
||||||
market: string;
|
market: string;
|
||||||
|
candleType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketCandleSub {
|
export interface MarketCandleSub {
|
||||||
@@ -54,12 +55,12 @@ export function useLiveStrategyMarkers({
|
|||||||
const onSignalRef = useRef(onSignal);
|
const onSignalRef = useRef(onSignal);
|
||||||
onSignalRef.current = onSignal;
|
onSignalRef.current = onSignal;
|
||||||
|
|
||||||
const pushMarker = useCallback((market: string, data: {
|
const pushMarker = useCallback((market: string, candleType: string, data: {
|
||||||
time: number; close: number; signal: string;
|
time: number; close: number; signal: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (data.signal !== 'BUY' && data.signal !== 'SELL') return;
|
if (data.signal !== 'BUY' && data.signal !== 'SELL') return;
|
||||||
|
|
||||||
const key = `${market}:${data.time}:${data.signal}`;
|
const key = `${market}:${candleType}:${data.time}:${data.signal}`;
|
||||||
if (seenRef.current.has(key)) return;
|
if (seenRef.current.has(key)) return;
|
||||||
seenRef.current.add(key);
|
seenRef.current.add(key);
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export function useLiveStrategyMarkers({
|
|||||||
signal: data.signal as 'BUY' | 'SELL',
|
signal: data.signal as 'BUY' | 'SELL',
|
||||||
price: data.close,
|
price: data.close,
|
||||||
market,
|
market,
|
||||||
|
candleType,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prev = markersByMarketRef.current[market] ?? [];
|
const prev = markersByMarketRef.current[market] ?? [];
|
||||||
@@ -117,9 +119,15 @@ export function useLiveStrategyMarkers({
|
|||||||
time: number;
|
time: number;
|
||||||
close: number;
|
close: number;
|
||||||
signal?: string;
|
signal?: string;
|
||||||
|
candleType?: string;
|
||||||
}>(msg);
|
}>(msg);
|
||||||
if (!data?.signal || data.signal === 'NONE') return;
|
if (!data?.signal || data.signal === 'NONE') return;
|
||||||
pushMarker(market, { time: data.time, close: data.close, signal: data.signal });
|
const resolvedCandleType = data.candleType ?? ct;
|
||||||
|
pushMarker(market, resolvedCandleType, {
|
||||||
|
time: data.time,
|
||||||
|
close: data.close,
|
||||||
|
signal: data.signal,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ type GetVisual = (
|
|||||||
defaultHlines?: HLineDef[],
|
defaultHlines?: HLineDef[],
|
||||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
|
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
|
||||||
|
|
||||||
|
/** 탭 전체 추가 — 기존 차트 지표를 제거하고 types 목록만 새로 구성 */
|
||||||
|
export function replaceIndicatorConfigsFromTypes(
|
||||||
|
types: string[],
|
||||||
|
getParams: GetParams,
|
||||||
|
getVisualConfig: GetVisual,
|
||||||
|
newId: () => string,
|
||||||
|
): IndicatorConfig[] {
|
||||||
|
return buildIndicatorConfigsFromTypes(types, [], getParams, getVisualConfig, newId);
|
||||||
|
}
|
||||||
|
|
||||||
/** 지표 타입 목록 → 차트에 추가할 IndicatorConfig 배열 (이미 활성인 type 제외) */
|
/** 지표 타입 목록 → 차트에 추가할 IndicatorConfig 배열 (이미 활성인 type 제외) */
|
||||||
export function buildIndicatorConfigsFromTypes(
|
export function buildIndicatorConfigsFromTypes(
|
||||||
types: string[],
|
types: string[],
|
||||||
|
|||||||
Reference in New Issue
Block a user