Files
goldenChart/frontend/src/components/BacktestSettingsPanel.tsx
T
2026-05-23 15:11:48 +09:00

336 lines
15 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.
/**
* 설정 화면 — 백테스팅 설정 패널 (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;