지표선택 목록 추가

This commit is contained in:
Macbook
2026-06-16 10:14:05 +09:00
parent 7440453bb3
commit 2670c31d37
7 changed files with 490 additions and 37 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* 상향 돌파 경로 판별 — 복합(AND) 조건 프리셋
* @see 상향돌파 케이스별 판별.md
*/
import type { ConditionDSL } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { getFieldOpts } from './strategyEditorShared';
import {
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
THRESHOLD_HL_UNDER,
} from './thresholdSymbols';
import { thresholdField } from './conditionPeriods';
import type { ConditionPreset, ConditionPresetGraph } from './conditionPresets';
const DEFAULT_PATH_LOOKBACK = 30;
/** 상향 돌파 경로 판별 지원 지표 */
const PATH_ELIGIBLE = new Set([
'RSI', 'CCI', 'STOCHASTIC', 'WILLIAMS_R',
'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL',
'VR', 'BWI', 'TRIX',
]);
type PathLineConfig = {
valueField: string;
targetField: string;
floorField: string;
};
function pathLines(indicatorType: string): PathLineConfig | null {
switch (indicatorType) {
case 'RSI':
return { valueField: 'RSI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'CCI':
return { valueField: 'CCI_VALUE', targetField: thresholdField(50), floorField: THRESHOLD_HL_MID };
case 'STOCHASTIC':
return { valueField: 'STOCH_K', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'WILLIAMS_R':
return { valueField: 'WILLIAMS_R_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'PSYCHOLOGICAL':
case 'INVEST_PSYCHOLOGICAL':
return { valueField: 'PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'NEW_PSYCHOLOGICAL':
return { valueField: 'NEW_PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'VR':
return { valueField: 'VR_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'BWI':
return { valueField: 'BWI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
case 'TRIX':
return { valueField: 'TRIX_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
default:
return null;
}
}
function fieldLabel(
indicatorType: string,
field: string,
def: DefType,
signalType: 'buy' | 'sell',
mock: ConditionDSL,
): string {
return getFieldOpts(indicatorType, signalType, def, mock).find(o => o.value === field)?.label ?? field;
}
function withLookback(patch: Partial<ConditionDSL>, n = DEFAULT_PATH_LOOKBACK): Partial<ConditionDSL> {
return { ...patch, lookbackPeriod: n };
}
function withExistsIn(patch: Partial<ConditionDSL>, n = DEFAULT_PATH_LOOKBACK): Partial<ConditionDSL> {
return { ...patch, candleRangeMode: 'EXISTS_IN', candleRange: n };
}
function withNotExistsIn(patch: Partial<ConditionDSL>, n = DEFAULT_PATH_LOOKBACK): Partial<ConditionDSL> {
return { ...patch, candleRangeMode: 'NOT_EXISTS_IN', candleRange: n };
}
/** ① 0(바닥선) 위에서 목표선 첫 상향 돌파 */
function buildPathFirstCross(cfg: PathLineConfig): Partial<ConditionDSL>[] {
const { valueField, targetField, floorField } = cfg;
return [
{ conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField },
withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }),
withNotExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }),
];
}
/** ② 목표선 위→아래 조정(바닥선 유지) 후 목표선 재상향 돌파 */
function buildPathPullbackRecross(cfg: PathLineConfig): Partial<ConditionDSL>[] {
const { valueField, targetField, floorField } = cfg;
return [
{ conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField },
withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }),
withExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }),
withExistsIn({ conditionType: 'LT', leftField: valueField, rightField: targetField }),
];
}
/** ③ 바닥선 이탈(침체) 후 변곡·목표선 상향 돌파 */
function buildPathDeepRecovery(cfg: PathLineConfig): Partial<ConditionDSL>[] {
const { valueField, targetField, floorField } = cfg;
return [
{ conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField },
withLookback({ conditionType: 'LOWEST_LTE', leftField: valueField, rightField: floorField }),
];
}
function enrichPathPreset(
preset: ConditionPreset,
indicatorType: string,
def: DefType,
signalType: 'buy' | 'sell',
cfg: PathLineConfig,
): ConditionPreset {
const mockCross: ConditionDSL = {
indicatorType,
conditionType: 'CROSS_UP',
leftField: cfg.valueField,
rightField: cfg.targetField,
};
const left = fieldLabel(indicatorType, cfg.valueField, def, signalType, mockCross);
const target = fieldLabel(indicatorType, cfg.targetField, def, signalType, mockCross);
const floor = fieldLabel(indicatorType, cfg.floorField, def, signalType, {
...mockCross,
conditionType: 'LOWEST_GTE',
rightField: cfg.floorField,
});
const n = DEFAULT_PATH_LOOKBACK;
const descById: Record<string, { label: string; description: string }> = {
path_first_cross: {
label: `${target} 첫 상향 돌파 (${floor} 이상 유지)`,
description: `${left}이(가) 최근 ${n}봉 동안 ${floor} 아래로 내려간 적 없고, ${target} 위 체류 이력도 없을 때 ${target}을(를) 처음 상향 돌파 (AND ${preset.compound?.length ?? 3}조건)`,
},
path_pullback_recross: {
label: `${target} 되돌림 후 재상향 돌파`,
description: `${left}이(가) ${floor} 위를 유지한 채 ${target} 위·아래를 오간 뒤, 다시 ${target}을(를) 상향 돌파 (AND ${preset.compound?.length ?? 4}조건)`,
},
path_deep_recovery: {
label: `${floor} 침체 후 ${target} 상향 돌파`,
description: `최근 ${n}봉 내 ${left}이(가) ${floor} 이하(침체) 구간을 거친 뒤, ${target}을(를) 상향 돌파하는 V자 반등 (AND ${preset.compound?.length ?? 2}조건)`,
},
};
const extra = descById[preset.id];
if (!extra) return preset;
return { ...preset, label: extra.label, description: extra.description };
}
export function getPathBreakoutPresets(
indicatorType: string,
def: DefType,
signalType: 'buy' | 'sell',
): ConditionPreset[] {
if (!PATH_ELIGIBLE.has(indicatorType)) return [];
const cfg = pathLines(indicatorType);
if (!cfg) return [];
const base = (id: string, graph: ConditionPresetGraph, compound: Partial<ConditionDSL>[]): ConditionPreset => ({
id,
label: id,
description: '',
graph,
patch: {},
category: 'path',
compound,
tags: ['buy'],
});
const presets: ConditionPreset[] = [
base('path_first_cross', 'path_first_cross', buildPathFirstCross(cfg)),
base('path_pullback_recross', 'path_pullback_recross', buildPathPullbackRecross(cfg)),
base('path_deep_recovery', 'path_deep_recovery', buildPathDeepRecovery(cfg)),
];
return presets.map(p => enrichPathPreset(p, indicatorType, def, signalType, cfg));
}
export function isCompoundConditionPreset(preset: ConditionPreset): boolean {
return !!preset.compound && preset.compound.length > 0;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* 조건 프리셋 — 지표·기준선 값 반영 라벨/설명 생성
*/
import type { ConditionDSL } from './strategyTypes';
import { CONDITION_LABEL } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { getFieldOpts } from './strategyEditorShared';
import type { ConditionPresetGraph } from './conditionPresets';
type PresetLike = {
id: string;
label: string;
description: string;
graph: ConditionPresetGraph;
patch: Partial<ConditionDSL>;
tags?: ('buy' | 'sell')[];
};
function resolveFieldLabel(
indicatorType: string,
field: string | undefined,
def: DefType,
signalType: 'buy' | 'sell',
mockCond: ConditionDSL,
): string {
if (!field || field === 'NONE') return '';
const opts = getFieldOpts(indicatorType, signalType, def, mockCond);
return opts.find(o => o.value === field)?.label ?? field;
}
function condVerb(condType: string): string {
switch (condType) {
case 'CROSS_UP': return '상향 돌파';
case 'CROSS_DOWN': return '하향 돌파';
case 'GT': return '초과';
case 'LT': return '미만';
case 'GTE': return '이상';
case 'LTE': return '이하';
case 'EQ': return '같음';
case 'NEQ': return '다름';
case 'SLOPE_UP': return '상승 기울기';
case 'SLOPE_DOWN': return '하락 기울기';
default: return CONDITION_LABEL[condType] ?? condType;
}
}
function buildDescription(
left: string,
right: string,
condType: string,
slopePeriod?: number,
): string {
const sp = slopePeriod ?? 3;
switch (condType) {
case 'CROSS_UP':
if (left && right) {
return `${left}이(가) ${right}을(를) 아래에서 위로 돌파하는 순간 (직전 봉 ≤ 기준, 현재 봉 > 기준)`;
}
return '기준선을 아래에서 위로 돌파하는 순간';
case 'CROSS_DOWN':
if (left && right) {
return `${left}이(가) ${right}을(를) 위에서 아래로 이탈하는 순간 (직전 봉 ≥ 기준, 현재 봉 < 기준)`;
}
return '기준선을 위에서 아래로 이탈하는 순간';
case 'GT':
return left && right
? `${left}이(가) ${right}보다 큰 상태`
: '비교 대상보다 큰 상태';
case 'LT':
return left && right
? `${left}이(가) ${right}보다 작은 상태`
: '비교 대상보다 작은 상태';
case 'GTE':
return left && right
? `${left}이(가) ${right} 이상인 상태`
: '기준선 이상 유지';
case 'LTE':
return left && right
? `${left}이(가) ${right} 이하인 상태`
: '기준선 이하 유지';
case 'SLOPE_UP':
return left
? `${left}이(가) 최근 ${sp}봉 구간에서 상승 추세인 상태`
: `최근 ${sp}봉 구간 상승 추세`;
case 'SLOPE_DOWN':
return left
? `${left}이(가) 최근 ${sp}봉 구간에서 하락 추세인 상태`
: `최근 ${sp}봉 구간 하락 추세`;
case 'ABOVE_CLOUD':
return '종가가 일목균형표 구름대 위에 있는 상태';
case 'BELOW_CLOUD':
return '종가가 일목균형표 구름대 아래에 있는 상태';
case 'CLOUD_BREAK_UP':
return '종가가 구름 상단을 상향 돌파하는 순간';
case 'CLOUD_BREAK_DOWN':
return '종가가 구름 하단을 하향 이탈하는 순간';
default:
if (left && right) {
return `${left}${condVerb(condType)}${right}`;
}
return condVerb(condType);
}
}
function buildTitle(left: string, right: string, condType: string): string {
const verb = condVerb(condType);
if (condType === 'SLOPE_UP' || condType === 'SLOPE_DOWN') {
return left ? `${left} ${verb}` : verb;
}
if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN'].includes(condType)) {
return CONDITION_LABEL[condType] ?? verb;
}
if (left && right) {
return `${left} ${verb} ${right}`;
}
if (left) return `${left} ${verb}`;
if (right) return `${verb} ${right}`;
return verb;
}
/** 프리셋 patch + DEF 기준으로 목록 제목·설명을 구체화 */
export function enrichConditionPresetLabels(
preset: PresetLike,
indicatorType: string,
def: DefType,
signalType: 'buy' | 'sell',
): PresetLike {
const patch = preset.patch;
const condType = patch.conditionType ?? 'GT';
if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN', 'IN_CLOUD'].includes(condType)) {
const label = CONDITION_LABEL[condType] ?? preset.label;
const description = buildDescription('', '', condType);
return { ...preset, label, description };
}
const mockCond: ConditionDSL = {
indicatorType,
conditionType: condType,
leftField: patch.leftField,
rightField: patch.rightField,
};
const left = patch.leftField && patch.leftField !== 'NONE'
? resolveFieldLabel(indicatorType, patch.leftField, def, signalType, mockCond)
: '';
const right = patch.rightField && patch.rightField !== 'NONE'
? resolveFieldLabel(indicatorType, patch.rightField, def, signalType, mockCond)
: '';
if (!left && !right) {
return preset;
}
return {
...preset,
label: buildTitle(left, right, condType),
description: buildDescription(left, right, condType, patch.slopePeriod),
};
}
+61 -10
View File
@@ -11,6 +11,10 @@ import {
THRESHOLD_HL_UNDER,
} from './thresholdSymbols';
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
import { enrichConditionPresetLabels } from './conditionPresetLabels';
import { getPathBreakoutPresets, isCompoundConditionPreset } from './conditionPathPresets';
import { genId } from './strategyEditorShared';
import type { LogicNode } from './strategyTypes';
export type ConditionPresetGraph =
| 'cross_up'
@@ -21,7 +25,12 @@ export type ConditionPresetGraph =
| 'slope_down'
| 'dual_cross_up'
| 'dual_cross_down'
| 'between';
| 'between'
| 'path_first_cross'
| 'path_pullback_recross'
| 'path_deep_recovery';
export type ConditionPresetCategory = 'basic' | 'path';
export type ConditionPreset = {
id: string;
@@ -29,6 +38,9 @@ export type ConditionPreset = {
description: string;
graph: ConditionPresetGraph;
patch: Partial<ConditionDSL>;
/** 복합(AND) — 여러 조건을 한 번에 구성 */
compound?: Partial<ConditionDSL>[];
category?: ConditionPresetCategory;
tags?: ('buy' | 'sell')[];
};
@@ -75,7 +87,7 @@ function compare(
function slope(up: boolean, left: string, tags?: ('buy' | 'sell')[]): ConditionPreset {
return {
id: up ? 'slope_up' : 'slope_down',
id: up ? `slope_up_${left}` : `slope_down_${left}`,
label: up ? '상승 기울기' : '하락 기울기',
description: up ? '최근 구간 상승 추세' : '최근 구간 하락 추세',
graph: up ? 'slope_up' : 'slope_down',
@@ -269,15 +281,25 @@ export function getConditionPresetsForIndicator(
signalType: 'buy' | 'sell',
): ConditionPreset[] {
const builder = PRESET_BUILDERS[indicatorType];
if (!builder) return [];
const all = builder(def, signalType);
if (!builder) return getPathBreakoutPresets(indicatorType, def, signalType);
const basic = builder(def, signalType);
const path = getPathBreakoutPresets(indicatorType, def, signalType);
const all = [...basic, ...path];
const seen = new Set<string>();
return all.filter(p => {
const key = `${p.patch.conditionType}:${p.patch.leftField}:${p.patch.rightField}:${p.label}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return all
.filter(p => {
if (isCompoundConditionPreset(p)) {
return !seen.has(p.id);
}
const key = `${p.patch.conditionType}:${p.patch.leftField}:${p.patch.rightField}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(p => {
if (isCompoundConditionPreset(p)) return p;
return enrichConditionPresetLabels(p, indicatorType, def, signalType);
});
}
export function applyConditionPreset(
@@ -303,3 +325,32 @@ export function applyConditionPreset(
const inherited = initConditionPeriodsInherit(cond.indicatorType, merged, def);
return applyCondTypeDefaults(inherited, condType, def);
}
/** 복합 프리셋 → AND 노드 (동일 id 유지) */
export function applyCompoundConditionPreset(
nodeId: string,
cond: ConditionDSL,
preset: ConditionPreset,
def: DefType,
): LogicNode {
const patches = preset.compound ?? [];
const children: LogicNode[] = patches.map(patch => {
const single: ConditionPreset = {
id: `${preset.id}_part`,
label: '',
description: '',
graph: preset.graph,
patch,
};
return {
id: genId(),
type: 'CONDITION',
condition: applyConditionPreset(cond, single, def),
};
});
return {
id: nodeId,
type: 'AND',
children,
};
}