수익률 평기 기준 통일

This commit is contained in:
Macbook
2026-06-20 09:47:35 +09:00
parent 6a5ac71344
commit fd63c101e5
6 changed files with 151 additions and 80 deletions
+11 -18
View File
@@ -1,7 +1,6 @@
import type { OHLCVBar } from '../types';
import type { StrategyDto } from './backendApi';
import { fetchLiveConditionScanSignals } from './backendApi';
import { buildEvalParamsFromStrategy } from './strategyEvaluationParams';
import { fetchStrategyEvaluationSignals } from './strategyEvaluationSignals';
import { buildStrategyEvaluationToolbarSummary } from './strategyEvaluationReport';
export interface StrategyBulkEvalResult {
@@ -49,26 +48,20 @@ export async function evaluateStrategyBulkReturns(opts: {
throw new Error('평가 데이터 부족');
}
const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams);
const signals = await fetchLiveConditionScanSignals(
opts.market,
const { signals, initialCapital, commissionRate } = await fetchStrategyEvaluationSignals({
strategyId,
opts.bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
opts.timeframe,
indicatorParams,
opts.evaluationBarCount,
);
strategy: opts.strategy,
market: opts.market,
timeframe: opts.timeframe,
bars: opts.bars,
evaluationBarCount: opts.evaluationBarCount,
getParams: opts.getParams,
});
const summary = buildStrategyEvaluationToolbarSummary(signals, {
initialCapital: opts.initialCapital,
initialCapital: initialCapital ?? opts.initialCapital,
symbol: opts.market.replace(/^KRW-/, ''),
commissionRate,
});
return {
+21 -9
View File
@@ -66,6 +66,7 @@ export function simulateEvaluationPortfolio(
initialCapital: number,
symbol: string,
mode: BuyAllocationMode,
commissionRate = 0,
): EvaluationSimulationResult {
const sorted = sortSignals(signals);
const curve: EquityPoint[] = [];
@@ -107,9 +108,10 @@ export function simulateEvaluationPortfolio(
const frac = buyCashFraction(buyIndexInCycle, mode);
if (frac != null && cash > 0) {
const spend = cash * frac;
if (spend > 0 && s.price > 0) {
const commission = spend * commissionRate;
if (spend > 0 && s.price > 0 && cash >= spend + commission) {
const buyQty = spend / s.price;
cash -= spend;
cash -= spend + commission;
totalCost += spend;
qty += buyQty;
tradeId += 1;
@@ -126,10 +128,12 @@ export function simulateEvaluationPortfolio(
markEquity(s.time, s.price);
} else if (SELL_TYPES.has(s.type) && qty > 0) {
const proceeds = qty * s.price;
const pnl = proceeds - totalCost;
const commission = proceeds * commissionRate;
const netProceeds = proceeds - commission;
const pnl = netProceeds - totalCost;
const avgEntry = totalCost / qty;
const pnlPct = avgEntry > 0 ? (s.price - avgEntry) / avgEntry : 0;
cash += proceeds;
const pnlPct = avgEntry > 0 ? (netProceeds / qty - avgEntry) / avgEntry : 0;
cash += netProceeds;
tradeId += 1;
trades.push({
id: tradeId,
@@ -309,6 +313,7 @@ export function buildStrategyEvaluationToolbarSummary(
opts?: {
initialCapital?: number;
symbol?: string;
commissionRate?: number;
},
): StrategyEvaluationToolbarSummary {
const filtered = filterSignalsForEvaluationReport(signals);
@@ -324,10 +329,11 @@ export function buildStrategyEvaluationToolbarSummary(
const capital = opts?.initialCapital ?? 10_000_000;
const symbol = opts?.symbol ?? '';
const commissionRate = opts?.commissionRate ?? 0;
const stillHolding = isStillHolding(filtered);
const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full');
const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split');
const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full', commissionRate);
const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split', commissionRate);
const fullAnalysis = buildAnalysisFromSimulation(fullSim, capital, 0, stillHolding);
const splitAnalysis = buildAnalysisFromSimulation(splitSim, capital, 0, stillHolding);
@@ -350,9 +356,11 @@ export function buildStrategyEvaluationReportModel(opts: {
createdAt?: string;
firstBarClose?: number;
lastBarClose?: number;
commissionRate?: number;
}): BacktestAnalysisReportModel | null {
const filtered = filterSignalsForEvaluationReport(opts.signals);
const stillHolding = isStillHolding(filtered);
const commissionRate = opts.commissionRate ?? 0;
const firstClose = opts.firstBarClose ?? filtered[0]?.price ?? 0;
const lastClose = opts.lastBarClose ?? filtered[filtered.length - 1]?.price ?? 0;
@@ -360,7 +368,9 @@ export function buildStrategyEvaluationReportModel(opts: {
const modes: BuyAllocationMode[] = ['full', 'split'];
const evaluationAllocations: EvaluationAllocationReport[] = modes.map(mode => {
const sim = simulateEvaluationPortfolio(filtered, opts.initialCapital, opts.symbol, mode);
const sim = simulateEvaluationPortfolio(
filtered, opts.initialCapital, opts.symbol, mode, commissionRate,
);
return {
mode,
label: ALLOCATION_LABELS[mode],
@@ -384,6 +394,8 @@ export function buildStrategyEvaluationReportModel(opts: {
reportKind: 'backtest',
evaluationAllocations,
reportSignalFilterNote:
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. 매도는 보유 물량 전량 매도로 가정합니다.',
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. '
+ '매도는 보유 물량 전량 매도로 가정합니다. '
+ '체결가·청산 Rule은 백테스트 설정(슬리피지·손절/익절·진입/청산가)과 동일합니다.',
};
}
@@ -0,0 +1,72 @@
/**
* 전략평가 — 차트·상단 요약·분석 레포트·일괄평가 공통 시그널 소스
*
* runBacktest(SIGNAL_ONLY) + 백테스트 설정(슬리피지·손절/익절·진입/청산가) 기준.
* scan-signals(종가·이상적 체결)와 달리 실제 백테스트 엔진과 동일한 Rule·체결가를 사용한다.
*/
import type { OHLCVBar } from '../types';
import {
loadBacktestSettings,
runBacktest,
type BacktestSettingsDto,
type BacktestSignal,
type StrategyDto,
} from './backendApi';
import { buildEvalParamsFromStrategy } from './strategyEvaluationParams';
export interface StrategyEvaluationSignalsResult {
signals: BacktestSignal[];
initialCapital: number;
settings: BacktestSettingsDto;
commissionRate: number;
}
export function resolveEvaluationCommissionRate(settings: BacktestSettingsDto): number {
if (settings.commissionType === 'ZERO') return 0;
return settings.commissionRate ?? 0.0015;
}
export async function fetchStrategyEvaluationSignals(opts: {
strategyId: number;
strategy: StrategyDto;
market: string;
timeframe: string;
bars: OHLCVBar[];
evaluationBarCount: number;
getParams: (type: string) => Record<string, number | string | boolean>;
backtestSettings?: BacktestSettingsDto | null;
}): Promise<StrategyEvaluationSignalsResult> {
const btSettings = opts.backtestSettings ?? await loadBacktestSettings();
const settings: BacktestSettingsDto = { ...btSettings, positionMode: 'SIGNAL_ONLY' };
const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams);
const res = await runBacktest({
strategyId: opts.strategyId,
bars: opts.bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe: opts.timeframe,
symbol: opts.market,
strategyName: opts.strategy.name,
settings,
indicatorParams,
evaluationBarCount: opts.evaluationBarCount,
});
if (!res?.signals) {
throw new Error('시그널 계산 실패');
}
const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000;
return {
signals: res.signals,
initialCapital,
settings,
commissionRate: resolveEvaluationCommissionRate(settings),
};
}