백테스팅 적용

This commit is contained in:
Macbook
2026-05-25 20:23:54 +09:00
parent 02d14e4b2b
commit cae1c3a624
33 changed files with 3399 additions and 385 deletions
@@ -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>
);
}