Files
goldenChart/frontend/src/components/StrategyPage.tsx
T
2026-05-24 12:38:18 +09:00

1615 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 투자전략 화면 — 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<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: { 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 (
<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">📈 이동평균 &amp; 밴드</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>
</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;