복합지표 전략 추가
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user