전략편집기 메뉴 추가

This commit is contained in:
Macbook
2026-05-24 06:20:18 +09:00
parent bbe82e510b
commit ea39f7df27
24 changed files with 4897 additions and 29 deletions
+729
View File
@@ -0,0 +1,729 @@
/** Shared strategy editor utilities — extracted from StrategyPage */
import React from 'react';
import type { IndicatorConfig } from '../types/index';
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
export interface StrategyDto {
id: number;
name: string;
description?: string;
buyCondition?: LogicNode | null;
sellCondition?: LogicNode | null;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface ValidationResult {
isValid: boolean;
errors: { message: string }[];
warnings: { message: string }[];
}
export type DefType = typeof DEF_DEFAULTS;
let _cnt = 0;
export const genId = () => `n_${++_cnt}_${Date.now()}`;
export const STORAGE_KEY = 'gc_strat_v2';
export const loadStratsLocal = (): StrategyDto[] => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
};
export const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
interface HLThresh { over?: number; mid?: number; under?: number; }
/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */
const DEF_DEFAULTS = {
rsiPeriod: 9,
macdFast: 12, macdSlow: 26, macdSignal: 9,
cciPeriod: 13,
stochK: 12, stochD: 5,
adxPeriod: 14,
trixPeriod: 12, trixSignal: 9,
dmiPeriod: 14,
obvPeriod: 9, obvSignal: 9,
bbPeriod: 20, bbStdDev: 2,
ichTenkan: 9, ichKijun: 26, ichSenkouB: 52,
newPsy: 12, investPsy: 10,
williamsR: 14,
bwiPeriod: 20,
vrPeriod: 10,
volOscShort: 5, volOscLong: 10,
dispUltra: 5, dispShort: 10, dispMid: 20, dispLong: 60,
maLines: [5, 10, 20, 60, 120] as number[],
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
hlThresh: {
RSI: { over: 70, mid: 50, under: 30 },
STOCHASTIC: { over: 80, mid: 50, under: 20 },
CCI: { over: 100, mid: 0, under: -100 },
WILLIAMS_R: { over: -20, mid: -50, under: -80 },
BWI: { over: 80, mid: 50, under: 20 },
VR: { over: 200, mid: 100, under: 50 },
PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
NEW_PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
INVEST_PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
MACD: { mid: 0 },
ADX: { over: 40, mid: 25, under: 20 },
DMI: { over: 30, mid: 20, under: 10 },
TRIX: { mid: 0 },
VOLUME_OSC: { mid: 0 },
} as Record<string, HLThresh>,
};
/**
* 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 ?? {};
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
typeof params[key] === 'number' ? (params[key] as number) : fallback;
// ── 각 지표별 실제 레지스트리 type명 + params 키 ──────────────────────────
// RSI → type:'RSI', params.length
const rsi = p('RSI');
// MACD → type:'MACD', params.fastLength / slowLength / signalLength
const macd = p('MACD');
// CCI → type:'CCI', params.length
const cci = p('CCI');
// Stochastic → type:'Stochastic', params.kLength / dSmoothing
const stoch = p('Stochastic');
// ADX → type:'ADX', params.adxSmoothing / diLength
const adx = p('ADX');
// TRIX → type:'TRIX', params.length / signalLength
const trix = p('TRIX');
// DMI → type:'DMI', params.diLength / adxSmoothing
const dmi = p('DMI');
// BollingerBands→ type:'BollingerBands', params.length / mult
const bb = p('BollingerBands');
// IchimokuCloud → type:'IchimokuCloud', params.conversionPeriods / basePeriods / laggingSpan2Periods
const ich = p('IchimokuCloud');
// Williams %R → type:'WilliamsPercentRange', params.length
const wr = p('WilliamsPercentRange');
const vo = p('VolumeOscillator');
const vrInd = p('VR');
const disp = p('Disparity');
const nPsy = p('Psychological') ?? p('NewPsychological');
const iPsy = p('InvestPsychological');
const obvP = p('OBV');
// SMA (MA lines)→ type:'SMA', params keys depend on smaConfig
const sma = p('SMA');
// MA 기간 배열: SMA 설정에서 period1~period11 추출 (smaConfig 구조)
let maLines = DEF_DEFAULTS.maLines;
const smaPeriods = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
.map(i => sma[`period${i}`])
.filter(v => typeof v === 'number' && (v as number) > 0) as number[];
if (smaPeriods.length >= 2) maLines = smaPeriods;
// ── hline 임계값 추출 ───────────────────────────────────────────────────────
// DSL 지표타입 → 레지스트리 type명 매핑
const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI',
STOCHASTIC: 'Stochastic',
CCI: 'CCI',
WILLIAMS_R: 'WilliamsPercentRange',
MACD: 'MACD',
DMI: 'DMI',
ADX: 'ADX',
TRIX: 'TRIX',
VOLUME_OSC: 'VolumeOscillator',
VR: 'VR',
DISPARITY: 'Disparity',
PSYCHOLOGICAL: 'Psychological',
NEW_PSYCHOLOGICAL: 'Psychological',
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
OBV: 'OBV',
BOLLINGER: 'BollingerBands',
};
const extractThresh = (dslType: string): HLThresh => {
const regType = DSL_TO_REGISTRY[dslType];
if (!regType) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
const config = activeIndicators.find(i => i.type === regType);
const defHl = getIndicatorDef(regType)?.hlines ?? [];
const hlines = config?.hlines ?? defHl;
if (hlines.length === 0) return DEF_DEFAULTS.hlThresh[dslType] ?? {};
const prices = hlines.map(h => h.price);
const find = (lbl: string) => hlines.find(h =>
(h.label ?? getHLineLabel(h.price, prices)) === lbl
);
const result: HLThresh = {};
const ov = find('과열선'); if (ov) result.over = ov.price;
const md = find('중앙선') ?? find('기준선'); if (md) result.mid = md.price;
const un = find('침체선'); if (un) result.under = un.price;
// 값이 하나도 없으면 기본값 사용
if (result.over === undefined && result.mid === undefined && result.under === undefined)
return DEF_DEFAULTS.hlThresh[dslType] ?? {};
return {
...DEF_DEFAULTS.hlThresh[dslType],
...result,
};
};
const hlThresh: Record<string, HLThresh> = {};
for (const dslType of Object.keys(DEF_DEFAULTS.hlThresh)) {
hlThresh[dslType] = extractThresh(dslType);
}
return {
rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod),
macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast),
macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow),
macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal),
cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod),
stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK),
stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD),
adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod),
trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod),
trixSignal: num(trix, 'signalLength', DEF_DEFAULTS.trixSignal),
dmiPeriod: num(dmi, 'diLength', num(dmi, 'length', DEF_DEFAULTS.dmiPeriod)),
obvPeriod: num(obvP, 'maLength', DEF_DEFAULTS.obvPeriod),
obvSignal: num(obvP, 'maLength', DEF_DEFAULTS.obvSignal),
bbPeriod: num(bb, 'length', DEF_DEFAULTS.bbPeriod),
bbStdDev: num(bb, 'mult', DEF_DEFAULTS.bbStdDev),
ichTenkan: num(ich, 'conversionPeriods', DEF_DEFAULTS.ichTenkan),
ichKijun: num(ich, 'basePeriods', DEF_DEFAULTS.ichKijun),
ichSenkouB: num(ich, 'laggingSpan2Periods', DEF_DEFAULTS.ichSenkouB),
newPsy: num(nPsy, 'length', DEF_DEFAULTS.newPsy),
investPsy: num(iPsy, 'length', DEF_DEFAULTS.investPsy),
williamsR: num(wr, 'length', DEF_DEFAULTS.williamsR),
bwiPeriod: DEF_DEFAULTS.bwiPeriod, // BWI는 레지스트리 미등록
vrPeriod: num(vrInd, 'length', DEF_DEFAULTS.vrPeriod),
volOscShort: num(vo, 'shortLength', DEF_DEFAULTS.volOscShort),
volOscLong: num(vo, 'longLength', DEF_DEFAULTS.volOscLong),
dispUltra: num(disp, 'ultraLength', DEF_DEFAULTS.dispUltra),
dispShort: num(disp, 'shortLength', DEF_DEFAULTS.dispShort),
dispMid: num(disp, 'midLength', DEF_DEFAULTS.dispMid),
dispLong: num(disp, 'longLength', DEF_DEFAULTS.dispLong),
maLines,
hlThresh,
};
}
/**
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
* 예: 70 → "K_70", -100 → "K_-100"
* 백엔드 StrategyDslToTa4jAdapter에서 "K_" 접두어를 파싱하여 FixedDecimalIndicator 생성.
*/
const kv = (price: number) => `K_${price}`;
// ─── 공통 조건 옵션 ───────────────────────────────────────────────────────────
const COND_OPTIONS = [
{ v: 'GT', l: '초과 (>)' },
{ v: 'LT', l: '미만 (<)' },
{ v: 'GTE', l: '이상 (>=)' },
{ v: 'LTE', l: '이하 (<=)' },
{ v: 'EQ', l: '같음 (==)' },
{ v: 'NEQ', l: '다름 (!=)' },
{ v: 'CROSS_UP', l: '상향 돌파' },
{ v: 'CROSS_DOWN', l: '하향 돌파' },
{ v: 'SLOPE_UP', l: '상승 기울기' },
{ v: 'SLOPE_DOWN', l: '하락 기울기' },
{ v: 'DIFF_GT', l: '차이 > 값' },
{ v: 'DIFF_LT', l: '차이 < 값' },
{ v: 'HOLD_N_DAYS', l: 'N일 연속 유지' },
];
const ICHIMOKU_CONDS = [
...COND_OPTIONS,
{ v: 'ABOVE_CLOUD', l: '구름 위' },
{ v: 'BELOW_CLOUD', l: '구름 아래' },
{ v: 'IN_CLOUD', l: '구름 안' },
{ v: 'CLOUD_BREAK_UP', l: '구름 상향 돌파' },
{ v: 'CLOUD_BREAK_DOWN', l: '구름 하향 돌파' },
{ v: 'SPAN1_GT_SPAN2', l: '선행스팬1 > 선행스팬2' },
{ v: 'SPAN1_LT_SPAN2', l: '선행스팬1 < 선행스팬2' },
{ v: 'SPAN1_CROSS_UP_SPAN2', l: '선행스팬1 상향돌파 선행스팬2' },
{ v: 'SPAN1_CROSS_DOWN_SPAN2', l: '선행스팬1 하향돌파 선행스팬2' },
{ v: 'LAGGING_GT_PRICE', l: '후행스팬 > 가격' },
{ v: 'LAGGING_LT_PRICE', l: '후행스팬 < 가격' },
];
// ─── 지표별 fieldOptions 빌더 ────────────────────────────────────────────────
type Opt = { value: string; label: string };
const NONE: Opt = { value: 'NONE', label: '선택안함' };
export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => {
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
/** 해당 지표의 hline 임계값 — 없으면 기본값 */
const th = (defaults: HLThresh): Required<HLThresh> => {
const saved = DEF.hlThresh[ind] ?? {};
return {
over: saved.over ?? defaults.over ?? 100,
mid: saved.mid ?? defaults.mid ?? 0,
under: saved.under ?? defaults.under ?? -100,
};
};
switch(ind) {
case 'RSI': {
const { over, mid, under } = th({ over:70, mid:50, under:30 });
return condOpts([
{ value:'RSI_VALUE', label:`RSI 값(${DEF.rsiPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'STOCHASTIC': {
const { over, mid, under } = th({ over:80, mid:50, under:20 });
return condOpts([
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'CCI': {
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
return condOpts([
{ value:'CCI_VALUE', label:`CCI 값(${DEF.cciPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'ADX': {
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
return condOpts([
{ value:'ADX_VALUE', label:`ADX 값(${DEF.adxPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'TRIX': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'TRIX_VALUE', label:`TRIX 값(${DEF.trixPeriod}일)` },
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
{ value:kv(mid), label:`중앙선(${mid})` },
]);
}
case 'DMI': {
const { over, mid, under } = th({ over:30, mid:20, under:10 });
return condOpts([
{ value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` },
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
{ value:kv(over), label:`과열(${over})` },
{ value:kv(mid), label:`중간값(${mid})` },
{ value:kv(under), label:`침체(${under})` },
]);
}
case 'OBV': return condOpts([
{ value:'OBV_LINE', label:`OBV선(${DEF.obvPeriod}일)` },
{ value:'OBV_SIGNAL', label:`신호선(${DEF.obvSignal}일)` },
]);
case 'VOLUME': return condOpts([
{ value:'VOLUME_VALUE', label:'거래량' },
{ value:'VOLUME_MA', label:'거래량 이동평균' },
]);
case 'MA': return [
NONE,
...DEF.maLines.map((p, i) => ({ value:`MA${p}`, label:`MA${i+1}(${p}일)` })),
{ value:'CLOSE_PRICE', label:'종가' },
];
case 'EMA': return [
NONE,
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
{ value:'CLOSE_PRICE', label:'종가' },
];
case 'DISPARITY': return condOpts([
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
{ value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` },
{ value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` },
{ value:kv(100), label:'중앙선(100)' },
]);
case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([
{ value:'PSY_VALUE', label:`심리도 값(${DEF.newPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'INVEST_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([
{ value:'INVEST_PSY_VALUE', label:`투자심리도 값(${DEF.investPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
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:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'BWI': {
const { over, mid, under } = th({ over:80, mid:50, under:20 });
return condOpts([
{ value:'BWI_VALUE', label:`BWI 값(${DEF.bwiPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'VR': {
const { over, mid, under } = th({ over:200, mid:100, under:50 });
return condOpts([
{ value:'VR_VALUE', label:`VR 값(${DEF.vrPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` },
]);
}
case 'VOLUME_OSC': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
{ value:kv(mid), label:`중앙선(${mid})` },
]);
}
case 'MACD': {
const { mid } = th({ mid:0 });
return condOpts([
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
{ value:'HISTOGRAM', label:'히스토그램' },
{ value:kv(mid), label:`중앙선(${mid})` },
]);
}
case 'BOLLINGER': return condOpts([
{ value:'UPPER_BAND', label:`상단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value:'MIDDLE_BAND', label:`중심선(${DEF.bbPeriod}일)` },
{ value:'LOWER_BAND', label:`하단밴드(${DEF.bbPeriod}일, σ${DEF.bbStdDev})` },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'OPEN_PRICE', label:'시가' },
{ value:'HIGH_PRICE', label:'고가' },
{ value:'LOW_PRICE', label:'저가' },
]);
case 'ICHIMOKU': return condOpts([
{ value:'CONVERSION_LINE', label:`전환선(${DEF.ichTenkan}일)` },
{ value:'BASE_LINE', label:`기준선(${DEF.ichKijun}일)` },
{ value:'LEADING_SPAN1', label:'선행스팬1' },
{ value:'LEADING_SPAN2', label:`선행스팬2(${DEF.ichSenkouB}일)` },
{ value:'LAGGING_SPAN', label:'후행스팬' },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'OPEN_PRICE', label:'시가' },
{ value:'HIGH_PRICE', label:'고가' },
{ value:'LOW_PRICE', label:'저가' },
]);
default: return [NONE];
}
};
const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
void signalType;
// 저장된 hline 과열선 값 우선 사용
const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def);
const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def);
const adxMid = DEF.hlThresh['ADX']?.mid ?? 25;
const map: Record<string, {l:string;r:string}> = {
RSI: { l:'RSI_VALUE', r: over(70) },
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
CCI: { l:'CCI_VALUE', r: over(100) },
ADX: { l:'ADX_VALUE', r: kv(adxMid) },
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
DMI: { l:'PDI', r:'MDI' },
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: over(75) },
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: over(-20) },
BWI: { l:'BWI_VALUE', r: over(80) },
VR: { l:'VR_VALUE', r: over(200) },
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: mid(0) },
MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' },
BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' },
ICHIMOKU: { l:'CONVERSION_LINE', r:'BASE_LINE' },
};
return map[ind] ?? { l:'NONE', r:'NONE' };
};
// conditionType 변경 시 자동 필드 조정
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
const updated = { ...cond, conditionType: newCondType };
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);
if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) {
if (!isValid(updated.leftField)) updated.leftField = def.l;
if (!isValid(updated.rightField)) updated.rightField = def.r;
if (['DIFF_GT','DIFF_LT'].includes(newCondType)) updated.compareValue = updated.compareValue ?? 0;
} else if (['SLOPE_UP','SLOPE_DOWN'].includes(newCondType)) {
if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE';
updated.slopePeriod = updated.slopePeriod ?? 3;
} else if (newCondType === 'HOLD_N_DAYS') {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
updated.holdDays = updated.holdDays ?? 3;
}
// Ichimoku 특수처리
if (cond.indicatorType === 'ICHIMOKU') {
if (['ABOVE_CLOUD','BELOW_CLOUD','IN_CLOUD','CLOUD_BREAK_UP','CLOUD_BREAK_DOWN'].includes(newCondType)) {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
} else if (['SPAN1_GT_SPAN2','SPAN1_LT_SPAN2','SPAN1_CROSS_UP_SPAN2','SPAN1_CROSS_DOWN_SPAN2'].includes(newCondType)) {
updated.leftField = 'LEADING_SPAN1'; updated.rightField = 'LEADING_SPAN2';
} else if (['LAGGING_GT_PRICE','LAGGING_LT_PRICE'].includes(newCondType)) {
updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE';
}
}
return updated;
};
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v;
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 = condLabel(c.conditionType);
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
return `${c.indicatorType} - ${L} ${C}`;
}
if (node.type === 'AND') {
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
}
if (node.type === 'OR') {
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
return parts.length === 0 ? '(OR 그룹)' : parts.join('\nOR ');
}
if (node.type === 'NOT') {
const c = node.children?.[0];
return c ? `NOT (${nodeToText(c, DEF)})` : '(NOT 그룹)';
}
return '';
};
export const nodeToFormula = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
if (!node) return '';
if (node.type === 'CONDITION') return `(${nodeToText(node, DEF)})`;
if (node.type === 'AND') {
const parts = (node.children ?? []).map(c => nodeToFormula(c, DEF));
return parts.length === 0 ? '(빈 AND)' : `(${parts.join(' AND ')})`;
}
if (node.type === 'OR') {
const parts = (node.children ?? []).map(c => nodeToFormula(c, DEF));
return parts.length === 0 ? '(빈 OR)' : `(${parts.join(' OR ')})`;
}
if (node.type === 'NOT') {
const c = node.children?.[0];
return c ? `NOT ${nodeToFormula(c, DEF)}` : '(빈 NOT)';
}
return '';
};
// ─── 트리 조작 ────────────────────────────────────────────────────────────────
export const updateNode = (root: LogicNode, id: string, fn: (n: LogicNode) => LogicNode): LogicNode => {
if (root.id === id) return fn(root);
if (!root.children) return root;
return { ...root, children: root.children.map(c => updateNode(c, id, fn)) };
};
export const deleteNode = (root: LogicNode, id: string): LogicNode | null => {
if (root.id === id) return null;
if (!root.children) return root;
const children = root.children.map(c => deleteNode(c, id)).filter(Boolean) as LogicNode[];
return { ...root, children };
};
export const addChild = (root: LogicNode, parentId: string, child: LogicNode): LogicNode => {
if (root.id === parentId) {
if (root.type === 'NOT' && (root.children?.length ?? 0) >= 1) { alert('NOT는 자식 1개만 가능합니다.'); return root; }
return { ...root, children: [...(root.children ?? []), child] };
}
if (!root.children) return root;
return { ...root, children: root.children.map(c => addChild(c, parentId, child)) };
};
/** 팔레트·터치로 루트에 추가할 때 — OR/AND 그룹이면 자식으로 붙이고, 단일 조건만 AND로 묶음 */
export const mergeAtRoot = (root: LogicNode | null, newNode: LogicNode, isOperator: boolean): LogicNode => {
if (!root) return newNode;
if (isOperator) return { ...newNode, children: [root] };
if (root.type === 'AND' || root.type === 'OR') {
return { ...root, children: [...(root.children ?? []), newNode] };
}
return { id: genId(), type: 'AND', children: [root, newNode] };
};
// ─── 검증 ─────────────────────────────────────────────────────────────────────
export const validateTree = (node: LogicNode): ValidationResult => {
const errors: { message: string }[] = [];
const warnings: { message: string }[] = [];
const check = (n: LogicNode) => {
if (n.type === 'AND' || n.type === 'OR') {
if (!n.children || n.children.length === 0) errors.push({ message: `${n.type} 노드에 자식 조건이 없습니다.` });
else if (n.children.length === 1) warnings.push({ message: `${n.type} 노드에 자식이 1개뿐입니다.` });
n.children?.forEach(check);
} else if (n.type === 'NOT') {
if (!n.children || n.children.length === 0) errors.push({ message: 'NOT 노드에 자식 조건이 없습니다.' });
else if (n.children.length > 1) errors.push({ message: 'NOT 노드는 자식이 1개여야 합니다.' });
n.children?.forEach(check);
} else if (n.type === 'CONDITION') {
if (!n.condition) errors.push({ message: '조건 내용이 비어있습니다.' });
}
};
check(node);
return { isValid: errors.length === 0, errors, warnings };
};
// ─── 조건 편집 UI (인라인 4컬럼) ─────────────────────────────────────────────
interface CondEditorProps {
cond: ConditionDSL;
signalType: 'buy'|'sell';
onChange: (c: ConditionDSL) => void;
def: DefType;
}
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 isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
const defaults = getDefaultFields(cond.indicatorType, signalType, def);
const getLeftValue = () => isValid(cond.leftField) ? cond.leftField! : defaults.l;
const getRightValue = () => isValid(cond.rightField) ? cond.rightField! : 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 rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(cond, 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,
OVERBOUGHT_100: 100, NEUTRAL_0: 0, OVERSOLD_NEG100: -100,
ADX_25: 25, ADX_50: 50,
OVERBOUGHT_75: 75, OVERSOLD_25: 25,
OVERBOUGHT_NEG20: -20, NEUTRAL_NEG50: -50, OVERSOLD_NEG80: -80,
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
ZERO_LINE: 0,
};
const upd: ConditionDSL = { ...cond, rightField: v };
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
onChange(upd);
};
return (
<div className="sp-cond-editor">
{/* 4컬럼 row */}
<div className="sp-cond-row4">
{/* 캔들 범위 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl"> </label>
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
onChange={e => onChange({ ...cond, 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>
{/* 조건대상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>
</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>
</div>
{/* 조건 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl"></label>
<select className="sp-cond-sel"
value={cond.conditionType}
onChange={e => handleCondType(e.target.value)}>
{condOpts.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
</div>
</div>
{/* 추가 필드 */}
{isDiffType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl"></label>
<input type="number" className="sp-cond-num"
value={cond.compareValue ?? ''} placeholder="예: 0.5"
onChange={e => onChange({ ...cond, compareValue: parseFloat(e.target.value) || 0 })} />
</div>
)}
{isSlopeType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl"> ()</label>
<input type="number" className="sp-cond-num" min={1} max={100}
value={cond.slopePeriod ?? ''} placeholder="예: 5"
onChange={e => onChange({ ...cond, slopePeriod: parseInt(e.target.value) || 3 })} />
</div>
)}
{isHoldType && (
<div className="sp-cond-extra">
<label className="sp-cond-lbl"> ()</label>
<input type="number" className="sp-cond-num" min={1} max={30}
value={cond.holdDays ?? ''} placeholder="예: 3"
onChange={e => onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} />
</div>
)}
</div>
);
};
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
export const makeNode = (type: string, value: string, signalType: 'buy'|'sell', DEF: DefType): LogicNode => {
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
const def = getDefaultFields(value, signalType, DEF);
return {
id: genId(), type: 'CONDITION',
condition: {
indicatorType: value, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
leftField: def.l, rightField: def.r, candleRange: 1,
},
};
};