Files
goldenChart/frontend/src/components/BacktestSettingsModal.tsx
T
2026-06-07 15:12:42 +09:00

446 lines
19 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.
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>> = {
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<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>
);
}