336 lines
15 KiB
TypeScript
336 lines
15 KiB
TypeScript
/**
|
||
* 설정 화면 — 백테스팅 설정 패널 (2열 카드 레이아웃)
|
||
*/
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
type BacktestSettingsDto,
|
||
DEFAULT_BACKTEST_SETTINGS,
|
||
loadBacktestSettings,
|
||
saveBacktestSettings,
|
||
} from '../utils/backendApi';
|
||
|
||
// ── 백테스팅 전용 UI 컴포넌트 ───────────────────────────────────────────────
|
||
|
||
const BtSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||
<section className="stg-bt-section">
|
||
<h3 className="stg-bt-section-title">{title}</h3>
|
||
<div className="stg-bt-section-body">{children}</div>
|
||
</section>
|
||
);
|
||
|
||
const BtGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||
<div className="stg-bt-grid">{children}</div>
|
||
);
|
||
|
||
const BtField: React.FC<{
|
||
label: string;
|
||
desc?: string;
|
||
full?: boolean;
|
||
children: React.ReactNode;
|
||
}> = ({ label, desc, full, children }) => (
|
||
<div className={`stg-bt-field${full ? ' stg-bt-field--full' : ''}`}>
|
||
<div className="stg-bt-field-head">
|
||
<span className="stg-bt-field-label">{label}</span>
|
||
{desc && <span className="stg-bt-field-desc">{desc}</span>}
|
||
</div>
|
||
<div className="stg-bt-field-ctrl">{children}</div>
|
||
</div>
|
||
);
|
||
|
||
export interface BacktestSettingsPanelProps {
|
||
btAutoPopup?: boolean;
|
||
onBtAutoPopup?: (v: boolean) => void;
|
||
btShowPrice?: boolean;
|
||
onBtShowPrice?: (v: boolean) => void;
|
||
}
|
||
|
||
export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
|
||
btAutoPopup = true,
|
||
onBtAutoPopup,
|
||
btShowPrice = true,
|
||
onBtShowPrice,
|
||
}) => {
|
||
const [cfg, setCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadBacktestSettings().then(s => { if (s) setCfg(s); });
|
||
}, []);
|
||
|
||
const update = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
|
||
const next = { ...cfg, ...patch };
|
||
setCfg(next);
|
||
setSaving(true);
|
||
setSaved(false);
|
||
try {
|
||
await saveBacktestSettings(next);
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 1500);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [cfg]);
|
||
|
||
const field = <K extends keyof BacktestSettingsDto>(key: K, value: BacktestSettingsDto[K]) =>
|
||
update({ [key]: value } as Partial<BacktestSettingsDto>);
|
||
|
||
return (
|
||
<div className="stg-bt-panel">
|
||
{(saving || saved) && (
|
||
<div className={`stg-bt-status${saved ? ' stg-bt-status--ok' : ''}`}>
|
||
{saving ? '저장 중…' : '✓ 저장됨'}
|
||
</div>
|
||
)}
|
||
|
||
<BtSection title="표시 설정">
|
||
<BtGrid>
|
||
<BtField
|
||
label="결과 팝업 자동 표시"
|
||
desc="백테스팅 실행 완료 후 결과 팝업을 자동으로 띄웁니다. OFF이면 툴바의 '결과보기' 버튼을 직접 클릭해야 합니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={btAutoPopup} onChange={e => onBtAutoPopup?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</BtField>
|
||
<BtField
|
||
label="매수/매도 금액 표시"
|
||
desc="차트 마커에 체결 금액을 함께 표시합니다. ON: '매수 : 1,234,000' / OFF: '매수'만 표시."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={btShowPrice} onChange={e => onBtShowPrice?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</BtField>
|
||
</BtGrid>
|
||
</BtSection>
|
||
|
||
<BtSection title="자본 · 거래 규모">
|
||
<BtGrid>
|
||
<BtField
|
||
label="초기 자본 (원)"
|
||
desc="백테스팅 시뮬레이션의 시작 자본금입니다. 수익/손실과 최종 자산을 이 금액 기준으로 계산합니다."
|
||
>
|
||
<input
|
||
className="stg-input stg-input--wide"
|
||
type="number" min={100000} step={1000000}
|
||
value={cfg.initialCapital}
|
||
onChange={e => field('initialCapital', Number(e.target.value))}
|
||
/>
|
||
</BtField>
|
||
<BtField label="거래 규모 유형" desc="자본 비율(복리) 또는 매 거래 고정 금액 중 선택합니다.">
|
||
<select className="stg-select stg-select--wide"
|
||
value={cfg.tradeSizeType}
|
||
onChange={e => field('tradeSizeType', e.target.value as 'CAPITAL_PCT' | 'FIXED_AMOUNT')}>
|
||
<option value="CAPITAL_PCT">자본 비율 (%)</option>
|
||
<option value="FIXED_AMOUNT">고정 금액 (원)</option>
|
||
</select>
|
||
</BtField>
|
||
<BtField
|
||
label={cfg.tradeSizeType === 'CAPITAL_PCT' ? '사용 자본 비율 (%)' : '거래당 금액 (원)'}
|
||
desc={cfg.tradeSizeType === 'CAPITAL_PCT'
|
||
? '매 거래에 사용할 자본 비율. 100 = 전체 자본.'
|
||
: '매 거래마다 사용할 원화 금액.'}
|
||
>
|
||
<input
|
||
className="stg-input stg-input--wide"
|
||
type="number" min={1} step={cfg.tradeSizeType === 'CAPITAL_PCT' ? 1 : 100000}
|
||
value={cfg.tradeSizeValue}
|
||
onChange={e => field('tradeSizeValue', Number(e.target.value))}
|
||
/>
|
||
</BtField>
|
||
</BtGrid>
|
||
</BtSection>
|
||
|
||
<BtSection title="비용 모델">
|
||
<BtGrid>
|
||
<BtField label="수수료 유형" desc="LINEAR: 거래금액 × 수수료율. ZERO: 수수료 없음.">
|
||
<select className="stg-select stg-select--wide"
|
||
value={cfg.commissionType}
|
||
onChange={e => field('commissionType', e.target.value as 'LINEAR' | 'ZERO')}>
|
||
<option value="LINEAR">LINEAR (비율 수수료)</option>
|
||
<option value="ZERO">ZERO (수수료 없음)</option>
|
||
</select>
|
||
</BtField>
|
||
{cfg.commissionType === 'LINEAR' ? (
|
||
<BtField label="수수료율 (%)" desc="매수·매도 각 1회당 수수료율. 업비트 현물 약 0.05%.">
|
||
<input
|
||
className="stg-input stg-input--wide"
|
||
type="number" min={0} max={5} step={0.01}
|
||
value={(cfg.commissionRate ?? 0) * 100}
|
||
onChange={e => field('commissionRate', Number(e.target.value) / 100)}
|
||
/>
|
||
</BtField>
|
||
) : (
|
||
<div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />
|
||
)}
|
||
<BtField
|
||
label="슬리피지 (%)"
|
||
desc="의도 가격과 실제 체결 가격 차이. 매수는 높게, 매도는 낮게 체결되는 효과."
|
||
>
|
||
<input
|
||
className="stg-input stg-input--wide"
|
||
type="number" min={0} max={5} step={0.01}
|
||
value={(cfg.slippageRate ?? 0) * 100}
|
||
onChange={e => field('slippageRate', Number(e.target.value) / 100)}
|
||
/>
|
||
</BtField>
|
||
</BtGrid>
|
||
</BtSection>
|
||
|
||
<BtSection title="거래 가격 기준">
|
||
<BtGrid>
|
||
<BtField label="진입 가격 기준" desc="매수 신호 시 진입 가격. 종가 즉시 진입 또는 다음 봉 시가 진입.">
|
||
<select className="stg-select stg-select--wide"
|
||
value={cfg.entryPriceType}
|
||
onChange={e => field('entryPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
|
||
<option value="CLOSE">종가 (Close)</option>
|
||
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
||
</select>
|
||
</BtField>
|
||
<BtField label="청산 가격 기준" desc="매도 신호 시 청산 가격. 종가 즉시 청산 또는 다음 봉 시가 청산.">
|
||
<select className="stg-select stg-select--wide"
|
||
value={cfg.exitPriceType}
|
||
onChange={e => field('exitPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
|
||
<option value="CLOSE">종가 (Close)</option>
|
||
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
||
</select>
|
||
</BtField>
|
||
<BtField label="포지션 방향" desc="LONG(매수만), SHORT(공매도), BOTH(양방향) 중 선택.">
|
||
<select className="stg-select stg-select--wide"
|
||
value={cfg.positionDirection}
|
||
onChange={e => field('positionDirection', e.target.value as 'LONG' | 'SHORT' | 'BOTH')}>
|
||
<option value="LONG">LONG (매수만)</option>
|
||
<option value="SHORT">SHORT (공매도)</option>
|
||
<option value="BOTH">BOTH (양방향)</option>
|
||
</select>
|
||
</BtField>
|
||
</BtGrid>
|
||
</BtSection>
|
||
|
||
<BtSection title="리스크 관리">
|
||
<BtGrid>
|
||
<BtField label="손절 활성화" desc="진입가 대비 N% 하락 시 자동 청산 (StopLossRule).">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={!!cfg.stopLossEnabled}
|
||
onChange={e => field('stopLossEnabled', e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</BtField>
|
||
{cfg.stopLossEnabled ? (
|
||
<BtField label="손절 비율 (%)" desc="진입가 대비 하락 허용 비율.">
|
||
<input className="stg-input stg-input--wide" type="number" min={0.1} max={50} step={0.1}
|
||
value={cfg.stopLossPct ?? 2}
|
||
onChange={e => field('stopLossPct', Number(e.target.value))} />
|
||
</BtField>
|
||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||
|
||
<BtField label="익절 활성화" desc="진입가 대비 N% 상승 시 자동 청산 (StopGainRule).">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={!!cfg.takeProfitEnabled}
|
||
onChange={e => field('takeProfitEnabled', e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</BtField>
|
||
{cfg.takeProfitEnabled ? (
|
||
<BtField label="익절 비율 (%)" desc="진입가 대비 상승 목표 비율.">
|
||
<input className="stg-input stg-input--wide" type="number" min={0.1} max={200} step={0.1}
|
||
value={cfg.takeProfitPct ?? 5}
|
||
onChange={e => field('takeProfitPct', Number(e.target.value))} />
|
||
</BtField>
|
||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||
|
||
<BtField label="트레일링 스탑 활성화" desc="최고가 대비 N% 하락 시 자동 청산.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={!!cfg.trailingStopEnabled}
|
||
onChange={e => field('trailingStopEnabled', e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</BtField>
|
||
{cfg.trailingStopEnabled ? (
|
||
<BtField label="트레일링 비율 (%)" desc="최고가 대비 하락 허용 비율.">
|
||
<input className="stg-input stg-input--wide" type="number" min={0.1} max={50} step={0.1}
|
||
value={cfg.trailingStopPct ?? 2}
|
||
onChange={e => field('trailingStopPct', Number(e.target.value))} />
|
||
</BtField>
|
||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||
</BtGrid>
|
||
</BtSection>
|
||
|
||
<BtSection title="재진입 · 분할 청산">
|
||
<BtGrid>
|
||
<BtField label="재진입 대기 봉 수" desc="매도 후 다음 매수까지 최소 대기 캔들 수. 0 = 즉시 재진입.">
|
||
<input className="stg-input stg-input--wide" type="number" min={0} max={100} step={1}
|
||
value={cfg.reentryWaitBars ?? 0}
|
||
onChange={e => field('reentryWaitBars', Number(e.target.value))} />
|
||
</BtField>
|
||
<BtField label="최대 동시 포지션" desc="동시 보유 가능한 최대 포지션 수 (현재 단일 포지션 지원).">
|
||
<input className="stg-input stg-input--wide" type="number" min={1} max={10} step={1}
|
||
value={cfg.maxOpenTrades ?? 1}
|
||
onChange={e => field('maxOpenTrades', Number(e.target.value))} />
|
||
</BtField>
|
||
<BtField label="분할 청산 활성화" desc="첫 매도 신호 시 포지션의 일부만 청산합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={!!cfg.partialExitEnabled}
|
||
onChange={e => field('partialExitEnabled', e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</BtField>
|
||
{cfg.partialExitEnabled ? (
|
||
<BtField label="1차 청산 비율 (%)" desc="첫 매도 시 청산할 포지션 비율.">
|
||
<input className="stg-input stg-input--wide" type="number" min={10} max={90} step={5}
|
||
value={cfg.partialExitPct ?? 50}
|
||
onChange={e => field('partialExitPct', Number(e.target.value))} />
|
||
</BtField>
|
||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||
</BtGrid>
|
||
</BtSection>
|
||
|
||
<BtSection title="포지션 종속성 모드">
|
||
<BtField
|
||
full
|
||
label="매매 시그널 발생 조건"
|
||
desc="매도 시그널이 이전 매수(포지션) 없이도 발생할지 선택합니다."
|
||
>
|
||
<div className="stg-bt-radio-group">
|
||
<label className="stg-bt-radio">
|
||
<input
|
||
type="radio"
|
||
name="bt-pos-mode"
|
||
value="LONG_ONLY"
|
||
checked={(cfg.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
||
onChange={() => field('positionMode', 'LONG_ONLY')}
|
||
/>
|
||
<span className="stg-bt-radio-text">
|
||
<strong>보유 자산 기준 (Long-Only)</strong>
|
||
<span>매수 이력이 있을 때만 매도 허용 · Ta4j 표준 엄격 모드</span>
|
||
</span>
|
||
</label>
|
||
<label className="stg-bt-radio">
|
||
<input
|
||
type="radio"
|
||
name="bt-pos-mode"
|
||
value="SIGNAL_ONLY"
|
||
checked={cfg.positionMode === 'SIGNAL_ONLY'}
|
||
onChange={() => field('positionMode', 'SIGNAL_ONLY')}
|
||
/>
|
||
<span className="stg-bt-radio-text">
|
||
<strong>순수 지표 기준 (Signal-Only)</strong>
|
||
<span>포지션 무관, 지표 규칙 충족 시 즉시 매도 · 락 우회 모드</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</BtField>
|
||
{cfg.positionMode === 'SIGNAL_ONLY' && (
|
||
<div className="stg-bt-hint stg-bt-hint--warn">
|
||
Signal-Only 모드: 매도 시그널이 이전 매수 없이도 발생합니다
|
||
</div>
|
||
)}
|
||
</BtSection>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BacktestSettingsPanel;
|