전략편집기 횡보구간 컨트롤 추가

This commit is contained in:
Macbook
2026-06-12 09:08:44 +09:00
parent 74b0ea4ab6
commit d741d3fec6
8 changed files with 1601 additions and 19 deletions
+334
View File
@@ -0,0 +1,334 @@
/**
* 횡보(레인지) 필터 팔레트 — 전략편집기 우측 「횡보필터」 탭
* 각 항목은 LogicNode(조건 또는 AND/NOT 조합)를 생성합니다.
*/
import type { LogicNode } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { genId } from './strategyNodeIds';
import { THRESHOLD_HL_MID, THRESHOLD_HL_UNDER } from './thresholdSymbols';
export type SidewaysFilterMode = 'include' | 'exclude';
export type SidewaysFilterCategory =
| 'adx'
| 'bollinger'
| 'ma'
| 'ichimoku'
| 'donchian'
| 'oscillator'
| 'dmi'
| 'volume'
| 'combo';
export interface SidewaysFilterItem {
id: string;
label: string;
desc: string;
category: SidewaysFilterCategory;
/** include=횡보 구간만, exclude=횡보 제외(추세만) */
mode: SidewaysFilterMode;
build: (def: DefType) => LogicNode;
}
export const SIDEWAYS_FILTER_CATEGORY_LABEL: Record<SidewaysFilterCategory, string> = {
adx: 'ADX · 추세강도',
bollinger: '볼린저 밴드',
ma: 'MA · 이동평균 수렴',
ichimoku: '일목균형표',
donchian: 'Donchian · 채널',
oscillator: '오실레이터 중립',
dmi: 'DMI · 방향성',
volume: '거래량 · VR',
combo: '복합 횡보 필터',
};
function condition(
indicatorType: string,
conditionType: string,
leftField: string,
rightField: string,
extra?: Partial<NonNullable<LogicNode['condition']>>,
): LogicNode {
return {
id: genId(),
type: 'CONDITION',
condition: {
indicatorType,
conditionType,
leftField,
rightField,
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
...extra,
},
};
}
function andNode(...children: LogicNode[]): LogicNode {
return { id: genId(), type: 'AND', children };
}
function orNode(...children: LogicNode[]): LogicNode {
return { id: genId(), type: 'OR', children };
}
function notNode(child: LogicNode): LogicNode {
return { id: genId(), type: 'NOT', children: [child] };
}
function maField(def: DefType, index: number): string {
const p = def.maLines[index] ?? def.maLines[def.maLines.length - 1] ?? 20;
return `MA${p}`;
}
function diffCond(
ind: string,
left: string,
right: string,
op: 'DIFF_LT' | 'DIFF_GT',
compareValue: number,
): LogicNode {
return condition(ind, op, left, right, { compareValue });
}
function rsiBand(def: DefType, low: number, high: number): LogicNode {
return andNode(
condition('RSI', 'GTE', 'RSI_VALUE', `K_${low}`, { compareValue: low }),
condition('RSI', 'LTE', 'RSI_VALUE', `K_${high}`, { compareValue: high }),
);
}
function stochBand(low: number, high: number): LogicNode {
return andNode(
condition('STOCHASTIC', 'GTE', 'STOCH_K', `K_${low}`),
condition('STOCHASTIC', 'LTE', 'STOCH_K', `K_${high}`),
);
}
function cciBand(low: number, high: number): LogicNode {
return andNode(
condition('CCI', 'GTE', 'CCI_VALUE', `K_${low}`),
condition('CCI', 'LTE', 'CCI_VALUE', `K_${high}`),
);
}
function adxWeak(): LogicNode {
return condition('ADX', 'LT', 'ADX_VALUE', THRESHOLD_HL_MID);
}
function adxStrong(): LogicNode {
return condition('ADX', 'GTE', 'ADX_VALUE', THRESHOLD_HL_MID);
}
function adxVeryWeak(): LogicNode {
return condition('ADX', 'LT', 'ADX_VALUE', 'ADX_20');
}
function inCloud(): LogicNode {
return condition('ICHIMOKU', 'IN_CLOUD', 'CLOSE_PRICE', 'CLOSE_PRICE');
}
function bbSqueeze(def: DefType): LogicNode {
void def;
return diffCond('BOLLINGER', 'UPPER_BAND', 'LOWER_BAND', 'DIFF_LT', 2);
}
function maConverge(def: DefType, shortIdx: number, longIdx: number, threshold: number): LogicNode {
return diffCond('MA', maField(def, shortIdx), maField(def, longIdx), 'DIFF_LT', threshold);
}
function dcNarrow(period: number, threshold: number): LogicNode {
return diffCond(
'DONCHIAN',
`DC_UPPER_${period}`,
`DC_LOWER_${period}`,
'DIFF_LT',
threshold,
);
}
function buildInclude(inner: LogicNode): LogicNode {
return inner;
}
function buildExclude(inner: LogicNode): LogicNode {
return notNode(inner);
}
function item(
id: string,
label: string,
desc: string,
category: SidewaysFilterCategory,
mode: SidewaysFilterMode,
inner: LogicNode | ((def: DefType) => LogicNode),
): SidewaysFilterItem {
return {
id,
label,
desc,
category,
mode,
build: def => {
const node = typeof inner === 'function' ? inner(def) : inner;
return mode === 'exclude' ? buildExclude(node) : buildInclude(node);
},
};
}
/** 내장 횡보 필터 목록 */
export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [
// ── ADX ──
item('adx-lt-25', 'ADX < 25', '평균방향지수 25 미만 — 비추세·횡보', 'adx', 'include', adxWeak()),
item('adx-lt-20', 'ADX < 20', 'ADX 20 미만 — 강한 횡보', 'adx', 'include', adxVeryWeak()),
item('adx-slope-down', 'ADX 하락 중', '추세 강도가 줄어드는 구간', 'adx', 'include',
condition('ADX', 'SLOPE_DOWN', 'ADX_VALUE', 'ADX_VALUE', { slopePeriod: 3 })),
item('adx-gte-25', 'ADX ≥ 25', '추세 형성 구간 — 횡보 제외', 'adx', 'exclude', adxWeak()),
item('adx-gte-25-direct', 'ADX ≥ 25 (추세)', '추세 강도 25 이상', 'adx', 'include', adxStrong()),
item('adx-gte-20', 'ADX ≥ 20', '약한 추세 이상 — 횡보 제외', 'adx', 'exclude', adxVeryWeak()),
// ── 볼린저 ──
item('bb-squeeze', '볼린저 수축', '상·하단밴드 간격 좁음 (Squeeze) — compareValue 조절', 'bollinger', 'include', bbSqueeze),
item('bb-wide', '볼린저 확장', '밴드폭 넓음 — 변동성 확대·횡보 제외', 'bollinger', 'exclude', def => bbSqueeze(def)),
item('bb-close-near-mid', '종가 ≈ 중심선', '종가가 볼린저 중심선 근처', 'bollinger', 'include',
diffCond('BOLLINGER', 'CLOSE_PRICE', 'MIDDLE_BAND', 'DIFF_LT', 1)),
item('bb-close-in-band', '종가 밴드 내부', '하단 이상 & 상단 이하 (밴드 안)', 'bollinger', 'include', andNode(
condition('BOLLINGER', 'GTE', 'CLOSE_PRICE', 'LOWER_BAND'),
condition('BOLLINGER', 'LTE', 'CLOSE_PRICE', 'UPPER_BAND'),
)),
// ── MA ──
item('ma-5-20-narrow', 'MA 단·중 수렴', '단기·중기 MA 간격 좁음 — compareValue 조절', 'ma', 'include',
def => maConverge(def, 0, 1, 1)),
item('ma-5-60-narrow', 'MA 단·장 수렴', '단기·장기 MA 수렴', 'ma', 'include',
def => maConverge(def, 0, 3, 2)),
item('ma-10-20-narrow', 'MA2·MA3 수렴', '중기 MA 두 선 근접', 'ma', 'include',
def => maConverge(def, 1, 2, 1)),
item('ma-spread-wide', 'MA 간격 넓음', 'MA 스프레드 큼 — 추세·횡보 제외', 'ma', 'exclude',
def => maConverge(def, 0, 2, 5)),
// ── 일목 ──
item('ich-in-cloud', '구름 안', '종가가 일목 구름대 내부', 'ichimoku', 'include', inCloud()),
item('ich-not-cloud', '구름 밖', '구름 위 또는 아래 — 횡보 제외', 'ichimoku', 'exclude', inCloud()),
item('ich-span-thin', '구름 얇음', '선행1·선행2 간격 좁음', 'ichimoku', 'include',
diffCond('ICHIMOKU', 'LEADING_SPAN1', 'LEADING_SPAN2', 'DIFF_LT', 1)),
item('ich-above-cloud-excl', '구름 위 (추세)', '강세 구름 위 — 횡보 제외', 'ichimoku', 'exclude', inCloud()),
// ── Donchian ──
item('dc-9-narrow', '9일 채널 좁음', 'Donchian 9일 고저 폭 좁음', 'donchian', 'include', dcNarrow(9, 2)),
item('dc-20-narrow', '20일 채널 좁음', '한 달 박스권 — 채널 폭 좁음', 'donchian', 'include', dcNarrow(20, 3)),
item('dc-20-wide', '20일 채널 넓음', '채널 확장 — 횡보 제외', 'donchian', 'exclude', dcNarrow(20, 3)),
// ── 오실레이터 ──
item('rsi-40-60', 'RSI 40~60', 'RSI 중립대', 'oscillator', 'include', def => rsiBand(def, 40, 60)),
item('rsi-45-55', 'RSI 45~55', 'RSI 좁은 중립대', 'oscillator', 'include', def => rsiBand(def, 45, 55)),
item('rsi-not-neutral', 'RSI 중립 아님', 'RSI 40~60 밖 — 횡보 제외', 'oscillator', 'exclude', def => rsiBand(def, 40, 60)),
item('stoch-40-60', 'Stoch 40~60', '%K 중립대', 'oscillator', 'include', stochBand(40, 60)),
item('stoch-30-70', 'Stoch 30~70', '%K 넓은 중립', 'oscillator', 'include', stochBand(30, 70)),
item('cci-neutral', 'CCI -50~+50', 'CCI 중립 박스', 'oscillator', 'include', cciBand(-50, 50)),
item('cci-near-zero', 'CCI ≈ 0', 'CCI 0선 근처', 'oscillator', 'include',
diffCond('CCI', 'CCI_VALUE', THRESHOLD_HL_MID, 'DIFF_LT', 50)),
item('wr-neutral', 'Williams -60~-40', 'Williams %R 중립', 'oscillator', 'include', andNode(
condition('WILLIAMS_R', 'GTE', 'WILLIAMS_R_VALUE', 'K_-60'),
condition('WILLIAMS_R', 'LTE', 'WILLIAMS_R_VALUE', 'K_-40'),
)),
item('macd-hist-flat', 'MACD 히스토그램 ≈0', '모멘텀 약함', 'oscillator', 'include',
diffCond('MACD', 'HISTOGRAM', 'ZERO_LINE', 'DIFF_LT', 0.5)),
item('trix-near-zero', 'TRIX ≈ 0', 'TRIX 0선 근처', 'oscillator', 'include',
diffCond('TRIX', 'TRIX_VALUE', 'ZERO_LINE', 'DIFF_LT', 0.1)),
item('disp-near-100', '이격도 ≈ 100', '20일 이격도 기준선 근처', 'oscillator', 'include',
diffCond('DISPARITY', 'DISPARITY20', THRESHOLD_HL_MID, 'DIFF_LT', 3)),
// ── DMI ──
item('dmi-no-direction', '+DI ≈ -DI', '방향성 우열 없음', 'dmi', 'include',
diffCond('DMI', 'PDI', 'MDI', 'DIFF_LT', 5)),
item('dmi-both-weak', '+DI·-DI 모두 약함', '양 방향 모두 10 미만', 'dmi', 'include', andNode(
condition('DMI', 'LT', 'PDI', THRESHOLD_HL_UNDER),
condition('DMI', 'LT', 'MDI', THRESHOLD_HL_UNDER),
)),
item('dmi-clear-direction', '방향성 뚜렷', '+DI·-DI 차이 큼 — 횡보 제외', 'dmi', 'exclude',
diffCond('DMI', 'PDI', 'MDI', 'DIFF_LT', 5)),
// ── 거래량 ──
item('vol-below-ma', '거래량 < MA', '평균 대비 거래량 위축', 'volume', 'include',
condition('VOLUME', 'LT', 'VOLUME_VALUE', 'VOLUME_MA')),
item('vol-above-ma', '거래량 > MA', '거래량 증가 — 횡보 제외', 'volume', 'exclude',
condition('VOLUME', 'LT', 'VOLUME_VALUE', 'VOLUME_MA')),
item('vr-near-100', 'VR ≈ 100', 'VR 기준선 근처', 'volume', 'include',
diffCond('VR', 'VR_VALUE', THRESHOLD_HL_MID, 'DIFF_LT', 30)),
// ── 복합 ──
item('combo-adx-cloud', 'ADX<25 + 구름안', '비추세 + 일목 구름대', 'combo', 'include', andNode(adxWeak(), inCloud())),
item('combo-adx-rsi', 'ADX<25 + RSI중립', '비추세 + RSI 40~60', 'combo', 'include', def => andNode(adxWeak(), rsiBand(def, 40, 60))),
item('combo-adx-bb', 'ADX<25 + BB수축', '비추세 + 볼린저 수축', 'combo', 'include', def => andNode(adxWeak(), bbSqueeze(def))),
item('combo-adx-ma', 'ADX<25 + MA수렴', '비추세 + MA 단·중 수렴', 'combo', 'include',
def => andNode(adxWeak(), maConverge(def, 0, 1, 1))),
item('combo-cloud-rsi', '구름안 + RSI중립', '일목 횡보 + RSI 중립', 'combo', 'include', def => andNode(inCloud(), rsiBand(def, 40, 60))),
item('combo-triple', 'ADX+구름+RSI', '3중 횡보 확인', 'combo', 'include', def => andNode(adxWeak(), inCloud(), rsiBand(def, 40, 60))),
item('combo-loose-range', 'ADX<20 또는 구름안', '느슨한 횡보 OR', 'combo', 'include', orNode(adxVeryWeak(), inCloud())),
item('combo-trend-only', '추세만 (3중 제외)', 'NOT(ADX<25 ∧ 구름안 ∧ RSI중립)', 'combo', 'exclude', def =>
andNode(adxWeak(), inCloud(), rsiBand(def, 40, 60))),
item('combo-adx-dc', 'ADX<25 + 20일채널좁음', '비추세 + Donchian 박스', 'combo', 'include',
andNode(adxWeak(), dcNarrow(20, 3))),
item('combo-full-range', '풀 횡보 필터', 'ADX+BB수축+MA수렴+RSI', 'combo', 'include', def => andNode(
adxWeak(),
bbSqueeze(def),
maConverge(def, 0, 1, 1),
rsiBand(def, 45, 55),
)),
];
const FILTER_MAP = new Map(DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => [i.id, i]));
export function getSidewaysFilterItem(id: string): SidewaysFilterItem | undefined {
return FILTER_MAP.get(id);
}
export function buildSidewaysFilterNode(id: string, def: DefType): LogicNode | null {
const item = getSidewaysFilterItem(id);
if (!item) return null;
return item.build(def);
}
export function filterSidewaysItems(
items: SidewaysFilterItem[],
query: string,
modeFilter: 'all' | SidewaysFilterMode,
): SidewaysFilterItem[] {
const q = query.trim().toLowerCase();
return items.filter(i => {
if (modeFilter !== 'all' && i.mode !== modeFilter) return false;
if (!q) return true;
const cat = SIDEWAYS_FILTER_CATEGORY_LABEL[i.category];
return (
i.label.toLowerCase().includes(q)
|| i.desc.toLowerCase().includes(q)
|| i.id.toLowerCase().includes(q)
|| cat.toLowerCase().includes(q)
|| (i.mode === 'include' ? '횡보' : '제외').includes(q)
);
});
}
export function groupSidewaysFiltersByCategory(
items: SidewaysFilterItem[],
): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterItem[] }[] {
const order: SidewaysFilterCategory[] = [
'adx', 'bollinger', 'ma', 'ichimoku', 'donchian', 'oscillator', 'dmi', 'volume', 'combo',
];
const grouped = new Map<SidewaysFilterCategory, SidewaysFilterItem[]>();
for (const i of items) {
const list = grouped.get(i.category) ?? [];
list.push(i);
grouped.set(i.category, list);
}
return order
.filter(c => grouped.has(c))
.map(c => ({
category: c,
label: SIDEWAYS_FILTER_CATEGORY_LABEL[c],
items: grouped.get(c)!,
}));
}