전략편집기 수정
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user