diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 25e88ce..4b9c480 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -42,20 +42,7 @@ public class StrategyDslTimeframeNormalizer { if (root == null || root.isNull()) return root; if ("TIMEFRAME".equals(root.path("type").asText(""))) { - // START 다중 분봉(candleTypes) — stale 1m 제거 후 candleType 동기화 - 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; + return writeResolvedTimeframe(root, strategyName); } if (hasTimeframeScope(root)) return root; @@ -65,6 +52,9 @@ public class StrategyDslTimeframeNormalizer { wrapped.put("id", UUID.randomUUID().toString()); wrapped.put("type", "TIMEFRAME"); wrapped.put("candleType", primary); + ArrayNode candleTypes = objectMapper.createArrayNode(); + candleTypes.add(primary); + wrapped.set("candleTypes", candleTypes); ArrayNode children = objectMapper.createArrayNode(); children.add(root); wrapped.set("children", children); @@ -111,6 +101,9 @@ public class StrategyDslTimeframeNormalizer { } private static boolean hasExplicitStartTimeframe(JsonNode buyTf) { + Set resolved = resolveStartCandleTypes(buyTf); + if (resolved.size() > 1) return true; + if (resolved.size() == 1 && !resolved.contains("1m")) return true; if (hasCandleTypesArray(buyTf)) return true; String ct = LiveStrategyTimeframeService.normalize(buyTf.path("candleType").asText("1m")); return !"1m".equals(ct); @@ -274,17 +267,57 @@ public class StrategyDslTimeframeNormalizer { } /** - * START candleTypes 에 남은 레거시 1m 제거. - * + * START 평가 분봉 해석 — 조건 left/rightCandleType · START candleTypes/candleType 통합. + * 1m·3m·5m·15m·1h 등 모든 분봉에 동일 규칙 적용. */ public static Set resolveStartCandleTypes(JsonNode timeframeNode) { ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode); + Set fromStart = readStartCandleTypesFromNode(reconciled); + JsonNode subtree = firstTimeframeChild(timeframeNode); + Set 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 resolved = resolveStartCandleTypes(root); + if (resolved.isEmpty()) { + resolved = Set.of(inferPrimaryCandleType(root, strategyName)); + } + return copyTimeframeWithCandleTypes(root, resolved); + } + + private static Set readStartCandleTypesFromNode(JsonNode tf) { Set out = new LinkedHashSet<>(); - JsonNode arr = reconciled.path("candleTypes"); + JsonNode arr = tf.path("candleTypes"); if (arr.isArray() && !arr.isEmpty()) { for (JsonNode t : arr) { String ct = LiveStrategyTimeframeService.normalize(t.asText("")); @@ -292,11 +325,14 @@ public class StrategyDslTimeframeNormalizer { } return out; } - out.add(LiveStrategyTimeframeService.normalize( - reconciled.path("candleType").asText("1m"))); + out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m"))); return out; } + private static boolean isLegacyDefaultStartOnly(Set fromStart) { + return fromStart.size() == 1 && fromStart.contains("1m"); + } + private ObjectNode reconcileStartCandleTypes(JsonNode tf) { return reconcileStartCandleTypesStatic(tf); } @@ -349,9 +385,23 @@ public class StrategyDslTimeframeNormalizer { return copy; } - private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { + private ObjectNode copyTimeframeWithCandleTypes(JsonNode tf, Set types) { ObjectNode copy = tf.deepCopy(); - copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType)); + ArrayNode arr = JsonNodeFactory.instance.arrayNode(); + List 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; } + + /** @deprecated 단일 분봉 — {@link #copyTimeframeWithCandleTypes} 사용 */ + private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { + return copyTimeframeWithCandleTypes(tf, Set.of(candleType)); + } } diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index a41cefd..90a1b5b 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -159,6 +160,45 @@ class StrategyConditionTimeframeServiceTest { 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 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 void collectForStrategy_stale1m5mArrayUses5mOnlyWhenConditionsSay5m() { GcStrategy strategy = new GcStrategy(); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java index 44b09bf..12591eb 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -75,6 +76,26 @@ class StrategyDslTimeframeNormalizerTest { 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 void resolveStartCandleTypes_dropsStale1mPair() throws Exception { String json = """ @@ -95,6 +116,51 @@ class StrategyDslTimeframeNormalizerTest { 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 void normalize_preservesMultiCandleTypesOnSave() throws Exception { String json = """ diff --git a/frontend/src/components/LiveStrategyPanel.tsx b/frontend/src/components/LiveStrategyPanel.tsx index a4c27da..b9c1fbb 100644 --- a/frontend/src/components/LiveStrategyPanel.tsx +++ b/frontend/src/components/LiveStrategyPanel.tsx @@ -9,9 +9,11 @@ import { createPortal } from 'react-dom'; import type { Theme } from '../types'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { + repairStrategyTimeframes, saveLiveStrategySettings, type LiveStrategySettingsDto, } from '../utils/backendApi'; +import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync'; import { getKoreanName } from '../utils/marketNameCache'; interface Strategy { @@ -70,6 +72,15 @@ const LiveStrategyPanel: React.FC = ({ if (virtualDriven) return; const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; 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); setSaving(true); try { diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 0e51c5e..d7949be 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -34,6 +34,7 @@ import { updateStartCandleTypes, type EditorConditionState, } from '../utils/strategyConditionSerde'; +import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync'; import { START_NODE_ID, defaultStartMeta, @@ -190,8 +191,13 @@ export default function StrategyEditorPage({ theme }: Props) { })); const buyLayoutRef = useRef(buyLayout); const sellLayoutRef = useRef(sellLayout); + const buyConditionRef = useRef(buyCondition); + const sellConditionRef = useRef(sellCondition); buyLayoutRef.current = buyLayout; sellLayoutRef.current = sellLayout; + buyConditionRef.current = buyCondition; + sellConditionRef.current = sellCondition; + const timeframeAutosaveRef = useRef(null); const layoutRevisionRef = useRef(0); const persistLayoutTimerRef = useRef(null); const layoutPersistReadyRef = useRef(false); @@ -395,6 +401,22 @@ export default function StrategyEditorPage({ theme }: Props) { schedulePersistFlowLayout(layoutStrategyKey); }, [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[]) => { if (startId === START_NODE_ID) { const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes); @@ -415,16 +437,20 @@ export default function StrategyEditorPage({ theme }: Props) { startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp), })); schedulePersistFlowLayout(layoutStrategyKey); + scheduleTimeframePersist(); return; } const state = signalTab === 'buy' ? buyEditorState : sellEditorState; handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes)); + scheduleTimeframePersist(); }, [ buyEditorState, sellEditorState, handleEditorStateChange, layoutStrategyKey, schedulePersistFlowLayout, + scheduleTimeframePersist, + signalTab, ]); const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index cdfadfa..7907fb7 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -51,8 +51,8 @@ import { coerceFiniteNumber } from '../utils/safeFormat'; import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, - warnStrategyTimeframeMismatch, } from '../utils/virtualLiveStrategySync'; +import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync'; import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { @@ -284,7 +284,7 @@ const VirtualTradingPage: React.FC = ({ return; } 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; try { await syncVirtualTargetsToBackend(targets, session, true); diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index 4495723..5637471 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -311,7 +311,7 @@ function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null { children.forEach((tf, index) => { const candleTypes = readTimeframeCandleTypes(tf as LogicNode); const candleType = candleTypes[0]; - const branchRoot = tf.children?.[0] ?? null; + const branchRoot = syncSubtreeCandleTypes(tf.children?.[0] ?? null, candleType); if (index === 0) { startMeta[START_NODE_ID] = { candleTypes, candleType }; primaryRoot = branchRoot; @@ -341,10 +341,11 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition if (dsl.type === 'TIMEFRAME') { const candleTypes = readTimeframeCandleTypes(dsl); + const primary = candleTypes[0]; return { - root: dsl.children?.[0] ?? null, + root: syncSubtreeCandleTypes(dsl.children?.[0] ?? null, primary), startMeta: { - [START_NODE_ID]: { candleTypes, candleType: candleTypes[0] }, + [START_NODE_ID]: { candleTypes, candleType: primary }, }, extraStartIds: [], extraRoots: {}, @@ -354,7 +355,7 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition const inferred = inferPrimaryCandleFromTree(dsl); return { - root: dsl, + root: syncSubtreeCandleTypes(dsl, inferred), startMeta: { [START_NODE_ID]: { candleTypes: [inferred], candleType: inferred }, }, @@ -429,8 +430,21 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] { 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( decoded: Record, @@ -441,7 +455,10 @@ export function mergeStartMetaForLoad( ...stored, }; 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] }; } return merged; diff --git a/frontend/src/utils/strategyTimeframeSync.ts b/frontend/src/utils/strategyTimeframeSync.ts new file mode 100644 index 0000000..4ab47a4 --- /dev/null +++ b/frontend/src/utils/strategyTimeframeSync.ts @@ -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(); + 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(); + 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 { + 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 { + 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 }; diff --git a/frontend/src/utils/virtualLiveStrategySync.ts b/frontend/src/utils/virtualLiveStrategySync.ts index 7d9aa1d..0106d92 100644 --- a/frontend/src/utils/virtualLiveStrategySync.ts +++ b/frontend/src/utils/virtualLiveStrategySync.ts @@ -1,56 +1,8 @@ -import { loadStrategyTimeframes, saveLiveStrategySettings } from './backendApi'; -import { collectDslBranches } from './strategyConditionSerde'; +import { saveLiveStrategySettings } from './backendApi'; 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 { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; -function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[] { - if (!strategy) return []; - const set = new Set(); - 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 { - 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 설정과 동기화 */ export async function syncVirtualTargetsToBackend( targets: VirtualTargetItem[],