Files
goldenChart/frontend/src/utils/strategyEditorShared.tsx
T
2026-05-24 13:15:26 +09:00

745 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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',
DONCHIAN: 'DonchianChannels',
};
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 'DONCHIAN': return condOpts([
{ value:'DC_UPPER_10', label:'상단(10일 최고가)' },
{ value:'DC_UPPER_20', label:'상단(20일 최고가)' },
{ value:'DC_UPPER_55', label:'상단(55일 최고가)' },
{ value:'DC_MIDDLE_20', label:'중심(20일)' },
{ value:'DC_LOWER_10', label:'하단(10일 최저가)' },
{ value:'DC_LOWER_20', label:'하단(20일 최저가)' },
{ value:'DC_LOWER_55', label:'하단(55일 최저가)' },
{ value:'CLOSE_PRICE', label:'종가' },
{ value:'LOW_PRICE', label:'저가' },
{ value:'HIGH_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 } => {
// 저장된 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' },
DONCHIAN: signalType === 'buy'
? { l:'CLOSE_PRICE', r:'DC_UPPER_20' }
: { l:'CLOSE_PRICE', r:'DC_LOWER_20' },
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,
},
};
};