전략편집기 수정

This commit is contained in:
Macbook
2026-05-25 17:56:25 +09:00
parent aac6454724
commit 02d14e4b2b
39 changed files with 1974 additions and 288 deletions
+198 -39
View File
@@ -6,21 +6,36 @@ import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTy
import {
compositeDisplayName,
compositeElementLabel,
compositeFieldLabel,
makeCompositeCondition,
normalizeCompositeCondition,
parsePeriodFromCompositeField,
syncCompositeFields,
} from '../utils/compositeIndicators';
import ComboNumberInput from '../components/strategyEditor/ComboNumberInput';
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
import {
getCompositeFieldOpts,
getCompositePeriodPresetOptions,
getConditionRightPeriod,
getConditionThreshold,
getConditionValuePeriod,
getDefaultIndicatorPeriod,
getPeriodPresetOptions,
getThresholdBounds,
getThresholdPresetOptions,
initConditionPeriods,
isValuePeriodFieldStored,
parseThresholdField,
resolveFieldCustomKind,
getCompositeLeftCandleType,
getCompositeRightCandleType,
setCompositeLeftCandleType,
setCompositeRightCandleType,
setConditionThreshold,
setConditionRightPeriod,
setConditionValuePeriod,
} from '../utils/conditionPeriods';
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
export interface StrategyDto {
id: number;
@@ -305,7 +320,7 @@ export const getFieldOpts = (
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 (${rsiP}일)` },
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -325,7 +340,7 @@ export const getFieldOpts = (
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 (${cciP}일)` },
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -334,7 +349,7 @@ export const getFieldOpts = (
case 'ADX': {
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
return condOpts([
{ value:'ADX_VALUE', label:`ADX (${DEF.adxPeriod}일)` },
{ value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -343,7 +358,7 @@ export const getFieldOpts = (
case 'TRIX': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'TRIX_VALUE', label:`TRIX (${DEF.trixPeriod}일)` },
{ value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` },
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
{ value:kv(mid), label:`중앙선(${mid})` },
]);
@@ -387,7 +402,7 @@ export const getFieldOpts = (
case 'NEW_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([
{ value:'PSY_VALUE', label:`심리도 (${DEF.newPsy}일)` },
{ value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -396,7 +411,7 @@ export const getFieldOpts = (
case 'INVEST_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([
{ value:'INVEST_PSY_VALUE', label:`투자심리도 (${DEF.investPsy}일)` },
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -405,7 +420,7 @@ export const getFieldOpts = (
case 'WILLIAMS_R': {
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
return condOpts([
{ value:'WILLIAMS_R_VALUE', label:`Williams %R (${DEF.williamsR}일)` },
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -414,7 +429,7 @@ export const getFieldOpts = (
case 'BWI': {
const { over, mid, under } = th({ over:80, mid:50, under:20 });
return condOpts([
{ value:'BWI_VALUE', label:`BWI (${DEF.bwiPeriod}일)` },
{ value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -423,7 +438,7 @@ export const getFieldOpts = (
case 'VR': {
const { over, mid, under } = th({ over:200, mid:100, under:50 });
return condOpts([
{ value:'VR_VALUE', label:`VR (${DEF.vrPeriod}일)` },
{ value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
@@ -586,8 +601,16 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
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 leftOpts = getCompositeFieldOpts(c.indicatorType, 1, DEF, c);
const rightOpts = getCompositeFieldOpts(c.indicatorType, 2, DEF, c);
const L = leftOpts.find(o => o.value === c.leftField)?.label
?? compositeFieldLabel(c.indicatorType, 1, c.leftPeriod);
const R = rightOpts.find(o => o.value === c.rightField)?.label
?? compositeFieldLabel(c.indicatorType, 2, c.rightPeriod);
const lCt = getCompositeLeftCandleType(c);
const rCt = getCompositeRightCandleType(c);
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
return `${c.indicatorType} - ${L} ${C} ${R}${tf}`;
}
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
@@ -734,11 +757,59 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
};
if (normalized.composite) {
const leftOpts = getCompositeFieldOpts(normalized.indicatorType, 1, def, normalized);
const rightOpts = getCompositeFieldOpts(normalized.indicatorType, 2, def, normalized);
const leftField = normalized.leftField ?? leftOpts[0]?.value ?? 'NONE';
const rightField = normalized.rightField ?? rightOpts[0]?.value ?? 'NONE';
const leftIsPreset = leftOpts.some(o => o.value === leftField);
const rightIsPreset = rightOpts.some(o => o.value === rightField);
const leftPeriod = getConditionValuePeriod(normalized, def);
const rightPeriod = getConditionRightPeriod(normalized, def);
const periodPresetsLeft = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left');
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
const handleCompositeField = (side: 'left' | 'right', field: string) => {
const p = parsePeriodFromCompositeField(normalized.indicatorType, field);
if (p == null) return;
if (side === 'left') {
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
} else {
onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field }));
}
};
const leftCt = getCompositeLeftCandleType(normalized);
const rightCt = getCompositeRightCandleType(normalized);
return (
<div className="sp-cond-editor sp-cond-editor--composite">
<div className="sp-cond-composite-badge"> · {compositeDisplayName(normalized.indicatorType)}</div>
<div className="sp-cond-row-composite-tf">
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} </label>
<select
className="sp-cond-sel"
value={leftCt}
onChange={e => onChange(setCompositeLeftCandleType(normalized, e.target.value))}
>
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="sp-cond-field">
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} </label>
<select
className="sp-cond-sel"
value={rightCt}
onChange={e => onChange(setCompositeRightCandleType(normalized, e.target.value))}
>
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div className="sp-cond-row4">
<div className="sp-cond-field">
<label className="sp-cond-lbl"> </label>
@@ -752,23 +823,35 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
</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')}
<label className="sp-cond-lbl">1</label>
<ComboFieldSelect
options={leftOpts}
fieldValue={leftField}
isPresetField={leftIsPreset}
customKind="period"
customNumber={leftPeriod}
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 1, leftPeriod)}
numberPresets={periodPresetsLeft}
min={1}
max={500}
onChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
onFieldChange={f => handleCompositeField('left', f)}
onCustomNumberChange={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')}
<label className="sp-cond-lbl">2</label>
<ComboFieldSelect
options={rightOpts}
fieldValue={rightField}
isPresetField={rightIsPreset}
customKind="period"
customNumber={rightPeriod}
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 2, rightPeriod)}
numberPresets={periodPresetsRight}
min={1}
max={500}
onChange={p => onChange(setConditionRightPeriod(normalized, p))}
onFieldChange={f => handleCompositeField('right', f)}
onCustomNumberChange={p => onChange(setConditionRightPeriod(normalized, p))}
/>
</div>
<div className="sp-cond-field">
@@ -803,20 +886,57 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
{/* 조건대상1 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl">1</label>
<select className="sp-cond-sel"
value={isHoldType ? 'NONE' : getLeftValue()}
onChange={e => onChange({ ...cond, leftField: e.target.value })}>
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ComboFieldSelect
options={fieldOpts}
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
isPresetField={!isHoldType && (
fieldOpts.some(o => o.value === normalized.leftField)
|| (fieldOpts.some(o => o.value === getLeftValue())
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
customNumber={getConditionValuePeriod(normalized, def)}
numberPresets={getPeriodPresetOptions(normalized.indicatorType, def)}
min={1}
max={500}
disabled={isHoldType}
onFieldChange={v => {
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[normalized.indicatorType];
if (base && v === base) {
const p = getConditionValuePeriod(normalized, def);
onChange({ ...normalized, leftField: `${base}_${p}`, period: p });
} else {
onChange({ ...normalized, leftField: v });
}
}}
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
/>
</div>
{/* 조건대상2 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl">2</label>
<select className="sp-cond-sel"
value={rightValue}
onChange={e => handleRight(e.target.value)}>
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ComboFieldSelect
options={fieldOpts}
fieldValue={rightValue}
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
customNumber={getConditionThreshold(normalized, 'right')
?? parseThresholdField(normalized.rightField)
?? 0}
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
min={getThresholdBounds(normalized.indicatorType).min}
max={getThresholdBounds(normalized.indicatorType).max}
allowDecimal={normalized.indicatorType === 'CCI'}
disabled={isSlopeType || isHoldType}
onFieldChange={handleRight}
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v))}
/>
</div>
{/* 조건 */}
<div className="sp-cond-field">
@@ -858,7 +978,38 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
};
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
export type MakeNodeOptions = { composite?: boolean };
export type MakeNodeOptions = {
composite?: boolean;
/** 단일 보조지표 기간 오버라이드 */
period?: number;
/** 복합지표 단기·장기 기간 오버라이드 */
leftPeriod?: number;
rightPeriod?: number;
leftCandleType?: string;
rightCandleType?: string;
};
/** 팔레트 드래그 payload → makeNode 옵션 */
export function makeNodeOptionsFromPalette(data: {
composite?: boolean;
period?: number;
shortPeriod?: number;
longPeriod?: number;
leftCandleType?: string;
rightCandleType?: string;
}): MakeNodeOptions | undefined {
if (data.composite) {
return {
composite: true,
leftPeriod: data.shortPeriod,
rightPeriod: data.longPeriod,
leftCandleType: data.leftCandleType,
rightCandleType: data.rightCandleType,
};
}
if (data.period != null) return { period: data.period };
return undefined;
}
export const makeNode = (
type: string,
@@ -869,20 +1020,28 @@ export const makeNode = (
): 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),
};
let condition = makeCompositeCondition(value, signalType, DEF);
if (options.leftPeriod != null || options.rightPeriod != null
|| options.leftCandleType != null || options.rightCandleType != null) {
condition = syncCompositeFields({
...condition,
leftPeriod: options.leftPeriod ?? condition.leftPeriod,
rightPeriod: options.rightPeriod ?? condition.rightPeriod,
leftCandleType: options.leftCandleType ?? condition.leftCandleType,
rightCandleType: options.rightCandleType ?? condition.rightCandleType,
});
}
return { id: genId(), type: 'CONDITION', condition };
}
const def = getDefaultFields(value, signalType, DEF);
const period = options?.period ?? getDefaultIndicatorPeriod(value, 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),
period,
};
return {
id: genId(), type: 'CONDITION',