diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 0d99a33..e6e9ff5 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -86,6 +86,8 @@ public class StrategyDslToTa4jAdapter { Map.entry("VR", "VR"), Map.entry("DISPARITY", "Disparity"), Map.entry("DONCHIAN", "DonchianChannels"), + Map.entry("NEW_HIGH", "PriceExtreme"), + Map.entry("NEW_LOW", "PriceExtreme"), Map.entry("VOLUME", "VOLUME") ); @@ -281,6 +283,15 @@ public class StrategyDslToTa4jAdapter { return buildCrossTimeframeCompositeRule(cond, ctx); } + PriceExtremeNorm px = normalizePriceExtremeCondition(cond); + if (px != null) { + indType = px.indType(); + condType = px.condType(); + leftField = px.leftField(); + rightField = px.rightField(); + condPeriod = px.period(); + } + // DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회 String registryKey = toRegistryKey(indType); Map indParams = params != null @@ -291,7 +302,7 @@ public class StrategyDslToTa4jAdapter { Indicator left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod); Indicator right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod); - return switch (condType) { + Rule core = switch (condType) { case "GT" -> new OverIndicatorRule(left, right); case "LT" -> new UnderIndicatorRule(left, right); // GTE/LTE: OverIndicator OR 동일 (epsilon 비교) @@ -336,12 +347,72 @@ public class StrategyDslToTa4jAdapter { yield new BooleanRule(false); } }; + if (px != null) { + return nanSafeCompareRule(core, left, right); + } + return core; } catch (Exception e) { log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage()); return new BooleanRule(false); } } + /** + * 신고가·신저가 DSL 정규화 — 구버전 DC_UPPER/DC_LOWER·복합지표 승격 필드를 NH_PRIOR/NL_PRIOR 로 통일. + */ + private record PriceExtremeNorm(String indType, String condType, String leftField, String rightField, int period) {} + + private PriceExtremeNorm normalizePriceExtremeCondition(JsonNode cond) { + String ind = cond.path("indicatorType").asText(""); + if (!"NEW_HIGH".equals(ind) && !"NEW_LOW".equals(ind)) return null; + + String ct = cond.path("conditionType").asText("GT"); + String left = cond.path("leftField").asText("CLOSE_PRICE"); + String right = cond.path("rightField").asText(""); + + int period = cond.path("period").asInt(-1); + int leftP = cond.path("leftPeriod").asInt(-1); + if (period <= 0) period = leftP > 0 ? leftP : 9; + + if (right.startsWith("DC_UPPER_")) { + period = parseTrailingInt(right, "DC_UPPER_", period); + right = "NH_PRIOR_" + period; + } else if (right.startsWith("DC_LOWER_")) { + period = parseTrailingInt(right, "DC_LOWER_", period); + right = "NL_PRIOR_" + period; + } else if (right.startsWith("NH_PRIOR_")) { + period = parseTrailingInt(right, "NH_PRIOR_", period); + } else if (right.startsWith("NL_PRIOR_")) { + period = parseTrailingInt(right, "NL_PRIOR_", period); + } else if (right.isBlank() || "NONE".equals(right)) { + right = "NEW_HIGH".equals(ind) ? "NH_PRIOR_" + period : "NL_PRIOR_" + period; + } + + if (left.isBlank() || "NONE".equals(left)) left = "CLOSE_PRICE"; + + // Donchian 당일봉 포함 DC_* + CROSS 는 거의 성립하지 않음 → PRIOR 필드 + GTE/LTE + if ("NEW_HIGH".equals(ind) && "CROSS_UP".equals(ct) && !right.startsWith("NH_PRIOR_")) { + ct = "GTE"; + } else if ("NEW_LOW".equals(ind) && "CROSS_DOWN".equals(ct) && !right.startsWith("NL_PRIOR_")) { + ct = "LTE"; + } + + return new PriceExtremeNorm(ind, ct, left, right, period); + } + + /** 직전 N봉 극값이 NaN 인 구간(워밍업)에서는 조건 미충족 */ + private Rule nanSafeCompareRule(Rule inner, Indicator left, Indicator right) { + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + Num l = left.getValue(index); + Num r = right.getValue(index); + if (l == null || r == null || l.isNaN() || r.isNaN()) return false; + return inner.isSatisfied(index, tradingRecord); + } + }; + } + /** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */ private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) { if (!cond.path("composite").asBoolean(false)) return false; @@ -611,6 +682,15 @@ public class StrategyDslToTa4jAdapter { return new WilliamsRIndicator(s, period); } if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14)); + // 신고가·신저가 — 직전 N봉 최고/최저 (당일 봉 제외, PreviousValueIndicator) + if (field.startsWith("NH_PRIOR_")) { + int period = parseTrailingInt(field, "NH_PRIOR_", intP(p, "length", 9)); + return priorHighestHigh(s, period); + } + if (field.startsWith("NL_PRIOR_")) { + int period = parseTrailingInt(field, "NL_PRIOR_", intP(p, "length", 9)); + return priorLowestLow(s, period); + } // Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사 if (field.startsWith("DC_UPPER_")) { int period = parseTrailingInt(field, "DC_UPPER_", intP(p, "length", 20)); @@ -857,6 +937,25 @@ public class StrategyDslToTa4jAdapter { } } + /** + * 직전 N봉 고가 중 최고값 (현재 봉 제외). + * index t 에서 max(high[t-N]..high[t-1]). + */ + private Indicator priorHighestHigh(BarSeries s, int period) { + HighPriceIndicator high = new HighPriceIndicator(s); + HighestValueIndicator highest = new HighestValueIndicator(high, period); + return new PreviousValueIndicator(highest, 1); + } + + /** + * 직전 N봉 저가 중 최저값 (현재 봉 제외). + */ + private Indicator priorLowestLow(BarSeries s, int period) { + LowPriceIndicator low = new LowPriceIndicator(s); + LowestValueIndicator lowest = new LowestValueIndicator(low, period); + return new PreviousValueIndicator(lowest, 1); + } + // ── 파라미터 헬퍼 ───────────────────────────────────────────────────────── private int intP(Map p, String k, int def) { diff --git a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java new file mode 100644 index 0000000..b178cb5 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java @@ -0,0 +1,213 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.Rule; +import org.ta4j.core.TradingRecord; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 9일 신고가 매수 / 9일 신저가 매도 — Rule·백테스트 루프 동작 검증. + */ +class PriceExtremeRuleTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private StrategyDslToTa4jAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + } + + @Test + void nhPrior_buyGte_firesOnBreakout_notOnFirstBars() throws Exception { + BarSeries series = buildTrendSeries(120, true); + Rule buy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", + "conditionType": "GTE", + "leftField": "CLOSE_PRICE", + "rightField": "NH_PRIOR_9", + "period": 9 + }} + """); + + List hits = satisfiedIndices(buy, series); + assertTrue(hits.stream().noneMatch(i -> i < 9), + "9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함"); + assertFalse(hits.isEmpty(), "상승 구간에서 최소 1회 이상 매수 조건 충족"); + } + + @Test + void nlPrior_sellLte_firesOnBreakdown() throws Exception { + BarSeries series = buildTrendSeries(120, false); + Rule sell = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", + "conditionType": "LTE", + "leftField": "CLOSE_PRICE", + "rightField": "NL_PRIOR_9", + "period": 9 + }} + """); + + List hits = satisfiedIndices(sell, series); + assertFalse(hits.isEmpty(), "하락 구간에서 신저가 이탈 조건이 최소 1회 충족되어야 함"); + } + + /** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */ + @Test + void longOnlyLoop_buyThenSell_completesRoundTrip() throws Exception { + BarSeries series = buildCrashThenRecoverSeries(80); + Rule buy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "GTE", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 + }} + """); + Rule sell = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "LTE", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 + }} + """); + + SimResult sim = simulateLongOnly(buy, sell, series); + assertTrue(sim.buyBars.size() >= 1, "매수 시그널 최소 1회"); + assertTrue(sim.sellBars.size() >= 1, + "하락 구간에서 신저가 매도가 나와야 함. buys=" + sim.buyBars + " sells=" + sim.sellBars); + } + + @Test + void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception { + BarSeries series = buildCrashThenRecoverSeries(80); + Rule buy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "GTE", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 + }} + """); + Rule sellLegacy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", + "conditionType": "CROSS_DOWN", + "composite": true, + "leftField": "CLOSE_PRICE", + "rightField": "DC_LOWER_9", + "leftPeriod": 9, + "rightPeriod": 20 + }} + """); + + SimResult sim = simulateLongOnly(buy, sellLegacy, series); + assertTrue(sim.buyBars.size() >= 1); + assertTrue(sim.sellBars.size() >= 1, + "어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함"); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private Rule rule(BarSeries series, String json) throws Exception { + JsonNode node = MAPPER.readTree(json); + return adapter.toRule(node, series, Map.of()); + } + + private List satisfiedIndices(Rule rule, BarSeries series) { + List hits = new ArrayList<>(); + TradingRecord record = null; + for (int i = 0; i < series.getBarCount(); i++) { + if (rule.isSatisfied(i, record)) hits.add(i); + } + return hits; + } + + private static BarSeries buildTrendSeries(int barCount, boolean up) { + Duration period = Duration.ofDays(1); + BarSeries series = new BaseBarSeriesBuilder() + .withName("trend") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-01-01T00:00:00Z"); + double price = 100.0; + for (int i = 0; i < barCount; i++) { + double delta = up ? 1.5 : -1.5; + double close = price + delta; + double high = Math.max(price, close) + 0.5; + double low = Math.min(price, close) - 0.5; + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(price).highPrice(high).lowPrice(low).closePrice(close) + .volume(1000) + .add(); + price = close; + } + return series; + } + + /** 초반 횡보 후 급락 → 회복 (차트와 유사) */ + private static BarSeries buildCrashThenRecoverSeries(int barCount) { + Duration period = Duration.ofDays(1); + BarSeries series = new BaseBarSeriesBuilder() + .withName("crash-recover") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-01-01T00:00:00Z"); + double price = 100_000_000; + for (int i = 0; i < barCount; i++) { + double chg; + if (i < 15) chg = (i % 3 - 1) * 0.002; + else if (i < 35) chg = -0.04; + else chg = 0.008; + double close = price * (1 + chg); + double high = Math.max(price, close) * 1.005; + double low = Math.min(price, close) * 0.995; + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(price).highPrice(high).lowPrice(low).closePrice(close) + .volume(1000) + .add(); + price = close; + } + return series; + } + + private SimResult simulateLongOnly(Rule entryRule, Rule exitRule, BarSeries series) { + List buyBars = new ArrayList<>(); + List sellBars = new ArrayList<>(); + boolean inPosition = false; + org.ta4j.core.BaseTradingRecord record = new org.ta4j.core.BaseTradingRecord(); + for (int i = 0; i < series.getBarCount(); i++) { + if (!inPosition) { + if (entryRule.isSatisfied(i, record)) { + buyBars.add(i); + inPosition = true; + } + } else if (exitRule.isSatisfied(i, record)) { + sellBars.add(i); + inPosition = false; + } + } + return new SimResult(buyBars, sellBars); + } + + private record SimResult(List buyBars, List sellBars) {} +} diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 1245fb6..3e92a6d 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -7,6 +7,8 @@ import { loadPaperTrades, loadStrategies, saveLiveStrategySettings, + pinCandleWatch, + loadLiveStrategySettings, type PaperSummaryDto, type PaperTradeDto, type StrategyDto, @@ -39,7 +41,9 @@ import { type VirtualSessionConfig, type VirtualTargetItem, type VirtualCardViewMode, + resolveTargetCandleType, } from '../utils/virtualTradingStorage'; +import { normalizeStartCandleType } from '../utils/strategyStartNodes'; import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { @@ -231,10 +235,15 @@ const VirtualTradingPage: React.FC = ({ t.market === market ? { ...t, strategyId } : t, )); if (!session.running || !strategyId) return; + const target = targets.find(t => t.market === market); + const candleType = normalizeStartCandleType( + resolveTargetCandleType(target ?? { candleType: undefined }, snapshots[market]?.timeframe), + ); try { await saveLiveStrategySettings({ market, strategyId, + candleType, isLiveCheck: true, executionType: session.executionType, positionMode: session.positionMode, @@ -243,7 +252,59 @@ const VirtualTradingPage: React.FC = ({ } catch { window.alert('종목 전략 변경 저장에 실패했습니다.'); } - }, [session]); + }, [session, targets, snapshots]); + + const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => { + const normalized = normalizeStartCandleType(candleType); + setTargets(prev => prev.map(t => + t.market === market ? { ...t, candleType: normalized } : t, + )); + if (!session.running) return; + const target = targets.find(t => t.market === market); + const strategyId = target?.strategyId ?? session.globalStrategyId; + if (!strategyId) return; + try { + await saveLiveStrategySettings({ + market, + strategyId, + candleType: normalized, + isLiveCheck: true, + executionType: session.executionType, + positionMode: session.positionMode, + skipWatchlistSync: true, + }); + await pinCandleWatch(market, normalized); + if (normalized !== '1m') { + await pinCandleWatch(market, '1m'); + } + } catch { + window.alert('종목 평가 분봉 변경 저장에 실패했습니다.'); + } + }, [session, targets]); + + useEffect(() => { + let cancelled = false; + const missing = targets.filter(t => !t.candleType); + if (missing.length === 0) return; + void Promise.all( + missing.map(async t => { + try { + const s = await loadLiveStrategySettings(t.market); + return { market: t.market, candleType: s.candleType }; + } catch { + return { market: t.market, candleType: undefined }; + } + }), + ).then(rows => { + if (cancelled) return; + setTargets(prev => prev.map(t => { + const row = rows.find(r => r.market === t.market); + if (t.candleType || !row?.candleType) return t; + return { ...t, candleType: normalizeStartCandleType(row.candleType) }; + })); + }); + return () => { cancelled = true; }; + }, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]); const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => { if (!session.running || targets.length === 0) return; @@ -334,6 +395,7 @@ const VirtualTradingPage: React.FC = ({ snapshots={snapshots} session={session} onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)} + onTargetCandleTypeChange={(market, ct) => void handleTargetCandleTypeChange(market, ct)} liveStatusByMarket={mergedLiveStatus} lastTickAtByMarket={lastTickAtByMarket} selectedMarket={selectedMarket} diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index b684c17..eaefc7c 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -9,6 +9,7 @@ import { getConditionValuePeriod, getCompositePeriodPresetOptions, getPeriodPresetOptions, + getPeriodSettingsLabel, getThresholdBounds, getThresholdPresetOptions, hasEditableThreshold, @@ -121,7 +122,7 @@ export default function ConditionNodeSettings({ ) : usesValuePeriodField(condition) ? (