Files
goldenChart/frontend/src/utils/stableStrategyPairs.ts
T
2026-06-16 20:55:31 +09:00

647 lines
26 KiB
TypeScript

/**
* 추천 안정형 전략 — 터틀·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'
| 'ICHIMOKU_SANYAKU'
| '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 ICHIMOKU_SANYAKU_BUY = 'ICHIMOKU_SANYAKU_BUY';
export const ICHIMOKU_SANYAKU_SELL = 'ICHIMOKU_SANYAKU_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' },
[ICHIMOKU_SANYAKU_BUY]: { id: 'ICHIMOKU_SANYAKU', mode: 'buy' },
[ICHIMOKU_SANYAKU_SELL]: { id: 'ICHIMOKU_SANYAKU', 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: ICHIMOKU_SANYAKU_BUY, label: '일목 삼역호전 매수', strategyId: 'ICHIMOKU_SANYAKU', mode: 'buy',
desc: '구름 상향 돌파 트리거 + 삼역호전 필터 (보통: 후행·양운, 강함: MA·거래량)',
periodHint: '9 / 26 / 52',
},
{
value: ICHIMOKU_SANYAKU_SELL, label: '일목 삼역호전 매도', strategyId: 'ICHIMOKU_SANYAKU', mode: 'sell',
desc: '전환·기준·구름 이탈 OR — 돌파 우선, 이탈 유지 fallback',
periodHint: '9 / 26 / 52',
},
{
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 makeVolumeMaRatioFilter(
def: IndicatorPeriodDef,
ratio: number,
maLength = 5,
): LogicNode {
return makeCondition('VOLUME', 'VOLUME_GT_MA_RATIO', 'VOLUME_VALUE', 'VOLUME_MA', def, {
compareValue: ratio,
params: { length: maLength },
});
}
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));
}
/** 일목 삼역호전 — 매수 트리거 (1회성 돌파) */
function ichimokuSanyakuBuyTrigger(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode {
if (level === 'strict') {
return wrapOr([
makeCondition('ICHIMOKU', 'CLOUD_BREAK_UP', 'NONE', 'NONE', def),
makeCondition('ICHIMOKU', 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def),
]);
}
return makeCondition('ICHIMOKU', 'CLOUD_BREAK_UP', 'NONE', 'NONE', def);
}
/** 일목 삼역호전 — 공통 추세·구름 필터 */
function ichimokuSanyakuCoreFilters(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode[] {
const nodes: LogicNode[] = [
makeCondition('ICHIMOKU', 'GT', 'CONVERSION_LINE', 'BASE_LINE', def),
makeCondition('ICHIMOKU', 'ABOVE_CLOUD', 'CLOSE_PRICE', 'NONE', def),
];
if (level === 'relaxed') {
nodes.unshift(makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def));
return nodes;
}
nodes.unshift(
makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def),
makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'BASE_LINE', def),
);
nodes.push(
makeCondition('ICHIMOKU', 'SPAN1_GT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def),
makeCondition('ICHIMOKU', 'LAGGING_GT_PRICE', 'LAGGING_SPAN', 'CLOSE_PRICE', def),
);
if (level === 'strict') {
nodes.push(makeCondition('ICHIMOKU', 'LAGGING_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def));
}
return nodes;
}
/** 일목 삼역호전(三役好轉) — 구름 돌파 트리거 + 단계별 필터 */
function buildIchimokuSanyakuBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode {
const nodes: LogicNode[] = [
ichimokuSanyakuBuyTrigger(def, level),
...ichimokuSanyakuCoreFilters(def, level),
];
if (level === 'strict') {
nodes.push(makeCondition('MA', 'GT', 'CLOSE_PRICE', maField(60), def));
nodes.push(makeVolumeMaRatioFilter(def, 1.5, 5));
}
return wrapAnd(nodes, pairMeta('ICHIMOKU_SANYAKU', 'buy', level));
}
/** 일목 삼역호전 — 청산 (돌파 이벤트 + 이탈 fallback, 포지션 교대 시 1회) */
function buildIchimokuSanyakuSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode {
const branches: LogicNode[] = [
makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'CONVERSION_LINE', def),
makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'BASE_LINE', def),
makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CONVERSION_LINE', 'BASE_LINE', def),
makeCondition('ICHIMOKU', 'CLOUD_BREAK_DOWN', 'CLOSE_PRICE', 'NONE', def),
];
if (level !== 'strict') {
branches.push(makeCondition('ICHIMOKU', 'LT', 'CLOSE_PRICE', 'CONVERSION_LINE', def));
if (level === 'relaxed') {
branches.push(makeCondition('ICHIMOKU', 'BELOW_CLOUD', 'CLOSE_PRICE', 'NONE', def));
}
}
return wrapOrRoot(branches, pairMeta('ICHIMOKU_SANYAKU', '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 },
ICHIMOKU_SANYAKU: { buy: buildIchimokuSanyakuBuy, sell: buildIchimokuSanyakuSell },
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: '구름 아래',
SPAN1_GT_SPAN2: '양운(선행1>2)', LAGGING_GT_PRICE: '후행>26봉전 종가',
LAGGING_ABOVE_PRIOR_CLOUD: '후행>26봉전 구름', VOLUME_GT_MA_RATIO: '거래량≥MA×비율',
};
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 === 'ICHIMOKU_SANYAKU') {
if (level === 'strict') parts.push('후행구름·MA60·거래량150%');
else if (level === 'balanced') parts.push('구름돌파·양운·후행');
else 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 === 'ICHIMOKU_SANYAKU') {
const hasCloudBreak = conds.some(c => c.conditionType === 'CLOUD_BREAK_UP');
const hasChikouCross = conds.some(c => c.conditionType === 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD');
const hasLaggingCloud = conds.some(c => c.conditionType === 'LAGGING_ABOVE_PRIOR_CLOUD');
const hasVolRatio = conds.some(c => c.conditionType === 'VOLUME_GT_MA_RATIO');
const hasMa = conds.some(c => c.indicatorType === 'MA' && c.rightField === maField(60));
const hasLagging = conds.some(c => c.conditionType === 'LAGGING_GT_PRICE');
if (hasVolRatio || hasLaggingCloud) return 'strict';
if (hasMa) return 'strict';
if (hasLagging && (hasCloudBreak || hasChikouCross)) return 'balanced';
if (hasCloudBreak || hasChikouCross) 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;
}