백테스팅 적용
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||
import {
|
||||
buildVirtualTradingChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import BacktestOscillatorPanes from './BacktestOscillatorPanes';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
toTimeSec: number;
|
||||
fromTimeSec?: number;
|
||||
barCount: number;
|
||||
signals: BacktestSignal[];
|
||||
strategyId?: number | null;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
let _indSeq = 0;
|
||||
function newIndId() {
|
||||
_indSeq += 1;
|
||||
return `btd_${_indSeq}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function defaultOverlayIndicators(
|
||||
getParams: (t: string, d?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
): IndicatorConfig[] {
|
||||
const def = getIndicatorDef('SMA');
|
||||
if (!def) return [];
|
||||
const params: Record<string, number | string | boolean> = { ...def.defaultParams, ...getParams('SMA', def.defaultParams) };
|
||||
for (let i = 1; i <= 11; i++) {
|
||||
params[`length${i}`] = i === 1 ? 14 : 0;
|
||||
params[`visible${i}`] = i === 1;
|
||||
}
|
||||
const cfg = normalizeSmaConfig(enrichIndicatorConfig({
|
||||
id: newIndId(),
|
||||
type: 'SMA',
|
||||
params,
|
||||
plots: def.plots,
|
||||
hlines: def.hlines ?? [],
|
||||
timeframeVisibility: { '1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true, '1h': true, '4h': true, '1D': true, '1W': true, '1M': true },
|
||||
}));
|
||||
return [cfg];
|
||||
}
|
||||
|
||||
const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
symbol,
|
||||
timeframe: timeframeRaw,
|
||||
toTimeSec,
|
||||
fromTimeSec,
|
||||
barCount,
|
||||
signals,
|
||||
strategyId,
|
||||
theme = 'dark',
|
||||
}) => {
|
||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [chartType] = useState<ChartType>('candlestick');
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const signalsRef = useRef(signals);
|
||||
signalsRef.current = signals;
|
||||
const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId) {
|
||||
setStrategy(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategy(strategyId).then(s => {
|
||||
if (!cancelled) setStrategy(s ?? undefined);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [strategyId]);
|
||||
|
||||
const indicators = useMemo(() => {
|
||||
if (strategy) {
|
||||
const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
|
||||
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[timeframe] !== false);
|
||||
}
|
||||
return defaultOverlayIndicators(getParams);
|
||||
}, [strategy, getParams, getVisualConfig, timeframe]);
|
||||
|
||||
const effectiveBarCount = useMemo(() => {
|
||||
const barSec = timeframeBarSeconds(timeframeRaw);
|
||||
if (fromTimeSec && toTimeSec > fromTimeSec) {
|
||||
return Math.max(barCount, Math.min(500, Math.ceil((toTimeSec - fromTimeSec) / barSec) + 50));
|
||||
}
|
||||
return barCount;
|
||||
}, [barCount, fromTimeSec, toTimeSec, timeframeRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void loadAnalysisCandles(market, timeframe, toTimeSec, effectiveBarCount)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setBars(data);
|
||||
})
|
||||
.catch(e => {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : '차트 로드 실패');
|
||||
setBars([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, toTimeSec, effectiveBarCount]);
|
||||
|
||||
const applyMarkers = useCallback((attempt = 0) => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr?.hasMainSeries()) {
|
||||
if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1));
|
||||
return;
|
||||
}
|
||||
mgr.setBacktestMarkers(signalsRef.current, true);
|
||||
}, []);
|
||||
|
||||
const onManagerReady = useCallback((mgr: ChartManager) => {
|
||||
managerRef.current = mgr;
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
|
||||
const onCandlesReady = useCallback(() => {
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
}, [signals, bars, applyMarkers]);
|
||||
|
||||
useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: isUpbitMarket(market),
|
||||
managerRef,
|
||||
});
|
||||
|
||||
const symLabel = market.replace(/^KRW-/, '');
|
||||
const stratTf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframe;
|
||||
|
||||
return (
|
||||
<div className="btd-analysis-chart">
|
||||
<div className="btd-analysis-toolbar">
|
||||
<div className="btd-analysis-toolbar-left">
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{symLabel}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{timeframe}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">Candle</span>
|
||||
</div>
|
||||
<div className="btd-analysis-toolbar-right">
|
||||
<span className="btd-analysis-tool" title="자석 모드">⊕</span>
|
||||
<span className="btd-analysis-tool" title="드로잉">✎</span>
|
||||
<span className="btd-analysis-tool" title="삭제">⌫</span>
|
||||
<span className="btd-analysis-count">{effectiveBarCount.toLocaleString()}봉</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-main">
|
||||
<div className="btd-analysis-canvas-wrap">
|
||||
{loading && <div className="btd-analysis-loading">차트 로딩…</div>}
|
||||
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
||||
{!loading && !error && bars.length > 0 && (
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${toTimeSec}-${effectiveBarCount}`}
|
||||
bars={bars}
|
||||
barsMarket={market}
|
||||
market={market}
|
||||
timeframe={stratTf}
|
||||
chartType={chartType}
|
||||
theme={theme}
|
||||
mode="chart"
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[]}
|
||||
logScale={false}
|
||||
onCrosshair={noop}
|
||||
onManagerReady={onManagerReady}
|
||||
onCandlesReady={onCandlesReady}
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && bars.length === 0 && (
|
||||
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
{!loading && bars.length > 0 && <BacktestOscillatorPanes bars={bars} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestAnalysisChart;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { BacktestResultRecord } from '../../utils/backendApi';
|
||||
import type { LiveExecutionItem } from '../../utils/liveExecutionGroups';
|
||||
import BacktestSparkline from './BacktestSparkline';
|
||||
import { buildEquityFromSignals } from '../../utils/backtestEquity';
|
||||
import { paperTradesToSignals } from '../../utils/liveExecutionGroups';
|
||||
import { fmtListTimestamp, fmtShortTimestamp, pct, pctAbs } from '../../utils/backtestUiUtils';
|
||||
|
||||
export type ExecutionListTab = 'backtest' | 'live';
|
||||
export type BacktestListSort = 'strategy' | 'symbol' | 'return';
|
||||
|
||||
interface Props {
|
||||
tab: ExecutionListTab;
|
||||
onTabChange: (tab: ExecutionListTab) => void;
|
||||
backtestRecords: BacktestResultRecord[];
|
||||
liveItems: LiveExecutionItem[];
|
||||
selectedBacktestId: number | null;
|
||||
selectedLiveId: string | null;
|
||||
onSelectBacktest: (r: BacktestResultRecord) => void;
|
||||
onSelectLive: (item: LiveExecutionItem) => void;
|
||||
}
|
||||
|
||||
function parseBacktestSpark(r: BacktestResultRecord) {
|
||||
try {
|
||||
const signals = r.signalsJson ? JSON.parse(r.signalsJson) : [];
|
||||
const analysis = r.analysisJson ? JSON.parse(r.analysisJson) : null;
|
||||
const cap = analysis?.initialCapital ?? 10_000_000;
|
||||
return buildEquityFromSignals(signals, cap, r.symbol).curve;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseLiveSpark(item: LiveExecutionItem, initial = 10_000_000) {
|
||||
return buildEquityFromSignals(paperTradesToSignals(item.trades), initial, item.symbol).curve;
|
||||
}
|
||||
|
||||
export default function BacktestExecutionList({
|
||||
tab,
|
||||
onTabChange,
|
||||
backtestRecords,
|
||||
liveItems,
|
||||
selectedBacktestId,
|
||||
selectedLiveId,
|
||||
onSelectBacktest,
|
||||
onSelectLive,
|
||||
}: Props) {
|
||||
const [sort, setSort] = useState<BacktestListSort>('return');
|
||||
|
||||
const sortedBacktests = useMemo(() => {
|
||||
const list = [...backtestRecords];
|
||||
if (sort === 'strategy') list.sort((a, b) => (a.strategyName ?? '').localeCompare(b.strategyName ?? ''));
|
||||
else if (sort === 'symbol') list.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
else list.sort((a, b) => (b.totalReturn ?? 0) - (a.totalReturn ?? 0));
|
||||
return list;
|
||||
}, [backtestRecords, sort]);
|
||||
|
||||
return (
|
||||
<div className="btd-exec-list">
|
||||
<div className="btd-exec-tabs" role="tablist" aria-label="실행 목록">
|
||||
<button type="button" role="tab" aria-selected={tab === 'backtest'} className={`btd-exec-tab${tab === 'backtest' ? ' btd-exec-tab--on' : ''}`} onClick={() => onTabChange('backtest')}>백테스팅</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'live'} className={`btd-exec-tab${tab === 'live' ? ' btd-exec-tab--on' : ''}`} onClick={() => onTabChange('live')}>실시간 매매</button>
|
||||
</div>
|
||||
|
||||
{tab === 'backtest' && (
|
||||
<div className="btd-exec-filters" role="group" aria-label="목록 정렬">
|
||||
<button type="button" className={`btd-exec-filter${sort === 'strategy' ? ' btd-exec-filter--on' : ''}`} onClick={() => setSort('strategy')}>전략별</button>
|
||||
<button type="button" className={`btd-exec-filter${sort === 'symbol' ? ' btd-exec-filter--on' : ''}`} onClick={() => setSort('symbol')}>종목별</button>
|
||||
<button type="button" className={`btd-exec-filter${sort === 'return' ? ' btd-exec-filter--on' : ''}`} onClick={() => setSort('return')}>수익률순</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btd-exec-scroll">
|
||||
{tab === 'backtest' ? (
|
||||
sortedBacktests.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">실시간 차트에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedBacktests.map(r => {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const positive = ret >= 0;
|
||||
const active = selectedBacktestId === r.id;
|
||||
const sym = r.symbol.replace(/^KRW-/, '');
|
||||
return (
|
||||
<button key={r.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}>
|
||||
<div className="btd-history-card-row">
|
||||
<div className="btd-history-card-main">
|
||||
<span className="btd-history-title">{r.strategyName || '전략 없음'} | {sym} | {r.timeframe}</span>
|
||||
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
|
||||
</div>
|
||||
<div className="btd-history-card-side">
|
||||
<BacktestSparkline curve={parseBacktestSpark(r)} positive={positive} width={56} height={22} />
|
||||
<span className="btd-history-win">승률 {pctAbs(r.winRate ?? 0)}</span>
|
||||
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>총 수익률 {pct(ret)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : liveItems.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>실시간 매매 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">가상투자 화면에서 자동·수동<br />매매를 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
liveItems.map(item => {
|
||||
const positive = item.totalReturnPct >= 0;
|
||||
const active = selectedLiveId === item.id;
|
||||
const sym = item.symbol.replace(/^KRW-/, '');
|
||||
return (
|
||||
<button key={item.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectLive(item)}>
|
||||
<div className="btd-history-card-row">
|
||||
<div className="btd-history-card-main">
|
||||
<span className="btd-history-title">{item.strategyLabel} | {sym} | {item.sourceLabel}</span>
|
||||
<span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="btd-history-card-side">
|
||||
<BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} />
|
||||
<span className="btd-history-win">체결 {item.tradeCount}건</span>
|
||||
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { BacktestAnalysis } from '../../utils/backendApi';
|
||||
import BacktestSparkline from './BacktestSparkline';
|
||||
import type { EquityPoint } from '../../utils/backtestEquity';
|
||||
import { krw, pct, pctAbs } from '../../utils/backtestUiUtils';
|
||||
|
||||
interface Props {
|
||||
analysis: BacktestAnalysis | null;
|
||||
equityCurve: EquityPoint[];
|
||||
totalReturnOverride?: number;
|
||||
winRateOverride?: number;
|
||||
tradeCountOverride?: number;
|
||||
winCountOverride?: number;
|
||||
mddOverride?: number;
|
||||
sharpeOverride?: number;
|
||||
profitFactorOverride?: number;
|
||||
netProfitOverride?: number;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const num = (v: number, dec = 2) => (isFinite(v) ? v.toFixed(dec) : '–');
|
||||
|
||||
const fmtWon = (v: number) =>
|
||||
isFinite(v) ? `₩${Math.round(v).toLocaleString('ko-KR')}` : '–';
|
||||
|
||||
function MetricCell({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
tone?: 'up' | 'down' | 'gold' | 'neutral';
|
||||
}) {
|
||||
return (
|
||||
<div className="btd-analysis-metric">
|
||||
<span className="btd-analysis-metric-label">{label}</span>
|
||||
<span className={`btd-analysis-metric-value${tone ? ` btd-analysis-metric-value--${tone}` : ''}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BacktestKpiPanel({
|
||||
analysis,
|
||||
equityCurve,
|
||||
totalReturnOverride,
|
||||
winRateOverride,
|
||||
tradeCountOverride,
|
||||
winCountOverride,
|
||||
mddOverride,
|
||||
sharpeOverride,
|
||||
profitFactorOverride,
|
||||
netProfitOverride,
|
||||
emptyText = '분석 결과를 선택하세요.',
|
||||
}: Props) {
|
||||
const positive = useMemo(() => {
|
||||
const ret = totalReturnOverride ?? analysis?.totalReturnPct ?? 0;
|
||||
return ret >= 0;
|
||||
}, [analysis, totalReturnOverride]);
|
||||
|
||||
if (!analysis && totalReturnOverride == null) {
|
||||
return <div className="btd-analysis-empty">{emptyText}</div>;
|
||||
}
|
||||
|
||||
const totalReturn = totalReturnOverride ?? analysis?.totalReturnPct ?? 0;
|
||||
const winRate = winRateOverride ?? analysis?.winRate ?? 0;
|
||||
const tradeCount = tradeCountOverride ?? analysis?.numberOfPositions ?? 0;
|
||||
const winCount = winCountOverride ?? analysis?.numberOfWinning ?? Math.round(tradeCount * winRate);
|
||||
const lossCount = analysis?.numberOfLosing ?? Math.max(0, tradeCount - winCount);
|
||||
const breakEven = analysis?.numberOfBreakEven ?? Math.max(0, tradeCount - winCount - lossCount);
|
||||
const mdd = mddOverride ?? analysis?.maxDrawdownPct ?? 0;
|
||||
const sharpe = sharpeOverride ?? analysis?.sharpeRatio ?? 0;
|
||||
const profitFactor = profitFactorOverride ?? (
|
||||
analysis && analysis.grossLoss !== 0
|
||||
? Math.abs(analysis.grossProfit / analysis.grossLoss)
|
||||
: analysis?.profitLossRatio ?? 0
|
||||
);
|
||||
const netProfit = netProfitOverride ?? analysis?.totalProfitLoss ?? 0;
|
||||
const initialCap = analysis?.initialCapital ?? 0;
|
||||
const finalEquity = analysis?.finalEquity ?? initialCap + netProfit;
|
||||
const grossProfit = analysis?.grossProfit ?? 0;
|
||||
const grossLoss = analysis?.grossLoss ?? 0;
|
||||
const avgWin = winCount > 0 ? grossProfit / winCount : 0;
|
||||
const avgLoss = lossCount > 0 ? grossLoss / lossCount : 0;
|
||||
const buyHold = analysis?.buyAndHoldReturnPct ?? 0;
|
||||
const vsHold = analysis?.vsBuyAndHold ?? 0;
|
||||
const maxRunup = analysis?.maxRunupPct ?? 0;
|
||||
const sortino = analysis?.sortinoRatio ?? 0;
|
||||
const calmar = analysis?.calmarRatio ?? 0;
|
||||
const var95 = analysis?.valueAtRisk95 ?? 0;
|
||||
const es = analysis?.expectedShortfall ?? 0;
|
||||
const plRatio = analysis?.profitLossRatio ?? profitFactor;
|
||||
const recovery = mdd !== 0 ? totalReturn / Math.abs(mdd) : 0;
|
||||
|
||||
return (
|
||||
<div className="btd-analysis-stack">
|
||||
<section className="btd-analysis-card btd-analysis-card--profit">
|
||||
<header className="btd-analysis-card-head">
|
||||
<h3 className="btd-analysis-card-title">수익성</h3>
|
||||
<span className="btd-analysis-card-tag">Profitability</span>
|
||||
</header>
|
||||
<div className="btd-analysis-card-body">
|
||||
<div className="btd-analysis-hero">
|
||||
<div className="btd-analysis-hero-main">
|
||||
<span className="btd-analysis-hero-label">총 수익률</span>
|
||||
<strong className={`btd-analysis-hero-value ${totalReturn >= 0 ? 'up' : 'down'}`}>
|
||||
{pct(totalReturn)}
|
||||
</strong>
|
||||
</div>
|
||||
<BacktestSparkline curve={equityCurve} positive={positive} width={80} height={30} />
|
||||
</div>
|
||||
<div className="btd-analysis-grid">
|
||||
<MetricCell label="초기 자본" value={fmtWon(initialCap)} />
|
||||
<MetricCell label="최종 자산" value={fmtWon(finalEquity)} tone={finalEquity >= initialCap ? 'up' : 'down'} />
|
||||
<MetricCell label="순이익" value={krw(netProfit)} tone={netProfit >= 0 ? 'up' : 'down'} />
|
||||
<MetricCell label="평균 수익률" value={pct(analysis?.avgReturnPct ?? 0)} tone={(analysis?.avgReturnPct ?? 0) >= 0 ? 'up' : 'down'} />
|
||||
<MetricCell label="총 이익 (Gross)" value={krw(grossProfit)} tone="up" />
|
||||
<MetricCell label="총 손실 (Gross)" value={krw(-Math.abs(grossLoss))} tone="down" />
|
||||
<MetricCell label="Buy & Hold" value={pct(buyHold)} tone={buyHold >= 0 ? 'up' : 'down'} />
|
||||
<MetricCell label="vs Buy & Hold" value={num(vsHold, 2)} tone={vsHold >= 1 ? 'up' : 'down'} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="btd-analysis-card btd-analysis-card--risk">
|
||||
<header className="btd-analysis-card-head">
|
||||
<h3 className="btd-analysis-card-title">리스크 관리</h3>
|
||||
<span className="btd-analysis-card-tag">Risk</span>
|
||||
</header>
|
||||
<div className="btd-analysis-card-body">
|
||||
<div className="btd-analysis-hero btd-analysis-hero--compact">
|
||||
<div className="btd-analysis-hero-main">
|
||||
<span className="btd-analysis-hero-label">MDD (최대낙폭)</span>
|
||||
<strong className="btd-analysis-hero-value down">{pct(mdd)}</strong>
|
||||
</div>
|
||||
<div className="btd-analysis-hero-side">
|
||||
<span className="btd-analysis-side-label">Max Runup</span>
|
||||
<strong className="btd-analysis-side-value up">{pct(maxRunup)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-grid">
|
||||
<MetricCell label="Sharpe Ratio" value={num(sharpe)} tone="gold" />
|
||||
<MetricCell label="Sortino Ratio" value={num(sortino)} tone="gold" />
|
||||
<MetricCell label="Calmar Ratio" value={num(calmar)} tone="gold" />
|
||||
<MetricCell label="Recovery Factor" value={num(recovery)} tone={recovery >= 1 ? 'up' : 'neutral'} />
|
||||
<MetricCell label="VaR (95%)" value={pct(var95)} tone="down" />
|
||||
<MetricCell label="Expected Shortfall" value={pct(es)} tone="down" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="btd-analysis-card btd-analysis-card--stats">
|
||||
<header className="btd-analysis-card-head">
|
||||
<h3 className="btd-analysis-card-title">거래 통계</h3>
|
||||
<span className="btd-analysis-card-tag">Trades</span>
|
||||
</header>
|
||||
<div className="btd-analysis-card-body">
|
||||
<div className="btd-analysis-hero btd-analysis-hero--compact">
|
||||
<div className="btd-analysis-hero-main">
|
||||
<span className="btd-analysis-hero-label">승률</span>
|
||||
<strong className={`btd-analysis-hero-value ${winRate >= 0.5 ? 'up' : 'down'}`}>
|
||||
{pctAbs(winRate, 1)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="btd-analysis-hero-side">
|
||||
<span className="btd-analysis-side-label">총 거래</span>
|
||||
<strong className="btd-analysis-side-value">{tradeCount}회</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-grid">
|
||||
<MetricCell label="승리" value={`${winCount}회`} tone="up" />
|
||||
<MetricCell label="패배" value={`${lossCount}회`} tone="down" />
|
||||
<MetricCell label="무승부" value={`${breakEven}회`} tone="neutral" />
|
||||
<MetricCell label="체결 비율" value={tradeCount > 0 ? pctAbs(winCount / tradeCount, 0) : '0%'} tone="gold" />
|
||||
<MetricCell label="평균 수익" value={krw(avgWin)} tone="up" />
|
||||
<MetricCell label="평균 손실" value={krw(-Math.abs(avgLoss))} tone="down" />
|
||||
<MetricCell label="Profit Factor" value={num(profitFactor)} tone={profitFactor >= 1 ? 'up' : 'down'} />
|
||||
<MetricCell label="손익비 (P/L)" value={num(plRatio)} tone="gold" />
|
||||
</div>
|
||||
<p className="btd-analysis-footnote">
|
||||
총 {tradeCount}회 · 승 {winCount} / 패 {lossCount}
|
||||
{breakEven > 0 ? ` / 무 ${breakEven}` : ''}
|
||||
· PF {num(profitFactor)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { OHLCVBar } from '../../types';
|
||||
|
||||
function spark(values: number[], w: number, h: number, min?: number, max?: number): string {
|
||||
if (!values.length) return '';
|
||||
const lo = min ?? Math.min(...values);
|
||||
const hi = max ?? Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||||
const y = h - ((v - lo) / range) * (h - 6) - 3;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function computeRsi(closes: number[], period = 14): number[] {
|
||||
if (closes.length < period + 1) return [];
|
||||
const out: number[] = [];
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
if (d >= 0) avgGain += d; else avgLoss -= d;
|
||||
}
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
for (let i = period; i < closes.length; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
const gain = d > 0 ? d : 0;
|
||||
const loss = d < 0 ? -d : 0;
|
||||
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||
out.push(100 - 100 / (1 + rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeMacd(closes: number[]): number[] {
|
||||
const ema = (arr: number[], p: number) => {
|
||||
const k = 2 / (p + 1);
|
||||
let v = arr[0];
|
||||
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
|
||||
};
|
||||
const e12 = ema(closes, 12);
|
||||
const e26 = ema(closes, 26);
|
||||
return e12.map((v, i) => v - e26[i]);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
bars: OHLCVBar[];
|
||||
}
|
||||
|
||||
/** 백테스팅 분석 — RSI / MACD 하단 pane (참조 UI) */
|
||||
const BacktestOscillatorPanes: React.FC<Props> = ({ bars }) => {
|
||||
const { rsiPath, macdPath, rsiLast, macdLast } = useMemo(() => {
|
||||
const closes = bars.map(b => b.close);
|
||||
const rsi = computeRsi(closes).slice(-80);
|
||||
const macd = computeMacd(closes).slice(-80);
|
||||
return {
|
||||
rsiPath: spark(rsi, 280, 52, 0, 100),
|
||||
macdPath: spark(macd, 280, 52),
|
||||
rsiLast: rsi[rsi.length - 1],
|
||||
macdLast: macd[macd.length - 1],
|
||||
};
|
||||
}, [bars]);
|
||||
|
||||
if (bars.length < 20) return null;
|
||||
|
||||
return (
|
||||
<div className="btd-osc-stack">
|
||||
<div className="btd-osc-pane">
|
||||
<div className="btd-osc-head">
|
||||
<span className="btd-osc-label">RSI (14)</span>
|
||||
<span className="btd-osc-val">{rsiLast != null ? rsiLast.toFixed(1) : '–'}</span>
|
||||
</div>
|
||||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||||
<line x1="0" y1="15" x2="280" y2="15" className="btd-osc-ref btd-osc-ref--hi" />
|
||||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||||
<line x1="0" y1="37" x2="280" y2="37" className="btd-osc-ref btd-osc-ref--lo" />
|
||||
<path d={rsiPath} className="btd-osc-line btd-osc-line--rsi" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="btd-osc-pane">
|
||||
<div className="btd-osc-head">
|
||||
<span className="btd-osc-label">MACD (12, 26, 9)</span>
|
||||
<span className="btd-osc-val">{macdLast != null ? macdLast.toFixed(2) : '–'}</span>
|
||||
</div>
|
||||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||||
<path d={macdPath} className="btd-osc-line btd-osc-line--macd" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestOscillatorPanes;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import type { TradeHistoryRow } from '../../utils/backtestEquity';
|
||||
import { pct } from '../../utils/backtestUiUtils';
|
||||
|
||||
const fmtDateTime = (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
trades: TradeHistoryRow[];
|
||||
}
|
||||
|
||||
export default function BacktestTradeHistoryCard({ trades }: Props) {
|
||||
return (
|
||||
<section className="rsp-trade-card btd-result-card btd-result-card--trades btd-trades-card">
|
||||
<h3 className="rsp-trade-card-title">
|
||||
상세 거래 내역
|
||||
<span className="btd-trades-count">{trades.length}건</span>
|
||||
</h3>
|
||||
<div className="rsp-trade-card-body btd-trades-scroll">
|
||||
{trades.length === 0 ? (
|
||||
<p className="btd-trades-empty">거래 내역이 없습니다.</p>
|
||||
) : (
|
||||
<table className="btd-table btd-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th>
|
||||
<th>유형</th>
|
||||
<th>체결가</th>
|
||||
<th>손익</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
|
||||
<td className={t.side === 'buy' ? 'buy' : 'sell'}>{t.side === 'buy' ? '매수' : '매도'}</td>
|
||||
<td>{Math.round(t.price).toLocaleString()}</td>
|
||||
<td className={t.pnlPct != null ? (t.pnlPct >= 0 ? 'up' : 'down') : ''}>
|
||||
{t.pnlPct != null ? pct(t.pnlPct) : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user