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