1615 lines
75 KiB
TypeScript
1615 lines
75 KiB
TypeScript
/**
|
||
* 투자전략 화면 — frontend_golden StrategyTreeBuilderPage 완전 재구현
|
||
*
|
||
* 좌우 반전:
|
||
* 원본: [전략구성요소(L)] [편집기(C)] [전략목록탭(R)]
|
||
* 현재: [전략목록탭(L)] [편집기(C)] [전략구성요소(R)]
|
||
*/
|
||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||
import DraggableModalFrame from './DraggableModalFrame';
|
||
import type { Theme, IndicatorConfig } from '../types/index';
|
||
import {
|
||
loadStrategies, saveStrategy, deleteStrategy,
|
||
type StrategyDto as ApiStrategyDto,
|
||
} from '../utils/backendApi';
|
||
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
|
||
import {
|
||
getStrategyTemplates,
|
||
simpleTemplateToNode,
|
||
type StrategyTemplateDef,
|
||
} from '../utils/strategyPresets';
|
||
|
||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION';
|
||
|
||
interface ConditionDSL {
|
||
indicatorType: string;
|
||
conditionType: string;
|
||
leftField?: string;
|
||
rightField?: string;
|
||
targetValue?: number;
|
||
period?: number;
|
||
comparePeriod?: number;
|
||
compareValue?: number;
|
||
slopePeriod?: number;
|
||
holdDays?: number;
|
||
candleRange?: number;
|
||
}
|
||
|
||
interface LogicNode {
|
||
id: string;
|
||
type: LogicNodeType;
|
||
children?: LogicNode[];
|
||
condition?: ConditionDSL;
|
||
description?: string;
|
||
metadata?: string;
|
||
}
|
||
|
||
interface StrategyDto {
|
||
id: number;
|
||
name: string;
|
||
description?: string;
|
||
buyCondition?: LogicNode | null;
|
||
sellCondition?: LogicNode | null;
|
||
enabled: boolean;
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
}
|
||
|
||
interface ValidationResult {
|
||
isValid: boolean;
|
||
errors: { message: string }[];
|
||
warnings: { message: string }[];
|
||
}
|
||
|
||
// ─── 유틸 ─────────────────────────────────────────────────────────────────────
|
||
let _cnt = 0;
|
||
const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||
|
||
const STORAGE_KEY = 'gc_strat_v2';
|
||
const loadStratsLocal = (): StrategyDto[] => {
|
||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||
};
|
||
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>,
|
||
};
|
||
type DefType = typeof DEF_DEFAULTS;
|
||
|
||
/**
|
||
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
|
||
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
|
||
*/
|
||
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: '선택안함' };
|
||
|
||
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;
|
||
|
||
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 '';
|
||
};
|
||
|
||
const nodeToFormula = (node: LogicNode): string => {
|
||
if (!node) return '';
|
||
if (node.type === 'CONDITION') return `(${nodeToText(node)})`;
|
||
if (node.type === 'AND') {
|
||
const parts = (node.children ?? []).map(nodeToFormula);
|
||
return parts.length === 0 ? '(빈 AND)' : `(${parts.join(' AND ')})`;
|
||
}
|
||
if (node.type === 'OR') {
|
||
const parts = (node.children ?? []).map(nodeToFormula);
|
||
return parts.length === 0 ? '(빈 OR)' : `(${parts.join(' OR ')})`;
|
||
}
|
||
if (node.type === 'NOT') {
|
||
const c = node.children?.[0];
|
||
return c ? `NOT ${nodeToFormula(c)}` : '(빈 NOT)';
|
||
}
|
||
return '';
|
||
};
|
||
|
||
// ─── 트리 조작 ────────────────────────────────────────────────────────────────
|
||
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)) };
|
||
};
|
||
|
||
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 };
|
||
};
|
||
|
||
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로 묶음 */
|
||
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] };
|
||
};
|
||
|
||
// ─── 검증 ─────────────────────────────────────────────────────────────────────
|
||
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;
|
||
}
|
||
|
||
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>
|
||
);
|
||
};
|
||
|
||
// ─── 트리 노드 컴포넌트 ───────────────────────────────────────────────────────
|
||
|
||
const NODE_COLORS: Record<string, string> = {
|
||
AND: '#4caf50', OR: '#2196f3', NOT: '#ff9800',
|
||
};
|
||
const IND_COLOR = '#9c27b0';
|
||
|
||
interface TreeNodeProps {
|
||
node: LogicNode;
|
||
signalType: 'buy'|'sell';
|
||
onUpdate: (n: LogicNode) => void;
|
||
onDelete: () => void;
|
||
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string }) => void;
|
||
dragOverId?: string | null;
|
||
setDragOverId?: (id: string | null) => void;
|
||
depth?: number;
|
||
def: DefType;
|
||
}
|
||
|
||
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||
node, signalType, onUpdate, onDelete, onDropOnNode,
|
||
dragOverId, setDragOverId, depth = 0, def,
|
||
}) => {
|
||
const isLogic = ['AND','OR','NOT'].includes(node.type);
|
||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||
const label = node.type === 'CONDITION' && node.condition
|
||
? nodeToText(node, def)
|
||
: node.type === 'AND' ? 'AND (그리고)' : node.type === 'OR' ? 'OR (또는)' : 'NOT (반대)';
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
if (!isLogic) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
setDragOverId?.(node.id);
|
||
};
|
||
const handleDragLeave = () => setDragOverId?.(null);
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
if (!isLogic) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
setDragOverId?.(null);
|
||
try {
|
||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||
onDropOnNode?.(node.id, data);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const handleCondChange = (c: ConditionDSL) => onUpdate({ ...node, condition: c });
|
||
const handleChildUpdate = (childId: string, updated: LogicNode) =>
|
||
onUpdate({ ...node, children: (node.children ?? []).map(c => c.id === childId ? updated : c) });
|
||
const handleChildDelete = (childId: string) =>
|
||
onUpdate({ ...node, children: (node.children ?? []).filter(c => c.id !== childId) });
|
||
|
||
return (
|
||
<div
|
||
className={`sp-tree-node ${dragOverId === node.id ? 'sp-tree-node--over' : ''}`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
{/* 노드 헤더 */}
|
||
<div className="sp-node-head" style={{ borderLeftColor: color }}>
|
||
<span className="sp-node-badge" style={{ background: color }}>
|
||
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
||
</span>
|
||
<span className="sp-node-label">{label}</span>
|
||
<button className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
||
</div>
|
||
|
||
{/* 조건 인라인 편집기 (CONDITION 노드 항상 표시) */}
|
||
{node.type === 'CONDITION' && node.condition && (
|
||
<CondEditor cond={node.condition} signalType={signalType} onChange={handleCondChange} def={def} />
|
||
)}
|
||
|
||
{/* 자식 노드 */}
|
||
{isLogic && (
|
||
<div className="sp-node-children">
|
||
{(node.children ?? []).map(child => (
|
||
<TreeNodeComp
|
||
key={child.id}
|
||
node={child}
|
||
signalType={signalType}
|
||
onUpdate={u => handleChildUpdate(child.id, u)}
|
||
onDelete={() => handleChildDelete(child.id)}
|
||
onDropOnNode={onDropOnNode}
|
||
dragOverId={dragOverId}
|
||
setDragOverId={setDragOverId}
|
||
depth={depth + 1}
|
||
def={def}
|
||
/>
|
||
))}
|
||
{dragOverId === node.id && (
|
||
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── 팔레트 칩 (드래그 가능) ─────────────────────────────────────────────────
|
||
|
||
interface PChipProps {
|
||
type: 'operator'|'indicator';
|
||
value: string;
|
||
label: string;
|
||
color?: string;
|
||
onAdd: () => void;
|
||
}
|
||
|
||
const PChip: React.FC<PChipProps> = ({ type, value, label, color, onAdd }) => {
|
||
const onDragStart = (e: React.DragEvent) => {
|
||
e.dataTransfer.setData('application/json', JSON.stringify({ type, value, label }));
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
};
|
||
return (
|
||
<span draggable className={`sp-chip ${color ? `sp-chip--${color}` : 'sp-chip--ind'}`}
|
||
onDragStart={onDragStart} onClick={onAdd}>
|
||
{label}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
|
||
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,
|
||
},
|
||
};
|
||
};
|
||
|
||
// ─── 메인 페이지 ──────────────────────────────────────────────────────────────
|
||
|
||
interface Props { theme: Theme; activeIndicators?: IndicatorConfig[]; }
|
||
|
||
export const StrategyPage: React.FC<Props> = ({ activeIndicators = [] }) => {
|
||
/** 현재 차트 지표 설정에서 파라미터를 추출한 DEF */
|
||
const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]);
|
||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
const [dbSyncing, setDbSyncing] = useState(false);
|
||
|
||
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
|
||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||
const [currentTab, setCurrentTab] = useState<'buy'|'sell'>('buy');
|
||
const [rightTab, setRightTab] = useState<'list'|'preview'|'alerts'>('list');
|
||
const [paletteTab, setPaletteTab] = useState<'manual'|'auto'>('manual');
|
||
|
||
const [stratName, setStratName] = useState('');
|
||
const [stratDesc, setStratDesc] = useState('');
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [sortOrder, setSortOrder] = useState<'name-asc'|'name-desc'|'date-asc'|'date-desc'>('name-asc');
|
||
|
||
const [saveOpen, setSaveOpen] = useState(false);
|
||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
|
||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||
const showSnack = (msg: string, ok=true) => {
|
||
setSnack({ msg, ok });
|
||
setTimeout(() => setSnack(null), 3000);
|
||
};
|
||
|
||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||
|
||
const buyValidation = useMemo(() => buyCondition ? validateTree(buyCondition) : null, [buyCondition]);
|
||
const sellValidation = useMemo(() => sellCondition ? validateTree(sellCondition) : null, [sellCondition]);
|
||
|
||
const selectedStrategy = strategies.find(s => s.id === selectedId) ?? null;
|
||
|
||
// localStorage 백업
|
||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||
|
||
// 앱 시작 시 DB에서 전략 목록 로드 + localStorage 자동 마이그레이션
|
||
useEffect(() => {
|
||
setDbSyncing(true);
|
||
loadStrategies().then(async list => {
|
||
if (list && list.length > 0) {
|
||
// DB에 전략이 있으면 DB 목록 사용
|
||
const dtos: StrategyDto[] = list.map(s => ({
|
||
id: s.id ?? Date.now(),
|
||
name: s.name,
|
||
description: s.description,
|
||
buyCondition: s.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: s.sellCondition as LogicNode | null ?? null,
|
||
enabled: s.enabled ?? true,
|
||
createdAt: s.createdAt,
|
||
updatedAt: s.updatedAt,
|
||
}));
|
||
setStrategies(dtos);
|
||
} else {
|
||
// DB가 비어있으면 localStorage 전략을 DB로 자동 마이그레이션
|
||
const local = loadStratsLocal();
|
||
if (local.length > 0) {
|
||
console.info('[StrategyPage] DB 전략 없음 → localStorage 전략 DB 마이그레이션 시작:', local.length, '개');
|
||
const migrated: StrategyDto[] = [];
|
||
for (const s of local) {
|
||
try {
|
||
const saved = await saveStrategy({
|
||
name: s.name,
|
||
description: s.description,
|
||
buyCondition: s.buyCondition,
|
||
sellCondition: s.sellCondition,
|
||
enabled: s.enabled ?? true,
|
||
});
|
||
if (saved?.id) {
|
||
migrated.push({
|
||
id: saved.id,
|
||
name: saved.name,
|
||
description: saved.description,
|
||
buyCondition: saved.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: saved.sellCondition as LogicNode | null ?? null,
|
||
enabled: saved.enabled ?? true,
|
||
createdAt: saved.createdAt,
|
||
updatedAt: saved.updatedAt,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('[StrategyPage] 마이그레이션 실패:', s.name, e);
|
||
}
|
||
}
|
||
if (migrated.length > 0) {
|
||
setStrategies(migrated);
|
||
console.info('[StrategyPage] 마이그레이션 완료:', migrated.length, '개');
|
||
}
|
||
}
|
||
}
|
||
}).catch(e => console.warn('[StrategyPage] DB 로드 실패:', e))
|
||
.finally(() => setDbSyncing(false));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const getCurrent = () => currentTab === 'buy' ? buyCondition : sellCondition;
|
||
const setCurrent = (n: LogicNode | null) =>
|
||
currentTab === 'buy' ? setBuyCondition(n) : setSellCondition(n);
|
||
|
||
const sortedStrategies = useMemo(() => {
|
||
const arr = [...strategies];
|
||
arr.sort((a, b) => {
|
||
switch (sortOrder) {
|
||
case 'name-asc': return a.name.localeCompare(b.name, 'ko');
|
||
case 'name-desc': return b.name.localeCompare(a.name, 'ko');
|
||
case 'date-asc': return (a.updatedAt ?? '').localeCompare(b.updatedAt ?? '');
|
||
case 'date-desc': return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
|
||
default: return 0;
|
||
}
|
||
});
|
||
return arr;
|
||
}, [strategies, sortOrder]);
|
||
|
||
// ── 전략 선택 ───────────────────────────────────────────────────────────────
|
||
const handleSelectStrategy = (s: StrategyDto) => {
|
||
setSelectedId(s.id);
|
||
setStratName(s.name);
|
||
setStratDesc(s.description ?? '');
|
||
setBuyCondition(s.buyCondition ?? null);
|
||
setSellCondition(s.sellCondition ?? null);
|
||
setCurrentTab('buy');
|
||
};
|
||
|
||
const handleNew = () => {
|
||
setSelectedId(null);
|
||
setStratName(''); setStratDesc('');
|
||
setBuyCondition(null); setSellCondition(null);
|
||
setCurrentTab('buy');
|
||
};
|
||
|
||
// ── 저장 ────────────────────────────────────────────────────────────────────
|
||
const handleSave = async () => {
|
||
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
||
if (!buyCondition && !sellCondition) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||
setIsSaving(true);
|
||
try {
|
||
const payload: ApiStrategyDto = {
|
||
id: selectedId ?? undefined,
|
||
name: stratName,
|
||
description: stratDesc,
|
||
buyCondition,
|
||
sellCondition,
|
||
enabled: true,
|
||
};
|
||
const saved = await saveStrategy(payload);
|
||
const now = new Date().toISOString();
|
||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||
setStrategies(prev => {
|
||
const existing = prev.find(s => s.id === selectedId);
|
||
if (existing && selectedId) {
|
||
return prev.map(s => s.id === selectedId
|
||
? { ...s, id: dbId, name:stratName, description:stratDesc, buyCondition, sellCondition, updatedAt: saved?.updatedAt ?? now }
|
||
: s);
|
||
}
|
||
setSelectedId(dbId);
|
||
return [...prev, { id:dbId, name:stratName, description:stratDesc, buyCondition, sellCondition, enabled:true, createdAt:saved?.createdAt ?? now, updatedAt:saved?.updatedAt ?? now }];
|
||
});
|
||
if (!selectedId) setSelectedId(dbId);
|
||
setSaveOpen(false);
|
||
showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다');
|
||
} catch (e) {
|
||
console.error('[StrategyPage] 저장 실패:', e);
|
||
const msg = e instanceof Error ? e.message : '저장에 실패했습니다';
|
||
showSnack(msg.length > 120 ? `${msg.slice(0, 120)}…` : msg, false);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteConfirm = async () => {
|
||
if (!deleteId) return;
|
||
try {
|
||
await deleteStrategy(deleteId);
|
||
} catch (e) {
|
||
console.warn('[StrategyPage] DB 삭제 실패 (로컬만 삭제):', e);
|
||
}
|
||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||
if (selectedId === deleteId) handleNew();
|
||
setDeleteOpen(false);
|
||
setDeleteId(null);
|
||
showSnack('전략이 삭제되었습니다');
|
||
};
|
||
|
||
const handleDuplicate = async (s: StrategyDto) => {
|
||
const name = prompt('새 전략 이름:', `${s.name} (복사본)`);
|
||
if (!name) return;
|
||
const now = new Date().toISOString();
|
||
try {
|
||
const payload: ApiStrategyDto = { name, description: s.description, buyCondition: s.buyCondition, sellCondition: s.sellCondition, enabled: true };
|
||
const saved = await saveStrategy(payload);
|
||
const id = saved?.id ?? Date.now();
|
||
setStrategies(prev => [...prev, { ...s, id, name, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }]);
|
||
showSnack('전략이 복제되었습니다');
|
||
} catch {
|
||
const id = Date.now();
|
||
setStrategies(prev => [...prev, { ...s, id, name, createdAt:now, updatedAt:now }]);
|
||
showSnack('전략이 복제되었습니다 (DB 저장 실패)');
|
||
}
|
||
};
|
||
|
||
const handleToggleEnabled = (id: number) => {
|
||
setStrategies(prev => prev.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
|
||
};
|
||
|
||
// ── Export / Import ──────────────────────────────────────────────────────────
|
||
const handleExport = () => {
|
||
if (!buyCondition && !sellCondition) { showSnack('내보낼 조건이 없습니다', false); return; }
|
||
const data = { version:'1.0', name:stratName, description:stratDesc, buyCondition, sellCondition, exportedAt:new Date().toISOString() };
|
||
const a = Object.assign(document.createElement('a'), {
|
||
href: URL.createObjectURL(new Blob([JSON.stringify(data,null,2)], {type:'application/json'})),
|
||
download: `${stratName||'전략'}_${new Date().toISOString().slice(0,10)}.json`,
|
||
});
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
showSnack('JSON으로 내보냈습니다');
|
||
};
|
||
|
||
const handleImport = () => {
|
||
const input = document.createElement('input');
|
||
input.type = 'file'; input.accept = '.json';
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const data = JSON.parse(await file.text());
|
||
if (!data.buyCondition && !data.sellCondition) { showSnack('유효하지 않은 파일', false); return; }
|
||
setBuyCondition(data.buyCondition ?? null);
|
||
setSellCondition(data.sellCondition ?? null);
|
||
setStratName(data.name ?? '가져온 전략');
|
||
setStratDesc(data.description ?? '');
|
||
setSelectedId(null);
|
||
showSnack('전략을 가져왔습니다');
|
||
setRightTab('preview');
|
||
} catch { showSnack('파일 읽기 실패', false); }
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
const handleExportAll = () => {
|
||
const data = { version:'1.0', exportedAt:new Date().toISOString(), totalCount:strategies.length, strategies };
|
||
const a = Object.assign(document.createElement('a'), {
|
||
href: URL.createObjectURL(new Blob([JSON.stringify(data,null,2)], {type:'application/json'})),
|
||
download: `전략목록_${new Date().toISOString().slice(0,10)}.json`,
|
||
});
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
showSnack(`${strategies.length}개 전략이 내보내졌습니다`);
|
||
};
|
||
|
||
const handleImportAll = () => {
|
||
const input = document.createElement('input');
|
||
input.type = 'file'; input.accept = '.json';
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const data = JSON.parse(await file.text());
|
||
if (!data.strategies || !Array.isArray(data.strategies)) { showSnack('유효하지 않은 파일', false); return; }
|
||
const now = new Date().toISOString();
|
||
const newOnes: StrategyDto[] = data.strategies.map((s: any) => ({ ...s, id: Date.now() + Math.random(), createdAt:now, updatedAt:now }));
|
||
setStrategies(prev => [...prev, ...newOnes]);
|
||
showSnack(`${newOnes.length}개 전략이 가져와졌습니다`);
|
||
} catch { showSnack('파일 읽기 실패', false); }
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
// ── 드롭 처리 ───────────────────────────────────────────────────────────────
|
||
const applyDrop = useCallback((targetId: string | null, data: { type: string; value: string; label: string }) => {
|
||
const newNode = makeNode(data.type, data.value, currentTab, DEF);
|
||
const root = getCurrent();
|
||
if (!root) { setCurrent(newNode); return; }
|
||
if (!targetId) {
|
||
setCurrent(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||
return;
|
||
}
|
||
setCurrent(addChild(root, targetId, newNode));
|
||
}, [currentTab, buyCondition, sellCondition]);
|
||
|
||
const handleRootDrop = (e: React.DragEvent) => {
|
||
e.preventDefault(); setDragOverId(null);
|
||
try { applyDrop(null, JSON.parse(e.dataTransfer.getData('application/json'))); } catch { /* ignore */ }
|
||
};
|
||
|
||
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => {
|
||
applyDrop(targetId, data);
|
||
};
|
||
|
||
const handlePaletteClick = (type: 'operator'|'indicator', value: string, label: string) => {
|
||
applyDrop(null, { type, value, label });
|
||
};
|
||
|
||
// ── 템플릿 선택 ─────────────────────────────────────────────────────────────
|
||
const handleTemplateSelect = (tmpl: StrategyTemplateDef) => {
|
||
if (tmpl.signal !== currentTab) setCurrentTab(tmpl.signal);
|
||
const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition;
|
||
const root = tmpl.signal === 'buy' ? buyCondition : sellCondition;
|
||
|
||
if (tmpl.kind === 'composite') {
|
||
setRoot(tmpl.build());
|
||
setRightTab('preview');
|
||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||
return;
|
||
}
|
||
|
||
const newNode = simpleTemplateToNode(tmpl);
|
||
if (!root) { setRoot(newNode); }
|
||
else { setRoot(mergeAtRoot(root, newNode, false)); }
|
||
setRightTab('preview');
|
||
showSnack(`"${tmpl.label}" 템플릿이 추가됐습니다`);
|
||
};
|
||
|
||
// ── 팔레트 데이터 ──────────────────────────────────────────────────────────
|
||
const operators = [
|
||
{ type:'operator' as const, value:'AND', label:'AND (그리고)', color:'green' },
|
||
{ type:'operator' as const, value:'OR', label:'OR (또는)', color:'blue' },
|
||
{ type:'operator' as const, value:'NOT', label:'NOT (반대)', color:'orange' },
|
||
];
|
||
const maBandItems = [
|
||
{ type:'indicator' as const, value:'MA', label:'MA (이동평균)', color:'primary' },
|
||
{ type:'indicator' as const, value:'EMA', label:'EMA (지수이동평균)', color:'primary' },
|
||
{ type:'indicator' as const, value:'BOLLINGER',label:'볼린저밴드', color:'primary' },
|
||
{ type:'indicator' as const, value:'ICHIMOKU', label:'일목균형표', color:'primary' },
|
||
];
|
||
const indicatorItems = [
|
||
'PSYCHOLOGICAL:심리도', 'NEW_PSYCHOLOGICAL:심리도', 'INVEST_PSYCHOLOGICAL:투자심리도',
|
||
'MACD:MACD', 'CCI:CCI', 'ADX:ADX', 'BWI:BWI', 'DMI:DMI', 'OBV:OBV',
|
||
'RSI:RSI', 'STOCHASTIC:Stochastic', 'WILLIAMS_R:Williams %R',
|
||
'TRIX:TRIX', 'VOLUME_OSC:VolumeOSC', 'VR:VR', 'DISPARITY:이격도', 'VOLUME:거래량',
|
||
].map(s => { const [v,l] = s.split(':'); return { type:'indicator' as const, value:v, label:l, color:'ind' }; });
|
||
|
||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||
|
||
const currentRoot = getCurrent();
|
||
|
||
// ─── Render ─────────────────────────────────────────────────────────────────
|
||
return (
|
||
<div className="sp-page">
|
||
{/* ── 상단 툴바 ────────────────────────────────────────────────────── */}
|
||
<div className="sp-topbar">
|
||
<span className="sp-topbar-title">✏️ 전략 편집기</span>
|
||
{selectedStrategy && (
|
||
<span className="sp-topbar-chip">{selectedStrategy.name}</span>
|
||
)}
|
||
<div className="sp-topbar-spacer" />
|
||
<button className="sp-tb" onClick={handleNew}>+ 새 전략</button>
|
||
<button className="sp-tb sp-tb-icon" title="내보내기" onClick={handleExport} disabled={!buyCondition && !sellCondition}>↓</button>
|
||
<button className="sp-tb sp-tb-icon" title="가져오기" onClick={handleImport}>↑</button>
|
||
<button className="sp-tb sp-tb-icon" title="전체 내보내기" onClick={handleExportAll} disabled={strategies.length === 0}>⬇</button>
|
||
<button className="sp-tb sp-tb-icon" title="전체 가져오기" onClick={handleImportAll}>⬆</button>
|
||
</div>
|
||
|
||
{/* ── 3컬럼 본문 ───────────────────────────────────────────────────── */}
|
||
<div className="sp-body">
|
||
|
||
{/* ── 좌측: 전략목록/상세보기/알림목록 ──────────────────────────── */}
|
||
<aside className="sp-left">
|
||
<div className="sp-panel-tabs">
|
||
{(['list','preview','alerts'] as const).map(t => (
|
||
<button key={t}
|
||
className={`sp-panel-tab ${rightTab === t ? 'sp-panel-tab--on' : ''}`}
|
||
onClick={() => setRightTab(t)}>
|
||
{t === 'list' ? '📋 전략목록' : t === 'preview' ? '📝 상세보기' : '🔔 알림목록'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="sp-panel-body">
|
||
{/* 전략 목록 */}
|
||
{rightTab === 'list' && (
|
||
<div className="sp-strat-panel">
|
||
<div className="sp-strat-panel-head">
|
||
<h2 className="sp-strat-panel-title">전략 목록</h2>
|
||
{strategies.length > 0 && (
|
||
<select
|
||
className="sp-strat-sort"
|
||
value={sortOrder}
|
||
onChange={e => setSortOrder(e.target.value as typeof sortOrder)}
|
||
aria-label="전략 정렬"
|
||
>
|
||
<option value="name-asc">이름(A)</option>
|
||
<option value="name-desc">이름(D)</option>
|
||
<option value="date-asc">날짜(A)</option>
|
||
<option value="date-desc">날짜(D)</option>
|
||
</select>
|
||
)}
|
||
</div>
|
||
<button type="button" className="sp-new-strat-btn" onClick={handleNew}>
|
||
+ 새 전략 만들기
|
||
</button>
|
||
<div className="sp-strat-list">
|
||
{isLoading ? (
|
||
<p className="sp-strat-empty">로딩 중...</p>
|
||
) : strategies.length === 0 ? (
|
||
<p className="sp-strat-empty">저장된 전략이 없습니다</p>
|
||
) : (
|
||
sortedStrategies.map(s => {
|
||
const isSel = selectedId === s.id;
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={`sp-strat-item${isSel ? ' sp-strat-item--sel' : ''}`}
|
||
onClick={() => handleSelectStrategy(s)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
handleSelectStrategy(s);
|
||
}
|
||
}}
|
||
>
|
||
<span className="sp-strat-name" title={s.name}>{s.name}</span>
|
||
<div className="sp-strat-item-actions">
|
||
<button
|
||
type="button"
|
||
className="sp-strat-action"
|
||
title={s.enabled ? '알림 해제' : '알림 추가'}
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
handleToggleEnabled(s.id);
|
||
}}
|
||
>
|
||
{s.enabled ? '🔔' : '🔕'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="sp-strat-action"
|
||
title="복제"
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
handleDuplicate(s);
|
||
}}
|
||
>
|
||
⎘
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="sp-strat-del"
|
||
title="전략 삭제"
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
setDeleteId(s.id);
|
||
setDeleteOpen(true);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||
<path
|
||
fill="currentColor"
|
||
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<span className={`sp-strat-status${s.enabled ? ' sp-strat-status--on' : ''}`}>
|
||
{s.enabled ? '활성' : '비활성'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 상세보기 */}
|
||
{rightTab === 'preview' && (
|
||
<div className="sp-preview">
|
||
{buyCondition && (
|
||
<div className="sp-prev-sec">
|
||
<span className="sp-prev-badge sp-prev-badge--buy">📈 매수 조건</span>
|
||
<pre className="sp-prev-text">{nodeToText(buyCondition)}</pre>
|
||
</div>
|
||
)}
|
||
{sellCondition && (
|
||
<div className="sp-prev-sec">
|
||
<span className="sp-prev-badge sp-prev-badge--sell">📉 매도 조건</span>
|
||
<pre className="sp-prev-text">{nodeToText(sellCondition)}</pre>
|
||
</div>
|
||
)}
|
||
{(buyCondition || sellCondition) && (
|
||
<div className="sp-prev-sec">
|
||
<div className="sp-prev-section-title">━━━ 코드 로직 ━━━</div>
|
||
{buyCondition && <div className="sp-prev-formula"><span className="sp-prev-buy">매수 신호</span> = {nodeToFormula(buyCondition)}</div>}
|
||
{sellCondition && <div className="sp-prev-formula"><span className="sp-prev-sell">매도 신호</span> = {nodeToFormula(sellCondition)}</div>}
|
||
</div>
|
||
)}
|
||
{(buyCondition || sellCondition) && (
|
||
<div className="sp-prev-sec">
|
||
<div className="sp-prev-section-title">━━━ 검증 결과 ━━━</div>
|
||
{buyCondition && buyValidation && (
|
||
<div className="sp-validation">
|
||
<div className="sp-val-head"><span>📈 매수 조건</span></div>
|
||
{buyValidation.isValid
|
||
? <div className="sp-val-ok">✔ 논리적 오류가 없습니다</div>
|
||
: buyValidation.errors.map((e, i) => <div key={i} className="sp-val-err">✗ {e.message}</div>)
|
||
}
|
||
{buyValidation.warnings.map((w, i) => <div key={i} className="sp-val-warn">⚠ {w.message}</div>)}
|
||
</div>
|
||
)}
|
||
{sellCondition && sellValidation && (
|
||
<div className="sp-validation">
|
||
<div className="sp-val-head"><span>📉 매도 조건</span></div>
|
||
{sellValidation.isValid
|
||
? <div className="sp-val-ok">✔ 논리적 오류가 없습니다</div>
|
||
: sellValidation.errors.map((e, i) => <div key={i} className="sp-val-err">✗ {e.message}</div>)
|
||
}
|
||
{sellValidation.warnings.map((w, i) => <div key={i} className="sp-val-warn">⚠ {w.message}</div>)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!buyCondition && !sellCondition && (
|
||
<div className="sp-empty-info">매수 또는 매도 조건을 추가하세요</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 알림 목록 */}
|
||
{rightTab === 'alerts' && (
|
||
<div>
|
||
<div className="sp-list-header">
|
||
<span>알림 활성화된 전략: {strategies.filter(s => s.enabled).length}개</span>
|
||
</div>
|
||
{strategies.filter(s => s.enabled).length === 0
|
||
? <div className="sp-empty-info">알림이 활성화된 전략이 없습니다.<br/>목록에서 🔔 아이콘을 클릭하세요.</div>
|
||
: strategies.filter(s => s.enabled).map(s => (
|
||
<div key={s.id} className="sp-alert-item">
|
||
<span className="sp-alert-dot" />
|
||
<span className="sp-alert-name">{s.name}</span>
|
||
<button className="sp-li-btn sp-li-btn--del"
|
||
onClick={() => handleToggleEnabled(s.id)}>🔕</button>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
{/* ── 중앙: 편집기 ───────────────────────────────────────────────── */}
|
||
<main className="sp-editor">
|
||
{/* 매수/매도 탭 + 저장·새로고침 */}
|
||
<div className="sp-signal-tabs-bar">
|
||
<div className="sp-signal-tabs">
|
||
<button
|
||
className={`sp-signal-tab ${currentTab === 'buy' ? 'sp-signal-tab--buy' : ''}`}
|
||
onClick={() => setCurrentTab('buy')}>
|
||
📈 매수 조건
|
||
{buyCondition && <span className="sp-signal-dot sp-signal-dot--buy" />}
|
||
</button>
|
||
<button
|
||
className={`sp-signal-tab ${currentTab === 'sell' ? 'sp-signal-tab--sell' : ''}`}
|
||
onClick={() => setCurrentTab('sell')}>
|
||
📉 매도 조건
|
||
{sellCondition && <span className="sp-signal-dot sp-signal-dot--sell" />}
|
||
</button>
|
||
</div>
|
||
<div className="sp-signal-tab-actions">
|
||
<button
|
||
className="sp-tb sp-tb-save"
|
||
onClick={() => setSaveOpen(true)}
|
||
disabled={!buyCondition && !sellCondition}
|
||
>
|
||
{selectedStrategy ? '✎ 수정' : '✔ 저장'}
|
||
</button>
|
||
<button
|
||
className="sp-tb"
|
||
onClick={() => { setIsLoading(true); setTimeout(() => setIsLoading(false), 300); showSnack('새로고침 완료'); }}
|
||
>
|
||
↺ 새로고침
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 드롭존 */}
|
||
<div className="sp-dropzone"
|
||
onDragOver={e => e.preventDefault()}
|
||
onDrop={handleRootDrop}>
|
||
{!currentRoot ? (
|
||
<div className="sp-drop-empty">
|
||
왼쪽 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.
|
||
</div>
|
||
) : (
|
||
<TreeNodeComp
|
||
node={currentRoot}
|
||
signalType={currentTab}
|
||
onUpdate={n => setCurrent(n)}
|
||
onDelete={() => setCurrent(null)}
|
||
onDropOnNode={handleDropOnNode}
|
||
dragOverId={dragOverId}
|
||
setDragOverId={setDragOverId}
|
||
def={DEF}
|
||
/>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
{/* ── 우측: 전략 구성 요소 ──────────────────────────────────────── */}
|
||
<aside className="sp-palette">
|
||
<div className="sp-pal-header">🎨 전략 구성 요소</div>
|
||
<div className="sp-pal-tabs">
|
||
<button className={`sp-pal-tab ${paletteTab === 'manual' ? 'sp-pal-tab--on' : ''}`}
|
||
onClick={() => setPaletteTab('manual')}>✋ 수동설정</button>
|
||
<button className={`sp-pal-tab ${paletteTab === 'auto' ? 'sp-pal-tab--on' : ''}`}
|
||
onClick={() => setPaletteTab('auto')}>✨ 자동설정</button>
|
||
</div>
|
||
|
||
<div className="sp-pal-body">
|
||
{paletteTab === 'manual' && (
|
||
<>
|
||
<div className="sp-pal-sec">
|
||
<div className="sp-pal-sec-title">🔗 논리 연산자</div>
|
||
<div className="sp-pal-chips">
|
||
{operators.map(it => (
|
||
<PChip key={it.value} type={it.type} value={it.value} label={it.label} color={it.color}
|
||
onAdd={() => handlePaletteClick(it.type, it.value, it.label)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="sp-pal-sec">
|
||
<div className="sp-pal-sec-title">📈 이동평균 & 밴드</div>
|
||
<div className="sp-pal-chips">
|
||
{maBandItems.map(it => (
|
||
<PChip key={it.value} type={it.type} value={it.value} label={it.label} color={it.color}
|
||
onAdd={() => handlePaletteClick(it.type, it.value, it.label)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="sp-pal-sec">
|
||
<div className="sp-pal-sec-title">📊 보조 지표 (16가지)</div>
|
||
<div className="sp-pal-chips">
|
||
{indicatorItems.map(it => (
|
||
<PChip key={it.value} type={it.type} value={it.value} label={it.label}
|
||
onAdd={() => handlePaletteClick(it.type, it.value, it.label)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
{paletteTab === 'auto' && (
|
||
<div className="sp-pal-auto">
|
||
<div className="sp-pal-auto-info">✨ 전략 템플릿을 선택하면 자동으로 조건이 추가됩니다</div>
|
||
{templates.map((t, i) => (
|
||
<div key={i} className="sp-pal-tmpl" onClick={() => handleTemplateSelect(t)}>
|
||
<span className={`sp-tmpl-badge ${t.signal === 'buy' ? 'sp-tmpl-badge--buy' : 'sp-tmpl-badge--sell'}`}>
|
||
{t.signal === 'buy' ? '매수' : '매도'}
|
||
</span>
|
||
<span className="sp-tmpl-name">{t.label}</span>
|
||
{'description' in t && t.description && (
|
||
<span className="sp-tmpl-desc">{t.description}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
{/* ── 저장 다이얼로그 ─────────────────────────────────────────────── */}
|
||
{saveOpen && (
|
||
<DraggableModalFrame
|
||
onClose={() => setSaveOpen(false)}
|
||
title={selectedStrategy ? '전략 수정' : '전략 저장'}
|
||
>
|
||
{selectedStrategy && (
|
||
<div className="sp-modal-info">기존 전략 "{selectedStrategy.name}"을(를) 수정합니다.</div>
|
||
)}
|
||
<div className="sp-modal-chips">
|
||
<span className={`sp-modal-chip ${buyCondition ? 'sp-modal-chip--buy' : ''}`}>
|
||
📈 {buyCondition ? '매수 조건 설정됨' : '매수 조건 없음'}
|
||
</span>
|
||
<span className={`sp-modal-chip ${sellCondition ? 'sp-modal-chip--sell' : ''}`}>
|
||
📉 {sellCondition ? '매도 조건 설정됨' : '매도 조건 없음'}
|
||
</span>
|
||
</div>
|
||
<label className="sp-modal-lbl">전략 이름 *</label>
|
||
<input className="sp-modal-inp" value={stratName} placeholder="전략 이름"
|
||
onChange={e => setStratName(e.target.value)} autoFocus />
|
||
<label className="sp-modal-lbl">설명 (선택사항)</label>
|
||
<textarea className="sp-modal-inp sp-modal-ta" value={stratDesc} rows={3}
|
||
placeholder="전략 설명을 입력하세요"
|
||
onChange={e => setStratDesc(e.target.value)} />
|
||
<div className="sp-modal-actions">
|
||
<button className="sp-modal-btn sp-modal-btn--cancel" onClick={() => setSaveOpen(false)}>취소</button>
|
||
<button className="sp-modal-btn sp-modal-btn--ok" onClick={handleSave}
|
||
disabled={isSaving || !stratName.trim()}>
|
||
{isSaving ? (selectedStrategy ? '수정 중...' : '저장 중...') : (selectedStrategy ? '수정' : '저장')}
|
||
</button>
|
||
</div>
|
||
</DraggableModalFrame>
|
||
)}
|
||
|
||
{/* ── 삭제 확인 다이얼로그 ────────────────────────────────────────── */}
|
||
{deleteOpen && (
|
||
<DraggableModalFrame onClose={() => setDeleteOpen(false)} title="전략 삭제">
|
||
<p style={{ color: 'var(--text)', margin: '0 0 16px' }}>정말로 이 전략을 삭제하시겠습니까?</p>
|
||
<div className="sp-modal-actions">
|
||
<button className="sp-modal-btn sp-modal-btn--cancel" onClick={() => setDeleteOpen(false)}>취소</button>
|
||
<button className="sp-modal-btn sp-modal-btn--del" onClick={handleDeleteConfirm}>삭제</button>
|
||
</div>
|
||
</DraggableModalFrame>
|
||
)}
|
||
|
||
{/* ── 스낵바 ──────────────────────────────────────────────────────── */}
|
||
{snack && (
|
||
<div className={`sp-snack ${snack.ok ? 'sp-snack--ok' : 'sp-snack--err'}`}>
|
||
{snack.ok ? '✔' : '✗'} {snack.msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StrategyPage;
|