전략편집기 메뉴 추가
This commit is contained in:
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'backtest', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'settings',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'backtest', 'notifications', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
chart: '실시간차트',
|
||||
paper: '모의투자',
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
notifications: '알림',
|
||||
settings: '설정',
|
||||
@@ -65,6 +66,10 @@ export function canAccessMenu(
|
||||
menuId: string,
|
||||
): boolean {
|
||||
if (!permissions) return menuId === 'chart';
|
||||
if (menuId === 'strategy-editor') {
|
||||
if (permissions['strategy-editor'] === true) return true;
|
||||
return permissions.strategy === true;
|
||||
}
|
||||
return permissions[menuId] === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,666 @@
|
||||
import type { Connection, Edge, Node } from '@xyflow/react';
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { nodeToText, type DefType } from './strategyEditorShared';
|
||||
|
||||
export const START_NODE_ID = '__strategy_start__';
|
||||
|
||||
const H_GAP = 280;
|
||||
const V_GAP = 130;
|
||||
|
||||
export type HandleSide = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
const OPPOSE_SIDE: Record<HandleSide, HandleSide> = {
|
||||
top: 'bottom',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
right: 'left',
|
||||
};
|
||||
|
||||
export type StrategyFlowNodeData = {
|
||||
logicNode?: LogicNode;
|
||||
label?: string;
|
||||
def?: DefType;
|
||||
signalTab?: 'buy' | 'sell';
|
||||
selected?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
||||
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
||||
onDragLeaveTarget?: (targetId: string) => void;
|
||||
dragOver?: boolean;
|
||||
activeSourceSide?: HandleSide | null;
|
||||
/** 논리 트리상 자식을 가질 수 있으면 true (START·AND·OR·빈 NOT) */
|
||||
canSourceConnect?: boolean;
|
||||
/** 논리 트리상 부모를 가질 수 있으면 true (조건·연산자, START 제외) */
|
||||
canTargetConnect?: boolean;
|
||||
/** 전략 트리 미연결 — 편집기에만 표시, 수식·저장 제외 */
|
||||
isOrphan?: boolean;
|
||||
};
|
||||
|
||||
export function isOrphanNode(orphans: LogicNode[], id: string): boolean {
|
||||
return orphans.some(o => o.id === id);
|
||||
}
|
||||
|
||||
export function findLogicNode(
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
id: string,
|
||||
): LogicNode | null {
|
||||
if (id === START_NODE_ID) return null;
|
||||
const inTree = findNodeInTree(root, id);
|
||||
if (inTree) return inTree;
|
||||
return orphans.find(o => o.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** 팔레트 드롭 시 트리에 즉시 연결되는 대상(START·트리 내 노드) */
|
||||
export function isPaletteConnectTarget(
|
||||
anchorId: string,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
): boolean {
|
||||
if (isOrphanNode(orphans, anchorId)) return false;
|
||||
if (anchorId === START_NODE_ID) return true;
|
||||
if (!root) return false;
|
||||
return !!findNodeInTree(root, anchorId);
|
||||
}
|
||||
|
||||
/** 노드별 연결 가능 역할 (DSL 트리 규칙) */
|
||||
export function getNodeConnectionCaps(
|
||||
nodeId: string,
|
||||
logicNode: LogicNode | undefined,
|
||||
): { canSource: boolean; canTarget: boolean } {
|
||||
if (nodeId === START_NODE_ID) return { canSource: true, canTarget: false };
|
||||
if (!logicNode) return { canSource: false, canTarget: false };
|
||||
switch (logicNode.type) {
|
||||
case 'CONDITION':
|
||||
return { canSource: false, canTarget: true };
|
||||
case 'NOT':
|
||||
return { canSource: (logicNode.children?.length ?? 0) < 1, canTarget: true };
|
||||
case 'AND':
|
||||
case 'OR':
|
||||
return { canSource: true, canTarget: true };
|
||||
default:
|
||||
return { canSource: false, canTarget: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */
|
||||
export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean {
|
||||
if (anchorId === START_NODE_ID) return true;
|
||||
if (!root) return false;
|
||||
const node = findNodeInTree(root, anchorId);
|
||||
if (!node || node.type === 'CONDITION') return false;
|
||||
return getNodeConnectionCaps(anchorId, node).canSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* DSL 부모→자식 방향으로 연결 가능한지 (source=부모, target=자식).
|
||||
* React Flow Loose 모드에서는 conn.source/target이 드래그 방향에 따라 뒤집힐 수 있음.
|
||||
*/
|
||||
export function isValidStrategyConnectionDirected(
|
||||
parentId: string,
|
||||
childId: string,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[] = [],
|
||||
): boolean {
|
||||
if (!parentId || !childId || parentId === childId) return false;
|
||||
if (childId === START_NODE_ID) return false;
|
||||
|
||||
const childOrphan = orphans.find(o => o.id === childId);
|
||||
const parentOrphan = orphans.find(o => o.id === parentId);
|
||||
|
||||
if (childOrphan) {
|
||||
if (parentId === START_NODE_ID) return true;
|
||||
const parentNode = findNodeInTree(root, parentId);
|
||||
if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parentOrphan) {
|
||||
if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false;
|
||||
const childNode = findNodeInTree(root, childId);
|
||||
if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false;
|
||||
if (root && isDescendant(root, childId, parentId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!root) return false;
|
||||
|
||||
const parentNode = parentId === START_NODE_ID ? null : findNodeInTree(root, parentId);
|
||||
const childNode = findNodeInTree(root, childId);
|
||||
if (parentId !== START_NODE_ID && !parentNode) return false;
|
||||
if (!childNode) return false;
|
||||
|
||||
const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined);
|
||||
const childCaps = getNodeConnectionCaps(childId, childNode);
|
||||
if (!parentCaps.canSource || !childCaps.canTarget) return false;
|
||||
|
||||
if (parentId !== START_NODE_ID && isDescendant(root, childId, parentId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 수동 연결·재연결 — 드래그 방향과 무관하게 부모→자식이 성립하면 유효 */
|
||||
export function isValidStrategyConnection(
|
||||
conn: Connection,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[] = [],
|
||||
): boolean {
|
||||
const { source, target } = conn;
|
||||
if (!source || !target) return false;
|
||||
return (
|
||||
isValidStrategyConnectionDirected(source, target, root, orphans)
|
||||
|| isValidStrategyConnectionDirected(target, source, root, orphans)
|
||||
);
|
||||
}
|
||||
|
||||
/** onConnect용 — 부모·자식 ID (드래그 시작 방향 우선, 불가 시 반대 방향) */
|
||||
export function resolveStrategyConnection(
|
||||
conn: Connection,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[] = [],
|
||||
): { parentId: string; childId: string } | null {
|
||||
const { source, target } = conn;
|
||||
if (!source || !target) return null;
|
||||
if (isValidStrategyConnectionDirected(source, target, root, orphans)) {
|
||||
return { parentId: source, childId: target };
|
||||
}
|
||||
if (isValidStrategyConnectionDirected(target, source, root, orphans)) {
|
||||
return { parentId: target, childId: source };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNodeDimensions(nodeId: string): { w: number; h: number } {
|
||||
return nodeId === START_NODE_ID
|
||||
? { w: FLOW_START_W, h: FLOW_START_H }
|
||||
: { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
||||
}
|
||||
|
||||
/** 커서/드롭 위치가 노드 중심 기준 어느 방향인지 */
|
||||
export function pickSideFromPoint(
|
||||
flowPos: { x: number; y: number },
|
||||
nodePos: { x: number; y: number },
|
||||
dim: { w: number; h: number },
|
||||
): HandleSide {
|
||||
const cx = nodePos.x + dim.w / 2;
|
||||
const cy = nodePos.y + dim.h / 2;
|
||||
const dx = flowPos.x - cx;
|
||||
const dy = flowPos.y - cy;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left';
|
||||
return dy >= 0 ? 'bottom' : 'top';
|
||||
}
|
||||
|
||||
/** 드롭이 앵커의 좌/우/상/하 쪽이면 해당 면 연결점 활성화 */
|
||||
export function connectionSidesFromDrop(
|
||||
anchorPos: { x: number; y: number },
|
||||
anchorDim: { w: number; h: number },
|
||||
dropPos: { x: number; y: number },
|
||||
): { sourceSide: HandleSide; targetSide: HandleSide; sourceHandle: string; targetHandle: string } {
|
||||
const sourceSide = pickSideFromPoint(dropPos, anchorPos, anchorDim);
|
||||
const targetSide = OPPOSE_SIDE[sourceSide];
|
||||
return {
|
||||
sourceSide,
|
||||
targetSide,
|
||||
sourceHandle: `s-${sourceSide}`,
|
||||
targetHandle: `t-${targetSide}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnPositionNearAnchor(
|
||||
anchorPos: { x: number; y: number },
|
||||
anchorDim: { w: number; h: number },
|
||||
side: HandleSide,
|
||||
): { x: number; y: number } {
|
||||
const gap = 48;
|
||||
const child = { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
||||
switch (side) {
|
||||
case 'right':
|
||||
return { x: anchorPos.x + anchorDim.w + gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 };
|
||||
case 'left':
|
||||
return { x: anchorPos.x - child.w - gap, y: anchorPos.y + anchorDim.h / 2 - child.h / 2 };
|
||||
case 'bottom':
|
||||
return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y + anchorDim.h + gap };
|
||||
case 'top':
|
||||
return { x: anchorPos.x + anchorDim.w / 2 - child.w / 2, y: anchorPos.y - child.h - gap };
|
||||
}
|
||||
}
|
||||
|
||||
export function findNodeAtFlowPosition(
|
||||
flowPos: { x: number; y: number },
|
||||
nodes: { id: string; position: { x: number; y: number } }[],
|
||||
padding = 16,
|
||||
): { id: string; position: { x: number; y: number } } | null {
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
const dim = getNodeDimensions(n.id);
|
||||
if (
|
||||
flowPos.x >= n.position.x - padding
|
||||
&& flowPos.x <= n.position.x + dim.w + padding
|
||||
&& flowPos.y >= n.position.y - padding
|
||||
&& flowPos.y <= n.position.y + dim.h + padding
|
||||
) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 미연결 고아 노드를 드래그해 다른 노드 위에 올렸을 때 연결 방향 해석 */
|
||||
export function resolveOrphanDragConnection(
|
||||
orphanId: string,
|
||||
orphanPos: { x: number; y: number },
|
||||
nodes: { id: string; position: { x: number; y: number } }[],
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
): { parentId: string; childId: string } | null {
|
||||
const dim = getNodeDimensions(orphanId);
|
||||
const center = {
|
||||
x: orphanPos.x + dim.w / 2,
|
||||
y: orphanPos.y + dim.h / 2,
|
||||
};
|
||||
const hit = findNodeAtFlowPosition(
|
||||
center,
|
||||
nodes.filter(n => n.id !== orphanId),
|
||||
);
|
||||
if (!hit) return null;
|
||||
if (isOrphanNode(orphans, hit.id)) return null;
|
||||
|
||||
return resolveStrategyConnection(
|
||||
{ source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null },
|
||||
root,
|
||||
orphans,
|
||||
) ?? resolveStrategyConnection(
|
||||
{ source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null },
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
}
|
||||
|
||||
/** 드래그 연결 시 부모→자식 핸들 방향 */
|
||||
export function buildConnectionFromPositions(
|
||||
parentId: string,
|
||||
childId: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): Connection {
|
||||
const parentPos = positions.get(parentId) ?? { x: 0, y: 0 };
|
||||
const childPos = positions.get(childId) ?? { x: 0, y: 0 };
|
||||
const parentDim = getNodeDimensions(parentId);
|
||||
const childDim = getNodeDimensions(childId);
|
||||
const childCenter = {
|
||||
x: childPos.x + childDim.w / 2,
|
||||
y: childPos.y + childDim.h / 2,
|
||||
};
|
||||
const { sourceHandle, targetHandle } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
|
||||
return {
|
||||
source: parentId,
|
||||
target: childId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
export type EdgeHandleBinding = {
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
manual?: boolean;
|
||||
};
|
||||
|
||||
export const FLOW_NODE_W = 200;
|
||||
export const FLOW_NODE_H = 72;
|
||||
export const FLOW_START_W = 100;
|
||||
export const FLOW_START_H = 56;
|
||||
|
||||
function nodeCenter(
|
||||
id: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): { x: number; y: number } {
|
||||
const p = positions.get(id) ?? { x: 0, y: 0 };
|
||||
const w = id === START_NODE_ID ? FLOW_START_W : FLOW_NODE_W;
|
||||
const h = id === START_NODE_ID ? FLOW_START_H : FLOW_NODE_H;
|
||||
return { x: p.x + w / 2, y: p.y + h / 2 };
|
||||
}
|
||||
|
||||
/** 두 노드 상대 위치에 따라 최적 연결 방향(핸들) 선택 */
|
||||
export function pickHandleSides(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): { sourceHandle: string; targetHandle: string } {
|
||||
const s = nodeCenter(sourceId, positions);
|
||||
const t = nodeCenter(targetId, positions);
|
||||
const dx = t.x - s.x;
|
||||
const dy = t.y - s.y;
|
||||
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
return dx >= 0
|
||||
? { sourceHandle: 's-right', targetHandle: 't-left' }
|
||||
: { sourceHandle: 's-left', targetHandle: 't-right' };
|
||||
}
|
||||
return dy >= 0
|
||||
? { sourceHandle: 's-bottom', targetHandle: 't-top' }
|
||||
: { sourceHandle: 's-top', targetHandle: 't-bottom' };
|
||||
}
|
||||
|
||||
export function applyEdgeHandles(
|
||||
edges: Edge[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
saved: Map<string, EdgeHandleBinding>,
|
||||
): Edge[] {
|
||||
return edges.map(edge => {
|
||||
const binding = saved.get(edge.id);
|
||||
if (binding?.manual && binding.sourceHandle && binding.targetHandle) {
|
||||
return { ...edge, sourceHandle: binding.sourceHandle, targetHandle: binding.targetHandle };
|
||||
}
|
||||
const picked = pickHandleSides(edge.source, edge.target, positions);
|
||||
const sourceHandle = binding?.sourceHandle ?? picked.sourceHandle;
|
||||
const targetHandle = binding?.targetHandle ?? picked.targetHandle;
|
||||
saved.set(edge.id, { sourceHandle, targetHandle, manual: binding?.manual });
|
||||
return { ...edge, sourceHandle, targetHandle };
|
||||
});
|
||||
}
|
||||
|
||||
function layoutTree(
|
||||
node: LogicNode,
|
||||
parentId: string,
|
||||
depth: number,
|
||||
siblingIndex: number,
|
||||
siblingCount: number,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
dragOverId: string | null,
|
||||
): void {
|
||||
const yBase = 220;
|
||||
const y = yBase + (siblingIndex - (siblingCount - 1) / 2) * V_GAP;
|
||||
const pos = positions.get(node.id) ?? { x: 80 + depth * H_GAP, y };
|
||||
const flowType = node.type === 'CONDITION' ? 'condition' : 'logic';
|
||||
const caps = getNodeConnectionCaps(node.id, node);
|
||||
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: flowType,
|
||||
position: pos,
|
||||
data: {
|
||||
logicNode: node,
|
||||
label: nodeToText(node, def),
|
||||
def,
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
dragOver: dragOverId === node.id,
|
||||
canSourceConnect: caps.canSource,
|
||||
canTargetConnect: caps.canTarget,
|
||||
} satisfies StrategyFlowNodeData,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
edges.push({
|
||||
id: `${parentId}-${node.id}`,
|
||||
source: parentId,
|
||||
target: node.id,
|
||||
type: 'strategy',
|
||||
animated: node.type === 'CONDITION',
|
||||
className: 'se-flow-edge',
|
||||
});
|
||||
|
||||
const children = node.children ?? [];
|
||||
children.forEach((child, i) => {
|
||||
layoutTree(child, node.id, depth + 1, i, children.length, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
|
||||
});
|
||||
}
|
||||
|
||||
function appendOrphanFlowNodes(
|
||||
orphans: LogicNode[],
|
||||
nodes: Node[],
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
): void {
|
||||
let orphanY = 80;
|
||||
for (const node of orphans) {
|
||||
const pos = positions.get(node.id) ?? { x: 40, y: orphanY };
|
||||
orphanY += V_GAP;
|
||||
const flowType = node.type === 'CONDITION' ? 'condition' : 'logic';
|
||||
const caps = getNodeConnectionCaps(node.id, node);
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: flowType,
|
||||
position: pos,
|
||||
className: 'se-flow-node-orphan',
|
||||
data: {
|
||||
logicNode: node,
|
||||
label: nodeToText(node, def),
|
||||
def,
|
||||
signalTab,
|
||||
onDelete: callbacks.onDelete,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
canSourceConnect: caps.canSource,
|
||||
canTargetConnect: caps.canTarget,
|
||||
isOrphan: true,
|
||||
} satisfies StrategyFlowNodeData,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function logicNodeToFlow(
|
||||
root: LogicNode | null,
|
||||
def: DefType,
|
||||
signalTab: 'buy' | 'sell',
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||
dragOverId: string | null = null,
|
||||
orphans: LogicNode[] = [],
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
nodes.push({
|
||||
id: START_NODE_ID,
|
||||
type: 'start',
|
||||
position: positions.get(START_NODE_ID) ?? { x: 0, y: 220 },
|
||||
data: {
|
||||
label: 'START',
|
||||
signalTab,
|
||||
onDropTarget: callbacks.onDropTarget,
|
||||
onDragOverTarget: callbacks.onDragOverTarget,
|
||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||
canSourceConnect: true,
|
||||
canTargetConnect: false,
|
||||
} satisfies StrategyFlowNodeData,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
});
|
||||
|
||||
if (root) {
|
||||
layoutTree(root, START_NODE_ID, 1, 0, 1, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
|
||||
}
|
||||
|
||||
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function getIndicatorPeriodLabel(indicator: string, def: DefType): string {
|
||||
switch (indicator) {
|
||||
case 'RSI': return String(def.rsiPeriod);
|
||||
case 'MACD': return `${def.macdFast}/${def.macdSlow}`;
|
||||
case 'CCI': return String(def.cciPeriod);
|
||||
case 'STOCHASTIC': return `${def.stochK}/${def.stochD}`;
|
||||
case 'ADX': return String(def.adxPeriod);
|
||||
case 'MA': return String(def.maLines[0] ?? 5);
|
||||
case 'EMA': return String(def.maLines[0] ?? 5);
|
||||
case 'BOLLINGER': return String(def.bbPeriod);
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 트리에서 노드 추출 (부모에서 제거 후 반환) */
|
||||
export function extractNode(root: LogicNode, id: string): { tree: LogicNode | null; node: LogicNode | null } {
|
||||
if (root.id === id) return { tree: null, node: root };
|
||||
if (!root.children?.length) return { tree: root, node: null };
|
||||
|
||||
for (let i = 0; i < root.children.length; i++) {
|
||||
const child = root.children[i];
|
||||
if (child.id === id) {
|
||||
return {
|
||||
tree: { ...root, children: root.children.filter(c => c.id !== id) },
|
||||
node: child,
|
||||
};
|
||||
}
|
||||
const sub = extractNode(child, id);
|
||||
if (sub.node) {
|
||||
const newChildren = root.children.map(c => (c.id === child.id ? sub.tree! : c)).filter(Boolean) as LogicNode[];
|
||||
return { tree: { ...root, children: newChildren }, node: sub.node };
|
||||
}
|
||||
}
|
||||
return { tree: root, node: null };
|
||||
}
|
||||
|
||||
export function findNodeInTree(root: LogicNode | null, id: string): LogicNode | null {
|
||||
if (!root) return null;
|
||||
if (root.id === id) return root;
|
||||
for (const c of root.children ?? []) {
|
||||
const hit = findNodeInTree(c, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 트리에서 nodeId의 직계 부모 ID (루트의 부모는 START) */
|
||||
export function findParentIdInTree(
|
||||
root: LogicNode,
|
||||
nodeId: string,
|
||||
parentId: string = START_NODE_ID,
|
||||
): string | null {
|
||||
if (root.id === nodeId) return parentId;
|
||||
for (const c of root.children ?? []) {
|
||||
const hit = findParentIdInTree(c, nodeId, root.id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 연결 해제 시 미연결 목록으로 펼침 — Logic Expression·캔버스 모두 트리에서 제외 */
|
||||
export function subtreeToFlatOrphans(node: LogicNode): LogicNode[] {
|
||||
switch (node.type) {
|
||||
case 'CONDITION':
|
||||
return [{ ...node, children: undefined }];
|
||||
case 'NOT': {
|
||||
const inner = node.children?.[0];
|
||||
return inner ? subtreeToFlatOrphans(inner) : [];
|
||||
}
|
||||
case 'AND':
|
||||
case 'OR': {
|
||||
const kids = node.children ?? [];
|
||||
if (kids.length === 0) return [];
|
||||
return kids.flatMap(subtreeToFlatOrphans);
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 자식이 없는 AND/OR/NOT 제거 — 수식에서 (빈 AND) 등 방지 */
|
||||
export function pruneEmptyGates(root: LogicNode | null): LogicNode | null {
|
||||
if (!root) return null;
|
||||
if (root.type === 'CONDITION') return root;
|
||||
|
||||
const prunedChildren = (root.children ?? [])
|
||||
.map(c => pruneEmptyGates(c))
|
||||
.filter((c): c is LogicNode => c != null);
|
||||
|
||||
if (root.type === 'NOT') {
|
||||
if (prunedChildren.length === 0) return null;
|
||||
return { ...root, children: [prunedChildren[0]] };
|
||||
}
|
||||
|
||||
if (root.type === 'AND' || root.type === 'OR') {
|
||||
if (prunedChildren.length === 0) return null;
|
||||
return { ...root, children: prunedChildren };
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function mergeOrphans(existing: LogicNode[], added: LogicNode[]): LogicNode[] {
|
||||
const seen = new Set(existing.map(o => o.id));
|
||||
const next = [...existing];
|
||||
for (const o of added) {
|
||||
if (!seen.has(o.id)) {
|
||||
seen.add(o.id);
|
||||
next.push(o);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveDisconnectEndpoints(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode,
|
||||
orphans: LogicNode[],
|
||||
): { parentId: string; childId: string } | null {
|
||||
const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null };
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
||||
if (resolved) return resolved;
|
||||
|
||||
if (sourceId === START_NODE_ID && root.id === targetId) {
|
||||
return { parentId: START_NODE_ID, childId: targetId };
|
||||
}
|
||||
|
||||
const parentInTree = findParentIdInTree(root, targetId);
|
||||
if (parentInTree === sourceId) {
|
||||
return { parentId: sourceId, childId: targetId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isDescendant(root: LogicNode, ancestorId: string, nodeId: string): boolean {
|
||||
const ancestor = findNodeInTree(root, ancestorId);
|
||||
if (!ancestor) return false;
|
||||
return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId;
|
||||
}
|
||||
|
||||
/** 연결선 제거 — 자식 서브트리를 트리에서 분리·미연결(고아)로 → Logic Expression 제외 */
|
||||
export function disconnectTreeEdge(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode | null,
|
||||
orphans: LogicNode[],
|
||||
): { root: LogicNode | null; orphans: LogicNode[] } {
|
||||
if (!root) return { root, orphans };
|
||||
|
||||
const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans);
|
||||
if (!endpoints) return { root, orphans };
|
||||
|
||||
const { parentId, childId } = endpoints;
|
||||
if (childId === START_NODE_ID) return { root, orphans };
|
||||
|
||||
const childInTree = root.id === childId || !!findNodeInTree(root, childId);
|
||||
if (!childInTree) return { root, orphans };
|
||||
|
||||
if (parentId === START_NODE_ID && root.id === childId) {
|
||||
return {
|
||||
root: null,
|
||||
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)),
|
||||
};
|
||||
}
|
||||
|
||||
const { tree, node } = extractNode(root, childId);
|
||||
if (!node) return { root, orphans };
|
||||
|
||||
return {
|
||||
root: pruneEmptyGates(tree),
|
||||
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,26 @@ export function getSignalTypeKo(type: 'BUY' | 'SELL'): string {
|
||||
return type === 'BUY' ? '매수' : '매도';
|
||||
}
|
||||
|
||||
const CANDLE_TYPE_KO: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1d': '일봉',
|
||||
'1D': '일봉',
|
||||
};
|
||||
|
||||
/** 전략 체크·시그널 평가에 사용된 분봉 (한글) */
|
||||
export function formatCandleTypeKo(candleType?: string | null): string {
|
||||
const raw = (candleType ?? '1m').trim();
|
||||
if (!raw) return '1분봉';
|
||||
const lower = raw.toLowerCase();
|
||||
return CANDLE_TYPE_KO[raw] ?? CANDLE_TYPE_KO[lower] ?? raw;
|
||||
}
|
||||
|
||||
export function getSignalHeadline(item: TradeSignalDisplaySource): string {
|
||||
const korean = getKoreanName(item.market);
|
||||
const { coin } = parseMarket(item.market);
|
||||
@@ -47,8 +67,7 @@ export function getSignalHeadline(item: TradeSignalDisplaySource): string {
|
||||
}
|
||||
|
||||
export function getExecutionLabel(executionType?: string, candleType?: string): string {
|
||||
const candle = candleType ?? '1m';
|
||||
const candleKo = candle === '1m' ? '1분봉' : candle;
|
||||
const candleKo = formatCandleTypeKo(candleType);
|
||||
if (executionType === 'REALTIME_TICK') return `실시간 틱 · ${candleKo}`;
|
||||
if (executionType === 'CANDLE_CLOSE') return `봉 마감 · ${candleKo}`;
|
||||
return candleKo;
|
||||
@@ -68,12 +87,12 @@ export function buildSignalDetailRows(item: TradeSignalDisplaySource): Array<{ l
|
||||
const { primary, secondary } = getMarketDisplayLine(item.market);
|
||||
const rows: Array<{ label: string; value: string; highlight?: boolean }> = [
|
||||
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
||||
{ label: '매매 구분', value: `${getSignalTypeKo(item.signalType)} (${item.signalType})`, highlight: true },
|
||||
{ label: '매매 구분', value: getSignalTypeKo(item.signalType), highlight: true },
|
||||
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${item.candleType ?? '1m'}` },
|
||||
];
|
||||
|
||||
if (item.receivedAt && Math.abs(item.receivedAt - item.candleTime * 1000) > 2000) {
|
||||
if (item.receivedAt) {
|
||||
rows.push({ label: '수신 시각', value: formatSignalTime(Math.floor(item.receivedAt / 1000)) });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user