전략편집기 복합지표 기능 반영

This commit is contained in:
Macbook
2026-05-25 01:34:52 +09:00
parent ca249ad318
commit 20dd257c29
21 changed files with 1781 additions and 183 deletions
+219
View File
@@ -0,0 +1,219 @@
/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */
import type { ConditionDSL } from './strategyTypes';
import { getCompositeDefaultPeriods, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef } from './compositeIndicators';
export interface IndicatorPeriodDef {
rsiPeriod: number;
cciPeriod: number;
adxPeriod: number;
williamsR: number;
trixPeriod: number;
vrPeriod: number;
newPsy: number;
investPsy: number;
}
const VALUE_FIELD_PREFIX: Record<string, string> = {
RSI: 'RSI_VALUE',
CCI: 'CCI_VALUE',
ADX: 'ADX_VALUE',
WILLIAMS_R: 'WILLIAMS_R_VALUE',
TRIX: 'TRIX_VALUE',
VR: 'VR_VALUE',
PSYCHOLOGICAL: 'PSY_VALUE',
NEW_PSYCHOLOGICAL: 'PSY_VALUE',
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
};
export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number {
switch (indicatorType) {
case 'RSI': return def.rsiPeriod;
case 'CCI': return def.cciPeriod;
case 'ADX': return def.adxPeriod;
case 'WILLIAMS_R': return def.williamsR;
case 'TRIX': return def.trixPeriod;
case 'VR': return def.vrPeriod;
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': return def.newPsy;
case 'INVEST_PSYCHOLOGICAL': return def.investPsy;
default: return 14;
}
}
function parseFieldPeriod(field?: string): number | null {
if (!field) return null;
const m = field.match(/_(\d+)$/);
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
/** 조건 노드의 RSI 등 값(좌측) 기간 */
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
if (cond.composite) {
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
if (fromField) return fromField;
}
if (cond.period && cond.period > 0) return cond.period;
const fromField = parseFieldPeriod(cond.leftField);
if (fromField) return fromField;
return getDefaultIndicatorPeriod(cond.indicatorType, def);
}
export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
if (cond.composite) {
if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod;
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
if (fromField) return fromField;
}
const fromField = parseFieldPeriod(cond.rightField);
if (fromField) return fromField;
return getDefaultIndicatorPeriod(cond.indicatorType, def);
}
export function usesValuePeriodField(cond: ConditionDSL): boolean {
if (cond.composite) return true;
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
if (!prefix) return false;
const lf = cond.leftField ?? '';
return lf === prefix || lf.startsWith(`${prefix}_`);
}
export function parseThresholdField(field?: string): number | null {
if (!field?.startsWith('K_')) return null;
const n = parseFloat(field.slice(2));
return Number.isFinite(n) ? n : null;
}
export function thresholdField(value: number): string {
return `K_${value}`;
}
export function hasEditableThreshold(cond: ConditionDSL): boolean {
return parseThresholdField(cond.rightField) != null
|| parseThresholdField(cond.leftField) != null;
}
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
const field = side === 'left' ? cond.leftField : cond.rightField;
return parseThresholdField(field);
}
export function setConditionValuePeriod(
cond: ConditionDSL,
period: number,
_def: IndicatorPeriodDef,
): ConditionDSL {
const p = Math.max(1, Math.min(500, Math.round(period)));
if (cond.composite) {
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
}
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
const next: ConditionDSL = { ...cond, period: p };
if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) {
next.leftField = `${prefix}_${p}`;
}
return next;
}
export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL {
const p = Math.max(1, Math.min(500, Math.round(period)));
if (!cond.composite) return cond;
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p });
}
export function setConditionThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
value: number,
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
const current = cond[fieldKey];
if (!current?.startsWith('K_')) return cond;
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
};
}
export function initConditionPeriods(
indicatorType: string,
condition: ConditionDSL,
def: IndicatorPeriodDef,
): ConditionDSL {
if (condition.composite) return condition;
const prefix = VALUE_FIELD_PREFIX[indicatorType];
if (!prefix) return condition;
const period = getDefaultIndicatorPeriod(indicatorType, def);
const lf = condition.leftField ?? '';
if (lf === prefix || lf.startsWith(`${prefix}_`)) {
return { ...condition, period: condition.period ?? period, leftField: `${prefix}_${condition.period ?? period}` };
}
return { ...condition, period: condition.period ?? period };
}
export function hasNodeSettings(cond: ConditionDSL): boolean {
return usesValuePeriodField(cond)
|| hasEditableThreshold(cond)
|| !!cond.composite;
}
const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120];
export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] {
const base = getDefaultIndicatorPeriod(indicatorType, def);
return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b);
}
/** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */
export function getCompositePeriodPresetOptions(
indicatorType: string,
def: IndicatorPeriodDef,
side: 'left' | 'right',
): number[] {
const { short, long } = getCompositeDefaultPeriods(indicatorType, def as CompositePeriodDef);
const base = side === 'left' ? short : long;
return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b);
}
export function getThresholdPresetOptions(indicatorType: string): number[] {
switch (indicatorType) {
case 'RSI':
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL':
case 'INVEST_PSYCHOLOGICAL':
return [20, 25, 30, 50, 70, 75, 80];
case 'STOCHASTIC':
return [20, 50, 80];
case 'CCI':
return [-200, -100, -50, 0, 50, 100, 150, 200];
case 'WILLIAMS_R':
return [-80, -50, -20];
case 'ADX':
case 'DMI':
return [20, 25, 30, 40, 50];
case 'VR':
return [70, 100, 150, 200, 250];
default:
return [-100, -50, 0, 50, 100];
}
}
export function getThresholdBounds(indicatorType: string): { min: number; max: number } {
switch (indicatorType) {
case 'RSI':
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL':
case 'INVEST_PSYCHOLOGICAL':
case 'STOCHASTIC':
return { min: 0, max: 100 };
case 'WILLIAMS_R':
return { min: -100, max: 0 };
case 'CCI':
return { min: -500, max: 500 };
default:
return { min: -1000, max: 1000 };
}
}