diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java index a83a1f1..0eac027 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java @@ -90,7 +90,7 @@ public class BacktestSettingsDto { // ── 포지션 종속성 모드 ───────────────────────────────────────────────────── /** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 제어 */ @Builder.Default - private String positionMode = "SIGNAL_ONLY"; + private String positionMode = "LONG_ONLY"; // ── 분할 청산 ────────────────────────────────────────────────────────────── @Builder.Default diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index cd29f68..87f26a1 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -199,21 +199,23 @@ public class BacktestingService { double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); long time = barStartEpoch(series, i); - // ── SIGNAL_ONLY 모드: 포지션·자금 상태 무관, 조건 충족 시마다 전체 시그널 생성 ── + // ── SIGNAL_ONLY: 조건 충족 '시작' 봉만 시그널 (GTE 유지형 연속 중복 방지) ── if (signalOnly) { boolean entrySatisfied = entryRule.isSatisfied(i); boolean exitSatisfied = exitRule.isSatisfied(i); - if (entrySatisfied) { + boolean entryEdge = entrySatisfied && (i <= loopStart || !entryRule.isSatisfied(i - 1)); + boolean exitEdge = exitSatisfied && (i <= loopStart || !exitRule.isSatisfied(i - 1)); + if (entryEdge) { double effEntry = applySlippage(closePrice, cfg, true); String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0)); - log.debug("[Backtest:SIGNAL_ONLY] BUY @ bar={} price={}", i, effEntry); + log.debug("[Backtest:SIGNAL_ONLY] BUY(edge) @ bar={} price={}", i, effEntry); } - if (exitSatisfied) { + if (exitEdge) { double effExit = applySlippage(exitPrice, cfg, false); String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; signals.add(buildFillSignal(time, sellType, effExit, i, 1.0)); - log.debug("[Backtest:SIGNAL_ONLY] SELL @ bar={} price={}", i, effExit); + log.debug("[Backtest:SIGNAL_ONLY] SELL(edge) @ bar={} price={}", i, effExit); } continue; } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 9ab7879..3906268 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -930,6 +930,14 @@ public class StrategyDslToTa4jAdapter { }; } // MA (SMA) + if (field.startsWith("CLOSE_MAX_")) { + int period = parseTrailingInt(field, "CLOSE_MAX_", 33); + return new HighestValueIndicator(close, period); + } + if (field.startsWith("CLOSE_MIN_")) { + int period = parseTrailingInt(field, "CLOSE_MIN_", 33); + return new LowestValueIndicator(close, period); + } if (field.startsWith("MA") && !field.startsWith("MACD")) { try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); } catch (NumberFormatException ignored) { /* fall */ } diff --git a/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java b/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java index f91c496..488f6b8 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java @@ -57,8 +57,8 @@ public class StrategySignalDeterminer { int index, String positionMode) { try { if ("SIGNAL_ONLY".equals(positionMode)) { - boolean enterOk = entryRule != null && entryRule.isSatisfied(index); - boolean exitOk = exitRule != null && exitRule.isSatisfied(index); + boolean enterOk = entryRule != null && isRisingEdge(entryRule, index); + boolean exitOk = exitRule != null && isRisingEdge(exitRule, index); if (enterOk) return "BUY"; if (exitOk) return "SELL"; return "NONE"; @@ -84,8 +84,15 @@ public class StrategySignalDeterminer { } private String determineSignalOnly(Strategy strategy, int index) { - if (strategy.getEntryRule().isSatisfied(index)) return "BUY"; - if (strategy.getExitRule().isSatisfied(index)) return "SELL"; + if (isRisingEdge(strategy.getEntryRule(), index)) return "BUY"; + if (isRisingEdge(strategy.getExitRule(), index)) return "SELL"; return "NONE"; } + + /** GTE 등 유지형 조건 — 충족 시작 봉만 true (연속 시그널 방지) */ + private boolean isRisingEdge(Rule rule, int index) { + if (rule == null || !rule.isSatisfied(index)) return false; + if (index <= 0) return true; + return !rule.isSatisfied(index - 1); + } } diff --git a/backend/src/test/java/com/goldenchart/service/Inflection33RuleTest.java b/backend/src/test/java/com/goldenchart/service/Inflection33RuleTest.java new file mode 100644 index 0000000..013edd0 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/Inflection33RuleTest.java @@ -0,0 +1,156 @@ +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.*; + +/** + * 33변곡 — EMA33 IsRising/IsFalling + 종가/EMA 필터 + 33봉 종가 채널 돌파/이탈. + */ +class Inflection33RuleTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private StrategyDslToTa4jAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + } + + @Test + void closeMax33_resolvesAndCrossUpCanFire() throws Exception { + BarSeries series = buildTrendSeries(120, true); + Rule cross = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", + "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", + "rightField": "CLOSE_MAX_33" + }} + """); + + List hits = satisfiedIndices(cross, series); + assertTrue(hits.stream().noneMatch(i -> i < 33), + "33봉 미만에서는 CLOSE_MAX_33 채널이 정의되지 않아 돌파 조건이 없어야 함"); + } + + @Test + void closeMin33_resolvesAndCrossDownCanFire() throws Exception { + BarSeries series = buildTrendSeries(120, false); + Rule cross = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", + "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", + "rightField": "CLOSE_MIN_33" + }} + """); + + List hits = satisfiedIndices(cross, series); + assertTrue(hits.stream().noneMatch(i -> i < 33), + "33봉 미만에서는 CLOSE_MIN_33 채널이 정의되지 않아 이탈 조건이 없어야 함"); + assertFalse(hits.isEmpty(), "하락 추세에서 33봉 종가 최저 이탈이 최소 1회 발생해야 함"); + } + + @Test + void inflection33Buy_andTree_warmupBefore33() throws Exception { + BarSeries series = buildTrendSeries(120, true); + Rule buy = rule(series, """ + { "type": "AND", "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", "conditionType": "SLOPE_UP", + "leftField": "EMA33", "rightField": "NONE", "slopePeriod": 2 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "EMA33" }}, + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", "rightField": "CLOSE_MAX_33" }} + ]} + """); + + List hits = satisfiedIndices(buy, series); + assertTrue(hits.stream().noneMatch(i -> i < 33), + "워밍업 구간(33봉 미만)에서는 33변곡 매수 복합 조건이 없어야 함"); + } + + @Test + void inflection33Sell_orTree_firesOnDowntrend() throws Exception { + BarSeries series = buildTrendSeries(120, false); + Rule sell = rule(series, """ + { "type": "OR", "children": [ + { "type": "AND", "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", "conditionType": "SLOPE_DOWN", + "leftField": "EMA33", "rightField": "NONE", "slopePeriod": 2 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", "conditionType": "LT", + "leftField": "CLOSE_PRICE", "rightField": "EMA33" }} + ]}, + { "type": "CONDITION", "condition": { + "indicatorType": "EMA", "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", "rightField": "CLOSE_MIN_33" }} + ]} + """); + + List hits = satisfiedIndices(sell, series); + assertFalse(hits.isEmpty(), "하락 추세에서 33변곡 매도 OR 조건이 최소 1회 충족되어야 함"); + } + + // ── 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; + } +} diff --git a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java index b178cb5..53da55f 100644 --- a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java +++ b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java @@ -118,6 +118,61 @@ class PriceExtremeRuleTest { "어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함"); } + @Test + void newHigh920Buy_andTree_firesOnUptrendWithVolume() throws Exception { + BarSeries series = buildTrendSeries(120, true); + Rule buy = rule(series, """ + { "type": "AND", "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "GTE", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "GTE", + "leftField": "HIGH_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "ADX", "conditionType": "GTE", + "leftField": "ADX_VALUE", "rightField": "ADX_25" }}, + { "type": "CONDITION", "condition": { + "indicatorType": "VOLUME", "conditionType": "GT", + "leftField": "VOLUME_VALUE", "rightField": "VOLUME_MA" }} + ]} + """); + + List hits = satisfiedIndices(buy, series); + assertTrue(hits.stream().noneMatch(i -> i < 20), + "워밍업 구간(20봉 미만)에서는 복합 매수 조건이 없어야 함"); + } + + @Test + void newLow920Sell_andTree_firesOnDowntrend() throws Exception { + BarSeries series = buildTrendSeries(120, false); + Rule sell = rule(series, """ + { "type": "AND", "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "LTE", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "LTE", + "leftField": "LOW_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "ADX", "conditionType": "GTE", + "leftField": "ADX_VALUE", "rightField": "ADX_20" }}, + { "type": "CONDITION", "condition": { + "indicatorType": "VOLUME", "conditionType": "GT", + "leftField": "VOLUME_VALUE", "rightField": "VOLUME_MA" }} + ]} + """); + + List hits = satisfiedIndices(sell, series); + assertFalse(hits.isEmpty(), "하락 추세에서 9·20일 신저가 복합 매도 조건이 최소 1회 충족되어야 함"); + } + // ── helpers ───────────────────────────────────────────────────────────── private Rule rule(BarSeries series, String json) throws Exception { diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 85bc411..d305b5e 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -51,6 +51,35 @@ import { loadPaletteItems, type PaletteItem, } from '../utils/strategyPaletteStorage'; +import { + buildPriceExtremeBreakoutTree, + isPriceExtremeBreakoutPairPaletteValue, + isPriceExtremeBreakoutPairRoot, + findPriceExtremePairRootInForest, + patchPriceExtremePairInTree, + setPriceExtremePairFilterLevel, + inferPriceExtremeFilterLevel, + type PriceExtremeFilterLevel, +} from '../utils/priceExtremeBreakoutPair'; +import { + buildInflection33Tree, + isInflection33PaletteValue, + isInflection33PairRoot, + setInflection33PairFilterLevel, + patchInflection33PairInTree, + inferInflection33FilterLevel, + type Inflection33FilterLevel, +} from '../utils/inflection33Strategy'; +import { + buildStableStrategyTree, + isStableStrategyPaletteValue, + isStableStrategyPairRoot, + setStableStrategyPairFilterLevel, + patchStableStrategyPairInTree, + inferStableStrategyFilterLevel, + type StableStrategyFilterLevel, + type StableStrategyId, +} from '../utils/stableStrategyPairs'; import { buildStochOverboughtPairTree, findStochPairRootInForest, @@ -60,6 +89,9 @@ import { setStochPairSecondary, } from '../utils/stochOverboughtPair'; import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings'; +import PriceExtremePairNodeSettings from './strategyEditor/PriceExtremePairNodeSettings'; +import Inflection33PairNodeSettings from './strategyEditor/Inflection33PairNodeSettings'; +import StableStrategyPairNodeSettings from './strategyEditor/StableStrategyPairNodeSettings'; import { emptySignalFlowLayout, buildStrategyFlowLayoutStore, @@ -568,6 +600,96 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop handleOrphansChange, handleRootChange, handleExtraRootsChange, ]); + const applyPriceExtremeFilterLevel = useCallback((andNodeId: string, filterLevel: PriceExtremeFilterLevel) => { + if (currentOrphans.some(o => o.id === andNodeId)) { + handleOrphansChange(currentOrphans.map(o => ( + o.id === andNodeId ? setPriceExtremePairFilterLevel(o, filterLevel, DEF) : o + ))); + return; + } + if (currentRoot) { + const patched = patchPriceExtremePairInTree(currentRoot, andNodeId, filterLevel, DEF); + if (patched && patched !== currentRoot) { + handleRootChange(patched); + return; + } + } + for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { + if (!branch) continue; + const patched = patchPriceExtremePairInTree(branch, andNodeId, filterLevel, DEF); + if (patched && patched !== branch) { + handleExtraRootsChange({ + ...(currentLayout.extraRoots ?? {}), + [startId]: patched, + }); + return; + } + } + }, [ + currentRoot, currentOrphans, currentLayout.extraRoots, DEF, + handleOrphansChange, handleRootChange, handleExtraRootsChange, + ]); + + const applyInflection33FilterLevel = useCallback((pairNodeId: string, filterLevel: Inflection33FilterLevel) => { + if (currentOrphans.some(o => o.id === pairNodeId)) { + handleOrphansChange(currentOrphans.map(o => ( + o.id === pairNodeId ? setInflection33PairFilterLevel(o, filterLevel, DEF) : o + ))); + return; + } + if (currentRoot) { + const patched = patchInflection33PairInTree(currentRoot, pairNodeId, filterLevel, DEF); + if (patched && patched !== currentRoot) { + handleRootChange(patched); + return; + } + } + for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { + if (!branch) continue; + const patched = patchInflection33PairInTree(branch, pairNodeId, filterLevel, DEF); + if (patched && patched !== branch) { + handleExtraRootsChange({ + ...(currentLayout.extraRoots ?? {}), + [startId]: patched, + }); + return; + } + } + }, [ + currentRoot, currentOrphans, currentLayout.extraRoots, DEF, + handleOrphansChange, handleRootChange, handleExtraRootsChange, + ]); + + const applyStableStrategyFilterLevel = useCallback((pairNodeId: string, filterLevel: StableStrategyFilterLevel) => { + if (currentOrphans.some(o => o.id === pairNodeId)) { + handleOrphansChange(currentOrphans.map(o => ( + o.id === pairNodeId ? setStableStrategyPairFilterLevel(o, filterLevel, DEF) : o + ))); + return; + } + if (currentRoot) { + const patched = patchStableStrategyPairInTree(currentRoot, pairNodeId, filterLevel, DEF); + if (patched && patched !== currentRoot) { + handleRootChange(patched); + return; + } + } + for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { + if (!branch) continue; + const patched = patchStableStrategyPairInTree(branch, pairNodeId, filterLevel, DEF); + if (patched && patched !== branch) { + handleExtraRootsChange({ + ...(currentLayout.extraRoots ?? {}), + [startId]: patched, + }); + return; + } + } + }, [ + currentRoot, currentOrphans, currentLayout.extraRoots, DEF, + handleOrphansChange, handleRootChange, handleExtraRootsChange, + ]); + const stochPairEditForSelected = useMemo(() => { if (!selectedNodeId || selectedLogicNode?.type !== 'CONDITION' || !selectedLogicNode.condition) { return undefined; @@ -1147,10 +1269,20 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop const applyPaletteItem = useCallback((item: PaletteItem) => { const stochPair = isStochOverboughtPairPaletteValue(item.value); - const composite = item.kind === 'composite' && !stochPair; + const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value); + const inflection33 = isInflection33PaletteValue(item.value); + const stableStrategy = isStableStrategyPaletteValue(item.value); + const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !stableStrategy; const newNode = stochPair ? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF) - : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); + : priceExtremeBreakout + ? buildPriceExtremeBreakoutTree(item.value, DEF) + : inflection33 + ? buildInflection33Tree(item.value, DEF) + : stableStrategy + ? buildStableStrategyTree(item.value, DEF) + : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); + if (!newNode) return; const root = currentRoot; if (!root) setCurrentRoot(newNode); else setCurrentRoot(mergeAtRoot(root, newNode, false)); @@ -1774,6 +1906,48 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop /> )} + + {selectedLogicNode && isPriceExtremeBreakoutPairRoot(selectedLogicNode) && selectedLogicNode.priceExtremePair && ( +
+ 9·20 신고가/신저가 필터 + applyPriceExtremeFilterLevel(selectedLogicNode.id, next)} + onClose={() => {}} + /> +
+ )} + {selectedLogicNode && isInflection33PairRoot(selectedLogicNode) && selectedLogicNode.inflection33Pair && ( +
+ 33변곡 필터 + applyInflection33FilterLevel(selectedLogicNode.id, next)} + onClose={() => {}} + /> +
+ )} + {selectedLogicNode && isStableStrategyPairRoot(selectedLogicNode) && selectedLogicNode.stableStrategyPair && ( +
+ 추천 전략 필터 + applyStableStrategyFilterLevel(selectedLogicNode.id, next)} + onClose={() => {}} + /> +
+ )} ) : ( = { AND: '#00aaff', @@ -196,12 +215,15 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: const color = LOGIC_COLORS[node.type] ?? '#00aaff'; const isSell = d.signalTab === 'sell'; const isStochPair = isStochOverboughtPairRoot(node); + const isPriceExtremePair = isPriceExtremeBreakoutPairRoot(node); + const isInflection33Pair = isInflection33PairRoot(node); + const isStableStrategyPair = isStableStrategyPairRoot(node); const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d); const [settingsOpen, setSettingsOpen] = useState(false); return (
[{node.type}] )}
+ {isStableStrategyPair && node.stableStrategyPair && ( +
+ + {stableStrategyDisplayName( + node.stableStrategyPair.strategyId as StableStrategyId, + node.stableStrategyPair.mode, + )} + + +
+ )} + {isInflection33Pair && node.inflection33Pair && ( +
+ + {inflection33DisplayName(node.inflection33Pair.mode, node.inflection33Pair.period)} + + +
+ )} + {isPriceExtremePair && node.priceExtremePair && ( +
+ + {priceExtremePairDisplayName( + node.priceExtremePair.mode, + node.priceExtremePair.shortPeriod, + node.priceExtremePair.longPeriod, + )} + + +
+ )} {isStochPair && node.stochPair && (
@@ -257,6 +352,35 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }: onClose={() => setSettingsOpen(false)} /> )} + {settingsOpen && isStableStrategyPair && node.stableStrategyPair && ( + d.onUpdateStableStrategyFilterLevel?.(id, level)} + onClose={() => setSettingsOpen(false)} + /> + )} + {settingsOpen && isInflection33Pair && node.inflection33Pair && ( + d.onUpdateInflection33FilterLevel?.(id, level)} + onClose={() => setSettingsOpen(false)} + /> + )} + {settingsOpen && isPriceExtremePair && node.priceExtremePair && ( + d.onUpdatePriceExtremeFilterLevel?.(id, level)} + onClose={() => setSettingsOpen(false)} + /> + )}
); }); diff --git a/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx b/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx index a9178fa..7094d3f 100644 --- a/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx +++ b/frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx @@ -4,6 +4,9 @@ import PaletteItemModal from './PaletteItemModal'; import type { DefType } from '../../utils/strategyEditorShared'; import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators'; import { isStochOverboughtPairPaletteValue } from '../../utils/stochOverboughtPair'; +import { isPriceExtremeBreakoutPairPaletteValue } from '../../utils/priceExtremeBreakoutPair'; +import { isInflection33PaletteValue } from '../../utils/inflection33Strategy'; +import { isStableStrategyPaletteValue, STABLE_STRATEGY_PALETTE_ITEMS } from '../../utils/stableStrategyPairs'; import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods'; import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage'; import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout'; @@ -20,6 +23,18 @@ function periodLabel(item: PaletteItem, def: DefType): string { const sec = item.secondaryIndicator ?? 'CCI'; return `Stoch + ${getStrategyIndicatorDisplayName(sec)}`; } + if (item.kind === 'composite' && isPriceExtremeBreakoutPairPaletteValue(item.value)) { + const s = item.shortPeriod ?? 9; + const l = item.longPeriod ?? 20; + return `${s} / ${l}일`; + } + if (item.kind === 'composite' && isInflection33PaletteValue(item.value)) { + return `${item.period ?? 33}일`; + } + if (item.kind === 'composite' && isStableStrategyPaletteValue(item.value)) { + const meta = STABLE_STRATEGY_PALETTE_ITEMS.find(i => i.value === item.value); + return meta?.periodHint ?? ''; + } if (item.kind === 'composite') { const d = getCompositeDefaultPeriods(item.value, def); return `${d.short} / ${d.long}`; @@ -170,8 +185,15 @@ export default function IndicatorPaletteTab({ label={item.label} desc={item.desc} color={kind === 'composite' ? 'composite' : 'ind'} - composite={kind === 'composite' && !isStochOverboughtPairPaletteValue(item.value)} + composite={kind === 'composite' + && !isStochOverboughtPairPaletteValue(item.value) + && !isPriceExtremeBreakoutPairPaletteValue(item.value) + && !isInflection33PaletteValue(item.value) + && !isStableStrategyPaletteValue(item.value)} stochPair={isStochOverboughtPairPaletteValue(item.value)} + priceExtremeBreakout={isPriceExtremeBreakoutPairPaletteValue(item.value)} + inflection33={isInflection33PaletteValue(item.value)} + stableStrategy={isStableStrategyPaletteValue(item.value)} secondaryIndicator={item.secondaryIndicator} period={periodLabel(item, def)} periodValue={item.period} diff --git a/frontend/src/components/strategyEditor/Inflection33PairNodeSettings.tsx b/frontend/src/components/strategyEditor/Inflection33PairNodeSettings.tsx new file mode 100644 index 0000000..9502ca4 --- /dev/null +++ b/frontend/src/components/strategyEditor/Inflection33PairNodeSettings.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef } from 'react'; +import { + INFLECTION_33_FILTER_OPTIONS, + inflection33FilterSummary, + inflection33SummaryText, + type Inflection33FilterLevel, +} from '../../utils/inflection33Strategy'; + +interface Props { + mode: 'buy' | 'sell'; + period: number; + filterLevel: Inflection33FilterLevel; + onChange: (filterLevel: Inflection33FilterLevel) => void; + onClose: () => void; + popoverClassName?: string; + variant?: 'popover' | 'inline'; +} + +export default function Inflection33PairNodeSettings({ + mode, + period, + filterLevel, + onChange, + onClose, + popoverClassName = 'se-flow-settings-pop', + variant = 'popover', +}: Props) { + const ref = useRef(null); + + useEffect(() => { + if (variant === 'inline') return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [onClose, variant]); + + const body = ( + <> + + + + +

+ {INFLECTION_33_FILTER_OPTIONS.find(o => o.value === filterLevel)?.desc} +

+

+ {inflection33FilterSummary(mode, filterLevel)} +

+

+ {inflection33SummaryText(mode, period, filterLevel)} +

+ + ); + + if (variant === 'inline') { + return ( +
e.stopPropagation()}> + {body} +
+ ); + } + + return ( +
e.stopPropagation()} + > +
+ 필터 강도 + +
+ {body} +
+ ); +} diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index f47e616..3a48a25 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -6,6 +6,9 @@ export interface PaletteDragPayload { label: string; composite?: boolean; stochPair?: boolean; + priceExtremeBreakout?: boolean; + inflection33?: boolean; + stableStrategy?: boolean; secondaryIndicator?: string; period?: number; shortPeriod?: number; @@ -24,6 +27,9 @@ interface Props { longPeriod?: number; composite?: boolean; stochPair?: boolean; + priceExtremeBreakout?: boolean; + inflection33?: boolean; + stableStrategy?: boolean; secondaryIndicator?: string; selected?: boolean; onSelect?: () => void; @@ -32,13 +38,16 @@ interface Props { export default function PaletteChip({ type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod, - composite = false, stochPair = false, secondaryIndicator, selected = false, onSelect, onAdd, + composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, stableStrategy = false, secondaryIndicator, selected = false, onSelect, onAdd, }: Props) { const onDragStart = (e: React.DragEvent) => { const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined, stochPair: stochPair || undefined, + priceExtremeBreakout: priceExtremeBreakout || undefined, + inflection33: inflection33 || undefined, + stableStrategy: stableStrategy || undefined, secondaryIndicator, period: periodValue, shortPeriod, diff --git a/frontend/src/components/strategyEditor/PaletteItemModal.tsx b/frontend/src/components/strategyEditor/PaletteItemModal.tsx index c5d90a7..823f621 100644 --- a/frontend/src/components/strategyEditor/PaletteItemModal.tsx +++ b/frontend/src/components/strategyEditor/PaletteItemModal.tsx @@ -10,6 +10,9 @@ import { type PaletteItem, type PaletteItemKind, } from '../../utils/strategyPaletteStorage'; +import { isPriceExtremeBreakoutPairPaletteValue } from '../../utils/priceExtremeBreakoutPair'; +import { isInflection33PaletteValue } from '../../utils/inflection33Strategy'; +import { isStableStrategyPaletteValue } from '../../utils/stableStrategyPairs'; import { isStochOverboughtPairPaletteValue, STOCH_PAIR_SECONDARY_OPTIONS, @@ -39,6 +42,10 @@ export default function PaletteItemModal({ const [secondaryIndicator, setSecondaryIndicator] = useState('CCI'); const isStochPairEdit = kind === 'composite' && !!initial && isStochOverboughtPairPaletteValue(initial.value); + const isPriceExtremePairEdit = kind === 'composite' && !!initial && isPriceExtremeBreakoutPairPaletteValue(initial.value); + const isInflection33PairEdit = kind === 'composite' && !!initial && isInflection33PaletteValue(initial.value); + const isStableStrategyPairEdit = kind === 'composite' && !!initial && isStableStrategyPaletteValue(initial.value); + const isBuiltInCompositePair = isStochPairEdit || isPriceExtremePairEdit || isInflection33PairEdit || isStableStrategyPairEdit; useEffect(() => { if (!open) return; @@ -121,7 +128,7 @@ export default function PaletteItemModal({ return (
- {!isStochPairEdit && ( + {!isBuiltInCompositePair && ( <> onChange(e.target.value as PriceExtremeFilterLevel)} + > + {PRICE_EXTREME_FILTER_OPTIONS.map(o => ( + + ))} + + + +

+ {PRICE_EXTREME_FILTER_OPTIONS.find(o => o.value === filterLevel)?.desc} +

+

+ {priceExtremeFilterSummary(mode, filterLevel)} +

+

+ {priceExtremePairSummaryText(mode, shortPeriod, longPeriod, filterLevel)} +

+ + ); + + if (variant === 'inline') { + return ( +
e.stopPropagation()}> + {body} +
+ ); + } + + return ( +
e.stopPropagation()} + > +
+ 필터 강도 + +
+ {body} +
+ ); +} diff --git a/frontend/src/components/strategyEditor/StableStrategyPairNodeSettings.tsx b/frontend/src/components/strategyEditor/StableStrategyPairNodeSettings.tsx new file mode 100644 index 0000000..c0c39c3 --- /dev/null +++ b/frontend/src/components/strategyEditor/StableStrategyPairNodeSettings.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef } from 'react'; +import { + STABLE_STRATEGY_FILTER_OPTIONS, + stableStrategyDisplayName, + stableStrategyFilterSummary, + stableStrategySummaryText, + type StableStrategyFilterLevel, + type StableStrategyId, +} from '../../utils/stableStrategyPairs'; +import type { DefType } from '../../utils/strategyEditorShared'; + +interface Props { + strategyId: StableStrategyId; + mode: 'buy' | 'sell'; + filterLevel: StableStrategyFilterLevel; + onChange: (filterLevel: StableStrategyFilterLevel) => void; + onClose: () => void; + def: DefType; + popoverClassName?: string; + variant?: 'popover' | 'inline'; +} + +export default function StableStrategyPairNodeSettings({ + strategyId, + mode, + filterLevel, + onChange, + onClose, + def, + popoverClassName = 'se-flow-settings-pop', + variant = 'popover', +}: Props) { + const ref = useRef(null); + + useEffect(() => { + if (variant === 'inline') return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [onClose, variant]); + + const body = ( + <> + + + + +

+ {STABLE_STRATEGY_FILTER_OPTIONS.find(o => o.value === filterLevel)?.desc} +

+

+ {stableStrategyFilterSummary(strategyId, filterLevel)} +

+

+ {stableStrategySummaryText(strategyId, mode, filterLevel, def)} +

+ + ); + + if (variant === 'inline') { + return ( +
e.stopPropagation()}> + {body} +
+ ); + } + + return ( +
e.stopPropagation()} + > +
+ 필터 강도 + +
+ {body} +
+ ); +} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 2c283dd..c77e944 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -30,6 +30,18 @@ import { } from '../../utils/strategyEditorShared'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; +import { + setPriceExtremePairFilterLevel, + type PriceExtremeFilterLevel, +} from '../../utils/priceExtremeBreakoutPair'; +import { + setInflection33PairFilterLevel, + type Inflection33FilterLevel, +} from '../../utils/inflection33Strategy'; +import { + setStableStrategyPairFilterLevel, + type StableStrategyFilterLevel, +} from '../../utils/stableStrategyPairs'; import { START_NODE_ID, applyEdgeHandles, @@ -700,7 +712,22 @@ function StrategyEditorCanvasInner({ const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => { const patchGate = (n: LogicNode) => ( n.type === 'AND' || n.type === 'OR' - ? { ...n, type: gateType, stochPair: gateType === 'OR' ? n.stochPair : undefined } + ? { + ...n, + type: gateType, + stochPair: gateType === 'OR' ? n.stochPair : undefined, + priceExtremePair: gateType === 'AND' ? n.priceExtremePair : undefined, + inflection33Pair: n.inflection33Pair?.mode === 'buy' + ? (gateType === 'AND' ? n.inflection33Pair : undefined) + : n.inflection33Pair?.mode === 'sell' + ? (gateType === 'OR' ? n.inflection33Pair : undefined) + : undefined, + stableStrategyPair: n.stableStrategyPair?.mode === 'buy' + ? (gateType === 'AND' ? n.stableStrategyPair : undefined) + : n.stableStrategyPair?.mode === 'sell' + ? (gateType === 'OR' ? n.stableStrategyPair : undefined) + : undefined, + } : n ); if (isOrphanNode(orphans, id)) { @@ -747,10 +774,76 @@ function StrategyEditorCanvasInner({ } }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); + const handleUpdatePriceExtremeFilterLevel = useCallback((id: string, filterLevel: PriceExtremeFilterLevel) => { + const patchPair = (n: LogicNode) => setPriceExtremePairFilterLevel(n, filterLevel, def); + if (isOrphanNode(orphans, id)) { + onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); + return; + } + if (root && findNodeInTree(root, id)) { + onChange(updateNode(root, id, patchPair)); + return; + } + for (const [sid, branch] of Object.entries(extraRoots)) { + if (branch && findNodeInTree(branch, id)) { + onExtraRootsChange?.({ + ...extraRoots, + [sid]: updateNode(branch, id, patchPair), + }); + return; + } + } + }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); + + const handleUpdateInflection33FilterLevel = useCallback((id: string, filterLevel: Inflection33FilterLevel) => { + const patchPair = (n: LogicNode) => setInflection33PairFilterLevel(n, filterLevel, def); + if (isOrphanNode(orphans, id)) { + onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); + return; + } + if (root && findNodeInTree(root, id)) { + onChange(updateNode(root, id, patchPair)); + return; + } + for (const [sid, branch] of Object.entries(extraRoots)) { + if (branch && findNodeInTree(branch, id)) { + onExtraRootsChange?.({ + ...extraRoots, + [sid]: updateNode(branch, id, patchPair), + }); + return; + } + } + }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); + + const handleUpdateStableStrategyFilterLevel = useCallback((id: string, filterLevel: StableStrategyFilterLevel) => { + const patchPair = (n: LogicNode) => setStableStrategyPairFilterLevel(n, filterLevel, def); + if (isOrphanNode(orphans, id)) { + onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o))); + return; + } + if (root && findNodeInTree(root, id)) { + onChange(updateNode(root, id, patchPair)); + return; + } + for (const [sid, branch] of Object.entries(extraRoots)) { + if (branch && findNodeInTree(branch, id)) { + onExtraRootsChange?.({ + ...extraRoots, + [sid]: updateNode(branch, id, patchPair), + }); + return; + } + } + }, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]); + const flowCallbacks = useMemo(() => ({ onDelete: handleDelete, onUpdateCondition: handleUpdateCondition, onUpdateStochPairSecondary: handleUpdateStochPairSecondary, + onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel, + onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel, + onUpdateStableStrategyFilterLevel: handleUpdateStableStrategyFilterLevel, onChangeLogicGateType: handleChangeLogicGateType, onDropTarget: handleDropTarget, onDragOverTarget: handleDragOverTarget, @@ -758,7 +851,7 @@ function StrategyEditorCanvasInner({ onStartCandleTypesChange: handleStartCandleTypesChange, onDeleteStart: handleDeleteStart, }), [ - handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleChangeLogicGateType, + handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget, handleStartCandleTypesChange, handleDeleteStart, ]); diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index d11268b..e55c403 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1289,7 +1289,7 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { maxOpenTrades: 1, partialExitEnabled: false, partialExitPct: 50, - positionMode: 'SIGNAL_ONLY', + positionMode: 'LONG_ONLY', analysisMethod: 'MARK_TO_MARKET', }; diff --git a/frontend/src/utils/inflection33Strategy.ts b/frontend/src/utils/inflection33Strategy.ts new file mode 100644 index 0000000..b8ae562 --- /dev/null +++ b/frontend/src/utils/inflection33Strategy.ts @@ -0,0 +1,489 @@ +/** + * 33변곡 전략 — Ta4j IsRising/IsFalling(EMA33) + 종가/EMA 필터 + 33봉 종가 채널 돌파 + * + * 매수 AND: EMA33 상승전환 + (선택) 종가>EMA33 + 33봉 종가최고 돌파 + (선택) ADX·거래량 + * 매도 OR: (EMA33 하락전환 ∧ 종가 = { + strict: { + slopeBars: 2, + requireCloseAboveEma: true, + buyAdx: 25, + sellAdx: 20, + volume: true, + sellEmaBranch: true, + sellChannelBranch: true, + sellRequireCloseBelowEma: true, + }, + balanced: { + slopeBars: 2, + requireCloseAboveEma: true, + buyAdx: null, + sellAdx: null, + volume: false, + sellEmaBranch: true, + sellChannelBranch: true, + sellRequireCloseBelowEma: true, + }, + relaxed: { + slopeBars: 1, + requireCloseAboveEma: false, + buyAdx: null, + sellAdx: null, + volume: false, + sellEmaBranch: false, + sellChannelBranch: true, + sellRequireCloseBelowEma: false, + }, +}; + +export function emaField(period: number): string { + return `EMA${Math.max(1, Math.round(period))}`; +} + +/** ta4j HighestValueIndicator(close, N) — 33봉 종가 채널 상단 */ +export function closeMaxField(period: number): string { + return `CLOSE_MAX_${Math.max(1, Math.round(period))}`; +} + +/** ta4j LowestValueIndicator(close, N) — 33봉 종가 채널 하단 */ +export function closeMinField(period: number): string { + return `CLOSE_MIN_${Math.max(1, Math.round(period))}`; +} + +export function isInflection33BuyPaletteValue(value: string): boolean { + return value === INFLECTION_33_BUY_VALUE; +} + +export function isInflection33SellPaletteValue(value: string): boolean { + return value === INFLECTION_33_SELL_VALUE; +} + +export function isInflection33PaletteValue(value: string): boolean { + return isInflection33BuyPaletteValue(value) || isInflection33SellPaletteValue(value); +} + +export function isInflection33PairRoot(node: LogicNode): boolean { + return !!node.inflection33Pair?.mode + && (node.type === 'AND' || node.type === 'OR'); +} + +function adxThresholdField(threshold: number): string { + return `ADX_${threshold}`; +} + +function makeCondition( + indicatorType: string, + conditionType: string, + leftField: string, + rightField: string, + def: IndicatorPeriodDef, + extra?: Partial, +): LogicNode { + const base: ConditionDSL = { + indicatorType, + conditionType, + leftField, + rightField, + candleRange: 1, + valuePeriodOverride: false, + thresholdOverride: false, + rightPeriodOverride: false, + ...extra, + }; + return { + id: genId(), + type: 'CONDITION', + condition: initConditionPeriodsInherit(indicatorType, base, def), + }; +} + +function makeFilterCondition( + indicatorType: string, + conditionType: string, + leftField: string, + rightField: string, + def: IndicatorPeriodDef, +): LogicNode { + const base: ConditionDSL = { + indicatorType, + conditionType, + leftField, + rightField, + candleRange: 1, + valuePeriodOverride: false, + thresholdOverride: rightField.startsWith('ADX_'), + rightPeriodOverride: false, + ...(rightField.startsWith('ADX_') + ? { targetValue: parseInt(rightField.split('_')[1], 10) } + : null), + }; + return { + id: genId(), + type: 'CONDITION', + condition: initConditionPeriodsInherit(indicatorType, base, def), + }; +} + +function resolveFilterLevel(level?: Inflection33FilterLevel): Inflection33FilterLevel { + return level ?? DEFAULT_INFLECTION33_FILTER_LEVEL; +} + +function flattenConditions(node: LogicNode): ConditionDSL[] { + if (node.type === 'CONDITION' && node.condition) return [node.condition]; + return (node.children ?? []).flatMap(flattenConditions); +} + +function findSlopeBars(node: LogicNode, mode: 'buy' | 'sell'): number | null { + const slopeType = mode === 'buy' ? 'SLOPE_UP' : 'SLOPE_DOWN'; + for (const c of flattenConditions(node)) { + if (c.conditionType === slopeType && c.slopePeriod != null) { + return c.slopePeriod; + } + } + return null; +} + +/** 저장된 트리에서 필터 강도 추론 (레거시 balanced 호환) */ +export function inferInflection33FilterLevel(node: LogicNode): Inflection33FilterLevel { + const stored = node.inflection33Pair?.filterLevel; + if (stored) return stored; + + const mode = node.inflection33Pair?.mode; + if (!mode) return DEFAULT_INFLECTION33_FILTER_LEVEL; + + const conds = flattenConditions(node); + const hasAdx = conds.some(c => c.indicatorType === 'ADX'); + const hasVol = conds.some(c => c.indicatorType === 'VOLUME'); + const adxCond = conds.find(c => c.indicatorType === 'ADX'); + const adxVal = adxCond?.targetValue + ?? (adxCond?.rightField?.match(/ADX_(\d+)/)?.[1] + ? parseInt(adxCond.rightField.match(/ADX_(\d+)/)![1], 10) + : null); + + if (mode === 'buy') { + if (hasAdx && adxVal != null && adxVal >= 25) return 'strict'; + const slope = findSlopeBars(node, 'buy'); + const hasCloseAboveEma = conds.some( + c => c.conditionType === 'GT' && c.leftField === 'CLOSE_PRICE' && c.rightField?.startsWith('EMA'), + ); + if (slope === 1 || !hasCloseAboveEma) return 'relaxed'; + if (hasAdx || hasVol) return 'strict'; + return 'balanced'; + } + + const topChildren = node.children ?? []; + const hasEmaBranch = topChildren.some(ch => { + const inner = flattenConditions(ch); + return inner.some(c => c.conditionType === 'SLOPE_DOWN'); + }); + const hasChannelBranch = topChildren.some(ch => { + const inner = flattenConditions(ch); + return inner.some(c => c.conditionType === 'CROSS_DOWN' && c.rightField?.startsWith('CLOSE_MIN_')); + }); + + if (!hasEmaBranch && hasChannelBranch) return 'relaxed'; + if (hasAdx && adxVal != null && adxVal >= 20) return 'strict'; + return 'balanced'; +} + +function buildBuyPriceChildren( + preset: Inflection33Preset, + period: number, + def: IndicatorPeriodDef, +): LogicNode[] { + const ema = emaField(period); + const maxF = closeMaxField(period); + const nodes: LogicNode[] = [ + makeCondition('EMA', 'SLOPE_UP', ema, 'NONE', def, { slopePeriod: preset.slopeBars }), + ]; + if (preset.requireCloseAboveEma) { + nodes.push(makeCondition('EMA', 'GT', 'CLOSE_PRICE', ema, def)); + } + nodes.push(makeCondition('EMA', 'CROSS_UP', 'CLOSE_PRICE', maxF, def)); + return nodes; +} + +function buildFilterChildren( + mode: 'buy' | 'sell', + preset: Inflection33Preset, + def: IndicatorPeriodDef, +): LogicNode[] { + const nodes: LogicNode[] = []; + const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; + if (adx != null) { + nodes.push(makeFilterCondition('ADX', 'GTE', 'ADX_VALUE', adxThresholdField(adx), def)); + } + if (preset.volume && mode === 'buy') { + nodes.push(makeFilterCondition('VOLUME', 'GT', 'VOLUME_VALUE', 'VOLUME_MA', def)); + } + return nodes; +} + +function wrapAnd(children: LogicNode[]): LogicNode { + if (children.length === 1) return children[0]; + return { id: genId(), type: 'AND', children }; +} + +function buildSellBranchChildren( + preset: Inflection33Preset, + period: number, + def: IndicatorPeriodDef, +): LogicNode[] { + const ema = emaField(period); + const minF = closeMinField(period); + const branches: LogicNode[] = []; + + if (preset.sellEmaBranch) { + const emaChildren: LogicNode[] = [ + makeCondition('EMA', 'SLOPE_DOWN', ema, 'NONE', def, { slopePeriod: preset.slopeBars }), + ]; + if (preset.sellRequireCloseBelowEma) { + emaChildren.push(makeCondition('EMA', 'LT', 'CLOSE_PRICE', ema, def)); + } + if (preset.sellAdx != null) { + emaChildren.push(makeFilterCondition('ADX', 'GTE', 'ADX_VALUE', adxThresholdField(preset.sellAdx), def)); + } + branches.push(wrapAnd(emaChildren)); + } + + if (preset.sellChannelBranch) { + const channelChildren: LogicNode[] = [ + makeCondition('EMA', 'CROSS_DOWN', 'CLOSE_PRICE', minF, def), + ]; + if (preset.sellAdx != null) { + channelChildren.push(makeFilterCondition('ADX', 'GTE', 'ADX_VALUE', adxThresholdField(preset.sellAdx), def)); + } + branches.push(wrapAnd(channelChildren)); + } + + return branches; +} + +/** 33 EMA 상승 변곡 + (선택) 종가 EMA 위 + 33봉 종가 신고가 돌파 */ +export function buildInflection33BuyTree( + def: IndicatorPeriodDef, + period = DEFAULT_INFLECTION_PERIOD, + filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, +): LogicNode { + const p = Math.max(1, Math.round(period)); + const level = resolveFilterLevel(filterLevel); + const preset = FILTER_PRESETS[level]; + + return { + id: genId(), + type: 'AND', + inflection33Pair: { mode: 'buy', period: p, filterLevel: level }, + children: [ + ...buildBuyPriceChildren(preset, p, def), + ...buildFilterChildren('buy', preset, def), + ], + }; +} + +/** (EMA 하락 변곡 ∧ 종가 EMA 아래) OR 33봉 종가 신저가 이탈 */ +export function buildInflection33SellTree( + def: IndicatorPeriodDef, + period = DEFAULT_INFLECTION_PERIOD, + filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, +): LogicNode { + const p = Math.max(1, Math.round(period)); + const level = resolveFilterLevel(filterLevel); + const preset = FILTER_PRESETS[level]; + + return { + id: genId(), + type: 'OR', + inflection33Pair: { mode: 'sell', period: p, filterLevel: level }, + children: buildSellBranchChildren(preset, p, def), + }; +} + +export function buildInflection33Tree( + value: string, + def: IndicatorPeriodDef, + filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, +): LogicNode | null { + if (isInflection33BuyPaletteValue(value)) return buildInflection33BuyTree(def, DEFAULT_INFLECTION_PERIOD, filterLevel); + if (isInflection33SellPaletteValue(value)) return buildInflection33SellTree(def, DEFAULT_INFLECTION_PERIOD, filterLevel); + return null; +} + +export function inflection33FilterLevelLabel(level: Inflection33FilterLevel): string { + return INFLECTION_33_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level; +} + +export function inflection33FilterLevelDesc(level: Inflection33FilterLevel): string { + return INFLECTION_33_FILTER_OPTIONS.find(o => o.value === level)?.desc ?? ''; +} + +export function inflection33FilterSummary( + mode: 'buy' | 'sell', + level: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, +): string { + const preset = FILTER_PRESETS[level]; + const parts: string[] = []; + parts.push(`EMA 변곡 ${preset.slopeBars}봉`); + if (mode === 'buy') { + if (preset.requireCloseAboveEma) parts.push('종가>EMA'); + else parts.push('종가 EMA 필터 없음'); + const adx = preset.buyAdx; + if (adx != null) parts.push(`ADX≥${adx}`); + else parts.push('ADX 필터 없음'); + if (preset.volume) parts.push('거래량>MA'); + else parts.push('거래량 필터 없음'); + } else { + if (preset.sellEmaBranch) { + parts.push(preset.sellRequireCloseBelowEma ? 'EMA하락+종가EMA33'); + lines.push(`${period}봉 종가최고 돌파`); + if (preset.buyAdx != null) lines.push(`ADX≥${preset.buyAdx}`); + if (preset.volume) lines.push('거래량>MA'); + return lines.join(' AND '); + } + const parts: string[] = []; + if (preset.sellEmaBranch) { + const emaPart = preset.sellRequireCloseBelowEma + ? `${ema} 하락전환 AND 종가 nodeContainsId(c, targetId)); +} + +export function findInflection33PairRootContaining( + node: LogicNode | null | undefined, + targetId: string, +): LogicNode | null { + if (!node) return null; + if (isInflection33PairRoot(node) && nodeContainsId(node, targetId)) return node; + for (const child of node.children ?? []) { + const hit = findInflection33PairRootContaining(child, targetId); + if (hit) return hit; + } + return null; +} + +export function patchInflection33PairInTree( + root: LogicNode | null, + pairNodeId: string, + filterLevel: Inflection33FilterLevel, + def: IndicatorPeriodDef, +): LogicNode | null { + if (!root) return null; + if (root.id === pairNodeId && isInflection33PairRoot(root)) { + return setInflection33PairFilterLevel(root, filterLevel, def); + } + if (!root.children?.length) return root; + let changed = false; + const children = root.children.map(c => { + const patched = patchInflection33PairInTree(c, pairNodeId, filterLevel, def); + if (patched !== c) changed = true; + return patched ?? c; + }); + return changed ? { ...root, children } : root; +} diff --git a/frontend/src/utils/priceExtremeBreakoutPair.ts b/frontend/src/utils/priceExtremeBreakoutPair.ts new file mode 100644 index 0000000..6484fe4 --- /dev/null +++ b/frontend/src/utils/priceExtremeBreakoutPair.ts @@ -0,0 +1,441 @@ +/** + * 9·20일 신고가 매수 / 9·20일 신저가 매도 — Ta4j NH_PRIOR·NL_PRIOR + 조절 가능 필터 + */ +import type { ConditionDSL, LogicNode } from './strategyTypes'; +import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; +import { genId } from './strategyNodeIds'; +import { nhPriorField, nlPriorField, syncPriceExtremeSimpleFields } from './priceExtremeIndicators'; + +export const NEW_HIGH_9_20_BUY_VALUE = 'NEW_HIGH_9_20_BUY'; +export const NEW_LOW_9_20_SELL_VALUE = 'NEW_LOW_9_20_SELL'; + +export const DEFAULT_SHORT_PERIOD = 9; +export const DEFAULT_LONG_PERIOD = 20; + +/** 필터 강도 — 전략편집기에서 조절 */ +export type PriceExtremeFilterLevel = 'strict' | 'balanced' | 'relaxed'; + +export const DEFAULT_FILTER_LEVEL: PriceExtremeFilterLevel = 'balanced'; + +export const PRICE_EXTREME_FILTER_OPTIONS: { + value: PriceExtremeFilterLevel; + label: string; + desc: string; +}[] = [ + { + value: 'strict', + label: '강함', + desc: 'ADX·거래량 필터 최대 — 횡보 구간 강력 제외', + }, + { + value: 'balanced', + label: '보통 (권장)', + desc: 'ADX·거래량 완화 — 돌파 신호와 균형', + }, + { + value: 'relaxed', + label: '완화', + desc: '가격 돌파만 — ADX·거래량 필터 없음', + }, +]; + +type FilterPreset = { + buyAdx: number | null; + sellAdx: number | null; + volume: boolean; + /** strict — 단기(9) CROSS_UP 동시 확인 */ + shortCrossConfirm: boolean; + highLowConfirm: boolean; +}; + +const FILTER_PRESETS: Record = { + strict: { buyAdx: 25, sellAdx: 20, volume: true, shortCrossConfirm: true, highLowConfirm: true }, + balanced: { buyAdx: 20, sellAdx: 15, volume: true, shortCrossConfirm: false, highLowConfirm: true }, + relaxed: { buyAdx: null, sellAdx: null, volume: false, shortCrossConfirm: false, highLowConfirm: false }, +}; + +export function isNewHigh920BuyPaletteValue(value: string): boolean { + return value === NEW_HIGH_9_20_BUY_VALUE; +} + +export function isNewLow920SellPaletteValue(value: string): boolean { + return value === NEW_LOW_9_20_SELL_VALUE; +} + +export function isPriceExtremeBreakoutPairPaletteValue(value: string): boolean { + return isNewHigh920BuyPaletteValue(value) || isNewLow920SellPaletteValue(value); +} + +export function isPriceExtremeBreakoutPairRoot(node: LogicNode): boolean { + return node.type === 'AND' && !!node.priceExtremePair?.mode; +} + +function adxThresholdField(threshold: number): string { + return `ADX_${threshold}`; +} + +function makePriceExtremeCondition( + indicatorType: 'NEW_HIGH' | 'NEW_LOW', + conditionType: string, + leftField: string, + rightField: string, + period: number, +): LogicNode { + const base: ConditionDSL = { + indicatorType, + conditionType, + leftField, + rightField, + period, + candleRange: 1, + valuePeriodOverride: true, + thresholdOverride: false, + rightPeriodOverride: false, + }; + return { + id: genId(), + type: 'CONDITION', + condition: syncPriceExtremeSimpleFields(base), + }; +} + +function makeFilterCondition( + indicatorType: string, + conditionType: string, + leftField: string, + rightField: string, + def: IndicatorPeriodDef, +): LogicNode { + const base: ConditionDSL = { + indicatorType, + conditionType, + leftField, + rightField, + candleRange: 1, + valuePeriodOverride: false, + thresholdOverride: rightField.startsWith('ADX_'), + rightPeriodOverride: false, + ...(rightField.startsWith('ADX_') + ? { targetValue: parseInt(rightField.split('_')[1], 10) } + : null), + }; + return { + id: genId(), + type: 'CONDITION', + condition: initConditionPeriodsInherit(indicatorType, base, def), + }; +} + +function resolveFilterLevel(level?: PriceExtremeFilterLevel): PriceExtremeFilterLevel { + return level ?? DEFAULT_FILTER_LEVEL; +} + +/** 저장된 트리에서 필터 강도 추론 (레거시 strict 호환) */ +export function inferPriceExtremeFilterLevel(node: LogicNode): PriceExtremeFilterLevel { + const stored = node.priceExtremePair?.filterLevel; + if (stored) return stored; + + const children = node.children ?? []; + const hasAdx = children.some(c => c.condition?.indicatorType === 'ADX'); + const hasVol = children.some(c => c.condition?.indicatorType === 'VOLUME'); + if (!hasAdx && !hasVol) return 'relaxed'; + + const adxCond = children.find(c => c.condition?.indicatorType === 'ADX')?.condition; + const adxVal = adxCond?.targetValue + ?? (adxCond?.rightField?.match(/ADX_(\d+)/)?.[1] + ? parseInt(adxCond.rightField.match(/ADX_(\d+)/)![1], 10) + : null); + + if (adxVal != null && adxVal >= 25) return 'strict'; + if (adxVal != null && adxVal >= 20) return 'balanced'; + return 'balanced'; +} + +function buildPriceChildren( + mode: 'buy' | 'sell', + short: number, + long: number, + preset: FilterPreset, +): LogicNode[] { + const ind = mode === 'buy' ? 'NEW_HIGH' : 'NEW_LOW'; + const crossType = mode === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN'; + const priorShort = mode === 'buy' ? nhPriorField(short) : nlPriorField(short); + const priorLong = mode === 'buy' ? nhPriorField(long) : nlPriorField(long); + + const nodes: LogicNode[] = []; + + if (preset.shortCrossConfirm) { + nodes.push(makePriceExtremeCondition(ind, crossType, 'CLOSE_PRICE', priorShort, short)); + } + + // 장기 신고가/신저가 — 돌파 순간 1봉 (GTE 아님 → 연속 시그널 방지) + nodes.push(makePriceExtremeCondition(ind, crossType, 'CLOSE_PRICE', priorLong, long)); + + if (preset.highLowConfirm) { + nodes.push(makePriceExtremeCondition( + ind, + mode === 'buy' ? 'GTE' : 'LTE', + mode === 'buy' ? 'HIGH_PRICE' : 'LOW_PRICE', + priorLong, + long, + )); + } + + return nodes; +} + +function buildFilterChildren( + mode: 'buy' | 'sell', + preset: FilterPreset, + def: IndicatorPeriodDef, +): LogicNode[] { + const nodes: LogicNode[] = []; + const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; + if (adx != null) { + nodes.push(makeFilterCondition( + 'ADX', + 'GTE', + 'ADX_VALUE', + adxThresholdField(adx), + def, + )); + } + if (preset.volume) { + nodes.push(makeFilterCondition( + 'VOLUME', + 'GT', + 'VOLUME_VALUE', + 'VOLUME_MA', + def, + )); + } + return nodes; +} + +/** 9·20일 신고가 매수 */ +export function buildNewHigh920BuyTree( + def: IndicatorPeriodDef, + shortPeriod = DEFAULT_SHORT_PERIOD, + longPeriod = DEFAULT_LONG_PERIOD, + filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, +): LogicNode { + const short = Math.max(1, Math.round(shortPeriod)); + const long = Math.max(short + 1, Math.round(longPeriod)); + const level = resolveFilterLevel(filterLevel); + const preset = FILTER_PRESETS[level]; + + return { + id: genId(), + type: 'AND', + priceExtremePair: { mode: 'buy', shortPeriod: short, longPeriod: long, filterLevel: level }, + children: [ + ...buildPriceChildren('buy', short, long, preset), + ...buildFilterChildren('buy', preset, def), + ], + }; +} + +/** 9·20일 신저가 매도 */ +export function buildNewLow920SellTree( + def: IndicatorPeriodDef, + shortPeriod = DEFAULT_SHORT_PERIOD, + longPeriod = DEFAULT_LONG_PERIOD, + filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, +): LogicNode { + const short = Math.max(1, Math.round(shortPeriod)); + const long = Math.max(short + 1, Math.round(longPeriod)); + const level = resolveFilterLevel(filterLevel); + const preset = FILTER_PRESETS[level]; + + return { + id: genId(), + type: 'AND', + priceExtremePair: { mode: 'sell', shortPeriod: short, longPeriod: long, filterLevel: level }, + children: [ + ...buildPriceChildren('sell', short, long, preset), + ...buildFilterChildren('sell', preset, def), + ], + }; +} + +export function buildPriceExtremeBreakoutTree( + value: string, + def: IndicatorPeriodDef, + filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, +): LogicNode | null { + if (isNewHigh920BuyPaletteValue(value)) return buildNewHigh920BuyTree(def, DEFAULT_SHORT_PERIOD, DEFAULT_LONG_PERIOD, filterLevel); + if (isNewLow920SellPaletteValue(value)) return buildNewLow920SellTree(def, DEFAULT_SHORT_PERIOD, DEFAULT_LONG_PERIOD, filterLevel); + return null; +} + +export function filterLevelLabel(level: PriceExtremeFilterLevel): string { + return PRICE_EXTREME_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level; +} + +export function filterLevelDesc(level: PriceExtremeFilterLevel): string { + return PRICE_EXTREME_FILTER_OPTIONS.find(o => o.value === level)?.desc ?? ''; +} + +export function priceExtremePairDisplayName( + mode: 'buy' | 'sell', + short = DEFAULT_SHORT_PERIOD, + long = DEFAULT_LONG_PERIOD, +): string { + return mode === 'buy' + ? `${short}·${long}일 신고가 매수` + : `${short}·${long}일 신저가 매도`; +} + +export function priceExtremeFilterSummary( + mode: 'buy' | 'sell', + level: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, +): string { + const preset = FILTER_PRESETS[level]; + const parts: string[] = []; + const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; + if (adx != null) parts.push(`ADX≥${adx}`); + else parts.push('ADX 필터 없음'); + if (preset.volume) parts.push('거래량>MA'); + else parts.push('거래량 필터 없음'); + if (!preset.highLowConfirm) parts.push('고/저가 확인 생략'); + return parts.join(', '); +} + +export function priceExtremePairSummaryText( + mode: 'buy' | 'sell', + short = DEFAULT_SHORT_PERIOD, + long = DEFAULT_LONG_PERIOD, + filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, +): string { + const preset = FILTER_PRESETS[filterLevel]; + const lines: string[] = []; + if (preset.shortCrossConfirm) { + lines.push(`종가 ${short}일 ${mode === 'buy' ? '신고가' : '신저가'} ${mode === 'buy' ? '상향돌파' : '하향이탈'}`); + } + lines.push(`종가 ${long}일 ${mode === 'buy' ? '신고가 상향돌파' : '신저가 하향이탈'}`); + if (preset.highLowConfirm) { + lines.push(`${mode === 'buy' ? '고가' : '저가'}${mode === 'buy' ? '≥' : '≤'}${long}일선`); + } + const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; + if (adx != null) lines.push(`ADX≥${adx}`); + if (preset.volume) lines.push('거래량>MA'); + return lines.join(' AND '); +} + +export function priceExtremePairPaletteDesc(value: string): string { + if (isNewHigh920BuyPaletteValue(value)) { + return '9·20일 신고가 돌파 + 조절 가능 ADX·거래량 필터 (기본: 보통)'; + } + if (isNewLow920SellPaletteValue(value)) { + return '9·20일 신저가 이탈 + 조절 가능 ADX·거래량 필터 (기본: 보통)'; + } + return ''; +} + +/** 필터 강도 변경 — 노드 id·기간 유지, 자식 조건 재구성 */ +export function setPriceExtremePairFilterLevel( + node: LogicNode, + filterLevel: PriceExtremeFilterLevel, + def: IndicatorPeriodDef, +): LogicNode { + if (!isPriceExtremeBreakoutPairRoot(node) || !node.priceExtremePair) return node; + const { mode, shortPeriod, longPeriod } = node.priceExtremePair; + const rebuilt = mode === 'buy' + ? buildNewHigh920BuyTree(def, shortPeriod, longPeriod, filterLevel) + : buildNewLow920SellTree(def, shortPeriod, longPeriod, filterLevel); + return { ...rebuilt, id: node.id }; +} + +function nodeContainsId(node: LogicNode, targetId: string): boolean { + if (node.id === targetId) return true; + return (node.children ?? []).some(c => nodeContainsId(c, targetId)); +} + +export function findPriceExtremePairRootContaining( + node: LogicNode | null | undefined, + targetId: string, +): LogicNode | null { + if (!node) return null; + if (isPriceExtremeBreakoutPairRoot(node) && nodeContainsId(node, targetId)) return node; + for (const child of node.children ?? []) { + const hit = findPriceExtremePairRootContaining(child, targetId); + if (hit) return hit; + } + return null; +} + +export function findPriceExtremePairRootInForest( + roots: (LogicNode | null | undefined)[], + targetId: string, +): LogicNode | null { + for (const root of roots) { + const hit = findPriceExtremePairRootContaining(root, targetId); + if (hit) return hit; + } + return null; +} + +export function patchPriceExtremePairInTree( + root: LogicNode | null, + andNodeId: string, + filterLevel: PriceExtremeFilterLevel, + def: IndicatorPeriodDef, +): LogicNode | null { + if (!root) return null; + if (root.id === andNodeId && isPriceExtremeBreakoutPairRoot(root)) { + return setPriceExtremePairFilterLevel(root, filterLevel, def); + } + if (!root.children?.length) return root; + let changed = false; + const children = root.children.map(c => { + const patched = patchPriceExtremePairInTree(c, andNodeId, filterLevel, def); + if (patched !== c) changed = true; + return patched ?? c; + }); + return changed ? { ...root, children } : root; +} + +/** GTE/LTE 종가 조건 → CROSS 돌파 1봉 (과다 시그널·레거시 DSL 수리) */ +export function repairPriceExtremePairEntryEdges(node: LogicNode): LogicNode { + if (!isPriceExtremeBreakoutPairRoot(node)) return node; + const mode = node.priceExtremePair!.mode; + const children = (node.children ?? []).map(child => { + if (child.type !== 'CONDITION' || !child.condition) return child; + const c = child.condition; + if (c.indicatorType !== 'NEW_HIGH' && c.indicatorType !== 'NEW_LOW') return child; + if (c.leftField === 'HIGH_PRICE' || c.leftField === 'LOW_PRICE') return child; + if (c.leftField !== 'CLOSE_PRICE') return child; + if (mode === 'buy' && c.conditionType === 'GTE') { + return { ...child, condition: { ...c, conditionType: 'CROSS_UP' } }; + } + if (mode === 'sell' && c.conditionType === 'LTE') { + return { ...child, condition: { ...c, conditionType: 'CROSS_DOWN' } }; + } + return child; + }); + return { ...node, children }; +} + +function repairPriceExtremePairInTree(node: LogicNode): LogicNode { + let next = isPriceExtremeBreakoutPairRoot(node) + ? repairPriceExtremePairEntryEdges(node) + : node; + if (next.children?.length) { + next = { + ...next, + children: next.children.map(repairPriceExtremePairInTree), + }; + } + return next; +} + +/** 전략 로드 시 9·20 복합 — GTE/LTE 종가 조건을 CROSS 로 수리 */ +export function repairPriceExtremePairForest( + root: LogicNode | null, + orphans: LogicNode[] = [], +): { root: LogicNode | null; orphans: LogicNode[] } { + return { + root: root ? repairPriceExtremePairInTree(root) : null, + orphans: orphans.map(repairPriceExtremePairInTree), + }; +} diff --git a/frontend/src/utils/priceExtremeIndicators.ts b/frontend/src/utils/priceExtremeIndicators.ts index f046bb5..f3d840c 100644 --- a/frontend/src/utils/priceExtremeIndicators.ts +++ b/frontend/src/utils/priceExtremeIndicators.ts @@ -71,14 +71,20 @@ export function migratePriceExtremeCondition(cond: ConditionDSL): ConditionDSL { const leftField = cond.leftField && priceFields.has(cond.leftField) ? cond.leftField : 'CLOSE_PRICE'; + const rightField = priorExtremeField(ind, period); + let conditionType = cond.conditionType; + if (conditionType === 'CROSS_UP' && ind === 'NEW_HIGH' && !rightField.startsWith('NH_PRIOR_')) { + conditionType = 'GTE'; + } else if (conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW' && !rightField.startsWith('NL_PRIOR_')) { + conditionType = 'LTE'; + } return syncPriceExtremeSimpleFields({ ...cond, composite: false, leftField, + rightField, period, - conditionType: cond.conditionType === 'CROSS_UP' ? 'GTE' - : cond.conditionType === 'CROSS_DOWN' ? 'LTE' - : cond.conditionType, + conditionType, }); } @@ -96,13 +102,27 @@ export function migratePriceExtremeCondition(cond: ConditionDSL): ConditionDSL { }); } - if (migrated.conditionType === 'CROSS_UP' && ind === 'NEW_HIGH') { + if (migrated.conditionType === 'CROSS_UP' && ind === 'NEW_HIGH' + && !migrated.rightField?.startsWith('NH_PRIOR_')) { migrated = { ...migrated, conditionType: 'GTE' }; } - if (migrated.conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW') { + if (migrated.conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW' + && !migrated.rightField?.startsWith('NL_PRIOR_')) { migrated = { ...migrated, conditionType: 'LTE' }; } + // 종가 vs NH/NL_PRIOR — GTE/LTE 유지형은 봉마다 시그널 → CROSS 돌파 1봉으로 정규화 + if (ind === 'NEW_HIGH' && migrated.leftField === 'CLOSE_PRICE' + && migrated.conditionType === 'GTE' + && migrated.rightField?.startsWith('NH_PRIOR_')) { + migrated = { ...migrated, conditionType: 'CROSS_UP' }; + } + if (ind === 'NEW_LOW' && migrated.leftField === 'CLOSE_PRICE' + && migrated.conditionType === 'LTE' + && migrated.rightField?.startsWith('NL_PRIOR_')) { + migrated = { ...migrated, conditionType: 'CROSS_DOWN' }; + } + return migrated; } diff --git a/frontend/src/utils/stableStrategyPairs.ts b/frontend/src/utils/stableStrategyPairs.ts new file mode 100644 index 0000000..a5d8e09 --- /dev/null +++ b/frontend/src/utils/stableStrategyPairs.ts @@ -0,0 +1,535 @@ +/** + * 추천 안정형 전략 — 터틀·MA·일목·MACD·볼린저 등 복합지표 칩 + */ +import type { ConditionDSL, LogicNode } from './strategyTypes'; +import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; +import { genId } from './strategyNodeIds'; +import { donchianUpperField, donchianLowerField } from './compositeIndicators'; + +export type StableStrategyFilterLevel = 'strict' | 'balanced' | 'relaxed'; +export const DEFAULT_STABLE_FILTER_LEVEL: StableStrategyFilterLevel = 'balanced'; + +export type StableStrategyId = + | 'TURTLE_S1' + | 'TURTLE_S2' + | 'DONCHIAN_20' + | 'MA_TREND' + | 'ICHIMOKU_TREND' + | 'MACD_MOMENTUM' + | 'BOLLINGER_RANGE'; + +export const STABLE_STRATEGY_FILTER_OPTIONS: { + value: StableStrategyFilterLevel; + label: string; + desc: string; +}[] = [ + { value: 'strict', label: '강함', desc: 'ADX·거래량 등 필터 최대 — 신호 적음·품질↑' }, + { value: 'balanced', label: '보통 (권장)', desc: '기본 추천 조합' }, + { value: 'relaxed', label: '완화', desc: '핵심 조건만 — 신호 빈도↑' }, +]; + +type FilterFlags = { adx: number | null; volume: boolean }; + +const FILTER_FLAGS: Record = { + strict: { adx: 25, volume: true }, + balanced: { adx: 20, volume: false }, + relaxed: { adx: null, volume: false }, +}; + +const BOLLINGER_FILTER: Record = { + strict: 20, + balanced: 25, + relaxed: null, +}; + +// ── Palette values ─────────────────────────────────────────────────────────── + +export const TURTLE_S1_BUY = 'TURTLE_S1_BUY'; +export const TURTLE_S1_SELL = 'TURTLE_S1_SELL'; +export const TURTLE_S2_BUY = 'TURTLE_S2_BUY'; +export const TURTLE_S2_SELL = 'TURTLE_S2_SELL'; +export const DONCHIAN_20_BUY = 'DONCHIAN_20_BUY'; +export const DONCHIAN_20_SELL = 'DONCHIAN_20_SELL'; +export const MA_TREND_BUY = 'MA_TREND_BUY'; +export const MA_TREND_SELL = 'MA_TREND_SELL'; +export const ICHIMOKU_TREND_BUY = 'ICHIMOKU_TREND_BUY'; +export const ICHIMOKU_TREND_SELL = 'ICHIMOKU_TREND_SELL'; +export const MACD_MOMENTUM_BUY = 'MACD_MOMENTUM_BUY'; +export const MACD_MOMENTUM_SELL = 'MACD_MOMENTUM_SELL'; +export const BOLLINGER_RANGE_BUY = 'BOLLINGER_RANGE_BUY'; +export const BOLLINGER_RANGE_SELL = 'BOLLINGER_RANGE_SELL'; + +const VALUE_TO_META: Record = { + [TURTLE_S1_BUY]: { id: 'TURTLE_S1', mode: 'buy' }, + [TURTLE_S1_SELL]: { id: 'TURTLE_S1', mode: 'sell' }, + [TURTLE_S2_BUY]: { id: 'TURTLE_S2', mode: 'buy' }, + [TURTLE_S2_SELL]: { id: 'TURTLE_S2', mode: 'sell' }, + [DONCHIAN_20_BUY]: { id: 'DONCHIAN_20', mode: 'buy' }, + [DONCHIAN_20_SELL]: { id: 'DONCHIAN_20', mode: 'sell' }, + [MA_TREND_BUY]: { id: 'MA_TREND', mode: 'buy' }, + [MA_TREND_SELL]: { id: 'MA_TREND', mode: 'sell' }, + [ICHIMOKU_TREND_BUY]: { id: 'ICHIMOKU_TREND', mode: 'buy' }, + [ICHIMOKU_TREND_SELL]: { id: 'ICHIMOKU_TREND', mode: 'sell' }, + [MACD_MOMENTUM_BUY]: { id: 'MACD_MOMENTUM', mode: 'buy' }, + [MACD_MOMENTUM_SELL]: { id: 'MACD_MOMENTUM', mode: 'sell' }, + [BOLLINGER_RANGE_BUY]: { id: 'BOLLINGER_RANGE', mode: 'buy' }, + [BOLLINGER_RANGE_SELL]: { id: 'BOLLINGER_RANGE', mode: 'sell' }, +}; + +export interface StableStrategyPaletteMeta { + value: string; + label: string; + desc: string; + strategyId: StableStrategyId; + mode: 'buy' | 'sell'; + periodHint?: string; +} + +export const STABLE_STRATEGY_PALETTE_ITEMS: StableStrategyPaletteMeta[] = [ + { + value: TURTLE_S1_BUY, label: '터틀 S1 매수', strategyId: 'TURTLE_S1', mode: 'buy', + desc: '20일 최고가 돌파 진입 — 단기 추세추종 (리처드 데니스)', + periodHint: '20 / 10일', + }, + { + value: TURTLE_S1_SELL, label: '터틀 S1 청산', strategyId: 'TURTLE_S1', mode: 'sell', + desc: '10일 최저가 이탈 청산 — 진입보다 짧은 손절', + periodHint: '20 / 10일', + }, + { + value: TURTLE_S2_BUY, label: '터틀 S2 매수', strategyId: 'TURTLE_S2', mode: 'buy', + desc: '55일 최고가 돌파 — 장기 추세 포착', + periodHint: '55 / 20일', + }, + { + value: TURTLE_S2_SELL, label: '터틀 S2 청산', strategyId: 'TURTLE_S2', mode: 'sell', + desc: '20일 최저가 이탈 청산', + periodHint: '55 / 20일', + }, + { + value: DONCHIAN_20_BUY, label: '돈천 20일 매수', strategyId: 'DONCHIAN_20', mode: 'buy', + desc: '20일 채널 상단 돌파 — 박스권 상향 이탈', + periodHint: '20일', + }, + { + value: DONCHIAN_20_SELL, label: '돈천 20일 매도', strategyId: 'DONCHIAN_20', mode: 'sell', + desc: '20일 채널 하단 이탈 — 추세 이탈 청산', + periodHint: '20일', + }, + { + value: MA_TREND_BUY, label: 'MA 추세 매수', strategyId: 'MA_TREND', mode: 'buy', + desc: 'MA 골든크로스 + ADX 추세 확인', + periodHint: '20 / 60', + }, + { + value: MA_TREND_SELL, label: 'MA 추세 매도', strategyId: 'MA_TREND', mode: 'sell', + desc: 'MA 데드크로스 또는 단기선 이탈', + periodHint: '20 / 60', + }, + { + value: ICHIMOKU_TREND_BUY, label: '일목 추세 매수', strategyId: 'ICHIMOKU_TREND', mode: 'buy', + desc: '전환/기준선 골든크로스 + 구름 위', + periodHint: '9 / 26', + }, + { + value: ICHIMOKU_TREND_SELL, label: '일목 추세 매도', strategyId: 'ICHIMOKU_TREND', mode: 'sell', + desc: '전환/기준선 데드크로스 또는 구름 아래', + periodHint: '9 / 26', + }, + { + value: MACD_MOMENTUM_BUY, label: 'MACD 모멘텀 매수', strategyId: 'MACD_MOMENTUM', mode: 'buy', + desc: 'MACD 상향돌파 + EMA60 위 + ADX', + periodHint: 'MACD + EMA60', + }, + { + value: MACD_MOMENTUM_SELL, label: 'MACD 모멘텀 매도', strategyId: 'MACD_MOMENTUM', mode: 'sell', + desc: 'MACD 하향돌파 또는 EMA60 이탈', + periodHint: 'MACD + EMA60', + }, + { + value: BOLLINGER_RANGE_BUY, label: '볼린저 박스 매수', strategyId: 'BOLLINGER_RANGE', mode: 'buy', + desc: 'RSI 과매도 + ADX 약세 + 하단밴드 반등', + periodHint: 'RSI + BB', + }, + { + value: BOLLINGER_RANGE_SELL, label: '볼린저 박스 매도', strategyId: 'BOLLINGER_RANGE', mode: 'sell', + desc: 'RSI 과매수 또는 상단밴드 이탈', + periodHint: 'RSI + BB', + }, +]; + +export function isStableStrategyPaletteValue(value: string): boolean { + return value in VALUE_TO_META; +} + +export function stableStrategyMetaFromValue(value: string): { id: StableStrategyId; mode: 'buy' | 'sell' } | null { + return VALUE_TO_META[value] ?? null; +} + +export function isStableStrategyPairRoot(node: LogicNode): boolean { + return !!node.stableStrategyPair?.strategyId + && (node.type === 'AND' || node.type === 'OR'); +} + +function resolveFilterLevel(level?: StableStrategyFilterLevel): StableStrategyFilterLevel { + return level ?? DEFAULT_STABLE_FILTER_LEVEL; +} + +function adxField(threshold: number): string { + return `ADX_${threshold}`; +} + +function maField(period: number): string { + return `MA${Math.max(1, Math.round(period))}`; +} + +function emaField(period: number): string { + return `EMA${Math.max(1, Math.round(period))}`; +} + +function makeCondition( + indicatorType: string, + conditionType: string, + leftField: string, + rightField: string, + def: IndicatorPeriodDef, + extra?: Partial, +): LogicNode { + const base: ConditionDSL = { + indicatorType, + conditionType, + leftField, + rightField, + candleRange: 1, + valuePeriodOverride: false, + thresholdOverride: false, + rightPeriodOverride: false, + ...extra, + }; + return { + id: genId(), + type: 'CONDITION', + condition: initConditionPeriodsInherit(indicatorType, base, def), + }; +} + +function makeAdxFilter(threshold: number, def: IndicatorPeriodDef): LogicNode { + return makeCondition('ADX', 'GTE', 'ADX_VALUE', adxField(threshold), def, { targetValue: threshold }); +} + +function makeVolumeFilter(def: IndicatorPeriodDef): LogicNode { + return makeCondition('VOLUME', 'GT', 'VOLUME_VALUE', 'VOLUME_MA', def); +} + +function appendTrendFilters( + nodes: LogicNode[], + level: StableStrategyFilterLevel, + def: IndicatorPeriodDef, + mode: 'buy' | 'sell', +): LogicNode[] { + const flags = FILTER_FLAGS[level]; + const adx = mode === 'buy' ? flags.adx : (level === 'strict' ? 20 : flags.adx); + const out = [...nodes]; + if (adx != null) out.push(makeAdxFilter(adx, def)); + if (flags.volume && mode === 'buy') out.push(makeVolumeFilter(def)); + return out; +} + +function pairMeta( + strategyId: StableStrategyId, + mode: 'buy' | 'sell', + filterLevel: StableStrategyFilterLevel, +): LogicNode['stableStrategyPair'] { + return { strategyId, mode, filterLevel }; +} + +function wrapOr(children: LogicNode[]): LogicNode { + return { id: genId(), type: 'OR', children }; +} + +function wrapAnd(children: LogicNode[], meta: LogicNode['stableStrategyPair']): LogicNode { + return { id: genId(), type: 'AND', stableStrategyPair: meta, children }; +} + +function wrapOrRoot(children: LogicNode[], meta: LogicNode['stableStrategyPair']): LogicNode { + return { id: genId(), type: 'OR', stableStrategyPair: meta, children }; +} + +// ── Builders ───────────────────────────────────────────────────────────────── + +function buildTurtleS1Buy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const children = appendTrendFilters([ + makeCondition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', donchianUpperField(20), def), + ], level, def, 'buy'); + return wrapAnd(children, pairMeta('TURTLE_S1', 'buy', level)); +} + +function buildTurtleS1Sell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + return wrapOrRoot([ + makeCondition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', donchianLowerField(10), def), + ], pairMeta('TURTLE_S1', 'sell', level)); +} + +function buildTurtleS2Buy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const children = appendTrendFilters([ + makeCondition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', donchianUpperField(55), def), + ], level, def, 'buy'); + return wrapAnd(children, pairMeta('TURTLE_S2', 'buy', level)); +} + +function buildTurtleS2Sell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + return wrapOrRoot([ + makeCondition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', donchianLowerField(20), def), + ], pairMeta('TURTLE_S2', 'sell', level)); +} + +function buildDonchian20Buy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const children = appendTrendFilters([ + makeCondition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', donchianUpperField(20), def), + ], level, def, 'buy'); + return wrapAnd(children, pairMeta('DONCHIAN_20', 'buy', level)); +} + +function buildDonchian20Sell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + return wrapOrRoot([ + makeCondition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', donchianLowerField(20), def), + ], pairMeta('DONCHIAN_20', 'sell', level)); +} + +function maTrendPeriods(): { short: number; long: number } { + return { short: 20, long: 60 }; +} + +function buildMaTrendBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const { short, long } = maTrendPeriods(); + const nodes: LogicNode[] = [ + makeCondition('MA', 'CROSS_UP', maField(short), maField(long), def), + ]; + if (level !== 'relaxed') { + nodes.push(...appendTrendFilters([], level, def, 'buy')); + } + return wrapAnd(nodes, pairMeta('MA_TREND', 'buy', level)); +} + +function buildMaTrendSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const { short, long } = maTrendPeriods(); + const branches: LogicNode[] = [ + makeCondition('MA', 'CROSS_DOWN', maField(short), maField(long), def), + ]; + if (level === 'relaxed') { + branches.push(makeCondition('MA', 'LT', 'CLOSE_PRICE', maField(short), def)); + } + return wrapOrRoot(branches, pairMeta('MA_TREND', 'sell', level)); +} + +function buildIchimokuTrendBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const nodes: LogicNode[] = [ + makeCondition('ICHIMOKU', 'CROSS_UP', 'CONVERSION_LINE', 'BASE_LINE', def), + ]; + if (level !== 'relaxed') { + nodes.push(makeCondition('ICHIMOKU', 'ABOVE_CLOUD', 'CLOSE_PRICE', 'NONE', def)); + } + if (level === 'strict') { + nodes.push(makeAdxFilter(20, def)); + } + return wrapAnd(nodes, pairMeta('ICHIMOKU_TREND', 'buy', level)); +} + +function buildIchimokuTrendSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const branches: LogicNode[] = [ + makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CONVERSION_LINE', 'BASE_LINE', def), + ]; + if (level !== 'relaxed') { + branches.push(makeCondition('ICHIMOKU', 'BELOW_CLOUD', 'CLOSE_PRICE', 'NONE', def)); + } + return wrapOrRoot(branches, pairMeta('ICHIMOKU_TREND', 'sell', level)); +} + +function buildMacdMomentumBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const nodes: LogicNode[] = [ + makeCondition('MACD', 'CROSS_UP', 'MACD_LINE', 'SIGNAL_LINE', def), + ]; + if (level !== 'relaxed') { + nodes.push(makeCondition('EMA', 'GT', 'CLOSE_PRICE', emaField(60), def)); + nodes.push(...appendTrendFilters([], level, def, 'buy')); + } + return wrapAnd(nodes, pairMeta('MACD_MOMENTUM', 'buy', level)); +} + +function buildMacdMomentumSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const branches: LogicNode[] = [ + makeCondition('MACD', 'CROSS_DOWN', 'MACD_LINE', 'SIGNAL_LINE', def), + ]; + if (level !== 'relaxed') { + branches.push(makeCondition('EMA', 'LT', 'CLOSE_PRICE', emaField(60), def)); + } + return wrapOrRoot(branches, pairMeta('MACD_MOMENTUM', 'sell', level)); +} + +function buildBollingerRangeBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const adxCap = BOLLINGER_FILTER[level]; + const nodes: LogicNode[] = [ + makeCondition('RSI', 'LTE', 'RSI_VALUE', 'OVERSOLD_30', def), + makeCondition('BOLLINGER', 'CROSS_UP', 'CLOSE_PRICE', 'LOWER_BAND', def), + ]; + if (adxCap != null) { + nodes.splice(1, 0, makeCondition('ADX', 'LTE', 'ADX_VALUE', adxField(adxCap), def, { targetValue: adxCap })); + } + return wrapAnd(nodes, pairMeta('BOLLINGER_RANGE', 'buy', level)); +} + +function buildBollingerRangeSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + return wrapOrRoot([ + makeCondition('RSI', 'GTE', 'RSI_VALUE', 'OVERBOUGHT_70', def), + makeCondition('BOLLINGER', 'CROSS_UP', 'CLOSE_PRICE', 'UPPER_BAND', def), + ], pairMeta('BOLLINGER_RANGE', 'sell', level)); +} + +const BUILDERS: Record LogicNode; + sell: (def: IndicatorPeriodDef, level: StableStrategyFilterLevel) => LogicNode; +}> = { + TURTLE_S1: { buy: buildTurtleS1Buy, sell: buildTurtleS1Sell }, + TURTLE_S2: { buy: buildTurtleS2Buy, sell: buildTurtleS2Sell }, + DONCHIAN_20: { buy: buildDonchian20Buy, sell: buildDonchian20Sell }, + MA_TREND: { buy: buildMaTrendBuy, sell: buildMaTrendSell }, + ICHIMOKU_TREND: { buy: buildIchimokuTrendBuy, sell: buildIchimokuTrendSell }, + MACD_MOMENTUM: { buy: buildMacdMomentumBuy, sell: buildMacdMomentumSell }, + BOLLINGER_RANGE: { buy: buildBollingerRangeBuy, sell: buildBollingerRangeSell }, +}; + +export function buildStableStrategyTree( + value: string, + def: IndicatorPeriodDef, + filterLevel: StableStrategyFilterLevel = DEFAULT_STABLE_FILTER_LEVEL, +): LogicNode | null { + const meta = stableStrategyMetaFromValue(value); + if (!meta) return null; + const level = resolveFilterLevel(filterLevel); + const builder = BUILDERS[meta.id][meta.mode]; + return builder(def, level); +} + +export function stableStrategyDisplayName(strategyId: StableStrategyId, mode: 'buy' | 'sell'): string { + const item = STABLE_STRATEGY_PALETTE_ITEMS.find( + i => i.strategyId === strategyId && i.mode === mode, + ); + return item?.label ?? `${strategyId} ${mode === 'buy' ? '매수' : '매도'}`; +} + +export function stableStrategySummaryText( + strategyId: StableStrategyId, + mode: 'buy' | 'sell', + level: StableStrategyFilterLevel = DEFAULT_STABLE_FILTER_LEVEL, + def: IndicatorPeriodDef = { + rsiPeriod: 14, cciPeriod: 20, adxPeriod: 14, williamsR: 14, + trixPeriod: 12, vrPeriod: 26, psyPeriod: 12, newPsy: 12, investPsy: 12, + }, +): string { + const tree = BUILDERS[strategyId][mode](def, level); + const labels: Record = { + CROSS_UP: '상향돌파', CROSS_DOWN: '하향돌파', + GT: '>', LT: '<', GTE: '≥', LTE: '≤', + ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', + }; + const fmt = (n: LogicNode): string => { + if (n.type === 'CONDITION' && n.condition) { + const c = n.condition; + return `${c.indicatorType} ${c.leftField} ${labels[c.conditionType] ?? c.conditionType} ${c.rightField ?? ''}`.trim(); + } + if (n.type === 'AND' || n.type === 'OR') { + return (n.children ?? []).map(fmt).join(` ${n.type} `); + } + return ''; + }; + return fmt(tree); +} + +export function stableStrategyFilterSummary( + strategyId: StableStrategyId, + level: StableStrategyFilterLevel, +): string { + const flags = FILTER_FLAGS[level]; + const parts: string[] = [STABLE_STRATEGY_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level]; + if (flags.adx != null) parts.push(`ADX≥${flags.adx}`); + if (flags.volume) parts.push('거래량>MA'); + if (strategyId === 'BOLLINGER_RANGE') { + const cap = BOLLINGER_FILTER[level]; + if (cap != null) parts.push(`ADX≤${cap}(횡보)`); + else parts.push('ADX 횡보필터 없음'); + } + if (strategyId === 'ICHIMOKU_TREND' && level === 'relaxed') parts.push('구름필터 없음'); + if (strategyId === 'MACD_MOMENTUM' && level === 'relaxed') parts.push('EMA60·ADX 없음'); + if (strategyId === 'MA_TREND' && level === 'relaxed') parts.push('ADX 없음'); + return parts.join(', '); +} + +export function stableStrategyPaletteDesc(value: string): string { + return STABLE_STRATEGY_PALETTE_ITEMS.find(i => i.value === value)?.desc ?? ''; +} + +export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyFilterLevel { + const stored = node.stableStrategyPair?.filterLevel; + if (stored) return stored; + + const flat = (n: LogicNode): ConditionDSL[] => { + if (n.type === 'CONDITION' && n.condition) return [n.condition]; + return (n.children ?? []).flatMap(flat); + }; + const conds = flat(node); + const hasVol = conds.some(c => c.indicatorType === 'VOLUME'); + const adxGte = conds.find(c => c.indicatorType === 'ADX' && c.conditionType === 'GTE'); + const adxVal = adxGte?.targetValue + ?? (adxGte?.rightField?.match(/ADX_(\d+)/)?.[1] ? parseInt(adxGte.rightField.match(/ADX_(\d+)/)![1], 10) : null); + + if (hasVol || (adxVal != null && adxVal >= 25)) return 'strict'; + if (adxVal != null && adxVal >= 20) return 'balanced'; + + const id = node.stableStrategyPair?.strategyId; + if (id === 'ICHIMOKU_TREND' && !conds.some(c => c.conditionType === 'ABOVE_CLOUD' || c.conditionType === 'BELOW_CLOUD')) { + return 'relaxed'; + } + if (id === 'MACD_MOMENTUM' && !conds.some(c => c.rightField === emaField(60))) return 'relaxed'; + if (id === 'MA_TREND' && !conds.some(c => c.indicatorType === 'ADX')) return 'relaxed'; + if (id === 'BOLLINGER_RANGE') { + const adxLte = conds.find(c => c.indicatorType === 'ADX' && c.conditionType === 'LTE'); + if (!adxLte) return 'relaxed'; + } + return 'balanced'; +} + +export function setStableStrategyPairFilterLevel( + node: LogicNode, + filterLevel: StableStrategyFilterLevel, + def: IndicatorPeriodDef, +): LogicNode { + if (!isStableStrategyPairRoot(node) || !node.stableStrategyPair) return node; + const { strategyId, mode } = node.stableStrategyPair; + const id = strategyId as StableStrategyId; + const rebuilt = BUILDERS[id][mode](def, filterLevel); + return { ...rebuilt, id: node.id }; +} + +export function patchStableStrategyPairInTree( + root: LogicNode | null, + pairNodeId: string, + filterLevel: StableStrategyFilterLevel, + def: IndicatorPeriodDef, +): LogicNode | null { + if (!root) return null; + if (root.id === pairNodeId && isStableStrategyPairRoot(root)) { + return setStableStrategyPairFilterLevel(root, filterLevel, def); + } + if (!root.children?.length) return root; + let changed = false; + const children = root.children.map(c => { + const patched = patchStableStrategyPairInTree(c, pairNodeId, filterLevel, def); + if (patched !== c) changed = true; + return patched ?? c; + }); + return changed ? { ...root, children } : root; +} + +export function stableStrategyFilterLevelLabel(level: StableStrategyFilterLevel): string { + return STABLE_STRATEGY_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level; +} diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index cdded17..836d42b 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -17,6 +17,7 @@ import { THRESHOLD_HL_MID, THRESHOLD_HL_OVER, } from './thresholdSymbols'; +import { repairPriceExtremePairForest } from './priceExtremeBreakoutPair'; function migrateConditionThreshold( cond: ConditionDSL, @@ -87,10 +88,11 @@ export function migrateLogicRootForEditor( def: DefType, signalType: 'buy' | 'sell', ): { root: LogicNode | null; orphans: LogicNode[] } { - return { + const migrated = { root: root ? migrateLogicNode(root, def, signalType) : null, orphans: orphans.map(n => migrateLogicNode(n, def, signalType)), }; + return repairPriceExtremePairForest(migrated.root, migrated.orphans); } /** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */ diff --git a/frontend/src/utils/strategyDefSync.ts b/frontend/src/utils/strategyDefSync.ts index d2127d3..ec33c8a 100644 --- a/frontend/src/utils/strategyDefSync.ts +++ b/frontend/src/utils/strategyDefSync.ts @@ -23,15 +23,20 @@ import { inferThresholdSymbolFromLegacy, } from './thresholdSymbols'; import type { DefType } from './strategyEditorShared'; +import { isPriceExtremeBreakoutPairRoot } from './priceExtremeBreakoutPair'; function applyGlobalDefToCondition( cond: ConditionDSL, def: DefType, signalType: 'buy' | 'sell', + insidePriceExtremePair: boolean, ): ConditionDSL { let next: ConditionDSL = { ...cond }; if (isPriceExtremeIndicator(cond.indicatorType)) { + if (insidePriceExtremePair || isValuePeriodOverridden(cond)) { + return next; + } if (!isValuePeriodOverridden(cond)) { const period = getDefaultIndicatorPeriod(cond.indicatorType, def); next = syncPriceExtremeSimpleFields({ ...next, period, valuePeriodOverride: false }); @@ -76,20 +81,31 @@ function applyGlobalDefToCondition( return next; } -function syncConditionNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode { +function syncConditionNode( + node: LogicNode, + def: DefType, + signalType: 'buy' | 'sell', + insidePriceExtremePair: boolean, +): LogicNode { if (node.type !== 'CONDITION' || !node.condition) return node; return { ...node, - condition: applyGlobalDefToCondition(node.condition, def, signalType), + condition: applyGlobalDefToCondition(node.condition, def, signalType, insidePriceExtremePair), }; } -function syncTreeNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode { - let next = syncConditionNode(node, def, signalType); +function syncTreeNode( + node: LogicNode, + def: DefType, + signalType: 'buy' | 'sell', + insidePriceExtremePair = false, +): LogicNode { + const inPair = insidePriceExtremePair || isPriceExtremeBreakoutPairRoot(node); + let next = syncConditionNode(node, def, signalType, inPair); if (next.children?.length) { next = { ...next, - children: next.children.map(c => syncTreeNode(c, def, signalType)), + children: next.children.map(c => syncTreeNode(c, def, signalType, inPair)), }; } return next; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index e1a1d93..2062e1a 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -24,6 +24,34 @@ import { syncPriceExtremeSimpleFields, } from '../utils/priceExtremeIndicators'; import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage'; +import { + buildInflection33Tree, + isInflection33PairRoot, + isInflection33PaletteValue, + inflection33DisplayName, + inflection33SummaryText, + inferInflection33FilterLevel, + inflection33FilterLevelLabel, +} from '../utils/inflection33Strategy'; +import { + buildStableStrategyTree, + isStableStrategyPairRoot, + isStableStrategyPaletteValue, + stableStrategyDisplayName, + stableStrategySummaryText, + inferStableStrategyFilterLevel, + stableStrategyFilterLevelLabel, + type StableStrategyId, +} from '../utils/stableStrategyPairs'; +import { + buildPriceExtremeBreakoutTree, + isPriceExtremeBreakoutPairPaletteValue, + isPriceExtremeBreakoutPairRoot, + priceExtremePairDisplayName, + priceExtremePairSummaryText, + inferPriceExtremeFilterLevel, + filterLevelLabel, +} from '../utils/priceExtremeBreakoutPair'; import { buildStochOverboughtPairTree, isStochOverboughtPairPaletteValue, @@ -767,10 +795,35 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string return `${indName} - ${L} ${C}`; } if (node.type === 'AND') { + if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'buy') { + const { strategyId, mode } = node.stableStrategyPair; + const level = inferStableStrategyFilterLevel(node); + return `${stableStrategyDisplayName(strategyId as StableStrategyId, mode)} [필터:${stableStrategyFilterLevelLabel(level)}]\n${stableStrategySummaryText(strategyId as StableStrategyId, mode, level, DEF)}`; + } + if (isInflection33PairRoot(node) && node.inflection33Pair?.mode === 'buy') { + const { mode, period } = node.inflection33Pair; + const level = inferInflection33FilterLevel(node); + return `${inflection33DisplayName(mode, period)} [필터:${inflection33FilterLevelLabel(level)}]\n${inflection33SummaryText(mode, period, level)}`; + } + if (isPriceExtremeBreakoutPairRoot(node) && node.priceExtremePair) { + const { mode, shortPeriod, longPeriod } = node.priceExtremePair; + const level = inferPriceExtremeFilterLevel(node); + return `${priceExtremePairDisplayName(mode, shortPeriod, longPeriod)} [필터:${filterLevelLabel(level)}]\n${priceExtremePairSummaryText(mode, shortPeriod, longPeriod, level)}`; + } const parts = (node.children ?? []).map(c => nodeToText(c, DEF)); return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND '); } if (node.type === 'OR') { + if (isStableStrategyPairRoot(node) && node.stableStrategyPair?.mode === 'sell') { + const { strategyId, mode } = node.stableStrategyPair; + const level = inferStableStrategyFilterLevel(node); + return `${stableStrategyDisplayName(strategyId as StableStrategyId, mode)} [필터:${stableStrategyFilterLevelLabel(level)}]\n${stableStrategySummaryText(strategyId as StableStrategyId, mode, level, DEF)}`; + } + if (isInflection33PairRoot(node) && node.inflection33Pair?.mode === 'sell') { + const { mode, period } = node.inflection33Pair; + const level = inferInflection33FilterLevel(node); + return `${inflection33DisplayName(mode, period)} [필터:${inflection33FilterLevelLabel(level)}]\n${inflection33SummaryText(mode, period, level)}`; + } if (isStochOverboughtPairRoot(node)) { const sec = node.stochPair!.secondaryIndicatorType; return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`; @@ -1213,12 +1266,21 @@ export type MakeNodeOptions = { /** Stoch 과열×보조 복합 */ stochPair?: boolean; secondaryIndicator?: string; + /** 9·20일 신고가/신저가 복합 */ + priceExtremeBreakout?: boolean; + /** 33변곡 EMA+채널 복합 */ + inflection33?: boolean; + /** 추천 안정형 전략 복합 */ + stableStrategy?: boolean; }; /** 팔레트 드래그 payload → makeNode 옵션 */ export function makeNodeOptionsFromPalette(data: { composite?: boolean; stochPair?: boolean; + priceExtremeBreakout?: boolean; + inflection33?: boolean; + stableStrategy?: boolean; secondaryIndicator?: string; period?: number; shortPeriod?: number; @@ -1233,6 +1295,15 @@ export function makeNodeOptionsFromPalette(data: { secondaryIndicator: data.secondaryIndicator ?? 'CCI', }; } + if (data.priceExtremeBreakout || (data.value && isPriceExtremeBreakoutPairPaletteValue(data.value))) { + return { priceExtremeBreakout: true }; + } + if (data.inflection33 || (data.value && isInflection33PaletteValue(data.value))) { + return { inflection33: true }; + } + if (data.stableStrategy || (data.value && isStableStrategyPaletteValue(data.value))) { + return { stableStrategy: true }; + } if (data.composite) { return { composite: true, @@ -1254,6 +1325,18 @@ export const makeNode = ( if (options?.stochPair || isStochOverboughtPairPaletteValue(value)) { return buildStochOverboughtPairTree(options?.secondaryIndicator ?? 'CCI', DEF); } + if (options?.priceExtremeBreakout || isPriceExtremeBreakoutPairPaletteValue(value)) { + const tree = buildPriceExtremeBreakoutTree(value, DEF); + if (tree) return tree; + } + if (options?.inflection33 || isInflection33PaletteValue(value)) { + const tree = buildInflection33Tree(value, DEF); + if (tree) return tree; + } + if (options?.stableStrategy || isStableStrategyPaletteValue(value)) { + const tree = buildStableStrategyTree(value, DEF); + if (tree) return tree; + } if (options?.composite) { let condition = makeCompositeCondition(value, signalType, DEF); condition = { diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts index c1d9f92..ca630fe 100644 --- a/frontend/src/utils/strategyFlowLayout.ts +++ b/frontend/src/utils/strategyFlowLayout.ts @@ -42,6 +42,12 @@ export type StrategyFlowNodeData = { onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void; /** Stoch 과열×보조 복합 — 보조지표 변경 */ onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void; + /** 9·20 신고가/신저가 복합 — 필터 강도 변경 */ + onUpdatePriceExtremeFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; + /** 33변곡 EMA+채널 복합 — 필터 강도 변경 */ + onUpdateInflection33FilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; + /** 추천 안정형 전략 — 필터 강도 변경 */ + onUpdateStableStrategyFilterLevel?: (nodeId: string, filterLevel: 'strict' | 'balanced' | 'relaxed') => void; /** AND ↔ OR 전환 */ onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void; onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void; @@ -474,7 +480,7 @@ function layoutTree( signalTab: 'buy' | 'sell', callbacks: Pick< StrategyFlowNodeData, - 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType' + 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' >, dragOverId: string | null, @@ -497,6 +503,9 @@ function layoutTree( onDelete: callbacks.onDelete, onUpdateCondition: callbacks.onUpdateCondition, onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, + onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, + onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, + onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel, onChangeLogicGateType: callbacks.onChangeLogicGateType, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, @@ -626,7 +635,7 @@ function appendOrphanFlowNodes( signalTab: 'buy' | 'sell', callbacks: Pick< StrategyFlowNodeData, - 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType' + 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' >, ): void { @@ -649,6 +658,9 @@ function appendOrphanFlowNodes( onDelete: callbacks.onDelete, onUpdateCondition: callbacks.onUpdateCondition, onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary, + onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel, + onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel, + onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel, onChangeLogicGateType: callbacks.onChangeLogicGateType, onDropTarget: callbacks.onDropTarget, onDragOverTarget: callbacks.onDragOverTarget, @@ -673,6 +685,9 @@ export function logicNodeToFlow( | 'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' + | 'onUpdatePriceExtremeFilterLevel' + | 'onUpdateInflection33FilterLevel' + | 'onUpdateStableStrategyFilterLevel' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget' diff --git a/frontend/src/utils/strategyPaletteStorage.ts b/frontend/src/utils/strategyPaletteStorage.ts index 11c6d5c..a846920 100644 --- a/frontend/src/utils/strategyPaletteStorage.ts +++ b/frontend/src/utils/strategyPaletteStorage.ts @@ -5,6 +5,19 @@ import { STOCH_OVERBOUGHT_PAIR_VALUE, stochPairPaletteDesc, } from './stochOverboughtPair'; +import { + INFLECTION_33_BUY_VALUE, + INFLECTION_33_SELL_VALUE, + inflection33PaletteDesc, +} from './inflection33Strategy'; +import { + STABLE_STRATEGY_PALETTE_ITEMS, +} from './stableStrategyPairs'; +import { + NEW_HIGH_9_20_BUY_VALUE, + NEW_LOW_9_20_SELL_VALUE, + priceExtremePairPaletteDesc, +} from './priceExtremeBreakoutPair'; export type PaletteItemKind = 'auxiliary' | 'composite'; @@ -54,6 +67,42 @@ export const DEFAULT_COMPOSITE_ITEMS: Omit[] = [ builtIn: true, secondaryIndicator: 'CCI', }, + { + value: NEW_HIGH_9_20_BUY_VALUE, + label: '9·20일 신고가 매수', + desc: priceExtremePairPaletteDesc(NEW_HIGH_9_20_BUY_VALUE), + builtIn: true, + shortPeriod: 9, + longPeriod: 20, + }, + { + value: NEW_LOW_9_20_SELL_VALUE, + label: '9·20일 신저가 매도', + desc: priceExtremePairPaletteDesc(NEW_LOW_9_20_SELL_VALUE), + builtIn: true, + shortPeriod: 9, + longPeriod: 20, + }, + { + value: INFLECTION_33_BUY_VALUE, + label: '33변곡 매수', + desc: inflection33PaletteDesc(INFLECTION_33_BUY_VALUE), + builtIn: true, + period: 33, + }, + { + value: INFLECTION_33_SELL_VALUE, + label: '33변곡 매도', + desc: inflection33PaletteDesc(INFLECTION_33_SELL_VALUE), + builtIn: true, + period: 33, + }, + ...STABLE_STRATEGY_PALETTE_ITEMS.map(i => ({ + value: i.value, + label: i.label, + desc: i.desc, + builtIn: true, + })), ...COMPOSITE_INDICATOR_ITEMS.map(i => ({ value: i.value, label: i.label, diff --git a/frontend/src/utils/strategyPresets.ts b/frontend/src/utils/strategyPresets.ts index d298142..8375a81 100644 --- a/frontend/src/utils/strategyPresets.ts +++ b/frontend/src/utils/strategyPresets.ts @@ -1,6 +1,14 @@ import type { LogicNode } from './strategyTypes'; import { genId, type DefType } from './strategyEditorShared'; import { nhPriorField, nlPriorField } from './priceExtremeIndicators'; +import { + buildInflection33BuyTree, + buildInflection33SellTree, +} from './inflection33Strategy'; +import { + buildNewHigh920BuyTree, + buildNewLow920SellTree, +} from './priceExtremeBreakoutPair'; export type SimpleStrategyTemplate = { kind: 'simple'; @@ -86,26 +94,14 @@ function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode { ); } -/** 33변곡 매수 — 바닥권 시간변곡(약 33거래일 주기) 전환 구간 */ -export function build33InflectionBuyTree(): LogicNode { - return andTree( - condition('RSI', 'CROSS_UP', 'RSI_VALUE', 'OVERSOLD_30'), - condition('MA', 'CROSS_UP', 'MA5', 'MA20'), - condition('MA', 'CROSS_UP', 'CLOSE_PRICE', 'MA20'), - condition('MACD', 'CROSS_UP', 'MACD_LINE', 'SIGNAL_LINE'), - condition('DISPARITY', 'CROSS_UP', 'DISPARITY20', 'OVERSOLD_95'), - ); +/** @deprecated inflection33Strategy.buildInflection33BuyTree 사용 */ +export function build33InflectionBuyTree(def?: DefType): LogicNode { + return buildInflection33BuyTree(def ?? { maLines: [5, 10, 20, 60, 120] } as DefType); } -/** 33변곡 매도 — 상승 5파 끝(33·36·38일 변곡) 구간 이탈 */ -export function build33InflectionSellTree(): LogicNode { - return andTree( - condition('RSI', 'GTE', 'RSI_VALUE', 'OVERBOUGHT_70'), - condition('MA', 'CROSS_DOWN', 'MA5', 'MA20'), - condition('MA', 'CROSS_DOWN', 'CLOSE_PRICE', 'MA20'), - condition('MACD', 'CROSS_DOWN', 'MACD_LINE', 'SIGNAL_LINE'), - condition('DISPARITY', 'GTE', 'DISPARITY20', 'OVERBOUGHT_105'), - ); +/** @deprecated inflection33Strategy.buildInflection33SellTree 사용 */ +export function build33InflectionSellTree(def?: DefType): LogicNode { + return buildInflection33SellTree(def ?? { maLines: [5, 10, 20, 60, 120] } as DefType); } export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] { @@ -169,19 +165,33 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] { label: '20일 신저가 이탈', description: '종가가 직전 20봉 최저가 이하 — 추세 이탈 청산', }, + { + kind: 'composite', + signal: 'buy', + label: '9·20일 신고가 매수', + description: '9·20일 신고가 돌파 + ADX·거래량 필터 (기본: 보통)', + build: () => buildNewHigh920BuyTree(def), + }, + { + kind: 'composite', + signal: 'sell', + label: '9·20일 신저가 매도', + description: '9·20일 신저가 이탈 + ADX·거래량 필터 (기본: 보통)', + build: () => buildNewLow920SellTree(def), + }, { kind: 'composite', signal: 'buy', label: '33변곡 매수', - description: '바닥권 시간변곡 — RSI 과매도 탈출 + MA 골든크로스 + 20일선·이격도 돌파', - build: build33InflectionBuyTree, + description: '33 EMA 상승 변곡 + 종가 EMA 위 + 33봉 종가 채널 상향 돌파', + build: () => build33InflectionBuyTree(def), }, { kind: 'composite', signal: 'sell', label: '33변곡 매도', - description: '상승 5파 끝 변곡 — RSI 과매수 + MA 데드크로스 + 20일선·이격도 이탈', - build: build33InflectionSellTree, + description: '33 EMA 하락 변곡 이탈 OR 33봉 종가 채널 하향 이탈', + build: () => build33InflectionSellTree(def), }, { kind: 'composite', diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 3250724..d919fe9 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -44,6 +44,27 @@ export interface LogicNode { stochPair?: { secondaryIndicatorType: string; }; + /** 9·20일 신고가/신저가 AND 복합 — AND 루트에만 설정 */ + priceExtremePair?: { + mode: 'buy' | 'sell'; + shortPeriod: number; + longPeriod: number; + /** ADX·거래량 필터 강도 — strict | balanced | relaxed */ + filterLevel?: 'strict' | 'balanced' | 'relaxed'; + }; + /** 33변곡 EMA+채널 복합 — AND(매수) / OR(매도) 루트 */ + inflection33Pair?: { + mode: 'buy' | 'sell'; + period: number; + /** EMA·ADX·거래량 필터 강도 — strict | balanced | relaxed */ + filterLevel?: 'strict' | 'balanced' | 'relaxed'; + }; + /** 추천 안정형 전략 — 터틀·MA·일목·MACD·볼린저 등 */ + stableStrategyPair?: { + strategyId: string; + mode: 'buy' | 'sell'; + filterLevel?: 'strict' | 'balanced' | 'relaxed'; + }; } export interface StrategyDSLDto {