import React, { useCallback, useEffect, useState } from 'react'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { BacktestSettingsDto, DEFAULT_BACKTEST_SETTINGS, loadBacktestSettings, saveBacktestSettings, } from '../utils/backendApi'; // ── 설명 메타데이터 ───────────────────────────────────────────────────────── interface FieldMeta { label: string; description: string; } const FIELD_META: Partial> = { analysisMethod: { label: '투자분석 방식', description: '수익률·손익 산정 방식을 선택합니다.\n• 평가손익 포함(표준): 예수금 + 보유주식 시가평가. HTS 평가금액과 동일합니다.\n• 실현손익만: 청산 완료 거래의 실현손익만 반영합니다.', }, initialCapital: { label: '초기 자본', description: '백테스팅 시뮬레이션의 시작 자본금(원)입니다. 수익/손실 금액과 최종 자산을 이 금액 기준으로 계산합니다.\n예) 1,000만원 설정 → 총 수익률 +15% 달성 시 최종 자산 = 1,150만원으로 표시됩니다.', }, commissionType: { label: '수수료 유형', description: '매매 수수료 계산 방식을 선택합니다.\n• LINEAR: 거래금액 × 수수료율로 계산합니다. 국내 거래소 현물거래에 적합합니다.\n• ZERO: 수수료 없이 계산합니다. 순수 전략 성과만 검증할 때 사용합니다.', }, commissionRate: { label: '수수료율', description: '매수·매도 각 1회당 부과되는 수수료율입니다.\n예) 0.15% = 0.0015, 거래금액 100만원일 때 수수료 1,500원\n• 업비트 현물: 약 0.05% (0.0005)\n• 빗썸 현물: 약 0.25% (0.0025)\n왕복(매수+매도) 합산 수수료가 손익에서 차감됩니다.', }, slippageRate: { label: '슬리피지', description: '주문을 낼 때 의도한 가격과 실제 체결 가격의 차이 비율입니다.\n• 매수: 목표가보다 슬리피지만큼 높게 체결 (불리)\n• 매도: 목표가보다 슬리피지만큼 낮게 체결 (불리)\n예) 슬리피지 0.05%, 100만원 매수 → 실제 체결 1,000,500원\n거래량이 적거나 변동성이 높을수록 슬리피지가 커집니다.', }, entryPriceType: { label: '진입 가격 기준', description: '매수 신호 발생 시 어느 가격에 진입하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 진입. 구현이 단순하지만 후행성(look-ahead bias) 우려가 있습니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 진입. 실제 트레이딩과 가장 유사한 현실적 시뮬레이션입니다.', }, exitPriceType: { label: '청산 가격 기준', description: '매도 신호 발생 시 어느 가격에 청산하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 청산합니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 청산. 야간 갭 하락 등 실제 리스크를 반영합니다.', }, positionDirection: { label: '포지션 방향', description: '어떤 방향의 거래를 시뮬레이션할지 선택합니다.\n• LONG(매수만): 매수 후 가격 상승을 기대하는 전략입니다. 가장 일반적인 방식입니다.\n• SHORT(공매도): 매도 후 가격 하락을 기대합니다. 선물/마진 거래 전략 검증에 사용합니다.\n• BOTH(양방향): 매수 신호는 롱 진입, 매도 신호는 숏 진입으로 처리합니다.', }, tradeSizeType: { label: '거래 규모 유형', description: '매 거래마다 사용할 자금 규모 방식을 선택합니다.\n• CAPITAL_PCT(자본 비율): 현재 보유 자본의 N%를 거래에 사용합니다. 복리 효과가 반영됩니다.\n• FIXED_AMOUNT(고정 금액): 매 거래마다 동일한 금액(원)을 사용합니다. 단리 방식과 유사합니다.', }, tradeSizeValue: { label: '거래 규모 값', description: '자본 비율 선택 시: 매 거래에 사용할 자본 비율(%)입니다.\n예) 100 = 전체 자본 사용, 50 = 절반만 사용\n\n고정 금액 선택 시: 매 거래마다 사용할 원화 금액입니다.\n예) 1,000,000 = 매 거래당 100만원 고정 투자', }, stopLossEnabled: { label: '손절 활성화', description: '진입가 대비 N% 하락 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopLossRule).\n전략의 매도 조건보다 손절이 먼저 발동될 경우 손절로 청산됩니다.\n예) 손절 비율 2% 설정, 100만원 매수 → 가격이 98만원으로 하락 시 자동 손절', }, stopLossPct: { label: '손절 비율 (%)', description: '진입가 대비 이 비율만큼 가격이 하락하면 즉시 포지션을 청산합니다.\n예) 2.0 설정 → 진입가 100,000원일 때 98,000원 이하로 하락 시 손절\n낮은 값: 손실 최소화 but 잦은 손절로 기회 상실 가능\n높은 값: 큰 손실 허용 but 반전 기회 포착 가능', }, takeProfitEnabled: { label: '익절 활성화', description: '진입가 대비 N% 상승 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopGainRule).\n목표 수익률에 도달하면 확실히 이익을 실현합니다.\n예) 익절 비율 5% 설정, 100만원 매수 → 가격이 105만원으로 상승 시 자동 익절', }, takeProfitPct: { label: '익절 비율 (%)', description: '진입가 대비 이 비율만큼 가격이 상승하면 즉시 포지션을 청산합니다.\n예) 5.0 설정 → 진입가 100,000원일 때 105,000원 이상 상승 시 익절\n낮은 값: 자주 이익 실현 but 대세 상승장 추세 이탈 가능\n높은 값: 큰 수익 추구 but 수익 반납 위험 증가', }, trailingStopEnabled: { label: '트레일링 스탑 활성화', description: '포지션 보유 중 도달한 최고가 대비 N% 하락 시 자동 청산하는 기능입니다 (Ta4j TrailingStopLossRule).\n고정 손절과 달리 추세를 따라 기준점이 올라가므로 상승 추세를 최대한 활용하면서 수익을 보호합니다.\n예) 100만원 매수, 트레일링 2% → 가격이 120만원까지 상승 후 2% 하락한 117.6만원에서 청산', }, trailingStopPct: { label: '트레일링 비율 (%)', description: '포지션 진입 후 기록된 최고가 대비 이 비율만큼 하락하면 청산합니다.\n예) 2.0 설정, 최고가 120,000원 → 117,600원 (= 120,000 × 0.98) 이하 하락 시 청산\n낮은 값: 소폭 하락에도 청산, 수익 보호에 유리\n높은 값: 큰 조정을 허용, 장기 추세 추종에 유리', }, reentryWaitBars: { label: '재진입 대기 봉 수', description: '포지션 청산(매도) 후 다음 매수 신호를 받을 때까지 최소 대기해야 하는 봉(캔들) 수입니다.\n연속 매매를 방지하고 시장이 안정되길 기다리는 효과가 있습니다.\n예) 3 설정 → 매도 후 3봉 동안은 매수 신호 무시\n0: 즉시 재진입 허용 (기본값)', }, maxOpenTrades: { label: '최대 동시 포지션', description: '동시에 보유할 수 있는 최대 포지션(거래) 수입니다.\n현재 구현에서는 단일 포지션(1)만 완전히 지원됩니다.\n1로 설정 시: 이미 포지션을 보유 중이면 새 매수 신호를 무시합니다.', }, partialExitEnabled: { label: '분할 청산 활성화', description: '첫 매도 신호 발생 시 포지션 전부가 아닌 일부만 청산하는 기능입니다.\n리스크를 줄이면서도 나머지 포지션으로 추가 상승을 노릴 수 있습니다.\n예) 분할 청산 50% 설정 → 첫 매도 신호에 50% 청산, 이후 다음 신호 또는 손절/익절 시 나머지 50% 청산', }, partialExitPct: { label: '1차 청산 비율 (%)', description: '첫 번째 매도 신호 발생 시 현재 포지션의 몇 퍼센트를 청산할지 설정합니다.\n예) 50 설정 → 보유량의 절반을 1차 청산, 나머지는 계속 보유\n예) 30 설정 → 보유량의 30%만 1차 청산 (더 공격적 운용)', }, }; // ── 섹션 정의 ───────────────────────────────────────────────────────────── interface SettingField { key: keyof BacktestSettingsDto; type: 'number' | 'select' | 'toggle' | 'percent'; opts?: { value: string; label: string }[]; min?: number; max?: number; step?: number; /** 이 필드가 보이려면 이 조건이 true여야 함 */ visibleWhen?: (cfg: BacktestSettingsDto) => boolean; } interface SettingSection { title: string; fields: SettingField[]; } const SECTIONS: SettingSection[] = [ { title: '📊 투자분석 방식', fields: [ { key: 'analysisMethod', type: 'select', opts: [ { value: 'MARK_TO_MARKET', label: '평가손익 포함 (표준)' }, { value: 'REALIZED_ONLY', label: '실현손익만' }, ], }, ], }, { title: '💰 자본 설정', fields: [ { key: 'initialCapital', type: 'number', min: 100000, step: 1000000 }, ], }, { title: '💸 비용 모델', fields: [ { key: 'commissionType', type: 'select', opts: [{ value:'LINEAR', label:'LINEAR (비율 수수료)' }, { value:'ZERO', label:'ZERO (수수료 없음)' }], }, { key: 'commissionRate', type: 'percent', min: 0, max: 5, step: 0.01, visibleWhen: c => c.commissionType === 'LINEAR' }, { key: 'slippageRate', type: 'percent', min: 0, max: 5, step: 0.01 }, ], }, { title: '📈 거래 가격', fields: [ { key: 'entryPriceType', type: 'select', opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }], }, { key: 'exitPriceType', type: 'select', opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }], }, { key: 'positionDirection', type: 'select', opts: [ { value:'LONG', label:'LONG (매수만)' }, { value:'SHORT', label:'SHORT (공매도)' }, { value:'BOTH', label:'BOTH (양방향)' }, ], }, ], }, { title: '📦 거래 규모', fields: [ { key: 'tradeSizeType', type: 'select', opts: [{ value:'CAPITAL_PCT', label:'자본 비율 (%)' }, { value:'FIXED_AMOUNT', label:'고정 금액 (원)' }], }, { key: 'tradeSizeValue', type: 'number', min: 1, step: 1 }, ], }, { title: '🛑 손절', fields: [ { key: 'stopLossEnabled', type: 'toggle' }, { key: 'stopLossPct', type: 'number', min: 0.1, max: 50, step: 0.1, visibleWhen: c => !!c.stopLossEnabled }, ], }, { title: '🎯 익절', fields: [ { key: 'takeProfitEnabled', type: 'toggle' }, { key: 'takeProfitPct', type: 'number', min: 0.1, max: 200, step: 0.1, visibleWhen: c => !!c.takeProfitEnabled }, ], }, { title: '📉 트레일링 스탑', fields: [ { key: 'trailingStopEnabled', type: 'toggle' }, { key: 'trailingStopPct', type: 'number', min: 0.1, max: 50, step: 0.1, visibleWhen: c => !!c.trailingStopEnabled }, ], }, { title: '🔁 재진입 제어', fields: [ { key: 'reentryWaitBars', type: 'number', min: 0, max: 100, step: 1 }, { key: 'maxOpenTrades', type: 'number', min: 1, max: 10, step: 1 }, ], }, { title: '✂️ 분할 청산', fields: [ { key: 'partialExitEnabled', type: 'toggle' }, { key: 'partialExitPct', type: 'number', min: 10, max: 90, step: 5, visibleWhen: c => !!c.partialExitEnabled }, ], }, ]; // ── 컴포넌트 ──────────────────────────────────────────────────────────────── interface Props { onClose: () => void; onSettingsChange: (s: BacktestSettingsDto) => void; } export default function BacktestSettingsModal({ onClose, onSettingsChange }: Props) { const [cfg, setCfg] = useState(DEFAULT_BACKTEST_SETTINGS); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [focused, setFocused] = useState('initialCapital'); const { panelRef: modalRef, dragging, onHeaderPointerDown, headerTouchStyle, panelStyle, headerCursor, } = useDraggablePanel({ centerOnMount: true }); // 설정 로드 useEffect(() => { loadBacktestSettings().then(s => { if (s) setCfg(s); }); }, []); const set = (k: K, v: BacktestSettingsDto[K]) => setCfg(prev => ({ ...prev, [k]: v })); const handleSave = async () => { setSaving(true); const result = await saveBacktestSettings(cfg); if (result) { onSettingsChange({ ...cfg, ...result }); setSaved(true); setTimeout(() => setSaved(false), 1500); } setSaving(false); }; const focusMeta = focused ? FIELD_META[focused] : null; // 모든 섹션의 보이는 필드를 1차원 배열로 수집 (2열 그리드용) const allFields: Array<{ section: string; field: SettingField }> = []; for (const sec of SECTIONS) { for (const f of sec.fields) { if (!f.visibleWhen || f.visibleWhen(cfg)) { allFields.push({ section: sec.title, field: f }); } } } return (
{/* ── 타이틀바 (드래그 핸들) ── */}
백테스팅 설정
{/* ── 2열 설정 그리드 ── */}
{SECTIONS.map(sec => { const visibleFields = sec.fields.filter(f => !f.visibleWhen || f.visibleWhen(cfg)); if (visibleFields.length === 0) return null; return (
{sec.title}
{visibleFields.map(f => ( ))}
); })}
{/* ── 하단 설명 패널 ── */}
{focusMeta ? ( <> {focusMeta.label} {focusMeta.description} ) : ( 설정 항목을 클릭하면 상세 설명이 표시됩니다. )}
{/* ── 푸터 ── */}
); } // ── FieldRow ───────────────────────────────────────────────────────────────── function FieldRow({ field, cfg, focused, onFocus, onChange, }: { field: SettingField; cfg: BacktestSettingsDto; focused: keyof BacktestSettingsDto | null; onFocus: (k: keyof BacktestSettingsDto) => void; onChange: (k: K, v: BacktestSettingsDto[K]) => void; }) { const meta = FIELD_META[field.key]; const label = meta?.label ?? field.key; const isFocused = focused === field.key; const renderCtrl = () => { const val = cfg[field.key]; switch (field.type) { case 'toggle': return ( ); case 'select': return ( ); case 'percent': return (
onChange(field.key, Number(e.target.value) / 100 as BacktestSettingsDto[typeof field.key])} onClick={e => e.stopPropagation()} /> %
); default: return ( onChange(field.key, Number(e.target.value) as BacktestSettingsDto[typeof field.key])} onClick={e => e.stopPropagation()} /> ); } }; return (
onFocus(field.key)} > {label} {renderCtrl()}
); }