복합지표 전략 추가
This commit is contained in:
@@ -90,7 +90,7 @@ public class BacktestSettingsDto {
|
|||||||
// ── 포지션 종속성 모드 ─────────────────────────────────────────────────────
|
// ── 포지션 종속성 모드 ─────────────────────────────────────────────────────
|
||||||
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 제어 */
|
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 제어 */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String positionMode = "SIGNAL_ONLY";
|
private String positionMode = "LONG_ONLY";
|
||||||
|
|
||||||
// ── 분할 청산 ──────────────────────────────────────────────────────────────
|
// ── 분할 청산 ──────────────────────────────────────────────────────────────
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -199,21 +199,23 @@ public class BacktestingService {
|
|||||||
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
|
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
|
||||||
long time = barStartEpoch(series, i);
|
long time = barStartEpoch(series, i);
|
||||||
|
|
||||||
// ── SIGNAL_ONLY 모드: 포지션·자금 상태 무관, 조건 충족 시마다 전체 시그널 생성 ──
|
// ── SIGNAL_ONLY: 조건 충족 '시작' 봉만 시그널 (GTE 유지형 연속 중복 방지) ──
|
||||||
if (signalOnly) {
|
if (signalOnly) {
|
||||||
boolean entrySatisfied = entryRule.isSatisfied(i);
|
boolean entrySatisfied = entryRule.isSatisfied(i);
|
||||||
boolean exitSatisfied = exitRule.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);
|
double effEntry = applySlippage(closePrice, cfg, true);
|
||||||
String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||||
signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0));
|
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);
|
double effExit = applySlippage(exitPrice, cfg, false);
|
||||||
String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
||||||
signals.add(buildFillSignal(time, sellType, effExit, i, 1.0));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -930,6 +930,14 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// MA (SMA)
|
// 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")) {
|
if (field.startsWith("MA") && !field.startsWith("MACD")) {
|
||||||
try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); }
|
try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); }
|
||||||
catch (NumberFormatException ignored) { /* fall */ }
|
catch (NumberFormatException ignored) { /* fall */ }
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ public class StrategySignalDeterminer {
|
|||||||
int index, String positionMode) {
|
int index, String positionMode) {
|
||||||
try {
|
try {
|
||||||
if ("SIGNAL_ONLY".equals(positionMode)) {
|
if ("SIGNAL_ONLY".equals(positionMode)) {
|
||||||
boolean enterOk = entryRule != null && entryRule.isSatisfied(index);
|
boolean enterOk = entryRule != null && isRisingEdge(entryRule, index);
|
||||||
boolean exitOk = exitRule != null && exitRule.isSatisfied(index);
|
boolean exitOk = exitRule != null && isRisingEdge(exitRule, index);
|
||||||
if (enterOk) return "BUY";
|
if (enterOk) return "BUY";
|
||||||
if (exitOk) return "SELL";
|
if (exitOk) return "SELL";
|
||||||
return "NONE";
|
return "NONE";
|
||||||
@@ -84,8 +84,15 @@ public class StrategySignalDeterminer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String determineSignalOnly(Strategy strategy, int index) {
|
private String determineSignalOnly(Strategy strategy, int index) {
|
||||||
if (strategy.getEntryRule().isSatisfied(index)) return "BUY";
|
if (isRisingEdge(strategy.getEntryRule(), index)) return "BUY";
|
||||||
if (strategy.getExitRule().isSatisfied(index)) return "SELL";
|
if (isRisingEdge(strategy.getExitRule(), index)) return "SELL";
|
||||||
return "NONE";
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Integer> satisfiedIndices(Rule rule, BarSeries series) {
|
||||||
|
List<Integer> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,61 @@ class PriceExtremeRuleTest {
|
|||||||
"어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함");
|
"어댑터가 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<Integer> 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<Integer> hits = satisfiedIndices(sell, series);
|
||||||
|
assertFalse(hits.isEmpty(), "하락 추세에서 9·20일 신저가 복합 매도 조건이 최소 1회 충족되어야 함");
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Rule rule(BarSeries series, String json) throws Exception {
|
private Rule rule(BarSeries series, String json) throws Exception {
|
||||||
|
|||||||
@@ -51,6 +51,35 @@ import {
|
|||||||
loadPaletteItems,
|
loadPaletteItems,
|
||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
} from '../utils/strategyPaletteStorage';
|
} 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 {
|
import {
|
||||||
buildStochOverboughtPairTree,
|
buildStochOverboughtPairTree,
|
||||||
findStochPairRootInForest,
|
findStochPairRootInForest,
|
||||||
@@ -60,6 +89,9 @@ import {
|
|||||||
setStochPairSecondary,
|
setStochPairSecondary,
|
||||||
} from '../utils/stochOverboughtPair';
|
} from '../utils/stochOverboughtPair';
|
||||||
import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings';
|
import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings';
|
||||||
|
import PriceExtremePairNodeSettings from './strategyEditor/PriceExtremePairNodeSettings';
|
||||||
|
import Inflection33PairNodeSettings from './strategyEditor/Inflection33PairNodeSettings';
|
||||||
|
import StableStrategyPairNodeSettings from './strategyEditor/StableStrategyPairNodeSettings';
|
||||||
import {
|
import {
|
||||||
emptySignalFlowLayout,
|
emptySignalFlowLayout,
|
||||||
buildStrategyFlowLayoutStore,
|
buildStrategyFlowLayoutStore,
|
||||||
@@ -568,6 +600,96 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
handleOrphansChange, handleRootChange, handleExtraRootsChange,
|
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(() => {
|
const stochPairEditForSelected = useMemo(() => {
|
||||||
if (!selectedNodeId || selectedLogicNode?.type !== 'CONDITION' || !selectedLogicNode.condition) {
|
if (!selectedNodeId || selectedLogicNode?.type !== 'CONDITION' || !selectedLogicNode.condition) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1147,10 +1269,20 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
|
|
||||||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||||||
const stochPair = isStochOverboughtPairPaletteValue(item.value);
|
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
|
const newNode = stochPair
|
||||||
? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF)
|
? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF)
|
||||||
|
: 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);
|
: makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||||
|
if (!newNode) return;
|
||||||
const root = currentRoot;
|
const root = currentRoot;
|
||||||
if (!root) setCurrentRoot(newNode);
|
if (!root) setCurrentRoot(newNode);
|
||||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||||
@@ -1774,6 +1906,48 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedLogicNode && isPriceExtremeBreakoutPairRoot(selectedLogicNode) && selectedLogicNode.priceExtremePair && (
|
||||||
|
<div className="se-node-config-bar se-node-config-bar--price-extreme">
|
||||||
|
<span className="se-node-config-label">9·20 신고가/신저가 필터</span>
|
||||||
|
<PriceExtremePairNodeSettings
|
||||||
|
variant="inline"
|
||||||
|
mode={selectedLogicNode.priceExtremePair.mode}
|
||||||
|
shortPeriod={selectedLogicNode.priceExtremePair.shortPeriod}
|
||||||
|
longPeriod={selectedLogicNode.priceExtremePair.longPeriod}
|
||||||
|
filterLevel={inferPriceExtremeFilterLevel(selectedLogicNode)}
|
||||||
|
onChange={next => applyPriceExtremeFilterLevel(selectedLogicNode.id, next)}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLogicNode && isInflection33PairRoot(selectedLogicNode) && selectedLogicNode.inflection33Pair && (
|
||||||
|
<div className="se-node-config-bar se-node-config-bar--inflection33">
|
||||||
|
<span className="se-node-config-label">33변곡 필터</span>
|
||||||
|
<Inflection33PairNodeSettings
|
||||||
|
variant="inline"
|
||||||
|
mode={selectedLogicNode.inflection33Pair.mode}
|
||||||
|
period={selectedLogicNode.inflection33Pair.period}
|
||||||
|
filterLevel={inferInflection33FilterLevel(selectedLogicNode)}
|
||||||
|
onChange={next => applyInflection33FilterLevel(selectedLogicNode.id, next)}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLogicNode && isStableStrategyPairRoot(selectedLogicNode) && selectedLogicNode.stableStrategyPair && (
|
||||||
|
<div className="se-node-config-bar se-node-config-bar--stable-strategy">
|
||||||
|
<span className="se-node-config-label">추천 전략 필터</span>
|
||||||
|
<StableStrategyPairNodeSettings
|
||||||
|
variant="inline"
|
||||||
|
strategyId={selectedLogicNode.stableStrategyPair.strategyId as StableStrategyId}
|
||||||
|
mode={selectedLogicNode.stableStrategyPair.mode}
|
||||||
|
filterLevel={inferStableStrategyFilterLevel(selectedLogicNode)}
|
||||||
|
def={DEF}
|
||||||
|
onChange={next => applyStableStrategyFilterLevel(selectedLogicNode.id, next)}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<StrategyListEditor
|
<StrategyListEditor
|
||||||
|
|||||||
@@ -10,12 +10,31 @@ import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
|||||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
isPriceExtremeBreakoutPairRoot,
|
||||||
|
priceExtremePairDisplayName,
|
||||||
|
inferPriceExtremeFilterLevel,
|
||||||
|
} from '../../utils/priceExtremeBreakoutPair';
|
||||||
|
import {
|
||||||
|
isInflection33PairRoot,
|
||||||
|
inflection33DisplayName,
|
||||||
|
inferInflection33FilterLevel,
|
||||||
|
} from '../../utils/inflection33Strategy';
|
||||||
|
import Inflection33PairNodeSettings from './Inflection33PairNodeSettings';
|
||||||
|
import {
|
||||||
|
isStableStrategyPairRoot,
|
||||||
|
stableStrategyDisplayName,
|
||||||
|
inferStableStrategyFilterLevel,
|
||||||
|
type StableStrategyId,
|
||||||
|
} from '../../utils/stableStrategyPairs';
|
||||||
|
import StableStrategyPairNodeSettings from './StableStrategyPairNodeSettings';
|
||||||
import {
|
import {
|
||||||
isStochOverboughtPairRoot,
|
isStochOverboughtPairRoot,
|
||||||
stochPairDisplayName,
|
stochPairDisplayName,
|
||||||
} from '../../utils/stochOverboughtPair';
|
} from '../../utils/stochOverboughtPair';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
import StochPairNodeSettings from './StochPairNodeSettings';
|
import StochPairNodeSettings from './StochPairNodeSettings';
|
||||||
|
import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings';
|
||||||
|
|
||||||
const LOGIC_COLORS: Record<string, string> = {
|
const LOGIC_COLORS: Record<string, string> = {
|
||||||
AND: '#00aaff',
|
AND: '#00aaff',
|
||||||
@@ -196,12 +215,15 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
|||||||
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
||||||
const isSell = d.signalTab === 'sell';
|
const isSell = d.signalTab === 'sell';
|
||||||
const isStochPair = isStochOverboughtPairRoot(node);
|
const isStochPair = isStochOverboughtPairRoot(node);
|
||||||
|
const isPriceExtremePair = isPriceExtremeBreakoutPairRoot(node);
|
||||||
|
const isInflection33Pair = isInflection33PairRoot(node);
|
||||||
|
const isStableStrategyPair = isStableStrategyPairRoot(node);
|
||||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`se-flow-node se-flow-node--logic${isStochPair ? ' se-flow-node--stoch-pair' : ''}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}${settingsOpen ? ' se-flow-node--settings-open' : ''}`}
|
className={`se-flow-node se-flow-node--logic${isStochPair ? ' se-flow-node--stoch-pair' : ''}${isPriceExtremePair ? ' se-flow-node--price-extreme-pair' : ''}${isInflection33Pair ? ' se-flow-node--inflection33-pair' : ''}${isStableStrategyPair ? ' se-flow-node--stable-strategy-pair' : ''}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? ' se-flow-node--drop' : ''}${settingsOpen ? ' se-flow-node--settings-open' : ''}`}
|
||||||
style={{ '--se-gate-color': color } as React.CSSProperties}
|
style={{ '--se-gate-color': color } as React.CSSProperties}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
@@ -224,6 +246,79 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
|||||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isStableStrategyPair && node.stableStrategyPair && (
|
||||||
|
<div className="se-flow-gate-stoch-pair">
|
||||||
|
<span className="se-flow-gate-stoch-pair-title">
|
||||||
|
{stableStrategyDisplayName(
|
||||||
|
node.stableStrategyPair.strategyId as StableStrategyId,
|
||||||
|
node.stableStrategyPair.mode,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings"
|
||||||
|
title="필터 강도 설정"
|
||||||
|
aria-label="필터 강도 설정"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsOpen(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isInflection33Pair && node.inflection33Pair && (
|
||||||
|
<div className="se-flow-gate-stoch-pair">
|
||||||
|
<span className="se-flow-gate-stoch-pair-title">
|
||||||
|
{inflection33DisplayName(node.inflection33Pair.mode, node.inflection33Pair.period)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings"
|
||||||
|
title="필터 강도 설정"
|
||||||
|
aria-label="필터 강도 설정"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsOpen(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPriceExtremePair && node.priceExtremePair && (
|
||||||
|
<div className="se-flow-gate-stoch-pair">
|
||||||
|
<span className="se-flow-gate-stoch-pair-title">
|
||||||
|
{priceExtremePairDisplayName(
|
||||||
|
node.priceExtremePair.mode,
|
||||||
|
node.priceExtremePair.shortPeriod,
|
||||||
|
node.priceExtremePair.longPeriod,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings"
|
||||||
|
title="필터 강도 설정"
|
||||||
|
aria-label="필터 강도 설정"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsOpen(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isStochPair && node.stochPair && (
|
{isStochPair && node.stochPair && (
|
||||||
<div className="se-flow-gate-stoch-pair">
|
<div className="se-flow-gate-stoch-pair">
|
||||||
<span className="se-flow-gate-stoch-pair-title">
|
<span className="se-flow-gate-stoch-pair-title">
|
||||||
@@ -257,6 +352,35 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
|||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{settingsOpen && isStableStrategyPair && node.stableStrategyPair && (
|
||||||
|
<StableStrategyPairNodeSettings
|
||||||
|
strategyId={node.stableStrategyPair.strategyId as StableStrategyId}
|
||||||
|
mode={node.stableStrategyPair.mode}
|
||||||
|
filterLevel={inferStableStrategyFilterLevel(node)}
|
||||||
|
def={d.def!}
|
||||||
|
onChange={level => d.onUpdateStableStrategyFilterLevel?.(id, level)}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settingsOpen && isInflection33Pair && node.inflection33Pair && (
|
||||||
|
<Inflection33PairNodeSettings
|
||||||
|
mode={node.inflection33Pair.mode}
|
||||||
|
period={node.inflection33Pair.period}
|
||||||
|
filterLevel={inferInflection33FilterLevel(node)}
|
||||||
|
onChange={level => d.onUpdateInflection33FilterLevel?.(id, level)}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settingsOpen && isPriceExtremePair && node.priceExtremePair && (
|
||||||
|
<PriceExtremePairNodeSettings
|
||||||
|
mode={node.priceExtremePair.mode}
|
||||||
|
shortPeriod={node.priceExtremePair.shortPeriod}
|
||||||
|
longPeriod={node.priceExtremePair.longPeriod}
|
||||||
|
filterLevel={inferPriceExtremeFilterLevel(node)}
|
||||||
|
onChange={level => d.onUpdatePriceExtremeFilterLevel?.(id, level)}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import PaletteItemModal from './PaletteItemModal';
|
|||||||
import type { DefType } from '../../utils/strategyEditorShared';
|
import type { DefType } from '../../utils/strategyEditorShared';
|
||||||
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
||||||
import { isStochOverboughtPairPaletteValue } from '../../utils/stochOverboughtPair';
|
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 { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
|
||||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
||||||
@@ -20,6 +23,18 @@ function periodLabel(item: PaletteItem, def: DefType): string {
|
|||||||
const sec = item.secondaryIndicator ?? 'CCI';
|
const sec = item.secondaryIndicator ?? 'CCI';
|
||||||
return `Stoch + ${getStrategyIndicatorDisplayName(sec)}`;
|
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') {
|
if (item.kind === 'composite') {
|
||||||
const d = getCompositeDefaultPeriods(item.value, def);
|
const d = getCompositeDefaultPeriods(item.value, def);
|
||||||
return `${d.short} / ${d.long}`;
|
return `${d.short} / ${d.long}`;
|
||||||
@@ -170,8 +185,15 @@ export default function IndicatorPaletteTab({
|
|||||||
label={item.label}
|
label={item.label}
|
||||||
desc={item.desc}
|
desc={item.desc}
|
||||||
color={kind === 'composite' ? 'composite' : 'ind'}
|
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)}
|
stochPair={isStochOverboughtPairPaletteValue(item.value)}
|
||||||
|
priceExtremeBreakout={isPriceExtremeBreakoutPairPaletteValue(item.value)}
|
||||||
|
inflection33={isInflection33PaletteValue(item.value)}
|
||||||
|
stableStrategy={isStableStrategyPaletteValue(item.value)}
|
||||||
secondaryIndicator={item.secondaryIndicator}
|
secondaryIndicator={item.secondaryIndicator}
|
||||||
period={periodLabel(item, def)}
|
period={periodLabel(item, def)}
|
||||||
periodValue={item.period}
|
periodValue={item.period}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 = (
|
||||||
|
<>
|
||||||
|
<label className="se-flow-settings-field se-flow-settings-field--readonly">
|
||||||
|
<span>변곡 기간</span>
|
||||||
|
<output className="se-flow-settings-readout">
|
||||||
|
EMA{period} · {period}봉 종가 채널
|
||||||
|
</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>필터 강도</span>
|
||||||
|
<select
|
||||||
|
className="se-combo-num-select sp-cond-sel"
|
||||||
|
value={filterLevel}
|
||||||
|
onChange={e => onChange(e.target.value as Inflection33FilterLevel)}
|
||||||
|
>
|
||||||
|
{INFLECTION_33_FILTER_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="se-flow-settings-hint">
|
||||||
|
{INFLECTION_33_FILTER_OPTIONS.find(o => o.value === filterLevel)?.desc}
|
||||||
|
</p>
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
{inflection33FilterSummary(mode, filterLevel)}
|
||||||
|
</p>
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
{inflection33SummaryText(mode, period, filterLevel)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<div className="se-inflection33-inline-settings" onClick={e => e.stopPropagation()}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={popoverClassName}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="33변곡 복합 필터 설정"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="se-flow-settings-head">
|
||||||
|
<span>필터 강도</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
title="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ export interface PaletteDragPayload {
|
|||||||
label: string;
|
label: string;
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
stochPair?: boolean;
|
stochPair?: boolean;
|
||||||
|
priceExtremeBreakout?: boolean;
|
||||||
|
inflection33?: boolean;
|
||||||
|
stableStrategy?: boolean;
|
||||||
secondaryIndicator?: string;
|
secondaryIndicator?: string;
|
||||||
period?: number;
|
period?: number;
|
||||||
shortPeriod?: number;
|
shortPeriod?: number;
|
||||||
@@ -24,6 +27,9 @@ interface Props {
|
|||||||
longPeriod?: number;
|
longPeriod?: number;
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
stochPair?: boolean;
|
stochPair?: boolean;
|
||||||
|
priceExtremeBreakout?: boolean;
|
||||||
|
inflection33?: boolean;
|
||||||
|
stableStrategy?: boolean;
|
||||||
secondaryIndicator?: string;
|
secondaryIndicator?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
@@ -32,13 +38,16 @@ interface Props {
|
|||||||
|
|
||||||
export default function PaletteChip({
|
export default function PaletteChip({
|
||||||
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
|
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) {
|
}: Props) {
|
||||||
const onDragStart = (e: React.DragEvent) => {
|
const onDragStart = (e: React.DragEvent) => {
|
||||||
const payload: PaletteDragPayload = {
|
const payload: PaletteDragPayload = {
|
||||||
type, value, label,
|
type, value, label,
|
||||||
composite: composite || undefined,
|
composite: composite || undefined,
|
||||||
stochPair: stochPair || undefined,
|
stochPair: stochPair || undefined,
|
||||||
|
priceExtremeBreakout: priceExtremeBreakout || undefined,
|
||||||
|
inflection33: inflection33 || undefined,
|
||||||
|
stableStrategy: stableStrategy || undefined,
|
||||||
secondaryIndicator,
|
secondaryIndicator,
|
||||||
period: periodValue,
|
period: periodValue,
|
||||||
shortPeriod,
|
shortPeriod,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
type PaletteItemKind,
|
type PaletteItemKind,
|
||||||
} from '../../utils/strategyPaletteStorage';
|
} from '../../utils/strategyPaletteStorage';
|
||||||
|
import { isPriceExtremeBreakoutPairPaletteValue } from '../../utils/priceExtremeBreakoutPair';
|
||||||
|
import { isInflection33PaletteValue } from '../../utils/inflection33Strategy';
|
||||||
|
import { isStableStrategyPaletteValue } from '../../utils/stableStrategyPairs';
|
||||||
import {
|
import {
|
||||||
isStochOverboughtPairPaletteValue,
|
isStochOverboughtPairPaletteValue,
|
||||||
STOCH_PAIR_SECONDARY_OPTIONS,
|
STOCH_PAIR_SECONDARY_OPTIONS,
|
||||||
@@ -39,6 +42,10 @@ export default function PaletteItemModal({
|
|||||||
const [secondaryIndicator, setSecondaryIndicator] = useState('CCI');
|
const [secondaryIndicator, setSecondaryIndicator] = useState('CCI');
|
||||||
|
|
||||||
const isStochPairEdit = kind === 'composite' && !!initial && isStochOverboughtPairPaletteValue(initial.value);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -121,7 +128,7 @@ export default function PaletteItemModal({
|
|||||||
return (
|
return (
|
||||||
<DraggableModalFrame onClose={onClose} title={title}>
|
<DraggableModalFrame onClose={onClose} title={title}>
|
||||||
<div className="se-modal-body se-palette-item-modal">
|
<div className="se-modal-body se-palette-item-modal">
|
||||||
{!isStochPairEdit && (
|
{!isBuiltInCompositePair && (
|
||||||
<>
|
<>
|
||||||
<label className="se-field-lbl">지표 종류</label>
|
<label className="se-field-lbl">지표 종류</label>
|
||||||
<select
|
<select
|
||||||
@@ -185,7 +192,7 @@ export default function PaletteItemModal({
|
|||||||
onChange={setPeriod}
|
onChange={setPeriod}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : isStochPairEdit ? null : (
|
) : isBuiltInCompositePair ? null : (
|
||||||
<>
|
<>
|
||||||
<label className="se-field-lbl">
|
<label className="se-field-lbl">
|
||||||
단기 기간 (일)
|
단기 기간 (일)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
PRICE_EXTREME_FILTER_OPTIONS,
|
||||||
|
priceExtremeFilterSummary,
|
||||||
|
priceExtremePairSummaryText,
|
||||||
|
type PriceExtremeFilterLevel,
|
||||||
|
} from '../../utils/priceExtremeBreakoutPair';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'buy' | 'sell';
|
||||||
|
shortPeriod: number;
|
||||||
|
longPeriod: number;
|
||||||
|
filterLevel: PriceExtremeFilterLevel;
|
||||||
|
onChange: (filterLevel: PriceExtremeFilterLevel) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
popoverClassName?: string;
|
||||||
|
variant?: 'popover' | 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PriceExtremePairNodeSettings({
|
||||||
|
mode,
|
||||||
|
shortPeriod,
|
||||||
|
longPeriod,
|
||||||
|
filterLevel,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
popoverClassName = 'se-flow-settings-pop',
|
||||||
|
variant = 'popover',
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(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 = (
|
||||||
|
<>
|
||||||
|
<label className="se-flow-settings-field se-flow-settings-field--readonly">
|
||||||
|
<span>가격 조건</span>
|
||||||
|
<output className="se-flow-settings-readout">
|
||||||
|
{shortPeriod}·{longPeriod}일 {mode === 'buy' ? '신고가' : '신저가'} 돌파
|
||||||
|
</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>필터 강도</span>
|
||||||
|
<select
|
||||||
|
className="se-combo-num-select sp-cond-sel"
|
||||||
|
value={filterLevel}
|
||||||
|
onChange={e => onChange(e.target.value as PriceExtremeFilterLevel)}
|
||||||
|
>
|
||||||
|
{PRICE_EXTREME_FILTER_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="se-flow-settings-hint">
|
||||||
|
{PRICE_EXTREME_FILTER_OPTIONS.find(o => o.value === filterLevel)?.desc}
|
||||||
|
</p>
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
{priceExtremeFilterSummary(mode, filterLevel)}
|
||||||
|
</p>
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
{priceExtremePairSummaryText(mode, shortPeriod, longPeriod, filterLevel)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<div className="se-price-extreme-inline-settings" onClick={e => e.stopPropagation()}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={popoverClassName}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="신고가·신저가 복합 필터 설정"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="se-flow-settings-head">
|
||||||
|
<span>필터 강도</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
title="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 = (
|
||||||
|
<>
|
||||||
|
<label className="se-flow-settings-field se-flow-settings-field--readonly">
|
||||||
|
<span>전략</span>
|
||||||
|
<output className="se-flow-settings-readout">
|
||||||
|
{stableStrategyDisplayName(strategyId, mode)}
|
||||||
|
</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>필터 강도</span>
|
||||||
|
<select
|
||||||
|
className="se-combo-num-select sp-cond-sel"
|
||||||
|
value={filterLevel}
|
||||||
|
onChange={e => onChange(e.target.value as StableStrategyFilterLevel)}
|
||||||
|
>
|
||||||
|
{STABLE_STRATEGY_FILTER_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="se-flow-settings-hint">
|
||||||
|
{STABLE_STRATEGY_FILTER_OPTIONS.find(o => o.value === filterLevel)?.desc}
|
||||||
|
</p>
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
{stableStrategyFilterSummary(strategyId, filterLevel)}
|
||||||
|
</p>
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
{stableStrategySummaryText(strategyId, mode, filterLevel, def)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<div className="se-stable-strategy-inline-settings" onClick={e => e.stopPropagation()}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={popoverClassName}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="추천 전략 필터 설정"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="se-flow-settings-head">
|
||||||
|
<span>필터 강도</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
title="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,18 @@ import {
|
|||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
||||||
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
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 {
|
import {
|
||||||
START_NODE_ID,
|
START_NODE_ID,
|
||||||
applyEdgeHandles,
|
applyEdgeHandles,
|
||||||
@@ -700,7 +712,22 @@ function StrategyEditorCanvasInner({
|
|||||||
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
||||||
const patchGate = (n: LogicNode) => (
|
const patchGate = (n: LogicNode) => (
|
||||||
n.type === 'AND' || n.type === 'OR'
|
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
|
: n
|
||||||
);
|
);
|
||||||
if (isOrphanNode(orphans, id)) {
|
if (isOrphanNode(orphans, id)) {
|
||||||
@@ -747,10 +774,76 @@ function StrategyEditorCanvasInner({
|
|||||||
}
|
}
|
||||||
}, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]);
|
}, [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(() => ({
|
const flowCallbacks = useMemo(() => ({
|
||||||
onDelete: handleDelete,
|
onDelete: handleDelete,
|
||||||
onUpdateCondition: handleUpdateCondition,
|
onUpdateCondition: handleUpdateCondition,
|
||||||
onUpdateStochPairSecondary: handleUpdateStochPairSecondary,
|
onUpdateStochPairSecondary: handleUpdateStochPairSecondary,
|
||||||
|
onUpdatePriceExtremeFilterLevel: handleUpdatePriceExtremeFilterLevel,
|
||||||
|
onUpdateInflection33FilterLevel: handleUpdateInflection33FilterLevel,
|
||||||
|
onUpdateStableStrategyFilterLevel: handleUpdateStableStrategyFilterLevel,
|
||||||
onChangeLogicGateType: handleChangeLogicGateType,
|
onChangeLogicGateType: handleChangeLogicGateType,
|
||||||
onDropTarget: handleDropTarget,
|
onDropTarget: handleDropTarget,
|
||||||
onDragOverTarget: handleDragOverTarget,
|
onDragOverTarget: handleDragOverTarget,
|
||||||
@@ -758,7 +851,7 @@ function StrategyEditorCanvasInner({
|
|||||||
onStartCandleTypesChange: handleStartCandleTypesChange,
|
onStartCandleTypesChange: handleStartCandleTypesChange,
|
||||||
onDeleteStart: handleDeleteStart,
|
onDeleteStart: handleDeleteStart,
|
||||||
}), [
|
}), [
|
||||||
handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleChangeLogicGateType,
|
handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleUpdatePriceExtremeFilterLevel, handleUpdateInflection33FilterLevel, handleUpdateStableStrategyFilterLevel, handleChangeLogicGateType,
|
||||||
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
||||||
handleStartCandleTypesChange, handleDeleteStart,
|
handleStartCandleTypesChange, handleDeleteStart,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1289,7 +1289,7 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
|||||||
maxOpenTrades: 1,
|
maxOpenTrades: 1,
|
||||||
partialExitEnabled: false,
|
partialExitEnabled: false,
|
||||||
partialExitPct: 50,
|
partialExitPct: 50,
|
||||||
positionMode: 'SIGNAL_ONLY',
|
positionMode: 'LONG_ONLY',
|
||||||
analysisMethod: 'MARK_TO_MARKET',
|
analysisMethod: 'MARK_TO_MARKET',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* 33변곡 전략 — Ta4j IsRising/IsFalling(EMA33) + 종가/EMA 필터 + 33봉 종가 채널 돌파
|
||||||
|
*
|
||||||
|
* 매수 AND: EMA33 상승전환 + (선택) 종가>EMA33 + 33봉 종가최고 돌파 + (선택) ADX·거래량
|
||||||
|
* 매도 OR: (EMA33 하락전환 ∧ 종가<EMA33) ∨ 33봉 종가최저 이탈 — 필터 강도에 따라 분기 조절
|
||||||
|
*/
|
||||||
|
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||||
|
import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods';
|
||||||
|
import { genId } from './strategyNodeIds';
|
||||||
|
|
||||||
|
export const INFLECTION_33_BUY_VALUE = 'INFLECTION_33_BUY';
|
||||||
|
export const INFLECTION_33_SELL_VALUE = 'INFLECTION_33_SELL';
|
||||||
|
export const DEFAULT_INFLECTION_PERIOD = 33;
|
||||||
|
export const DEFAULT_EMA_SLOPE_BARS = 2;
|
||||||
|
|
||||||
|
/** 필터 강도 — 전략편집기에서 조절 */
|
||||||
|
export type Inflection33FilterLevel = 'strict' | 'balanced' | 'relaxed';
|
||||||
|
|
||||||
|
export const DEFAULT_INFLECTION33_FILTER_LEVEL: Inflection33FilterLevel = 'balanced';
|
||||||
|
|
||||||
|
export const INFLECTION_33_FILTER_OPTIONS: {
|
||||||
|
value: Inflection33FilterLevel;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: 'strict',
|
||||||
|
label: '강함',
|
||||||
|
desc: 'EMA 2봉 변곡 + ADX·거래량 — 횡보·약한 돌파 제외',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'balanced',
|
||||||
|
label: '보통 (권장)',
|
||||||
|
desc: 'EMA 2봉 변곡 + 종가 EMA 필터 — 기본 33변곡',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'relaxed',
|
||||||
|
label: '완화',
|
||||||
|
desc: 'EMA 1봉 변곡, 종가 EMA 필터 생략 — 매수 빈도↑, 매도는 채널 이탈만',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Inflection33Preset = {
|
||||||
|
slopeBars: number;
|
||||||
|
requireCloseAboveEma: boolean;
|
||||||
|
buyAdx: number | null;
|
||||||
|
sellAdx: number | null;
|
||||||
|
volume: boolean;
|
||||||
|
sellEmaBranch: boolean;
|
||||||
|
sellChannelBranch: boolean;
|
||||||
|
sellRequireCloseBelowEma: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_PRESETS: Record<Inflection33FilterLevel, Inflection33Preset> = {
|
||||||
|
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<ConditionDSL>,
|
||||||
|
): 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하락+종가<EMA' : 'EMA하락만');
|
||||||
|
}
|
||||||
|
if (preset.sellChannelBranch) parts.push('채널 하단 이탈');
|
||||||
|
if (!preset.sellEmaBranch && preset.sellChannelBranch) parts.push('(EMA 분기 생략)');
|
||||||
|
const adx = preset.sellAdx;
|
||||||
|
if (adx != null) parts.push(`ADX≥${adx}`);
|
||||||
|
}
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inflection33DisplayName(mode: 'buy' | 'sell', period = DEFAULT_INFLECTION_PERIOD): string {
|
||||||
|
return mode === 'buy' ? `${period}변곡 매수` : `${period}변곡 매도`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inflection33SummaryText(
|
||||||
|
mode: 'buy' | 'sell',
|
||||||
|
period = DEFAULT_INFLECTION_PERIOD,
|
||||||
|
filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL,
|
||||||
|
): string {
|
||||||
|
const preset = FILTER_PRESETS[filterLevel];
|
||||||
|
const ema = emaField(period);
|
||||||
|
if (mode === 'buy') {
|
||||||
|
const lines: string[] = [
|
||||||
|
`${ema} 상승전환(${preset.slopeBars}봉)`,
|
||||||
|
];
|
||||||
|
if (preset.requireCloseAboveEma) lines.push('종가>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 종가<EMA33`
|
||||||
|
: `${ema} 하락전환(${preset.slopeBars}봉)`;
|
||||||
|
parts.push(`(${emaPart})`);
|
||||||
|
}
|
||||||
|
if (preset.sellChannelBranch) {
|
||||||
|
parts.push(`${period}봉 종가최저 이탈`);
|
||||||
|
}
|
||||||
|
if (preset.sellAdx != null) parts.push(`ADX≥${preset.sellAdx}`);
|
||||||
|
return parts.join(' OR ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inflection33PaletteDesc(value: string): string {
|
||||||
|
if (isInflection33BuyPaletteValue(value)) {
|
||||||
|
return '33 EMA 상승 변곡 + 종가 채널 돌파 + 조절 가능 필터 (기본: 보통)';
|
||||||
|
}
|
||||||
|
if (isInflection33SellPaletteValue(value)) {
|
||||||
|
return '33 EMA 하락 이탈 OR 종가 채널 하향 이탈 + 조절 가능 필터 (기본: 보통)';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 필터 강도 변경 — 노드 id·기간 유지, 자식 조건 재구성 */
|
||||||
|
export function setInflection33PairFilterLevel(
|
||||||
|
node: LogicNode,
|
||||||
|
filterLevel: Inflection33FilterLevel,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): LogicNode {
|
||||||
|
if (!isInflection33PairRoot(node) || !node.inflection33Pair) return node;
|
||||||
|
const { mode, period } = node.inflection33Pair;
|
||||||
|
const rebuilt = mode === 'buy'
|
||||||
|
? buildInflection33BuyTree(def, period, filterLevel)
|
||||||
|
: buildInflection33SellTree(def, period, 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 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;
|
||||||
|
}
|
||||||
@@ -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<PriceExtremeFilterLevel, FilterPreset> = {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,14 +71,20 @@ export function migratePriceExtremeCondition(cond: ConditionDSL): ConditionDSL {
|
|||||||
const leftField = cond.leftField && priceFields.has(cond.leftField)
|
const leftField = cond.leftField && priceFields.has(cond.leftField)
|
||||||
? cond.leftField
|
? cond.leftField
|
||||||
: 'CLOSE_PRICE';
|
: '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({
|
return syncPriceExtremeSimpleFields({
|
||||||
...cond,
|
...cond,
|
||||||
composite: false,
|
composite: false,
|
||||||
leftField,
|
leftField,
|
||||||
|
rightField,
|
||||||
period,
|
period,
|
||||||
conditionType: cond.conditionType === 'CROSS_UP' ? 'GTE'
|
conditionType,
|
||||||
: cond.conditionType === 'CROSS_DOWN' ? 'LTE'
|
|
||||||
: cond.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' };
|
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' };
|
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;
|
return migrated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<StableStrategyFilterLevel, FilterFlags> = {
|
||||||
|
strict: { adx: 25, volume: true },
|
||||||
|
balanced: { adx: 20, volume: false },
|
||||||
|
relaxed: { adx: null, volume: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOLLINGER_FILTER: Record<StableStrategyFilterLevel, number | null> = {
|
||||||
|
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<string, { id: StableStrategyId; mode: 'buy' | 'sell' }> = {
|
||||||
|
[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<ConditionDSL>,
|
||||||
|
): 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<StableStrategyId, {
|
||||||
|
buy: (def: IndicatorPeriodDef, level: StableStrategyFilterLevel) => 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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
THRESHOLD_HL_MID,
|
THRESHOLD_HL_MID,
|
||||||
THRESHOLD_HL_OVER,
|
THRESHOLD_HL_OVER,
|
||||||
} from './thresholdSymbols';
|
} from './thresholdSymbols';
|
||||||
|
import { repairPriceExtremePairForest } from './priceExtremeBreakoutPair';
|
||||||
|
|
||||||
function migrateConditionThreshold(
|
function migrateConditionThreshold(
|
||||||
cond: ConditionDSL,
|
cond: ConditionDSL,
|
||||||
@@ -87,10 +88,11 @@ export function migrateLogicRootForEditor(
|
|||||||
def: DefType,
|
def: DefType,
|
||||||
signalType: 'buy' | 'sell',
|
signalType: 'buy' | 'sell',
|
||||||
): { root: LogicNode | null; orphans: LogicNode[] } {
|
): { root: LogicNode | null; orphans: LogicNode[] } {
|
||||||
return {
|
const migrated = {
|
||||||
root: root ? migrateLogicNode(root, def, signalType) : null,
|
root: root ? migrateLogicNode(root, def, signalType) : null,
|
||||||
orphans: orphans.map(n => migrateLogicNode(n, def, signalType)),
|
orphans: orphans.map(n => migrateLogicNode(n, def, signalType)),
|
||||||
};
|
};
|
||||||
|
return repairPriceExtremePairForest(migrated.root, migrated.orphans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */
|
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */
|
||||||
|
|||||||
@@ -23,15 +23,20 @@ import {
|
|||||||
inferThresholdSymbolFromLegacy,
|
inferThresholdSymbolFromLegacy,
|
||||||
} from './thresholdSymbols';
|
} from './thresholdSymbols';
|
||||||
import type { DefType } from './strategyEditorShared';
|
import type { DefType } from './strategyEditorShared';
|
||||||
|
import { isPriceExtremeBreakoutPairRoot } from './priceExtremeBreakoutPair';
|
||||||
|
|
||||||
function applyGlobalDefToCondition(
|
function applyGlobalDefToCondition(
|
||||||
cond: ConditionDSL,
|
cond: ConditionDSL,
|
||||||
def: DefType,
|
def: DefType,
|
||||||
signalType: 'buy' | 'sell',
|
signalType: 'buy' | 'sell',
|
||||||
|
insidePriceExtremePair: boolean,
|
||||||
): ConditionDSL {
|
): ConditionDSL {
|
||||||
let next: ConditionDSL = { ...cond };
|
let next: ConditionDSL = { ...cond };
|
||||||
|
|
||||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||||
|
if (insidePriceExtremePair || isValuePeriodOverridden(cond)) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
if (!isValuePeriodOverridden(cond)) {
|
if (!isValuePeriodOverridden(cond)) {
|
||||||
const period = getDefaultIndicatorPeriod(cond.indicatorType, def);
|
const period = getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
next = syncPriceExtremeSimpleFields({ ...next, period, valuePeriodOverride: false });
|
next = syncPriceExtremeSimpleFields({ ...next, period, valuePeriodOverride: false });
|
||||||
@@ -76,20 +81,31 @@ function applyGlobalDefToCondition(
|
|||||||
return next;
|
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;
|
if (node.type !== 'CONDITION' || !node.condition) return node;
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
condition: applyGlobalDefToCondition(node.condition, def, signalType),
|
condition: applyGlobalDefToCondition(node.condition, def, signalType, insidePriceExtremePair),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTreeNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode {
|
function syncTreeNode(
|
||||||
let next = syncConditionNode(node, def, signalType);
|
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) {
|
if (next.children?.length) {
|
||||||
next = {
|
next = {
|
||||||
...next,
|
...next,
|
||||||
children: next.children.map(c => syncTreeNode(c, def, signalType)),
|
children: next.children.map(c => syncTreeNode(c, def, signalType, inPair)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -24,6 +24,34 @@ import {
|
|||||||
syncPriceExtremeSimpleFields,
|
syncPriceExtremeSimpleFields,
|
||||||
} from '../utils/priceExtremeIndicators';
|
} from '../utils/priceExtremeIndicators';
|
||||||
import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage';
|
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 {
|
import {
|
||||||
buildStochOverboughtPairTree,
|
buildStochOverboughtPairTree,
|
||||||
isStochOverboughtPairPaletteValue,
|
isStochOverboughtPairPaletteValue,
|
||||||
@@ -767,10 +795,35 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
|||||||
return `${indName} - ${L} ${C}`;
|
return `${indName} - ${L} ${C}`;
|
||||||
}
|
}
|
||||||
if (node.type === 'AND') {
|
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));
|
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
||||||
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
|
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
|
||||||
}
|
}
|
||||||
if (node.type === 'OR') {
|
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)) {
|
if (isStochOverboughtPairRoot(node)) {
|
||||||
const sec = node.stochPair!.secondaryIndicatorType;
|
const sec = node.stochPair!.secondaryIndicatorType;
|
||||||
return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`;
|
return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`;
|
||||||
@@ -1213,12 +1266,21 @@ export type MakeNodeOptions = {
|
|||||||
/** Stoch 과열×보조 복합 */
|
/** Stoch 과열×보조 복합 */
|
||||||
stochPair?: boolean;
|
stochPair?: boolean;
|
||||||
secondaryIndicator?: string;
|
secondaryIndicator?: string;
|
||||||
|
/** 9·20일 신고가/신저가 복합 */
|
||||||
|
priceExtremeBreakout?: boolean;
|
||||||
|
/** 33변곡 EMA+채널 복합 */
|
||||||
|
inflection33?: boolean;
|
||||||
|
/** 추천 안정형 전략 복합 */
|
||||||
|
stableStrategy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 팔레트 드래그 payload → makeNode 옵션 */
|
/** 팔레트 드래그 payload → makeNode 옵션 */
|
||||||
export function makeNodeOptionsFromPalette(data: {
|
export function makeNodeOptionsFromPalette(data: {
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
stochPair?: boolean;
|
stochPair?: boolean;
|
||||||
|
priceExtremeBreakout?: boolean;
|
||||||
|
inflection33?: boolean;
|
||||||
|
stableStrategy?: boolean;
|
||||||
secondaryIndicator?: string;
|
secondaryIndicator?: string;
|
||||||
period?: number;
|
period?: number;
|
||||||
shortPeriod?: number;
|
shortPeriod?: number;
|
||||||
@@ -1233,6 +1295,15 @@ export function makeNodeOptionsFromPalette(data: {
|
|||||||
secondaryIndicator: data.secondaryIndicator ?? 'CCI',
|
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) {
|
if (data.composite) {
|
||||||
return {
|
return {
|
||||||
composite: true,
|
composite: true,
|
||||||
@@ -1254,6 +1325,18 @@ export const makeNode = (
|
|||||||
if (options?.stochPair || isStochOverboughtPairPaletteValue(value)) {
|
if (options?.stochPair || isStochOverboughtPairPaletteValue(value)) {
|
||||||
return buildStochOverboughtPairTree(options?.secondaryIndicator ?? 'CCI', DEF);
|
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) {
|
if (options?.composite) {
|
||||||
let condition = makeCompositeCondition(value, signalType, DEF);
|
let condition = makeCompositeCondition(value, signalType, DEF);
|
||||||
condition = {
|
condition = {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export type StrategyFlowNodeData = {
|
|||||||
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
||||||
/** Stoch 과열×보조 복합 — 보조지표 변경 */
|
/** Stoch 과열×보조 복합 — 보조지표 변경 */
|
||||||
onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void;
|
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 전환 */
|
/** AND ↔ OR 전환 */
|
||||||
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
||||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => 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',
|
signalTab: 'buy' | 'sell',
|
||||||
callbacks: Pick<
|
callbacks: Pick<
|
||||||
StrategyFlowNodeData,
|
StrategyFlowNodeData,
|
||||||
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType'
|
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType'
|
||||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||||
>,
|
>,
|
||||||
dragOverId: string | null,
|
dragOverId: string | null,
|
||||||
@@ -497,6 +503,9 @@ function layoutTree(
|
|||||||
onDelete: callbacks.onDelete,
|
onDelete: callbacks.onDelete,
|
||||||
onUpdateCondition: callbacks.onUpdateCondition,
|
onUpdateCondition: callbacks.onUpdateCondition,
|
||||||
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
||||||
|
onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel,
|
||||||
|
onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel,
|
||||||
|
onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel,
|
||||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||||
onDropTarget: callbacks.onDropTarget,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
@@ -626,7 +635,7 @@ function appendOrphanFlowNodes(
|
|||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
callbacks: Pick<
|
callbacks: Pick<
|
||||||
StrategyFlowNodeData,
|
StrategyFlowNodeData,
|
||||||
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType'
|
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onUpdatePriceExtremeFilterLevel' | 'onUpdateInflection33FilterLevel' | 'onUpdateStableStrategyFilterLevel' | 'onChangeLogicGateType'
|
||||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||||
>,
|
>,
|
||||||
): void {
|
): void {
|
||||||
@@ -649,6 +658,9 @@ function appendOrphanFlowNodes(
|
|||||||
onDelete: callbacks.onDelete,
|
onDelete: callbacks.onDelete,
|
||||||
onUpdateCondition: callbacks.onUpdateCondition,
|
onUpdateCondition: callbacks.onUpdateCondition,
|
||||||
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
||||||
|
onUpdatePriceExtremeFilterLevel: callbacks.onUpdatePriceExtremeFilterLevel,
|
||||||
|
onUpdateInflection33FilterLevel: callbacks.onUpdateInflection33FilterLevel,
|
||||||
|
onUpdateStableStrategyFilterLevel: callbacks.onUpdateStableStrategyFilterLevel,
|
||||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||||
onDropTarget: callbacks.onDropTarget,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
@@ -673,6 +685,9 @@ export function logicNodeToFlow(
|
|||||||
| 'onDelete'
|
| 'onDelete'
|
||||||
| 'onUpdateCondition'
|
| 'onUpdateCondition'
|
||||||
| 'onUpdateStochPairSecondary'
|
| 'onUpdateStochPairSecondary'
|
||||||
|
| 'onUpdatePriceExtremeFilterLevel'
|
||||||
|
| 'onUpdateInflection33FilterLevel'
|
||||||
|
| 'onUpdateStableStrategyFilterLevel'
|
||||||
| 'onDropTarget'
|
| 'onDropTarget'
|
||||||
| 'onDragOverTarget'
|
| 'onDragOverTarget'
|
||||||
| 'onDragLeaveTarget'
|
| 'onDragLeaveTarget'
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import {
|
|||||||
STOCH_OVERBOUGHT_PAIR_VALUE,
|
STOCH_OVERBOUGHT_PAIR_VALUE,
|
||||||
stochPairPaletteDesc,
|
stochPairPaletteDesc,
|
||||||
} from './stochOverboughtPair';
|
} 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';
|
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||||
|
|
||||||
@@ -54,6 +67,42 @@ export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
|||||||
builtIn: true,
|
builtIn: true,
|
||||||
secondaryIndicator: 'CCI',
|
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 => ({
|
...COMPOSITE_INDICATOR_ITEMS.map(i => ({
|
||||||
value: i.value,
|
value: i.value,
|
||||||
label: i.label,
|
label: i.label,
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { LogicNode } from './strategyTypes';
|
import type { LogicNode } from './strategyTypes';
|
||||||
import { genId, type DefType } from './strategyEditorShared';
|
import { genId, type DefType } from './strategyEditorShared';
|
||||||
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
|
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
|
||||||
|
import {
|
||||||
|
buildInflection33BuyTree,
|
||||||
|
buildInflection33SellTree,
|
||||||
|
} from './inflection33Strategy';
|
||||||
|
import {
|
||||||
|
buildNewHigh920BuyTree,
|
||||||
|
buildNewLow920SellTree,
|
||||||
|
} from './priceExtremeBreakoutPair';
|
||||||
|
|
||||||
export type SimpleStrategyTemplate = {
|
export type SimpleStrategyTemplate = {
|
||||||
kind: 'simple';
|
kind: 'simple';
|
||||||
@@ -86,26 +94,14 @@ function ichimokuTenkanKijunCross(signal: 'buy' | 'sell'): LogicNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 33변곡 매수 — 바닥권 시간변곡(약 33거래일 주기) 전환 구간 */
|
/** @deprecated inflection33Strategy.buildInflection33BuyTree 사용 */
|
||||||
export function build33InflectionBuyTree(): LogicNode {
|
export function build33InflectionBuyTree(def?: DefType): LogicNode {
|
||||||
return andTree(
|
return buildInflection33BuyTree(def ?? { maLines: [5, 10, 20, 60, 120] } as DefType);
|
||||||
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'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 33변곡 매도 — 상승 5파 끝(33·36·38일 변곡) 구간 이탈 */
|
/** @deprecated inflection33Strategy.buildInflection33SellTree 사용 */
|
||||||
export function build33InflectionSellTree(): LogicNode {
|
export function build33InflectionSellTree(def?: DefType): LogicNode {
|
||||||
return andTree(
|
return buildInflection33SellTree(def ?? { maLines: [5, 10, 20, 60, 120] } as DefType);
|
||||||
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'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
||||||
@@ -169,19 +165,33 @@ export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] {
|
|||||||
label: '20일 신저가 이탈',
|
label: '20일 신저가 이탈',
|
||||||
description: '종가가 직전 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',
|
kind: 'composite',
|
||||||
signal: 'buy',
|
signal: 'buy',
|
||||||
label: '33변곡 매수',
|
label: '33변곡 매수',
|
||||||
description: '바닥권 시간변곡 — RSI 과매도 탈출 + MA 골든크로스 + 20일선·이격도 돌파',
|
description: '33 EMA 상승 변곡 + 종가 EMA 위 + 33봉 종가 채널 상향 돌파',
|
||||||
build: build33InflectionBuyTree,
|
build: () => build33InflectionBuyTree(def),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'composite',
|
kind: 'composite',
|
||||||
signal: 'sell',
|
signal: 'sell',
|
||||||
label: '33변곡 매도',
|
label: '33변곡 매도',
|
||||||
description: '상승 5파 끝 변곡 — RSI 과매수 + MA 데드크로스 + 20일선·이격도 이탈',
|
description: '33 EMA 하락 변곡 이탈 OR 33봉 종가 채널 하향 이탈',
|
||||||
build: build33InflectionSellTree,
|
build: () => build33InflectionSellTree(def),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'composite',
|
kind: 'composite',
|
||||||
|
|||||||
@@ -44,6 +44,27 @@ export interface LogicNode {
|
|||||||
stochPair?: {
|
stochPair?: {
|
||||||
secondaryIndicatorType: string;
|
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 {
|
export interface StrategyDSLDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user