전략시간봉 오류 재수정

This commit is contained in:
Macbook
2026-05-28 02:09:38 +09:00
parent 5f9b20d907
commit 9137864f48
8 changed files with 215 additions and 90 deletions
@@ -101,23 +101,33 @@ public class LiveStrategyEvaluator {
ta4jStorage.getOrCreate(market, candleType).getBarCount()); ta4jStorage.getOrCreate(market, candleType).getBarCount());
for (GcLiveStrategySettings s : settings) { for (GcLiveStrategySettings s : settings) {
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; String result = evaluateSettingOnCandleClose(s, market, candleType, maturedIndex);
if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue; if (!"NONE".equals(result)) return result;
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;
}
} }
return "NONE"; 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초 스케줄러에서 호출. * 방식 B — 3초 스케줄러에서 호출.
* *
@@ -141,37 +151,44 @@ public class LiveStrategyEvaluator {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market); List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE"; 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"; if (!ta4jStorage.exists(market, candleType)) return "NONE";
BarSeries series = ta4jStorage.getOrCreate(market, candleType); BarSeries series = ta4jStorage.getOrCreate(market, candleType);
if (series.isEmpty()) return "NONE"; if (series.isEmpty()) return "NONE";
int currentIndex = series.getEndIndex(); int currentIndex = series.getEndIndex();
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
// 동일 provisional 봉에서 이미 시그널을 냈으면 중복 발화 방지 String dedupeKey = market + ":" + candleType + ":" + s.getStrategyId();
String dedupeKey = market + ":" + candleType;
Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey);
if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE";
for (GcLiveStrategySettings s : settings) { String cacheKey = market + ":" + candleType + ":" + s.getStrategyId();
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; triggerRulesCache.remove(cacheKey);
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
if (s.getStrategyId() == null) continue;
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
// ★ Ta4j CachedIndicator 캐시 무효화 — Rule 트리 재빌드 String result = evaluate(market, candleType, s.getStrategyId(),
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); s.getPositionMode(), currentIndex,
triggerRulesCache.remove(cacheKey); s.getDeviceId(), s.getUserId());
if (!"NONE".equals(result)) {
String result = evaluate(market, candleType, s.getStrategyId(), log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}",
s.getPositionMode(), currentIndex, s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result);
s.getDeviceId(), s.getUserId()); realtimeSignaledIdx.put(dedupeKey, currentIndex);
if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK {} {} idx={} mode={} → {}",
market, candleType, currentIndex, s.getPositionMode(), result);
realtimeSignaledIdx.put(dedupeKey, currentIndex);
return result;
}
} }
return "NONE"; return result;
} }
/** /**
@@ -192,31 +209,39 @@ public class LiveStrategyEvaluator {
public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) { public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market); List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE"; if (settings.isEmpty()) return "NONE";
if (!ta4jStorage.exists(market, candleType)) return "NONE";
for (GcLiveStrategySettings s : settings) { for (GcLiveStrategySettings s : settings) {
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; String result = evaluateSettingRealtimeAtClose(s, market, candleType, maturedIndex);
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; if (!"NONE".equals(result)) return result;
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;
}
} }
return "NONE"; 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) { public void invalidateCache(String market) {
triggerRulesCache.keySet().removeIf(k -> k.startsWith(market + ":")); triggerRulesCache.keySet().removeIf(k -> k.startsWith(market + ":"));
@@ -170,40 +170,49 @@ public class BarBuilder {
*/ */
private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex, private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex,
Bar signalBar) { Bar signalBar) {
String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex); List<GcLiveStrategySettings> activeSettings = liveSettingsRepo.findActiveByMarket(market).stream()
String signalExecType = "CANDLE_CLOSE"; .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
.toList();
if (activeSettings.isEmpty()) return;
if ("NONE".equals(closeSignal)) { long candleTimeEpoch = signalBar.getEndTime().getEpochSecond()
closeSignal = liveStrategyEvaluator.evaluateRealtimeAtClose(market, candleType, maturedIndex); - signalBar.getTimePeriod().getSeconds();
signalExecType = "REALTIME_TICK";
}
if ("BUY".equals(closeSignal) || "SELL".equals(closeSignal)) { for (GcLiveStrategySettings s : activeSettings) {
publishStrategySignal(market, candleType, signalBar, closeSignal); if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
}
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return; String closeSignal = "NONE";
String signalExecType = null;
try { if ("CANDLE_CLOSE".equals(s.getExecutionType())) {
final String execType = signalExecType; closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose(
GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market) s, market, candleType, maturedIndex);
.stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) signalExecType = "CANDLE_CLOSE";
&& execType.equals(s.getExecutionType())).findFirst().orElse(null); } else if ("REALTIME_TICK".equals(s.getExecutionType())) {
long candleTimeEpoch = signalBar.getEndTime().getEpochSecond() closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose(
- signalBar.getTimePeriod().getSeconds(); s, market, candleType, maturedIndex);
String devId = activeSetting != null ? activeSetting.getDeviceId() : null; signalExecType = "REALTIME_TICK";
Long uid = activeSetting != null ? activeSetting.getUserId() : null; }
Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null;
tradeSignalService.save( if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
devId, uid, market, stratId, null,
closeSignal, signalBar.getClosePrice().doubleValue(), publishStrategySignal(market, candleType, signalBar, closeSignal);
candleTimeEpoch, candleType, execType); try {
if (devId != null) { tradeSignalService.save(
orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, s.getDeviceId(), s.getUserId(),
signalBar.getClosePrice().doubleValue()); 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());
} }
} }
@@ -69,7 +69,7 @@ public class LiveStrategyScheduler {
BarSeries series = ta4jStorage.getOrCreate(market, candleType); BarSeries series = ta4jStorage.getOrCreate(market, candleType);
if (series.isEmpty()) return; if (series.isEmpty()) return;
String signal = evaluator.evaluateRealtimeTick(market, candleType); String signal = evaluator.evaluateSettingRealtimeTick(s, market, candleType);
if ("BUY".equals(signal) || "SELL".equals(signal)) { if ("BUY".equals(signal) || "SELL".equals(signal)) {
// 현재 진행 중인 캔들 정보 조회 // 현재 진행 중인 캔들 정보 조회
org.ta4j.core.Bar lastBar = series.getLastBar(); org.ta4j.core.Bar lastBar = series.getLastBar();
@@ -13,7 +13,10 @@ import {
saveLiveStrategySettings, saveLiveStrategySettings,
type LiveStrategySettingsDto, type LiveStrategySettingsDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync'; import {
syncStrategyTimeframesFromLayoutIfNeeded,
warnStrategyTimeframeMismatch,
} from '../utils/strategyTimeframeSync';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
interface Strategy { interface Strategy {
@@ -73,6 +76,8 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
const prev = settings; const prev = settings;
if (next.isLiveCheck && next.strategyId != null) { if (next.isLiveCheck && next.strategyId != null) {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
if (!synced) return;
const ok = await warnStrategyTimeframeMismatch(next.strategyId); const ok = await warnStrategyTimeframeMismatch(next.strategyId);
if (!ok) return; if (!ok) return;
try { try {
+5
View File
@@ -8,6 +8,7 @@ import {
saveLiveStrategySettings, saveLiveStrategySettings,
type LiveStrategySettingsDto, type LiveStrategySettingsDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import { BacktestSettingsPanel } from './BacktestSettingsPanel'; import { BacktestSettingsPanel } from './BacktestSettingsPanel';
import IndicatorMainDefaultsPanel, { import IndicatorMainDefaultsPanel, {
type IndicatorSaveUiState, type IndicatorSaveUiState,
@@ -930,6 +931,10 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => { const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
if (virtualDriven) return; if (virtualDriven) return;
const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket }; const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket };
if (next.isLiveCheck && next.strategyId != null) {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
if (!synced) return;
}
setLiveSaving(true); setLiveSaving(true);
try { try {
const saved = await saveLiveStrategySettings(next); const saved = await saveLiveStrategySettings(next);
@@ -52,7 +52,10 @@ import {
syncVirtualTargetsToBackend, syncVirtualTargetsToBackend,
stopVirtualLiveOnBackend, stopVirtualLiveOnBackend,
} from '../utils/virtualLiveStrategySync'; } from '../utils/virtualLiveStrategySync';
import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync'; import {
syncStrategyTimeframesFromLayoutIfNeeded,
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,6 +287,11 @@ 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 synced = await syncStrategyTimeframesFromLayoutIfNeeded(
session.globalStrategyId,
globalStrat,
);
if (!synced) return;
const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat); const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat);
if (!ok) return; if (!ok) return;
try { try {
@@ -1,11 +1,13 @@
import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes } from './backendApi'; import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes } from './backendApi';
import { normalizeStartCandleType } from './strategyStartNodes'; import { normalizeStartCandleType } from './strategyStartNodes';
import { syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync';
/** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */ /** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */
export async function pinStrategyEvaluationTimeframes( export async function pinStrategyEvaluationTimeframes(
market: string, market: string,
strategyId: number, strategyId: number,
): Promise<string[]> { ): Promise<string[]> {
await syncStrategyTimeframesFromLayoutIfNeeded(strategyId);
try { try {
await repairStrategyTimeframes(strategyId); await repairStrategyTimeframes(strategyId);
} catch { } catch {
+76 -5
View File
@@ -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 { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
import { import {
alignBuySellStartCandleTypesForSave, alignBuySellStartCandleTypesForSave,
collectDslBranches, collectDslBranches,
collectTimeframesFromEditorState, collectTimeframesFromEditorState,
decodeConditionForEditor,
encodeConditionForSave, encodeConditionForSave,
mergeStartMetaForLoad,
normalizeStartCombineOp,
type EditorConditionState, type EditorConditionState,
} from './strategyConditionSerde'; } from './strategyConditionSerde';
import { START_NODE_ID, getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes'; import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
import type { LogicNode } from './strategyTypes'; import type { LogicNode } from './strategyTypes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
@@ -16,9 +19,11 @@ function collectFromFlowLayout(strategyId: number): string[] {
if (!layout) return []; if (!layout) return [];
const set = new Set<string>(); const set = new Set<string>();
for (const side of ['buy', 'sell'] as const) { for (const side of ['buy', 'sell'] as const) {
const meta = layout[side]?.startMeta?.[START_NODE_ID]; const metaMap = layout[side]?.startMeta;
if (!meta) continue; if (!metaMap) continue;
for (const ct of getStartCandleTypes(meta)) set.add(normalizeStartCandleType(ct)); for (const meta of Object.values(metaMap)) {
for (const ct of getStartCandleTypes(meta)) set.add(normalizeStartCandleType(ct));
}
} }
return [...set]; return [...set];
} }
@@ -94,6 +99,72 @@ export async function warnStrategyTimeframeMismatch(
return false; 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<boolean> {
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을 서버에 즉시 반영 */ /** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
export async function persistStrategyEvaluationTimeframes( export async function persistStrategyEvaluationTimeframes(
strategyId: number, strategyId: number,