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

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
+173 -26
View File
@@ -3,6 +3,24 @@ import React from 'react';
import type { IndicatorConfig } from '../types/index';
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
import {
compositeDisplayName,
compositeElementLabel,
makeCompositeCondition,
normalizeCompositeCondition,
syncCompositeFields,
} from '../utils/compositeIndicators';
import ComboNumberInput from '../components/strategyEditor/ComboNumberInput';
import {
getCompositePeriodPresetOptions,
getConditionRightPeriod,
getConditionValuePeriod,
getDefaultIndicatorPeriod,
initConditionPeriods,
parseThresholdField,
setConditionRightPeriod,
setConditionValuePeriod,
} from '../utils/conditionPeriods';
export interface StrategyDto {
id: number;
@@ -76,11 +94,14 @@ const DEF_DEFAULTS = {
/**
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
* (레거시 투자전략 화면용 — 차트 활성 지표와 동기화)
*/
export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
// 레지스트리 type명으로 조회
const p = (registryType: string) =>
activeIndicators.find(i => i.type === registryType)?.params ?? {};
// params 객체 참조 공유 방지
const p = (registryType: string) => {
const raw = activeIndicators.find(i => i.type === registryType)?.params;
return raw ? { ...raw } : {};
};
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
typeof params[key] === 'number' ? (params[key] as number) : fallback;
@@ -208,6 +229,14 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
};
}
/**
* 전략편집기 전용 DEF — 차트 activeIndicators와 무관한 고정 기본값.
* 조건 노드의 기간·임계값은 ConditionDSL(leftPeriod/rightPeriod/period)에만 저장된다.
*/
export function buildStrategyEditorDef(): DefType {
return buildDef([]);
}
/**
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
* 예: 70 → "K_70", -100 → "K_-100"
@@ -251,7 +280,12 @@ const ICHIMOKU_CONDS = [
type Opt = { value: string; label: string };
const NONE: Opt = { value: 'NONE', label: '선택안함' };
export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => {
export const getFieldOpts = (
ind: string,
signalType: 'buy'|'sell',
DEF: DefType,
cond?: ConditionDSL,
): Opt[] => {
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
@@ -269,8 +303,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
switch(ind) {
case 'RSI': {
const { over, mid, under } = th({ over:70, mid:50, under:30 });
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
return condOpts([
{ value:'RSI_VALUE', label:`RSI 값(${DEF.rsiPeriod}일)` },
{ value:'RSI_VALUE', label:`RSI 값(${rsiP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -288,8 +323,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
}
case 'CCI': {
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
return condOpts([
{ value:'CCI_VALUE', label:`CCI 값(${DEF.cciPeriod}일)` },
{ value:'CCI_VALUE', label:`CCI 값(${cciP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -483,6 +519,14 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
// conditionType 변경 시 자동 필드 조정
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
const updated = { ...cond, conditionType: newCondType };
if (cond.composite) {
return syncCompositeFields({
...updated,
composite: true,
leftPeriod: cond.leftPeriod,
rightPeriod: cond.rightPeriod,
});
}
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
const def = getDefaultFields(cond.indicatorType, 'buy', DEF);
@@ -516,14 +560,40 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v;
/** RSI_VALUE_9 → RSI_VALUE (옵션 조회용) */
export function resolveFieldOptionValue(indicatorType: string, field?: string): string | undefined {
if (!field) return field;
const bases: 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',
};
const base = bases[indicatorType];
if (base && (field === base || field.startsWith(`${base}_`))) return base;
return field;
}
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
if (!node) return '';
if (node.type === 'CONDITION' && node.condition) {
const c = node.condition;
const opts = getFieldOpts(c.indicatorType, 'buy', DEF);
const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
const c = normalizeCompositeCondition(node.condition);
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
const C = condLabel(c.conditionType);
if (c.composite && c.leftPeriod && c.rightPeriod) {
const name = compositeDisplayName(c.indicatorType).split(' + ')[0];
return `${c.indicatorType} - ${name}(${c.leftPeriod}) ${C} ${name}(${c.rightPeriod})`;
}
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? c.indicatorType;
const R = opts.find(o => o.value === rv)?.label
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
return `${c.indicatorType} - ${L} ${C}`;
}
@@ -624,24 +694,30 @@ interface CondEditorProps {
}
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def);
const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
const normalized = normalizeCompositeCondition(cond);
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
const defaults = getDefaultFields(cond.indicatorType, signalType, def);
const defaults = getDefaultFields(normalized.indicatorType, signalType, def);
const getLeftValue = () => isValid(cond.leftField) ? cond.leftField! : defaults.l;
const getRightValue = () => isValid(cond.rightField) ? cond.rightField! : defaults.r;
const getLeftValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
return isValid(v) ? v! : defaults.l;
};
const getRightValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.rightField);
return isValid(v) ? v! : defaults.r;
};
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(cond.conditionType);
const isHoldType = cond.conditionType === 'HOLD_N_DAYS';
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(cond.conditionType);
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(cond, newType, def));
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => {
// rightField 변경 시 임계값 자동 설정
const thresholds: Record<string,number> = {
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
@@ -652,11 +728,62 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
ZERO_LINE: 0,
};
const upd: ConditionDSL = { ...cond, rightField: v };
const upd: ConditionDSL = { ...normalized, rightField: v };
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
onChange(upd);
};
if (normalized.composite) {
const leftPeriod = getConditionValuePeriod(normalized, def);
const rightPeriod = getConditionRightPeriod(normalized, def);
return (
<div className="sp-cond-editor sp-cond-editor--composite">
<div className="sp-cond-composite-badge"> · {compositeDisplayName(normalized.indicatorType)}</div>
<div className="sp-cond-row4">
<div className="sp-cond-field">
<label className="sp-cond-lbl"> </label>
<select className="sp-cond-sel" value={normalized.candleRange ?? 1}
onChange={e => onChange({ ...normalized, candleRange: Number(e.target.value) })}>
<option value={1}> </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={5}>5 </option>
</select>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} ()</label>
<ComboNumberInput
value={leftPeriod}
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left')}
min={1}
max={500}
onChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
/>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} ()</label>
<ComboNumberInput
value={rightPeriod}
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right')}
min={1}
max={500}
onChange={p => onChange(setConditionRightPeriod(normalized, p))}
/>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl"></label>
<select className="sp-cond-sel"
value={normalized.conditionType}
onChange={e => handleCondType(e.target.value)}>
{condOpts.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
</div>
</div>
</div>
);
}
return (
<div className="sp-cond-editor">
{/* 4컬럼 row */}
@@ -731,14 +858,34 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
};
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
export const makeNode = (type: string, value: string, signalType: 'buy'|'sell', DEF: DefType): LogicNode => {
export type MakeNodeOptions = { composite?: boolean };
export const makeNode = (
type: string,
value: string,
signalType: 'buy'|'sell',
DEF: DefType,
options?: MakeNodeOptions,
): LogicNode => {
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
if (options?.composite) {
return {
id: genId(),
type: 'CONDITION',
condition: makeCompositeCondition(value, signalType, DEF),
};
}
const def = getDefaultFields(value, signalType, DEF);
const baseCondition: ConditionDSL = {
indicatorType: value,
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
leftField: def.l,
rightField: def.r,
candleRange: 1,
period: getDefaultIndicatorPeriod(value, DEF),
};
return {
id: genId(), type: 'CONDITION',
condition: {
indicatorType: value, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
leftField: def.l, rightField: def.r, candleRange: 1,
},
condition: initConditionPeriods(value, baseCondition, DEF),
};
};