diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 4bd0693..3be8cde 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -101,23 +101,33 @@ public class LiveStrategyEvaluator { ta4jStorage.getOrCreate(market, candleType).getBarCount()); for (GcLiveStrategySettings s : settings) { - if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; - if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue; - if (s.getStrategyId() == null) continue; - if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; - - String result = evaluate(market, candleType, s.getStrategyId(), - s.getPositionMode(), maturedIndex, - s.getDeviceId(), s.getUserId()); - if (!"NONE".equals(result)) { - log.info("[Evaluator] CANDLE_CLOSE {} {} idx={} mode={} → {}", - market, candleType, maturedIndex, s.getPositionMode(), result); - return result; - } + String result = evaluateSettingOnCandleClose(s, market, candleType, maturedIndex); + if (!"NONE".equals(result)) return result; } return "NONE"; } + /** + * 단일 live 설정 — 봉 마감(CANDLE_CLOSE) 평가. + */ + public String evaluateSettingOnCandleClose(GcLiveStrategySettings s, String market, + String candleType, int maturedIndex) { + if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE"; + if (!"CANDLE_CLOSE".equals(s.getExecutionType())) return "NONE"; + if (s.getStrategyId() == null) return "NONE"; + if (!ta4jStorage.exists(market, candleType)) return "NONE"; + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; + + String result = evaluate(market, candleType, s.getStrategyId(), + s.getPositionMode(), maturedIndex, + s.getDeviceId(), s.getUserId()); + if (!"NONE".equals(result)) { + log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}", + s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); + } + return result; + } + /** * 방식 B — 3초 스케줄러에서 호출. * @@ -141,37 +151,44 @@ public class LiveStrategyEvaluator { List settings = settingsRepo.findActiveByMarket(market); if (settings.isEmpty()) return "NONE"; + for (GcLiveStrategySettings s : settings) { + String result = evaluateSettingRealtimeTick(s, market, candleType); + if (!"NONE".equals(result)) return result; + } + return "NONE"; + } + + /** + * 단일 live 설정 — 3초 스케줄러(REALTIME_TICK) 평가. + */ + public String evaluateSettingRealtimeTick(GcLiveStrategySettings s, String market, + String candleType) { + if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE"; + if (!"REALTIME_TICK".equals(s.getExecutionType())) return "NONE"; + if (s.getStrategyId() == null) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE"; + BarSeries series = ta4jStorage.getOrCreate(market, candleType); if (series.isEmpty()) return "NONE"; int currentIndex = series.getEndIndex(); + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; - // 동일 provisional 봉에서 이미 시그널을 냈으면 중복 발화 방지 - String dedupeKey = market + ":" + candleType; + String dedupeKey = market + ":" + candleType + ":" + s.getStrategyId(); Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; - for (GcLiveStrategySettings s : settings) { - if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; - if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; - if (s.getStrategyId() == null) continue; - if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; + String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); + triggerRulesCache.remove(cacheKey); - // ★ Ta4j CachedIndicator 캐시 무효화 — Rule 트리 재빌드 - String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); - triggerRulesCache.remove(cacheKey); - - String result = evaluate(market, candleType, s.getStrategyId(), - s.getPositionMode(), currentIndex, - s.getDeviceId(), s.getUserId()); - if (!"NONE".equals(result)) { - log.info("[Evaluator] REALTIME_TICK {} {} idx={} mode={} → {}", - market, candleType, currentIndex, s.getPositionMode(), result); - realtimeSignaledIdx.put(dedupeKey, currentIndex); - return result; - } + String result = evaluate(market, candleType, s.getStrategyId(), + s.getPositionMode(), currentIndex, + s.getDeviceId(), s.getUserId()); + if (!"NONE".equals(result)) { + log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}", + s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result); + realtimeSignaledIdx.put(dedupeKey, currentIndex); } - return "NONE"; + return result; } /** @@ -192,31 +209,39 @@ public class LiveStrategyEvaluator { public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) { List settings = settingsRepo.findActiveByMarket(market); if (settings.isEmpty()) return "NONE"; - if (!ta4jStorage.exists(market, candleType)) return "NONE"; for (GcLiveStrategySettings s : settings) { - if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; - if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; - if (s.getStrategyId() == null) continue; - if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; - - // CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산 - String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); - triggerRulesCache.remove(cacheKey); - - String result = evaluate(market, candleType, s.getStrategyId(), - s.getPositionMode(), maturedIndex, - s.getDeviceId(), s.getUserId()); - if (!"NONE".equals(result)) { - log.info("[Evaluator] REALTIME_TICK @candle_close {} {} idx={} mode={} → {}", - market, candleType, maturedIndex, s.getPositionMode(), result); - realtimeSignaledIdx.put(market + ":" + candleType, maturedIndex); - return result; - } + String result = evaluateSettingRealtimeAtClose(s, market, candleType, maturedIndex); + if (!"NONE".equals(result)) return result; } return "NONE"; } + /** + * 단일 live 설정 — 봉 마감 시점 REALTIME_TICK 평가(교차 누락 방지). + */ + public String evaluateSettingRealtimeAtClose(GcLiveStrategySettings s, String market, + String candleType, int maturedIndex) { + if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE"; + if (!"REALTIME_TICK".equals(s.getExecutionType())) return "NONE"; + if (s.getStrategyId() == null) return "NONE"; + if (!ta4jStorage.exists(market, candleType)) return "NONE"; + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; + + String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); + triggerRulesCache.remove(cacheKey); + + String result = evaluate(market, candleType, s.getStrategyId(), + s.getPositionMode(), maturedIndex, + s.getDeviceId(), s.getUserId()); + if (!"NONE".equals(result)) { + log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}", + s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); + realtimeSignaledIdx.put(market + ":" + candleType + ":" + s.getStrategyId(), maturedIndex); + } + return result; + } + /** 설정 변경 시 해당 마켓 캐시 무효화 */ public void invalidateCache(String market) { triggerRulesCache.keySet().removeIf(k -> k.startsWith(market + ":")); diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index 53c56eb..e9cd102 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -170,40 +170,49 @@ public class BarBuilder { */ private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex, Bar signalBar) { - String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex); - String signalExecType = "CANDLE_CLOSE"; + List activeSettings = liveSettingsRepo.findActiveByMarket(market).stream() + .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) + .toList(); + if (activeSettings.isEmpty()) return; - if ("NONE".equals(closeSignal)) { - closeSignal = liveStrategyEvaluator.evaluateRealtimeAtClose(market, candleType, maturedIndex); - signalExecType = "REALTIME_TICK"; - } + long candleTimeEpoch = signalBar.getEndTime().getEpochSecond() + - signalBar.getTimePeriod().getSeconds(); - if ("BUY".equals(closeSignal) || "SELL".equals(closeSignal)) { - publishStrategySignal(market, candleType, signalBar, closeSignal); - } + for (GcLiveStrategySettings s : activeSettings) { + if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; - if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return; + String closeSignal = "NONE"; + String signalExecType = null; - try { - final String execType = signalExecType; - GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market) - .stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) - && execType.equals(s.getExecutionType())).findFirst().orElse(null); - long candleTimeEpoch = signalBar.getEndTime().getEpochSecond() - - signalBar.getTimePeriod().getSeconds(); - String devId = activeSetting != null ? activeSetting.getDeviceId() : null; - Long uid = activeSetting != null ? activeSetting.getUserId() : null; - Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null; - tradeSignalService.save( - devId, uid, market, stratId, null, - closeSignal, signalBar.getClosePrice().doubleValue(), - candleTimeEpoch, candleType, execType); - if (devId != null) { - orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, - signalBar.getClosePrice().doubleValue()); + if ("CANDLE_CLOSE".equals(s.getExecutionType())) { + closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose( + s, market, candleType, maturedIndex); + signalExecType = "CANDLE_CLOSE"; + } else if ("REALTIME_TICK".equals(s.getExecutionType())) { + closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose( + s, market, candleType, maturedIndex); + signalExecType = "REALTIME_TICK"; + } + + if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue; + + publishStrategySignal(market, candleType, signalBar, closeSignal); + try { + tradeSignalService.save( + s.getDeviceId(), s.getUserId(), + market, s.getStrategyId(), null, + closeSignal, signalBar.getClosePrice().doubleValue(), + candleTimeEpoch, candleType, signalExecType); + if (s.getDeviceId() != null) { + orderExecutionQueue.submitSignal( + s.getDeviceId(), s.getUserId(), market, + s.getStrategyId(), closeSignal, + signalBar.getClosePrice().doubleValue()); + } + } catch (Exception e) { + log.warn("[BarBuilder] 시그널 처리 실패 ({}/{} strategyId={}): {}", + market, candleType, s.getStrategyId(), e.getMessage()); } - } catch (Exception e) { - log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage()); } } diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index 006c695..0026c1d 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -69,7 +69,7 @@ public class LiveStrategyScheduler { BarSeries series = ta4jStorage.getOrCreate(market, candleType); if (series.isEmpty()) return; - String signal = evaluator.evaluateRealtimeTick(market, candleType); + String signal = evaluator.evaluateSettingRealtimeTick(s, market, candleType); if ("BUY".equals(signal) || "SELL".equals(signal)) { // 현재 진행 중인 캔들 정보 조회 org.ta4j.core.Bar lastBar = series.getLastBar(); diff --git a/frontend/src/components/LiveStrategyPanel.tsx b/frontend/src/components/LiveStrategyPanel.tsx index b9c1fbb..a07a730 100644 --- a/frontend/src/components/LiveStrategyPanel.tsx +++ b/frontend/src/components/LiveStrategyPanel.tsx @@ -13,7 +13,10 @@ import { saveLiveStrategySettings, type LiveStrategySettingsDto, } from '../utils/backendApi'; -import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync'; +import { + syncStrategyTimeframesFromLayoutIfNeeded, + warnStrategyTimeframeMismatch, +} from '../utils/strategyTimeframeSync'; import { getKoreanName } from '../utils/marketNameCache'; interface Strategy { @@ -73,6 +76,8 @@ const LiveStrategyPanel: React.FC = ({ const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; const prev = settings; if (next.isLiveCheck && next.strategyId != null) { + const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); + if (!synced) return; const ok = await warnStrategyTimeframeMismatch(next.strategyId); if (!ok) return; try { diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 9d097cd..9975747 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -8,6 +8,7 @@ import { saveLiveStrategySettings, type LiveStrategySettingsDto, } from '../utils/backendApi'; +import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { BacktestSettingsPanel } from './BacktestSettingsPanel'; import IndicatorMainDefaultsPanel, { type IndicatorSaveUiState, @@ -930,6 +931,10 @@ const StrategyPanel: React.FC = ({ const persistLive = useCallback(async (patch: Partial) => { if (virtualDriven) return; const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket }; + if (next.isLiveCheck && next.strategyId != null) { + const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); + if (!synced) return; + } setLiveSaving(true); try { const saved = await saveLiveStrategySettings(next); diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 7907fb7..704b94d 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -52,7 +52,10 @@ import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, } from '../utils/virtualLiveStrategySync'; -import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync'; +import { + syncStrategyTimeframesFromLayoutIfNeeded, + warnStrategyTimeframeMismatch, +} from '../utils/strategyTimeframeSync'; import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { @@ -284,6 +287,11 @@ const VirtualTradingPage: React.FC = ({ return; } const globalStrat = strategies.find(s => s.id === session.globalStrategyId); + const synced = await syncStrategyTimeframesFromLayoutIfNeeded( + session.globalStrategyId, + globalStrat, + ); + if (!synced) return; const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat); if (!ok) return; try { diff --git a/frontend/src/utils/strategyTimeframePin.ts b/frontend/src/utils/strategyTimeframePin.ts index c3ce162..4a38b09 100644 --- a/frontend/src/utils/strategyTimeframePin.ts +++ b/frontend/src/utils/strategyTimeframePin.ts @@ -1,11 +1,13 @@ import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes } from './backendApi'; import { normalizeStartCandleType } from './strategyStartNodes'; +import { syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync'; /** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */ export async function pinStrategyEvaluationTimeframes( market: string, strategyId: number, ): Promise { + await syncStrategyTimeframesFromLayoutIfNeeded(strategyId); try { await repairStrategyTimeframes(strategyId); } catch { diff --git a/frontend/src/utils/strategyTimeframeSync.ts b/frontend/src/utils/strategyTimeframeSync.ts index 4ab47a4..fefcdf8 100644 --- a/frontend/src/utils/strategyTimeframeSync.ts +++ b/frontend/src/utils/strategyTimeframeSync.ts @@ -1,13 +1,16 @@ -import { loadStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi'; +import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi'; import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage'; import { alignBuySellStartCandleTypesForSave, collectDslBranches, collectTimeframesFromEditorState, + decodeConditionForEditor, encodeConditionForSave, + mergeStartMetaForLoad, + normalizeStartCombineOp, type EditorConditionState, } from './strategyConditionSerde'; -import { START_NODE_ID, getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes'; +import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes'; import type { LogicNode } from './strategyTypes'; import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; @@ -16,9 +19,11 @@ function collectFromFlowLayout(strategyId: number): string[] { 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)); + const metaMap = layout[side]?.startMeta; + if (!metaMap) continue; + for (const meta of Object.values(metaMap)) { + for (const ct of getStartCandleTypes(meta)) set.add(normalizeStartCandleType(ct)); + } } return [...set]; } @@ -94,6 +99,72 @@ export async function warnStrategyTimeframeMismatch( return false; } +function buildEditorStateFromStrategy( + strategy: StrategyDto, + layout: StrategyFlowLayoutStore | null, + side: 'buy' | 'sell', +): EditorConditionState { + const decoded = decodeConditionForEditor( + (side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null, + ); + const snap = layout?.[side]; + return { + root: decoded.root, + startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta), + extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds, + extraRoots: snap?.extraRoots ?? decoded.extraRoots, + startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp), + }; +} + +/** + * flow layout(localStorage)의 START 분봉이 서버 DSL과 다르면 편집기 저장 없이 자동 동기화. + * @returns false — layout 없음 등으로 동기화 불가 + */ +export async function syncStrategyTimeframesFromLayoutIfNeeded( + strategyId: number, + strategy?: StrategyDto | null, +): Promise { + const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy); + 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))); + if (missingOnServer.length === 0) return true; + + const layout = loadStrategyFlowLayout(String(strategyId)); + if (!layout) { + return warnStrategyTimeframeMismatch(strategyId, strategy); + } + + const strat = strategy ?? await loadStrategy(strategyId); + if (!strat) return true; + + const buyState = buildEditorStateFromStrategy(strat, layout, 'buy'); + const sellState = buildEditorStateFromStrategy(strat, layout, 'sell'); + + await persistStrategyEvaluationTimeframes( + strategyId, + strat.name, + strat.description ?? '', + buyState, + sellState, + ); + try { + await repairStrategyTimeframes(strategyId); + } catch { + /* repair optional */ + } + return true; +} + /** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */ export async function persistStrategyEvaluationTimeframes( strategyId: number,