429 lines
19 KiB
TypeScript
429 lines
19 KiB
TypeScript
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<Record<keyof BacktestSettingsDto, FieldMeta>> = {
|
||
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: '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<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [focused, setFocused] = useState<keyof BacktestSettingsDto | null>('initialCapital');
|
||
|
||
const {
|
||
panelRef: modalRef,
|
||
dragging,
|
||
onHeaderPointerDown, headerTouchStyle,
|
||
panelStyle,
|
||
headerCursor,
|
||
} = useDraggablePanel({ centerOnMount: true });
|
||
|
||
// 설정 로드
|
||
useEffect(() => {
|
||
loadBacktestSettings().then(s => { if (s) setCfg(s); });
|
||
}, []);
|
||
|
||
const set = <K extends keyof BacktestSettingsDto>(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 (
|
||
<div
|
||
className="bts-modal"
|
||
ref={modalRef}
|
||
style={{
|
||
...panelStyle,
|
||
zIndex: 9100,
|
||
cursor: dragging ? 'grabbing' : undefined,
|
||
}}
|
||
>
|
||
{/* ── 타이틀바 (드래그 핸들) ── */}
|
||
<div
|
||
className="gc-popup-header bts-header"
|
||
onPointerDown={onHeaderPointerDown}
|
||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" style={{opacity:.6}}>
|
||
<circle cx="8" cy="8" r="2.5"/>
|
||
<path d="M8 1.5L8 3.5 M8 12.5L8 14.5 M1.5 8L3.5 8 M12.5 8L14.5 8
|
||
M3.4 3.4L4.8 4.8 M11.2 11.2L12.6 12.6 M12.6 3.4L11.2 4.8 M4.8 11.2L3.4 12.6"
|
||
strokeLinecap="round"/>
|
||
</svg>
|
||
<span className="bts-title">백테스팅 설정</span>
|
||
<button className="bts-close" onMouseDown={e => e.stopPropagation()} onClick={onClose}>✕</button>
|
||
</div>
|
||
|
||
{/* ── 2열 설정 그리드 ── */}
|
||
<div className="bts-body">
|
||
{SECTIONS.map(sec => {
|
||
const visibleFields = sec.fields.filter(f => !f.visibleWhen || f.visibleWhen(cfg));
|
||
if (visibleFields.length === 0) return null;
|
||
return (
|
||
<div key={sec.title} className="bts-section">
|
||
<div className="bts-section-title">{sec.title}</div>
|
||
<div className="bts-section-grid">
|
||
{visibleFields.map(f => (
|
||
<FieldRow
|
||
key={f.key}
|
||
field={f}
|
||
cfg={cfg}
|
||
focused={focused}
|
||
onFocus={setFocused}
|
||
onChange={set}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* ── 하단 설명 패널 ── */}
|
||
<div className="bts-desc-panel">
|
||
{focusMeta ? (
|
||
<>
|
||
<span className="bts-desc-label">{focusMeta.label}</span>
|
||
<span className="bts-desc-sep">—</span>
|
||
<span className="bts-desc-text">{focusMeta.description}</span>
|
||
</>
|
||
) : (
|
||
<span className="bts-desc-placeholder">설정 항목을 클릭하면 상세 설명이 표시됩니다.</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── 푸터 ── */}
|
||
<div className="bts-footer">
|
||
<button className="bts-btn bts-btn-reset" onClick={() => setCfg(DEFAULT_BACKTEST_SETTINGS)}>
|
||
기본값
|
||
</button>
|
||
<div style={{ flex: 1 }} />
|
||
<button className="bts-btn bts-btn-cancel" onClick={onClose}>취소</button>
|
||
<button className="bts-btn bts-btn-save" onClick={handleSave} disabled={saving}>
|
||
{saving ? '저장 중...' : saved ? '✓ 저장됨' : '확인'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── FieldRow ─────────────────────────────────────────────────────────────────
|
||
|
||
function FieldRow({
|
||
field, cfg, focused, onFocus, onChange,
|
||
}: {
|
||
field: SettingField;
|
||
cfg: BacktestSettingsDto;
|
||
focused: keyof BacktestSettingsDto | null;
|
||
onFocus: (k: keyof BacktestSettingsDto) => void;
|
||
onChange: <K extends keyof BacktestSettingsDto>(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 (
|
||
<button
|
||
className={`bts-toggle${val ? ' bts-toggle-on' : ''}`}
|
||
onClick={e => { e.stopPropagation(); onChange(field.key, !val as BacktestSettingsDto[typeof field.key]); }}
|
||
>
|
||
{val ? 'ON' : 'OFF'}
|
||
</button>
|
||
);
|
||
case 'select':
|
||
return (
|
||
<select
|
||
className="bts-select"
|
||
value={String(val)}
|
||
onChange={e => onChange(field.key, e.target.value as BacktestSettingsDto[typeof field.key])}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
{field.opts?.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
);
|
||
case 'percent':
|
||
return (
|
||
<div className="bts-pct-wrap">
|
||
<input
|
||
type="number"
|
||
className="bts-input"
|
||
value={((val as number) * 100).toFixed(3)}
|
||
min={field.min} max={field.max} step={field.step ?? 0.01}
|
||
onChange={e => onChange(field.key, Number(e.target.value) / 100 as BacktestSettingsDto[typeof field.key])}
|
||
onClick={e => e.stopPropagation()}
|
||
/>
|
||
<span className="bts-unit">%</span>
|
||
</div>
|
||
);
|
||
default:
|
||
return (
|
||
<input
|
||
type="number"
|
||
className="bts-input"
|
||
value={val as number}
|
||
min={field.min} max={field.max} step={field.step ?? 1}
|
||
onChange={e => onChange(field.key, Number(e.target.value) as BacktestSettingsDto[typeof field.key])}
|
||
onClick={e => e.stopPropagation()}
|
||
/>
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`bts-row${isFocused ? ' bts-row-focused' : ''}`}
|
||
onClick={() => onFocus(field.key)}
|
||
>
|
||
<span className="bts-row-label">{label}</span>
|
||
<span className="bts-row-ctrl">{renderCtrl()}</span>
|
||
</div>
|
||
);
|
||
}
|