백테스팅 적용
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import { API_BASE } from './backendApi';
|
||||
import { timeframeToCandleType } from './chartCandleType';
|
||||
import { fetchUpbitCandlesBeforeCached } from './requestCache';
|
||||
import { normalizeEpochSec } from './backtestUiUtils';
|
||||
|
||||
/** 백엔드·업비트 history API용 ISO (Z 접미사 없음 — 서버에서 추가) */
|
||||
export function toHistoryApiIso(epochSec: number): string {
|
||||
return new Date(epochSec * 1000).toISOString().replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
|
||||
/** 백테스팅·실매매 분석용 과거 캔들 로드 */
|
||||
export async function loadAnalysisCandles(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
toTimeSec: number,
|
||||
count: number,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const type = timeframeToCandleType(timeframe);
|
||||
const toSec = normalizeEpochSec(toTimeSec);
|
||||
const to = toHistoryApiIso(toSec);
|
||||
const safeCount = Math.max(50, Math.min(count, 500));
|
||||
const params = new URLSearchParams({
|
||||
market,
|
||||
type,
|
||||
to,
|
||||
count: String(safeCount),
|
||||
});
|
||||
|
||||
let data: OHLCVBar[] = [];
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`);
|
||||
if (res.ok) {
|
||||
const raw: Array<Record<string, number>> = await res.json();
|
||||
data = raw.map(d => ({
|
||||
time: d.time,
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume,
|
||||
}));
|
||||
}
|
||||
} catch { /* backend fallback below */ }
|
||||
|
||||
if (data.length === 0) {
|
||||
try {
|
||||
data = await fetchUpbitCandlesBeforeCached(market, timeframe, safeCount, to);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return data.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Timeframe } from '../types';
|
||||
|
||||
export function normalizeChartTimeframe(raw: string | undefined): Timeframe {
|
||||
const t = (raw ?? '1h').trim();
|
||||
const lower = t.toLowerCase();
|
||||
if (lower === '1d') return '1D';
|
||||
if (lower === '1w') return '1W';
|
||||
if (lower === '1m' && t === '1M') return '1M';
|
||||
if (['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h'].includes(lower)) return lower as Timeframe;
|
||||
if (t === '1D' || t === '1W' || t === '1M') return t as Timeframe;
|
||||
return '1h';
|
||||
}
|
||||
|
||||
/** Unix epoch → 초 단위 정규화 (유효하지 않으면 0) */
|
||||
export function normalizeEpochSecOptional(t: number | undefined | null): number {
|
||||
if (t == null || !Number.isFinite(t) || t <= 0) return 0;
|
||||
return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t);
|
||||
}
|
||||
|
||||
/** Unix epoch → 초 단위 정규화 (유효하지 않으면 현재 시각) */
|
||||
export function normalizeEpochSec(t: number | undefined | null): number {
|
||||
const v = normalizeEpochSecOptional(t);
|
||||
return v > 0 ? v : Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
export function fmtListTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
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')}`;
|
||||
}
|
||||
|
||||
export function fmtShortTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const yy = String(d.getFullYear()).slice(2);
|
||||
return `${yy}.${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')}`;
|
||||
}
|
||||
|
||||
export const pct = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%';
|
||||
|
||||
export const pctAbs = (v: number, dec = 0) =>
|
||||
isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '0%';
|
||||
|
||||
export const krw = (v: number) =>
|
||||
isFinite(v) ? `${v >= 0 ? '+' : ''}₩${Math.round(v).toLocaleString()}` : '–';
|
||||
|
||||
const TF_SEC: Record<string, number> = {
|
||||
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||||
};
|
||||
|
||||
export function timeframeBarSeconds(tf: string): number {
|
||||
const n = normalizeChartTimeframe(tf);
|
||||
return TF_SEC[n] ?? 3600;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
|
||||
import { computePaperMetrics } from './paperMetrics';
|
||||
|
||||
export interface LiveExecutionItem {
|
||||
id: string;
|
||||
symbol: string;
|
||||
strategyLabel: string;
|
||||
sourceLabel: string;
|
||||
trades: PaperTradeDto[];
|
||||
createdAt: string;
|
||||
totalReturnPct: number;
|
||||
winRatePct: number;
|
||||
tradeCount: number;
|
||||
mddPct: number;
|
||||
sharpeRatio: number;
|
||||
profitFactor: number;
|
||||
}
|
||||
|
||||
function dayKey(iso: string | null | undefined): string {
|
||||
if (!iso) return 'unknown';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'STRATEGY') return '자동';
|
||||
return '수동';
|
||||
}
|
||||
|
||||
/** 가상투자·모의투자 체결 이력을 실행 단위 목록으로 그룹 */
|
||||
export function buildLiveExecutionItems(
|
||||
trades: PaperTradeDto[],
|
||||
summary: PaperSummaryDto | null,
|
||||
strategyNames: Record<number, string> = {},
|
||||
): LiveExecutionItem[] {
|
||||
if (!trades.length) return [];
|
||||
|
||||
const groups = new Map<string, PaperTradeDto[]>();
|
||||
for (const t of trades) {
|
||||
const key = `${t.symbol}|${t.strategyId ?? 0}|${dayKey(t.createdAt)}|${t.source}`;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(t);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const initial = summary?.initialCapital ?? 10_000_000;
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([key, groupTrades]) => {
|
||||
const sorted = [...groupTrades].sort((a, b) =>
|
||||
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
|
||||
);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const metrics = computePaperMetrics(sorted, summary);
|
||||
const buyVol = sorted.filter(t => t.side === 'BUY').reduce((s, t) => s + t.netAmount, 0);
|
||||
const sellVol = sorted.filter(t => t.side === 'SELL').reduce((s, t) => s + t.netAmount, 0);
|
||||
const pnl = sellVol - buyVol;
|
||||
const totalReturnPct = initial > 0 ? pnl / initial : 0;
|
||||
|
||||
return {
|
||||
id: key,
|
||||
symbol: first.symbol,
|
||||
strategyLabel: first.strategyId != null
|
||||
? (strategyNames[first.strategyId] ?? `전략 #${first.strategyId}`)
|
||||
: '수동 매매',
|
||||
sourceLabel: sourceLabel(first.source),
|
||||
trades: sorted,
|
||||
createdAt: last.createdAt ?? first.createdAt ?? new Date().toISOString(),
|
||||
totalReturnPct,
|
||||
winRatePct: metrics.winRatePct / 100,
|
||||
tradeCount: sorted.length,
|
||||
mddPct: metrics.mddPct / 100,
|
||||
sharpeRatio: metrics.sharpeRatio,
|
||||
profitFactor: metrics.profitFactor,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function paperTradesToSignals(trades: PaperTradeDto[]) {
|
||||
return trades
|
||||
.filter(t => t.createdAt)
|
||||
.map(t => ({
|
||||
time: Math.floor(new Date(t.createdAt!).getTime() / 1000),
|
||||
type: t.side as 'BUY' | 'SELL',
|
||||
price: t.price,
|
||||
barIndex: 0,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user