전략 시간봉 오류 수정
This commit is contained in:
@@ -42,20 +42,7 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
if (root == null || root.isNull()) return root;
|
if (root == null || root.isNull()) return root;
|
||||||
|
|
||||||
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
||||||
// START 다중 분봉(candleTypes) — stale 1m 제거 후 candleType 동기화
|
return writeResolvedTimeframe(root, strategyName);
|
||||||
if (hasCandleTypesArray(root)) {
|
|
||||||
return syncTimeframePrimary(reconcileStartCandleTypes(root));
|
|
||||||
}
|
|
||||||
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
|
|
||||||
// 편집기에서 명시한 단일 분봉(3m, 5m 등)은 조건 노드 기본 1m 추론으로 덮어쓰지 않음
|
|
||||||
if (!"1m".equals(current)) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
String expected = inferPrimaryCandleType(root, strategyName);
|
|
||||||
if (!expected.equals(current)) {
|
|
||||||
return copyTimeframeWithCandleType(root, expected);
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasTimeframeScope(root)) return root;
|
if (hasTimeframeScope(root)) return root;
|
||||||
@@ -65,6 +52,9 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
wrapped.put("id", UUID.randomUUID().toString());
|
wrapped.put("id", UUID.randomUUID().toString());
|
||||||
wrapped.put("type", "TIMEFRAME");
|
wrapped.put("type", "TIMEFRAME");
|
||||||
wrapped.put("candleType", primary);
|
wrapped.put("candleType", primary);
|
||||||
|
ArrayNode candleTypes = objectMapper.createArrayNode();
|
||||||
|
candleTypes.add(primary);
|
||||||
|
wrapped.set("candleTypes", candleTypes);
|
||||||
ArrayNode children = objectMapper.createArrayNode();
|
ArrayNode children = objectMapper.createArrayNode();
|
||||||
children.add(root);
|
children.add(root);
|
||||||
wrapped.set("children", children);
|
wrapped.set("children", children);
|
||||||
@@ -111,6 +101,9 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasExplicitStartTimeframe(JsonNode buyTf) {
|
private static boolean hasExplicitStartTimeframe(JsonNode buyTf) {
|
||||||
|
Set<String> resolved = resolveStartCandleTypes(buyTf);
|
||||||
|
if (resolved.size() > 1) return true;
|
||||||
|
if (resolved.size() == 1 && !resolved.contains("1m")) return true;
|
||||||
if (hasCandleTypesArray(buyTf)) return true;
|
if (hasCandleTypesArray(buyTf)) return true;
|
||||||
String ct = LiveStrategyTimeframeService.normalize(buyTf.path("candleType").asText("1m"));
|
String ct = LiveStrategyTimeframeService.normalize(buyTf.path("candleType").asText("1m"));
|
||||||
return !"1m".equals(ct);
|
return !"1m".equals(ct);
|
||||||
@@ -274,17 +267,57 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* START candleTypes 에 남은 레거시 1m 제거.
|
* START 평가 분봉 해석 — 조건 left/rightCandleType · START candleTypes/candleType 통합.
|
||||||
* <ul>
|
* 1m·3m·5m·15m·1h 등 모든 분봉에 동일 규칙 적용.
|
||||||
* <li>[1m, 5m] + 조건 leftCandleType 5m → [5m] (편집기 5m만 선택·1m 기본값 잔존)</li>
|
|
||||||
* <li>[1m, 3m, 5m] 다중 OR 선택은 유지</li>
|
|
||||||
* <li>조건에 1m 명시 시 유지</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
public static Set<String> resolveStartCandleTypes(JsonNode timeframeNode) {
|
public static Set<String> resolveStartCandleTypes(JsonNode timeframeNode) {
|
||||||
ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode);
|
ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode);
|
||||||
|
Set<String> fromStart = readStartCandleTypesFromNode(reconciled);
|
||||||
|
JsonNode subtree = firstTimeframeChild(timeframeNode);
|
||||||
|
Set<String> fromConditions = collectExplicitCandleTypes(subtree);
|
||||||
|
|
||||||
|
if (fromConditions.isEmpty()) {
|
||||||
|
return fromStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromConditions.size() == 1) {
|
||||||
|
String required = fromConditions.iterator().next();
|
||||||
|
// 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만
|
||||||
|
if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) {
|
||||||
|
return Set.of(required);
|
||||||
|
}
|
||||||
|
// START 1m 기본값만 있고 조건은 다른 분봉
|
||||||
|
if (isLegacyDefaultStartOnly(fromStart) && !"1m".equals(required)) {
|
||||||
|
return Set.of(required);
|
||||||
|
}
|
||||||
|
// 의도적 다중 OR [1m,3m,5m] 또는 [3m,5m] — START 배열 유지
|
||||||
|
if (fromStart.size() >= 2 && fromStart.contains(required)) {
|
||||||
|
return fromStart;
|
||||||
|
}
|
||||||
|
if (!fromStart.equals(Set.of(required))) {
|
||||||
|
return Set.of(required);
|
||||||
|
}
|
||||||
|
return fromStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR)
|
||||||
|
if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) {
|
||||||
|
return fromStart;
|
||||||
|
}
|
||||||
|
return fromConditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) {
|
||||||
|
Set<String> resolved = resolveStartCandleTypes(root);
|
||||||
|
if (resolved.isEmpty()) {
|
||||||
|
resolved = Set.of(inferPrimaryCandleType(root, strategyName));
|
||||||
|
}
|
||||||
|
return copyTimeframeWithCandleTypes(root, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> readStartCandleTypesFromNode(JsonNode tf) {
|
||||||
Set<String> out = new LinkedHashSet<>();
|
Set<String> out = new LinkedHashSet<>();
|
||||||
JsonNode arr = reconciled.path("candleTypes");
|
JsonNode arr = tf.path("candleTypes");
|
||||||
if (arr.isArray() && !arr.isEmpty()) {
|
if (arr.isArray() && !arr.isEmpty()) {
|
||||||
for (JsonNode t : arr) {
|
for (JsonNode t : arr) {
|
||||||
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||||
@@ -292,11 +325,14 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
out.add(LiveStrategyTimeframeService.normalize(
|
out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m")));
|
||||||
reconciled.path("candleType").asText("1m")));
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isLegacyDefaultStartOnly(Set<String> fromStart) {
|
||||||
|
return fromStart.size() == 1 && fromStart.contains("1m");
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode reconcileStartCandleTypes(JsonNode tf) {
|
private ObjectNode reconcileStartCandleTypes(JsonNode tf) {
|
||||||
return reconcileStartCandleTypesStatic(tf);
|
return reconcileStartCandleTypesStatic(tf);
|
||||||
}
|
}
|
||||||
@@ -349,9 +385,23 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
private ObjectNode copyTimeframeWithCandleTypes(JsonNode tf, Set<String> types) {
|
||||||
ObjectNode copy = tf.deepCopy();
|
ObjectNode copy = tf.deepCopy();
|
||||||
copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType));
|
ArrayNode arr = JsonNodeFactory.instance.arrayNode();
|
||||||
|
List<String> ordered = new ArrayList<>(types);
|
||||||
|
for (String ct : ordered) {
|
||||||
|
arr.add(LiveStrategyTimeframeService.normalize(ct));
|
||||||
|
}
|
||||||
|
if (arr.isEmpty()) {
|
||||||
|
arr.add("1m");
|
||||||
|
}
|
||||||
|
copy.set("candleTypes", arr);
|
||||||
|
copy.put("candleType", arr.get(0).asText());
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated 단일 분봉 — {@link #copyTimeframeWithCandleTypes} 사용 */
|
||||||
|
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
||||||
|
return copyTimeframeWithCandleTypes(tf, Set.of(candleType));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
@@ -10,6 +10,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -159,6 +160,45 @@ class StrategyConditionTimeframeServiceTest {
|
|||||||
assertFalse(service.usesTimeframe(5L, "1m"));
|
assertFalse(service.usesTimeframe(5L, "1m"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collectForStrategy_staleStartUsesConditionTimeframeForAnyTf() {
|
||||||
|
for (String tf : List.of("3m", "5m", "15m", "1h")) {
|
||||||
|
GcStrategy strategy = new GcStrategy();
|
||||||
|
strategy.setId(10L);
|
||||||
|
strategy.setName("tf test " + tf);
|
||||||
|
strategy.setBuyConditionJson("""
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"candleTypes": ["1m", "%s"],
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"leftCandleType": "%s",
|
||||||
|
"rightCandleType": "%s"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(tf, tf, tf));
|
||||||
|
strategy.setSellConditionJson(null);
|
||||||
|
when(strategyRepo.findById(10L)).thenReturn(Optional.of(strategy));
|
||||||
|
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(1));
|
||||||
|
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenAnswer(inv -> {
|
||||||
|
String json = inv.getArgument(0);
|
||||||
|
String name = inv.getArgument(1);
|
||||||
|
return new StrategyDslTimeframeNormalizer(objectMapper).normalizeJson(json, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
Set<String> tfs = service.collectForStrategy(10L);
|
||||||
|
assertEquals(Set.of(tf), tfs, "expected only " + tf);
|
||||||
|
assertFalse(service.usesTimeframe(10L, "1m"), "1m should not be used for " + tf);
|
||||||
|
service.invalidate(10L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void collectForStrategy_stale1m5mArrayUses5mOnlyWhenConditionsSay5m() {
|
void collectForStrategy_stale1m5mArrayUses5mOnlyWhenConditionsSay5m() {
|
||||||
GcStrategy strategy = new GcStrategy();
|
GcStrategy strategy = new GcStrategy();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
@@ -75,6 +76,26 @@ class StrategyDslTimeframeNormalizerTest {
|
|||||||
assertEquals("5m", root.path("candleType").asText());
|
assertEquals("5m", root.path("candleType").asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveStartCandleTypes_meaningfulOverridesStaleStart1m() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "CCI",
|
||||||
|
"leftCandleType": "5m",
|
||||||
|
"rightCandleType": "5m"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolveStartCandleTypes_dropsStale1mPair() throws Exception {
|
void resolveStartCandleTypes_dropsStale1mPair() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
@@ -95,6 +116,51 @@ class StrategyDslTimeframeNormalizerTest {
|
|||||||
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveStartCandleTypes_resolves3mAnd15mAnd1h() throws Exception {
|
||||||
|
for (String tf : List.of("3m", "15m", "1h")) {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"leftCandleType": "%s",
|
||||||
|
"rightCandleType": "%s"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(tf, tf);
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
assertEquals(Set.of(tf), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root),
|
||||||
|
"expected " + tf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_alignsStaleStartToConditionTimeframe() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"candleTypes": ["1m", "15m"],
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "CCI",
|
||||||
|
"leftCandleType": "15m"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
JsonNode root = objectMapper.readTree(normalizer.normalizeJson(json, "test"));
|
||||||
|
assertEquals(1, root.path("candleTypes").size());
|
||||||
|
assertEquals("15m", root.path("candleTypes").get(0).asText());
|
||||||
|
assertEquals("15m", root.path("candleType").asText());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { createPortal } from 'react-dom';
|
|||||||
import type { Theme } from '../types';
|
import type { Theme } from '../types';
|
||||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||||
import {
|
import {
|
||||||
|
repairStrategyTimeframes,
|
||||||
saveLiveStrategySettings,
|
saveLiveStrategySettings,
|
||||||
type LiveStrategySettingsDto,
|
type LiveStrategySettingsDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
|
import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
import { getKoreanName } from '../utils/marketNameCache';
|
||||||
|
|
||||||
interface Strategy {
|
interface Strategy {
|
||||||
@@ -70,6 +72,15 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|||||||
if (virtualDriven) return;
|
if (virtualDriven) return;
|
||||||
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
||||||
const prev = settings;
|
const prev = settings;
|
||||||
|
if (next.isLiveCheck && next.strategyId != null) {
|
||||||
|
const ok = await warnStrategyTimeframeMismatch(next.strategyId);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await repairStrategyTimeframes(next.strategyId);
|
||||||
|
} catch {
|
||||||
|
/* repair optional */
|
||||||
|
}
|
||||||
|
}
|
||||||
onSettingsChange?.(next);
|
onSettingsChange?.(next);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
updateStartCandleTypes,
|
updateStartCandleTypes,
|
||||||
type EditorConditionState,
|
type EditorConditionState,
|
||||||
} from '../utils/strategyConditionSerde';
|
} from '../utils/strategyConditionSerde';
|
||||||
|
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
|
||||||
import {
|
import {
|
||||||
START_NODE_ID,
|
START_NODE_ID,
|
||||||
defaultStartMeta,
|
defaultStartMeta,
|
||||||
@@ -190,8 +191,13 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
}));
|
}));
|
||||||
const buyLayoutRef = useRef(buyLayout);
|
const buyLayoutRef = useRef(buyLayout);
|
||||||
const sellLayoutRef = useRef(sellLayout);
|
const sellLayoutRef = useRef(sellLayout);
|
||||||
|
const buyConditionRef = useRef(buyCondition);
|
||||||
|
const sellConditionRef = useRef(sellCondition);
|
||||||
buyLayoutRef.current = buyLayout;
|
buyLayoutRef.current = buyLayout;
|
||||||
sellLayoutRef.current = sellLayout;
|
sellLayoutRef.current = sellLayout;
|
||||||
|
buyConditionRef.current = buyCondition;
|
||||||
|
sellConditionRef.current = sellCondition;
|
||||||
|
const timeframeAutosaveRef = useRef<number | null>(null);
|
||||||
const layoutRevisionRef = useRef(0);
|
const layoutRevisionRef = useRef(0);
|
||||||
const persistLayoutTimerRef = useRef<number | null>(null);
|
const persistLayoutTimerRef = useRef<number | null>(null);
|
||||||
const layoutPersistReadyRef = useRef(false);
|
const layoutPersistReadyRef = useRef(false);
|
||||||
@@ -395,6 +401,22 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
schedulePersistFlowLayout(layoutStrategyKey);
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
|
const scheduleTimeframePersist = useCallback(() => {
|
||||||
|
if (selectedId == null || !stratName.trim()) return;
|
||||||
|
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
|
||||||
|
const id = selectedId;
|
||||||
|
const name = stratName;
|
||||||
|
const desc = stratDesc;
|
||||||
|
timeframeAutosaveRef.current = window.setTimeout(() => {
|
||||||
|
timeframeAutosaveRef.current = null;
|
||||||
|
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||||||
|
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||||||
|
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
|
||||||
|
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
|
||||||
|
});
|
||||||
|
}, 700);
|
||||||
|
}, [selectedId, stratName, stratDesc]);
|
||||||
|
|
||||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||||
if (startId === START_NODE_ID) {
|
if (startId === START_NODE_ID) {
|
||||||
const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes);
|
const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes);
|
||||||
@@ -415,16 +437,20 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
|
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
|
||||||
}));
|
}));
|
||||||
schedulePersistFlowLayout(layoutStrategyKey);
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
|
scheduleTimeframePersist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||||
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
|
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
|
||||||
|
scheduleTimeframePersist();
|
||||||
}, [
|
}, [
|
||||||
buyEditorState,
|
buyEditorState,
|
||||||
sellEditorState,
|
sellEditorState,
|
||||||
handleEditorStateChange,
|
handleEditorStateChange,
|
||||||
layoutStrategyKey,
|
layoutStrategyKey,
|
||||||
schedulePersistFlowLayout,
|
schedulePersistFlowLayout,
|
||||||
|
scheduleTimeframePersist,
|
||||||
|
signalTab,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ import { coerceFiniteNumber } from '../utils/safeFormat';
|
|||||||
import {
|
import {
|
||||||
syncVirtualTargetsToBackend,
|
syncVirtualTargetsToBackend,
|
||||||
stopVirtualLiveOnBackend,
|
stopVirtualLiveOnBackend,
|
||||||
warnStrategyTimeframeMismatch,
|
|
||||||
} from '../utils/virtualLiveStrategySync';
|
} from '../utils/virtualLiveStrategySync';
|
||||||
|
import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync';
|
||||||
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||||
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||||
import {
|
import {
|
||||||
@@ -284,7 +284,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const globalStrat = strategies.find(s => s.id === session.globalStrategyId);
|
const globalStrat = strategies.find(s => s.id === session.globalStrategyId);
|
||||||
const ok = await warnStrategyTimeframeMismatch(globalStrat, session.globalStrategyId);
|
const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await syncVirtualTargetsToBackend(targets, session, true);
|
await syncVirtualTargetsToBackend(targets, session, true);
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null {
|
|||||||
children.forEach((tf, index) => {
|
children.forEach((tf, index) => {
|
||||||
const candleTypes = readTimeframeCandleTypes(tf as LogicNode);
|
const candleTypes = readTimeframeCandleTypes(tf as LogicNode);
|
||||||
const candleType = candleTypes[0];
|
const candleType = candleTypes[0];
|
||||||
const branchRoot = tf.children?.[0] ?? null;
|
const branchRoot = syncSubtreeCandleTypes(tf.children?.[0] ?? null, candleType);
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
startMeta[START_NODE_ID] = { candleTypes, candleType };
|
startMeta[START_NODE_ID] = { candleTypes, candleType };
|
||||||
primaryRoot = branchRoot;
|
primaryRoot = branchRoot;
|
||||||
@@ -341,10 +341,11 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
|||||||
|
|
||||||
if (dsl.type === 'TIMEFRAME') {
|
if (dsl.type === 'TIMEFRAME') {
|
||||||
const candleTypes = readTimeframeCandleTypes(dsl);
|
const candleTypes = readTimeframeCandleTypes(dsl);
|
||||||
|
const primary = candleTypes[0];
|
||||||
return {
|
return {
|
||||||
root: dsl.children?.[0] ?? null,
|
root: syncSubtreeCandleTypes(dsl.children?.[0] ?? null, primary),
|
||||||
startMeta: {
|
startMeta: {
|
||||||
[START_NODE_ID]: { candleTypes, candleType: candleTypes[0] },
|
[START_NODE_ID]: { candleTypes, candleType: primary },
|
||||||
},
|
},
|
||||||
extraStartIds: [],
|
extraStartIds: [],
|
||||||
extraRoots: {},
|
extraRoots: {},
|
||||||
@@ -354,7 +355,7 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
|
|||||||
|
|
||||||
const inferred = inferPrimaryCandleFromTree(dsl);
|
const inferred = inferPrimaryCandleFromTree(dsl);
|
||||||
return {
|
return {
|
||||||
root: dsl,
|
root: syncSubtreeCandleTypes(dsl, inferred),
|
||||||
startMeta: {
|
startMeta: {
|
||||||
[START_NODE_ID]: { candleTypes: [inferred], candleType: inferred },
|
[START_NODE_ID]: { candleTypes: [inferred], candleType: inferred },
|
||||||
},
|
},
|
||||||
@@ -429,8 +430,21 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
|||||||
return collectEditorBranches(decodeConditionForEditor(dsl));
|
return collectEditorBranches(decodeConditionForEditor(dsl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
|
||||||
|
function pickMergedStartCandleTypes(decoded: string[], stored?: string[]): string[] {
|
||||||
|
const fromDb = normalizeCandleTypesList(decoded);
|
||||||
|
const fromStored = stored?.length ? normalizeCandleTypesList(stored) : [];
|
||||||
|
const dbIsLegacyDefault = fromDb.length === 1 && fromDb[0] === '1m';
|
||||||
|
const storedDiffers = fromStored.length > 0
|
||||||
|
&& (fromStored.length !== fromDb.length || fromStored.some((ct, i) => ct !== fromDb[i]));
|
||||||
|
if (storedDiffers && (dbIsLegacyDefault || fromDb.length === 0)) {
|
||||||
|
return fromStored;
|
||||||
|
}
|
||||||
|
return fromDb;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전략 로드 시 startMeta 병합 — DB DSL에서 복원한 시간봉이 localStorage보다 우선.
|
* 전략 로드 시 startMeta 병합 — DB가 1m 기본값만 있고 localStorage에 다른 분봉이 있으면 localStorage 우선.
|
||||||
*/
|
*/
|
||||||
export function mergeStartMetaForLoad(
|
export function mergeStartMetaForLoad(
|
||||||
decoded: Record<string, StartNodeMeta>,
|
decoded: Record<string, StartNodeMeta>,
|
||||||
@@ -441,7 +455,10 @@ export function mergeStartMetaForLoad(
|
|||||||
...stored,
|
...stored,
|
||||||
};
|
};
|
||||||
for (const [startId, meta] of Object.entries(decoded)) {
|
for (const [startId, meta] of Object.entries(decoded)) {
|
||||||
const types = getStartCandleTypes(meta);
|
const types = pickMergedStartCandleTypes(
|
||||||
|
getStartCandleTypes(meta),
|
||||||
|
stored?.[startId] ? getStartCandleTypes(stored[startId]) : undefined,
|
||||||
|
);
|
||||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { loadStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
|
||||||
|
import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
|
||||||
|
import {
|
||||||
|
alignBuySellStartCandleTypesForSave,
|
||||||
|
collectDslBranches,
|
||||||
|
collectTimeframesFromEditorState,
|
||||||
|
encodeConditionForSave,
|
||||||
|
type EditorConditionState,
|
||||||
|
} from './strategyConditionSerde';
|
||||||
|
import { START_NODE_ID, getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
|
||||||
|
import type { LogicNode } from './strategyTypes';
|
||||||
|
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||||
|
|
||||||
|
function collectFromFlowLayout(strategyId: number): string[] {
|
||||||
|
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||||
|
if (!layout) return [];
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const side of ['buy', 'sell'] as const) {
|
||||||
|
const meta = layout[side]?.startMeta?.[START_NODE_ID];
|
||||||
|
if (!meta) continue;
|
||||||
|
for (const ct of getStartCandleTypes(meta)) set.add(normalizeStartCandleType(ct));
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromStrategyDto(strategy: StrategyDto | null | undefined): string[] {
|
||||||
|
if (!strategy) return [];
|
||||||
|
const set = new Set<string>();
|
||||||
|
const buy = strategy.buyCondition as LogicNode | null | undefined;
|
||||||
|
const sell = strategy.sellCondition as LogicNode | null | undefined;
|
||||||
|
for (const b of [
|
||||||
|
...collectDslBranches(buy ?? null),
|
||||||
|
...collectDslBranches(sell ?? null),
|
||||||
|
]) {
|
||||||
|
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
||||||
|
for (const ct of types) set.add(normalizeStartCandleType(ct));
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 (서버 DSL보다 우선) */
|
||||||
|
export function collectUiEvaluationTimeframes(
|
||||||
|
strategyId: number,
|
||||||
|
strategy?: StrategyDto | null,
|
||||||
|
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||||
|
): string[] {
|
||||||
|
if (editorState) {
|
||||||
|
const fromEditor = [
|
||||||
|
...collectTimeframesFromEditorState(editorState.buy),
|
||||||
|
...collectTimeframesFromEditorState(editorState.sell),
|
||||||
|
].map(normalizeStartCandleType);
|
||||||
|
if (fromEditor.length > 0) return [...new Set(fromEditor)];
|
||||||
|
}
|
||||||
|
const fromLayout = collectFromFlowLayout(strategyId);
|
||||||
|
if (fromLayout.length > 0) return fromLayout;
|
||||||
|
return collectFromStrategyDto(strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 분봉과 서버 DSL 불일치 시 안내.
|
||||||
|
* @returns false — 진행 불가(저장 필요)
|
||||||
|
*/
|
||||||
|
export async function warnStrategyTimeframeMismatch(
|
||||||
|
strategyId: number,
|
||||||
|
strategy?: StrategyDto | null,
|
||||||
|
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy, editorState);
|
||||||
|
if (uiTfs.length === 0) return true;
|
||||||
|
|
||||||
|
let serverTfs: string[];
|
||||||
|
try {
|
||||||
|
serverTfs = await loadStrategyTimeframes(strategyId);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||||
|
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||||
|
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||||
|
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
||||||
|
|
||||||
|
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
||||||
|
// 양쪽 모두 1m만 — 일치로 간주
|
||||||
|
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
||||||
|
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||||
|
window.alert(
|
||||||
|
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||||
|
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 자동 저장된 뒤 실시간 체크를 켜 주세요.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
|
||||||
|
export async function persistStrategyEvaluationTimeframes(
|
||||||
|
strategyId: number,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
buy: EditorConditionState,
|
||||||
|
sell: EditorConditionState,
|
||||||
|
): Promise<void> {
|
||||||
|
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||||
|
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||||
|
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||||
|
await saveStrategy({
|
||||||
|
id: strategyId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
buyCondition: encodedBuy,
|
||||||
|
sellCondition: encodedSell,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { StrategyFlowLayoutStore };
|
||||||
@@ -1,56 +1,8 @@
|
|||||||
import { loadStrategyTimeframes, saveLiveStrategySettings } from './backendApi';
|
import { saveLiveStrategySettings } from './backendApi';
|
||||||
import { collectDslBranches } from './strategyConditionSerde';
|
|
||||||
import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
|
import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
|
||||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
|
||||||
import type { StrategyDto } from './backendApi';
|
|
||||||
import type { LogicNode } from './strategyTypes';
|
|
||||||
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
|
||||||
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
|
||||||
|
|
||||||
function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[] {
|
|
||||||
if (!strategy) return [];
|
|
||||||
const set = new Set<string>();
|
|
||||||
const buy = strategy.buyCondition as LogicNode | null | undefined;
|
|
||||||
const sell = strategy.sellCondition as LogicNode | null | undefined;
|
|
||||||
for (const b of [
|
|
||||||
...collectDslBranches(buy ?? null),
|
|
||||||
...collectDslBranches(sell ?? null),
|
|
||||||
]) {
|
|
||||||
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
|
||||||
for (const ct of types) set.add(normalizeStartCandleType(ct));
|
|
||||||
}
|
|
||||||
return [...set];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI(편집기)와 서버 DSL의 평가 분봉이 다르면 안내.
|
|
||||||
* @returns false — 계속 진행 불가(재저장 필요)
|
|
||||||
*/
|
|
||||||
export async function warnStrategyTimeframeMismatch(
|
|
||||||
strategy: StrategyDto | null | undefined,
|
|
||||||
strategyId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!strategy) return true;
|
|
||||||
const uiTfs = collectUiTimeframes(strategy);
|
|
||||||
if (uiTfs.length === 0 || (uiTfs.length === 1 && uiTfs[0] === '1m')) return true;
|
|
||||||
|
|
||||||
let serverTfs: string[];
|
|
||||||
try {
|
|
||||||
serverTfs = await loadStrategyTimeframes(strategyId);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
|
||||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(tf));
|
|
||||||
if (missingOnServer.length === 0) return true;
|
|
||||||
|
|
||||||
window.alert(
|
|
||||||
`전략 화면에는 ${uiTfs.join(', ')} 봉으로 보이지만, 서버에는 ${[...serverSet].join(', ') || '1m'} 만 저장되어 있습니다.\n\n`
|
|
||||||
+ '전략편집기에서 「저장」을 눌러 다시 저장한 뒤 가상매매를 시작하세요.',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
|
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
|
||||||
export async function syncVirtualTargetsToBackend(
|
export async function syncVirtualTargetsToBackend(
|
||||||
targets: VirtualTargetItem[],
|
targets: VirtualTargetItem[],
|
||||||
|
|||||||
Reference in New Issue
Block a user