/** * 투자전략 화면 — 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'; // ─── 타입 ───────────────────────────────────────────────────────────────────── 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, }; 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, 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 = { 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 = {}; 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 => { 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 = { 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 = ({ 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 = { 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 (
{/* 4컬럼 row */}
{/* 캔들 범위 */}
{/* 조건대상1 */}
{/* 조건대상2 */}
{/* 조건 */}
{/* 추가 필드 */} {isDiffType && (
onChange({ ...cond, compareValue: parseFloat(e.target.value) || 0 })} />
)} {isSlopeType && (
onChange({ ...cond, slopePeriod: parseInt(e.target.value) || 3 })} />
)} {isHoldType && (
onChange({ ...cond, holdDays: parseInt(e.target.value) || 3 })} />
)}
); }; // ─── 트리 노드 컴포넌트 ─────────────────────────────────────────────────────── const NODE_COLORS: Record = { 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 = ({ 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 (
{/* 노드 헤더 */}
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type} {label}
{/* 조건 인라인 편집기 (CONDITION 노드 항상 표시) */} {node.type === 'CONDITION' && node.condition && ( )} {/* 자식 노드 */} {isLogic && (
{(node.children ?? []).map(child => ( handleChildUpdate(child.id, u)} onDelete={() => handleChildDelete(child.id)} onDropOnNode={onDropOnNode} dragOverId={dragOverId} setDragOverId={setDragOverId} depth={depth + 1} def={def} /> ))} {dragOverId === node.id && (
여기에 드롭하세요
)}
)}
); }; // ─── 팔레트 칩 (드래그 가능) ───────────────────────────────────────────────── interface PChipProps { type: 'operator'|'indicator'; value: string; label: string; color?: string; onAdd: () => void; } const PChip: React.FC = ({ 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 ( {label} ); }; // ─── 노드 생성 헬퍼 ─────────────────────────────────────────────────────────── 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 = ({ activeIndicators = [] }) => { /** 현재 차트 지표 설정에서 파라미터를 추출한 DEF */ const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]); const [strategies, setStrategies] = useState(() => loadStratsLocal()); const [selectedId, setSelectedId] = useState(null); const [dbSyncing, setDbSyncing] = useState(false); const [buyCondition, setBuyCondition] = useState(null); const [sellCondition, setSellCondition] = useState(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(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(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: { ind: string; cond: string; l: string; r: string; signal: 'buy'|'sell'; label: string }) => { const newNode: LogicNode = { id: genId(), type: 'CONDITION', condition: { indicatorType: tmpl.ind, conditionType: tmpl.cond, leftField: tmpl.l, rightField: tmpl.r, candleRange: 1 }, }; if (tmpl.signal !== currentTab) setCurrentTab(tmpl.signal); const root = tmpl.signal === 'buy' ? buyCondition : sellCondition; const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition; 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 = [ { ind:'MA', cond:'CROSS_UP', l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}`, signal:'buy' as const, label:'MA 골든크로스 매수' }, { ind:'MA', cond:'CROSS_DOWN', l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}`, signal:'sell' as const, label:'MA 데드크로스 매도' }, { ind:'RSI', cond:'LTE', l:'RSI_VALUE', r:'OVERSOLD_30', signal:'buy' as const, label:'RSI 과매도 매수' }, { ind:'RSI', cond:'GTE', l:'RSI_VALUE', r:'OVERBOUGHT_70', signal:'sell' as const, label:'RSI 과매수 매도' }, { ind:'MACD', cond:'CROSS_UP', l:'MACD_LINE', r:'SIGNAL_LINE', signal:'buy' as const, label:'MACD 시그널 상향 돌파' }, { ind:'MACD', cond:'CROSS_DOWN', l:'MACD_LINE', r:'SIGNAL_LINE', signal:'sell' as const, label:'MACD 시그널 하향 돌파' }, { ind:'BOLLINGER', cond:'CROSS_UP', l:'CLOSE_PRICE', r:'LOWER_BAND', signal:'buy' as const, label:'볼린저 하단 돌파 매수' }, { ind:'BOLLINGER', cond:'CROSS_UP', l:'CLOSE_PRICE', r:'UPPER_BAND', signal:'sell' as const, label:'볼린저 상단 돌파 매도' }, { ind:'CCI', cond:'CROSS_UP', l:'CCI_VALUE', r:'OVERSOLD_NEG100', signal:'buy' as const, label:'CCI 과매도 반등' }, { ind:'PSYCHOLOGICAL', cond:'CROSS_UP', l:'PSY_VALUE', r:'OVERBOUGHT_75', signal:'buy' as const, label:'심리도 상향 돌파' }, { ind:'STOCHASTIC', cond:'CROSS_UP', l:'STOCH_K', r:'STOCH_D', signal:'buy' as const, label:'스토캐스틱 골든크로스' }, { ind:'ADX', cond:'CROSS_UP', l:'ADX_VALUE', r:'ADX_25', signal:'buy' as const, label:'ADX 중앙선 상향 돌파' }, ]; const currentRoot = getCurrent(); // ─── Render ───────────────────────────────────────────────────────────────── return (
{/* ── 상단 툴바 ────────────────────────────────────────────────────── */}
✏️ 전략 편집기 {selectedStrategy && ( {selectedStrategy.name} )}
{/* ── 3컬럼 본문 ───────────────────────────────────────────────────── */}
{/* ── 좌측: 전략목록/상세보기/알림목록 ──────────────────────────── */} {/* ── 중앙: 편집기 ───────────────────────────────────────────────── */}
{/* 매수/매도 탭 + 저장·새로고침 */}
{/* 드롭존 */}
e.preventDefault()} onDrop={handleRootDrop}> {!currentRoot ? (
왼쪽 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.
) : ( setCurrent(n)} onDelete={() => setCurrent(null)} onDropOnNode={handleDropOnNode} dragOverId={dragOverId} setDragOverId={setDragOverId} def={DEF} /> )}
{/* ── 우측: 전략 구성 요소 ──────────────────────────────────────── */}
{/* ── 저장 다이얼로그 ─────────────────────────────────────────────── */} {saveOpen && ( setSaveOpen(false)} title={selectedStrategy ? '전략 수정' : '전략 저장'} > {selectedStrategy && (
기존 전략 "{selectedStrategy.name}"을(를) 수정합니다.
)}
📈 {buyCondition ? '매수 조건 설정됨' : '매수 조건 없음'} 📉 {sellCondition ? '매도 조건 설정됨' : '매도 조건 없음'}
setStratName(e.target.value)} autoFocus />