전략편집기 복합지표 기능 반영
This commit is contained in:
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user