가상매매 수정

This commit is contained in:
Macbook
2026-05-27 17:21:48 +09:00
parent aca895e9fd
commit 701a9b2881
6 changed files with 201 additions and 45 deletions
@@ -0,0 +1,36 @@
package com.goldenchart.config;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.service.StrategyConditionTimeframeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 기동 시 전략 DSL TIMEFRAME·분봉 자동 보정 (레거시 3분봉 전략 등).
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class StrategyDslRepairRunner implements ApplicationRunner {
private final GcStrategyRepository strategyRepo;
private final StrategyConditionTimeframeService conditionTimeframes;
@Override
public void run(ApplicationArguments args) {
int count = 0;
for (GcStrategy strategy : strategyRepo.findAll()) {
if (strategy.getId() == null) continue;
conditionTimeframes.invalidate(strategy.getId());
conditionTimeframes.collectForStrategy(strategy.getId());
count++;
}
if (count > 0) {
log.info("[StrategyDslRepair] 전략 {}건 분봉 DSL 점검 완료", count);
}
}
}
@@ -31,6 +31,7 @@ public class LiveStrategySettingsService {
private final DynamicSubscriptionManager subscriptionManager;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final StrategyConditionTimeframeService conditionTimeframes;
private final StrategyService strategyService;
// ── 조회 ──────────────────────────────────────────────────────────────────
@@ -235,6 +236,8 @@ public class LiveStrategySettingsService {
private void pinIfReady(String market, LiveStrategySettingsDto dto) {
if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) {
strategyService.repairTimeframeWrappers(dto.getStrategyId());
conditionTimeframes.invalidate(dto.getStrategyId());
pinAllStrategyTimeframes(market, dto.getStrategyId());
}
}
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
@@ -19,15 +19,27 @@ import java.util.concurrent.ConcurrentHashMap;
* START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class StrategyConditionTimeframeService {
private final GcStrategyRepository strategyRepo;
private final ObjectMapper objectMapper;
private final StrategyDslTimeframeNormalizer dslNormalizer;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final ConcurrentHashMap<Long, Set<String>> cache = new ConcurrentHashMap<>();
public StrategyConditionTimeframeService(
GcStrategyRepository strategyRepo,
ObjectMapper objectMapper,
StrategyDslTimeframeNormalizer dslNormalizer,
@Lazy LiveStrategyEvaluator liveStrategyEvaluator) {
this.strategyRepo = strategyRepo;
this.objectMapper = objectMapper;
this.dslNormalizer = dslNormalizer;
this.liveStrategyEvaluator = liveStrategyEvaluator;
}
public Set<String> collectForStrategy(long strategyId) {
return cache.computeIfAbsent(strategyId, this::loadFromDb);
}
@@ -53,33 +65,79 @@ public class StrategyConditionTimeframeService {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty()) return Set.of("1m");
GcStrategy strategy = opt.get();
ensureDslRepaired(strategy);
Set<String> out = new LinkedHashSet<>();
collectFromJson(strategy.getBuyConditionJson(), out);
collectFromJson(strategy.getSellConditionJson(), out);
return out.isEmpty() ? Set.of("1m") : out;
collectFromJson(strategy.getBuyConditionJson(), out, strategy.getName());
collectFromJson(strategy.getSellConditionJson(), out, strategy.getName());
if (out.isEmpty()) {
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
return Set.of(fromName != null ? fromName : "1m");
}
return out;
}
private void collectFromJson(String json, Set<String> out) {
if (json == null || json.isBlank()) return;
try {
collectFromNode(objectMapper.readTree(json), out);
} catch (Exception e) {
log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage());
out.add("1m");
/** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */
private void ensureDslRepaired(GcStrategy strategy) {
boolean changed = false;
String name = strategy.getName();
if (strategy.getBuyConditionJson() != null && !strategy.getBuyConditionJson().isBlank()) {
String fixed = dslNormalizer.normalizeJson(strategy.getBuyConditionJson(), name);
if (!fixed.equals(strategy.getBuyConditionJson())) {
strategy.setBuyConditionJson(fixed);
changed = true;
}
}
if (strategy.getSellConditionJson() != null && !strategy.getSellConditionJson().isBlank()) {
String fixed = dslNormalizer.normalizeJson(strategy.getSellConditionJson(), name);
if (!fixed.equals(strategy.getSellConditionJson())) {
strategy.setSellConditionJson(fixed);
changed = true;
}
}
if (changed) {
strategyRepo.save(strategy);
liveStrategyEvaluator.invalidateStrategy(strategy.getId());
log.info("[StrategyTimeframes] DSL 분봉 보정 strategyId={} name={}",
strategy.getId(), name);
}
}
private void collectFromNode(JsonNode node, Set<String> out) {
private void collectFromJson(String json, Set<String> out, String strategyName) {
if (json == null || json.isBlank()) return;
try {
collectFromNode(objectMapper.readTree(json), out, strategyName);
} catch (Exception e) {
log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage());
addFallbackTimeframe(out, strategyName);
}
}
private void collectFromNode(JsonNode node, Set<String> out, String strategyName) {
if (node == null || node.isNull()) return;
if (collectTimeframesFromNode(node, out)) return;
Set<String> explicit = StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(node);
if (!explicit.isEmpty()) {
out.addAll(explicit);
Set<String> meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node);
if (!meaningful.isEmpty()) {
out.addAll(meaningful);
return;
}
// TIMEFRAME·명시 분봉 없음 — 기본 1m
out.add("1m");
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategyName);
if (fromName != null) {
out.add(fromName);
return;
}
addFallbackTimeframe(out, strategyName);
}
private void addFallbackTimeframe(Set<String> out, String strategyName) {
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategyName);
out.add(fromName != null ? fromName : "1m");
}
/**
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 전략 DSL에 TIMEFRAME 래핑이 없으면 평가 분봉을 추론해 래핑한다.
@@ -19,13 +20,36 @@ import java.util.UUID;
@RequiredArgsConstructor
public class StrategyDslTimeframeNormalizer {
private static final Pattern NAME_3M = Pattern.compile("3\\s*분|3m", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_5M = Pattern.compile("5\\s*분|5m", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_10M = Pattern.compile("10\\s*분|10m", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_15M = Pattern.compile("15\\s*분|15m", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_30M = Pattern.compile("30\\s*분|30m", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_1H = Pattern.compile("1\\s*시간|1h", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_4H = Pattern.compile("4\\s*시간|4h", Pattern.CASE_INSENSITIVE);
private static final Pattern NAME_1D = Pattern.compile("일봉|1\\s*일|1d", Pattern.CASE_INSENSITIVE);
private final ObjectMapper objectMapper;
public JsonNode normalize(JsonNode root) {
public JsonNode normalize(JsonNode root) {
return normalize(root, null);
}
public JsonNode normalize(JsonNode root, String strategyName) {
if (root == null || root.isNull()) return root;
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
String expected = inferPrimaryCandleType(root, strategyName);
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
if (!expected.equals(current)) {
return copyTimeframeWithCandleType(root, expected);
}
return root;
}
if (hasTimeframeScope(root)) return root;
String primary = inferPrimaryCandleType(root);
String primary = inferPrimaryCandleType(root, strategyName);
ObjectNode wrapped = objectMapper.createObjectNode();
wrapped.put("id", UUID.randomUUID().toString());
wrapped.put("type", "TIMEFRAME");
@@ -37,16 +61,34 @@ public class StrategyDslTimeframeNormalizer {
}
public String normalizeJson(String json) {
return normalizeJson(json, null);
}
public String normalizeJson(String json, String strategyName) {
if (json == null || json.isBlank()) return json;
try {
JsonNode root = objectMapper.readTree(json);
JsonNode normalized = normalize(root);
JsonNode normalized = normalize(root, strategyName);
return objectMapper.writeValueAsString(normalized);
} catch (Exception e) {
return json;
}
}
/** 전략 이름에서 평가 분봉 추론 (예: cci_3분봉 테스트 → 3m) */
public static String inferFromStrategyName(String name) {
if (name == null || name.isBlank()) return null;
if (NAME_3M.matcher(name).find()) return "3m";
if (NAME_5M.matcher(name).find()) return "5m";
if (NAME_10M.matcher(name).find()) return "10m";
if (NAME_15M.matcher(name).find()) return "15m";
if (NAME_30M.matcher(name).find()) return "30m";
if (NAME_4H.matcher(name).find()) return "4h";
if (NAME_1H.matcher(name).find()) return "1h";
if (NAME_1D.matcher(name).find()) return "1d";
return null;
}
/** 루트 TIMEFRAME 또는 AND|OR + 전부 TIMEFRAME 자식 */
public static boolean hasTimeframeScope(JsonNode node) {
if (node == null || node.isNull()) return false;
@@ -88,6 +130,16 @@ public class StrategyDslTimeframeNormalizer {
return out;
}
/** 조건 기본값 1m 은 제외하고 의미 있는 분봉만 */
public static Set<String> collectMeaningfulCandleTypes(JsonNode node) {
Set<String> all = collectExplicitCandleTypes(node);
Set<String> meaningful = new LinkedHashSet<>();
for (String ct : all) {
if (!"1m".equals(ct)) meaningful.add(ct);
}
return meaningful;
}
private static void collectExplicitCandleTypes(JsonNode node, Set<String> out) {
if (node == null || node.isNull()) return;
@@ -117,21 +169,33 @@ public class StrategyDslTimeframeNormalizer {
}
public static String inferPrimaryCandleType(JsonNode node) {
return inferPrimaryCandleType(node, null);
}
public static String inferPrimaryCandleType(JsonNode node, String strategyName) {
JsonNode nested = findNestedTimeframe(node);
if (nested != null) {
return LiveStrategyTimeframeService.normalize(nested.path("candleType").asText("1m"));
String ct = LiveStrategyTimeframeService.normalize(nested.path("candleType").asText("1m"));
if (!"1m".equals(ct)) return ct;
String fromName = inferFromStrategyName(strategyName);
if (fromName != null) return fromName;
return ct;
}
Set<String> meaningful = collectMeaningfulCandleTypes(node);
if (!meaningful.isEmpty()) return meaningful.iterator().next();
String fromName = inferFromStrategyName(strategyName);
if (fromName != null) return fromName;
Set<String> explicit = collectExplicitCandleTypes(node);
if (explicit.isEmpty()) return "1m";
Set<String> non1m = new LinkedHashSet<>();
for (String ct : explicit) {
if (!"1m".equals(ct)) non1m.add(ct);
}
if (non1m.size() == 1) return non1m.iterator().next();
if (non1m.size() > 1) return non1m.iterator().next();
if (explicit.size() == 1) return explicit.iterator().next();
return explicit.iterator().next();
return "1m";
}
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
ObjectNode copy = tf.deepCopy();
copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType));
return copy;
}
}
@@ -56,13 +56,16 @@ public class StrategyService {
else if (entity.getEnabled() == null) entity.setEnabled(true);
try {
String strategyName = entity.getName() != null ? entity.getName() : dto.getName();
entity.setBuyConditionJson(
dto.getBuyCondition() != null
? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getBuyCondition()))
? dslTimeframeNormalizer.normalizeJson(
objectMapper.writeValueAsString(dto.getBuyCondition()), strategyName)
: null);
entity.setSellConditionJson(
dto.getSellCondition() != null
? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getSellCondition()))
? dslTimeframeNormalizer.normalizeJson(
objectMapper.writeValueAsString(dto.getSellCondition()), strategyName)
: null);
} catch (Exception e) {
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
@@ -83,14 +86,16 @@ public class StrategyService {
return repository.findById(id).map(entity -> {
boolean changed = false;
if (entity.getBuyConditionJson() != null && !entity.getBuyConditionJson().isBlank()) {
String fixed = dslTimeframeNormalizer.normalizeJson(entity.getBuyConditionJson());
String fixed = dslTimeframeNormalizer.normalizeJson(
entity.getBuyConditionJson(), entity.getName());
if (!fixed.equals(entity.getBuyConditionJson())) {
entity.setBuyConditionJson(fixed);
changed = true;
}
}
if (entity.getSellConditionJson() != null && !entity.getSellConditionJson().isBlank()) {
String fixed = dslTimeframeNormalizer.normalizeJson(entity.getSellConditionJson());
String fixed = dslTimeframeNormalizer.normalizeJson(
entity.getSellConditionJson(), entity.getName());
if (!fixed.equals(entity.getSellConditionJson())) {
entity.setSellConditionJson(fixed);
changed = true;
@@ -199,16 +199,6 @@ export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
</button>
</div>
</div>
{viewMode === 'detail' && (
<>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<div className="vtd-grid-head-legend">
<span className="vtd-legend vtd-legend--match"> </span>
<span className="vtd-legend vtd-legend--pending"> </span>
<span className="vtd-legend vtd-legend--nomatch"> </span>
</div>
</>
)}
{session.running && (
<>
<span className="vtd-grid-head-divider" aria-hidden="true" />