수익률 평기 기준 통일
This commit is contained in:
@@ -46,6 +46,7 @@ public class BacktestingService {
|
|||||||
private final GcBacktestResultRepository resultRepository;
|
private final GcBacktestResultRepository resultRepository;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final IndicatorSettingsService indicatorSettingsService;
|
private final IndicatorSettingsService indicatorSettingsService;
|
||||||
|
private final StrategyDslTimeframeNormalizer timeframeNormalizer;
|
||||||
|
|
||||||
private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();
|
private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();
|
||||||
|
|
||||||
@@ -111,12 +112,20 @@ public class BacktestingService {
|
|||||||
|
|
||||||
// ── 멀티 타임프레임 지원: DSL에서 상위봉 타입 추출 후 집계 시리즈 빌드 ──────
|
// ── 멀티 타임프레임 지원: DSL에서 상위봉 타입 추출 후 집계 시리즈 빌드 ──────
|
||||||
String primaryTf = normalizeTf(req.getTimeframe());
|
String primaryTf = normalizeTf(req.getTimeframe());
|
||||||
|
// scan-signals·전략평가 차트와 동일 — 차트 시간봉에 맞게 DSL 리매핑
|
||||||
|
if (buyDsl != null && !buyDsl.isNull()) {
|
||||||
|
buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDsl, primaryTf);
|
||||||
|
}
|
||||||
|
if (sellDsl != null && !sellDsl.isNull()) {
|
||||||
|
sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDsl, primaryTf);
|
||||||
|
}
|
||||||
Map<String, BarSeries> seriesOverrides = buildSeriesOverrides(
|
Map<String, BarSeries> seriesOverrides = buildSeriesOverrides(
|
||||||
series, primaryTf, buyDsl, sellDsl);
|
series, primaryTf, buyDsl, sellDsl);
|
||||||
|
|
||||||
|
String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null;
|
||||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||||
series, params, visual, null, null, false, seriesOverrides);
|
series, params, visual, market, null, false, seriesOverrides);
|
||||||
Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
|
Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
|
||||||
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
|
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
|
||||||
? adapter.toRule(sellDsl, ruleCtx)
|
? adapter.toRule(sellDsl, ruleCtx)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
loadStrategies,
|
loadStrategies,
|
||||||
loadStrategy,
|
loadStrategy,
|
||||||
loadBacktestSettings,
|
loadBacktestSettings,
|
||||||
runBacktest,
|
type BacktestSettingsDto,
|
||||||
type BacktestSignal,
|
type BacktestSignal,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
formatEvaluationReturnPct,
|
formatEvaluationReturnPct,
|
||||||
padEvaluationSignalCount,
|
padEvaluationSignalCount,
|
||||||
} from '../utils/strategyEvaluationReport';
|
} from '../utils/strategyEvaluationReport';
|
||||||
|
import { resolveEvaluationCommissionRate } from '../utils/strategyEvaluationSignals';
|
||||||
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
|
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
|
||||||
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
|
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
|
||||||
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
||||||
@@ -152,6 +153,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
|
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
|
||||||
const [signalScanRunning, setSignalScanRunning] = useState(false);
|
const [signalScanRunning, setSignalScanRunning] = useState(false);
|
||||||
const [initialCapital, setInitialCapital] = useState(10_000_000);
|
const [initialCapital, setInitialCapital] = useState(10_000_000);
|
||||||
|
const [backtestSettings, setBacktestSettings] = useState<BacktestSettingsDto | null>(null);
|
||||||
const [editorModalOpen, setEditorModalOpen] = useState(false);
|
const [editorModalOpen, setEditorModalOpen] = useState(false);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
||||||
@@ -440,17 +442,24 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadBacktestSettings().then(s => {
|
void loadBacktestSettings().then(s => {
|
||||||
|
setBacktestSettings(s);
|
||||||
if (s?.initialCapital) setInitialCapital(s.initialCapital);
|
if (s?.initialCapital) setInitialCapital(s.initialCapital);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const evaluationCommissionRate = useMemo(
|
||||||
|
() => (backtestSettings ? resolveEvaluationCommissionRate(backtestSettings) : 0.0015),
|
||||||
|
[backtestSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const analysisSummary = useMemo(() => {
|
const analysisSummary = useMemo(() => {
|
||||||
if (!selectedStrategy) return null;
|
if (!selectedStrategy) return null;
|
||||||
return buildStrategyEvaluationToolbarSummary(backtestSignals, {
|
return buildStrategyEvaluationToolbarSummary(backtestSignals, {
|
||||||
initialCapital,
|
initialCapital,
|
||||||
symbol: market.replace(/^KRW-/, ''),
|
symbol: market.replace(/^KRW-/, ''),
|
||||||
|
commissionRate: evaluationCommissionRate,
|
||||||
});
|
});
|
||||||
}, [selectedStrategy, backtestSignals, initialCapital, market]);
|
}, [selectedStrategy, backtestSignals, initialCapital, market, evaluationCommissionRate]);
|
||||||
|
|
||||||
const bulkEval = useStrategyBulkEvaluation({
|
const bulkEval = useStrategyBulkEvaluation({
|
||||||
strategies,
|
strategies,
|
||||||
@@ -622,10 +631,14 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
if (!selectedStrategyId || bars.length < 10) return null;
|
if (!selectedStrategyId || bars.length < 10) return null;
|
||||||
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
|
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
|
||||||
const windowKey = evalWindowMeta.windowEndTimeSec ?? 'latest';
|
const windowKey = evalWindowMeta.windowEndTimeSec ?? 'latest';
|
||||||
return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}`;
|
const lastSig = backtestSignals[backtestSignals.length - 1];
|
||||||
}, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec]);
|
const sigKey = backtestSignals.length > 0
|
||||||
|
? `${backtestSignals.length}:${lastSig?.time}:${lastSig?.price}`
|
||||||
|
: '0';
|
||||||
|
return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}|${sigKey}`;
|
||||||
|
}, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec, backtestSignals]);
|
||||||
|
|
||||||
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading;
|
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading || signalScanRunning;
|
||||||
|
|
||||||
const aiVerifyDisabled = reportDisabled
|
const aiVerifyDisabled = reportDisabled
|
||||||
|| evalLoading
|
|| evalLoading
|
||||||
@@ -724,36 +737,10 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
const gen = ++reportGenRef.current;
|
const gen = ++reportGenRef.current;
|
||||||
setReportLoading(true);
|
setReportLoading(true);
|
||||||
try {
|
try {
|
||||||
const [btSettings] = await Promise.all([loadBacktestSettings()]);
|
|
||||||
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
|
||||||
const evaluationBarCount = evalWindowMeta.evaluationBarCount;
|
const evaluationBarCount = evalWindowMeta.evaluationBarCount;
|
||||||
const { evalBars } = resolveEvaluationBarSlice(bars, evaluationBarCount);
|
const { evalBars } = resolveEvaluationBarSlice(bars, evaluationBarCount);
|
||||||
const res = await runBacktest({
|
|
||||||
strategyId: selectedStrategyId,
|
|
||||||
bars: bars.map(b => ({
|
|
||||||
time: b.time,
|
|
||||||
open: b.open,
|
|
||||||
high: b.high,
|
|
||||||
low: b.low,
|
|
||||||
close: b.close,
|
|
||||||
volume: b.volume,
|
|
||||||
})),
|
|
||||||
timeframe: chartTimeframe,
|
|
||||||
symbol: market,
|
|
||||||
strategyName: selectedStrategy.name,
|
|
||||||
settings: { ...btSettings, positionMode: 'SIGNAL_ONLY' },
|
|
||||||
indicatorParams,
|
|
||||||
evaluationBarCount,
|
|
||||||
});
|
|
||||||
if (gen !== reportGenRef.current) return;
|
|
||||||
if (!res) {
|
|
||||||
window.alert('분석 레포트 생성에 실패했습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000;
|
|
||||||
const model = buildStrategyEvaluationReportModel({
|
const model = buildStrategyEvaluationReportModel({
|
||||||
signals: res.signals,
|
signals: backtestSignals,
|
||||||
initialCapital,
|
initialCapital,
|
||||||
strategyName: selectedStrategy.name ?? strategyLabel,
|
strategyName: selectedStrategy.name ?? strategyLabel,
|
||||||
symbol: market.replace(/^KRW-/, ''),
|
symbol: market.replace(/^KRW-/, ''),
|
||||||
@@ -761,7 +748,9 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
barCount: evaluationBarCount,
|
barCount: evaluationBarCount,
|
||||||
firstBarClose: evalBars[0]?.close,
|
firstBarClose: evalBars[0]?.close,
|
||||||
lastBarClose: evalBars[evalBars.length - 1]?.close,
|
lastBarClose: evalBars[evalBars.length - 1]?.close,
|
||||||
|
commissionRate: evaluationCommissionRate,
|
||||||
});
|
});
|
||||||
|
if (gen !== reportGenRef.current) return;
|
||||||
if (!model) {
|
if (!model) {
|
||||||
window.alert('분석 데이터를 생성할 수 없습니다.');
|
window.alert('분석 데이터를 생성할 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
@@ -782,12 +771,14 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
selectedStrategy,
|
selectedStrategy,
|
||||||
reportCacheKey,
|
reportCacheKey,
|
||||||
reportModel,
|
reportModel,
|
||||||
getEvalParams,
|
backtestSignals,
|
||||||
bars,
|
initialCapital,
|
||||||
chartTimeframe,
|
|
||||||
market,
|
|
||||||
strategyLabel,
|
strategyLabel,
|
||||||
|
market,
|
||||||
|
chartTimeframe,
|
||||||
|
bars,
|
||||||
evalWindowMeta.evaluationBarCount,
|
evalWindowMeta.evaluationBarCount,
|
||||||
|
evaluationCommissionRate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { getIndicatorDef } from '../../utils/indicatorRegistry';
|
|||||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||||
import { getKoreanName } from '../../utils/marketNameCache';
|
import { getKoreanName } from '../../utils/marketNameCache';
|
||||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||||
import { fetchLiveConditionScanSignals } from '../../utils/backendApi';
|
import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals';
|
||||||
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
|
||||||
import {
|
import {
|
||||||
BACKTEST_DISPLAY_BAR_COUNT,
|
BACKTEST_DISPLAY_BAR_COUNT,
|
||||||
@@ -394,23 +394,17 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
setBacktestRunning(true);
|
setBacktestRunning(true);
|
||||||
setBacktestError(null);
|
setBacktestError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchLiveConditionScanSignals(
|
const { signals: nextSignals } = await fetchStrategyEvaluationSignals({
|
||||||
|
strategyId: strategy.id,
|
||||||
|
strategy,
|
||||||
market,
|
market,
|
||||||
strategy.id,
|
|
||||||
barData.map(b => ({
|
|
||||||
time: b.time,
|
|
||||||
open: b.open,
|
|
||||||
high: b.high,
|
|
||||||
low: b.low,
|
|
||||||
close: b.close,
|
|
||||||
volume: b.volume,
|
|
||||||
})),
|
|
||||||
timeframe,
|
timeframe,
|
||||||
indicatorParams,
|
bars: barData,
|
||||||
evaluationBarCountRef.current,
|
evaluationBarCount: evaluationBarCountRef.current,
|
||||||
);
|
getParams,
|
||||||
|
});
|
||||||
if (gen !== backtestGenRef.current) return;
|
if (gen !== backtestGenRef.current) return;
|
||||||
setSignals(res);
|
setSignals(nextSignals);
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen !== backtestGenRef.current) return;
|
if (gen !== backtestGenRef.current) return;
|
||||||
@@ -420,7 +414,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
if (gen === backtestGenRef.current) setBacktestRunning(false);
|
if (gen === backtestGenRef.current) setBacktestRunning(false);
|
||||||
}
|
}
|
||||||
}, [strategy, market, timeframe, indicatorParams, applyMarkers]);
|
}, [strategy, market, timeframe, getParams, applyMarkers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!strategy?.id || loading || bars.length < 10) {
|
if (!strategy?.id || loading || bars.length < 10) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { OHLCVBar } from '../types';
|
import type { OHLCVBar } from '../types';
|
||||||
import type { StrategyDto } from './backendApi';
|
import type { StrategyDto } from './backendApi';
|
||||||
import { fetchLiveConditionScanSignals } from './backendApi';
|
import { fetchStrategyEvaluationSignals } from './strategyEvaluationSignals';
|
||||||
import { buildEvalParamsFromStrategy } from './strategyEvaluationParams';
|
|
||||||
import { buildStrategyEvaluationToolbarSummary } from './strategyEvaluationReport';
|
import { buildStrategyEvaluationToolbarSummary } from './strategyEvaluationReport';
|
||||||
|
|
||||||
export interface StrategyBulkEvalResult {
|
export interface StrategyBulkEvalResult {
|
||||||
@@ -49,26 +48,20 @@ export async function evaluateStrategyBulkReturns(opts: {
|
|||||||
throw new Error('평가 데이터 부족');
|
throw new Error('평가 데이터 부족');
|
||||||
}
|
}
|
||||||
|
|
||||||
const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams);
|
const { signals, initialCapital, commissionRate } = await fetchStrategyEvaluationSignals({
|
||||||
const signals = await fetchLiveConditionScanSignals(
|
|
||||||
opts.market,
|
|
||||||
strategyId,
|
strategyId,
|
||||||
opts.bars.map(b => ({
|
strategy: opts.strategy,
|
||||||
time: b.time,
|
market: opts.market,
|
||||||
open: b.open,
|
timeframe: opts.timeframe,
|
||||||
high: b.high,
|
bars: opts.bars,
|
||||||
low: b.low,
|
evaluationBarCount: opts.evaluationBarCount,
|
||||||
close: b.close,
|
getParams: opts.getParams,
|
||||||
volume: b.volume,
|
});
|
||||||
})),
|
|
||||||
opts.timeframe,
|
|
||||||
indicatorParams,
|
|
||||||
opts.evaluationBarCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
const summary = buildStrategyEvaluationToolbarSummary(signals, {
|
const summary = buildStrategyEvaluationToolbarSummary(signals, {
|
||||||
initialCapital: opts.initialCapital,
|
initialCapital: initialCapital ?? opts.initialCapital,
|
||||||
symbol: opts.market.replace(/^KRW-/, ''),
|
symbol: opts.market.replace(/^KRW-/, ''),
|
||||||
|
commissionRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function simulateEvaluationPortfolio(
|
|||||||
initialCapital: number,
|
initialCapital: number,
|
||||||
symbol: string,
|
symbol: string,
|
||||||
mode: BuyAllocationMode,
|
mode: BuyAllocationMode,
|
||||||
|
commissionRate = 0,
|
||||||
): EvaluationSimulationResult {
|
): EvaluationSimulationResult {
|
||||||
const sorted = sortSignals(signals);
|
const sorted = sortSignals(signals);
|
||||||
const curve: EquityPoint[] = [];
|
const curve: EquityPoint[] = [];
|
||||||
@@ -107,9 +108,10 @@ export function simulateEvaluationPortfolio(
|
|||||||
const frac = buyCashFraction(buyIndexInCycle, mode);
|
const frac = buyCashFraction(buyIndexInCycle, mode);
|
||||||
if (frac != null && cash > 0) {
|
if (frac != null && cash > 0) {
|
||||||
const spend = cash * frac;
|
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;
|
const buyQty = spend / s.price;
|
||||||
cash -= spend;
|
cash -= spend + commission;
|
||||||
totalCost += spend;
|
totalCost += spend;
|
||||||
qty += buyQty;
|
qty += buyQty;
|
||||||
tradeId += 1;
|
tradeId += 1;
|
||||||
@@ -126,10 +128,12 @@ export function simulateEvaluationPortfolio(
|
|||||||
markEquity(s.time, s.price);
|
markEquity(s.time, s.price);
|
||||||
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
||||||
const proceeds = qty * s.price;
|
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 avgEntry = totalCost / qty;
|
||||||
const pnlPct = avgEntry > 0 ? (s.price - avgEntry) / avgEntry : 0;
|
const pnlPct = avgEntry > 0 ? (netProceeds / qty - avgEntry) / avgEntry : 0;
|
||||||
cash += proceeds;
|
cash += netProceeds;
|
||||||
tradeId += 1;
|
tradeId += 1;
|
||||||
trades.push({
|
trades.push({
|
||||||
id: tradeId,
|
id: tradeId,
|
||||||
@@ -309,6 +313,7 @@ export function buildStrategyEvaluationToolbarSummary(
|
|||||||
opts?: {
|
opts?: {
|
||||||
initialCapital?: number;
|
initialCapital?: number;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
commissionRate?: number;
|
||||||
},
|
},
|
||||||
): StrategyEvaluationToolbarSummary {
|
): StrategyEvaluationToolbarSummary {
|
||||||
const filtered = filterSignalsForEvaluationReport(signals);
|
const filtered = filterSignalsForEvaluationReport(signals);
|
||||||
@@ -324,10 +329,11 @@ export function buildStrategyEvaluationToolbarSummary(
|
|||||||
|
|
||||||
const capital = opts?.initialCapital ?? 10_000_000;
|
const capital = opts?.initialCapital ?? 10_000_000;
|
||||||
const symbol = opts?.symbol ?? '';
|
const symbol = opts?.symbol ?? '';
|
||||||
|
const commissionRate = opts?.commissionRate ?? 0;
|
||||||
const stillHolding = isStillHolding(filtered);
|
const stillHolding = isStillHolding(filtered);
|
||||||
|
|
||||||
const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full');
|
const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full', commissionRate);
|
||||||
const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split');
|
const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split', commissionRate);
|
||||||
const fullAnalysis = buildAnalysisFromSimulation(fullSim, capital, 0, stillHolding);
|
const fullAnalysis = buildAnalysisFromSimulation(fullSim, capital, 0, stillHolding);
|
||||||
const splitAnalysis = buildAnalysisFromSimulation(splitSim, capital, 0, stillHolding);
|
const splitAnalysis = buildAnalysisFromSimulation(splitSim, capital, 0, stillHolding);
|
||||||
|
|
||||||
@@ -350,9 +356,11 @@ export function buildStrategyEvaluationReportModel(opts: {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
firstBarClose?: number;
|
firstBarClose?: number;
|
||||||
lastBarClose?: number;
|
lastBarClose?: number;
|
||||||
|
commissionRate?: number;
|
||||||
}): BacktestAnalysisReportModel | null {
|
}): BacktestAnalysisReportModel | null {
|
||||||
const filtered = filterSignalsForEvaluationReport(opts.signals);
|
const filtered = filterSignalsForEvaluationReport(opts.signals);
|
||||||
const stillHolding = isStillHolding(filtered);
|
const stillHolding = isStillHolding(filtered);
|
||||||
|
const commissionRate = opts.commissionRate ?? 0;
|
||||||
|
|
||||||
const firstClose = opts.firstBarClose ?? filtered[0]?.price ?? 0;
|
const firstClose = opts.firstBarClose ?? filtered[0]?.price ?? 0;
|
||||||
const lastClose = opts.lastBarClose ?? filtered[filtered.length - 1]?.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 modes: BuyAllocationMode[] = ['full', 'split'];
|
||||||
const evaluationAllocations: EvaluationAllocationReport[] = modes.map(mode => {
|
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 {
|
return {
|
||||||
mode,
|
mode,
|
||||||
label: ALLOCATION_LABELS[mode],
|
label: ALLOCATION_LABELS[mode],
|
||||||
@@ -384,6 +394,8 @@ export function buildStrategyEvaluationReportModel(opts: {
|
|||||||
reportKind: 'backtest',
|
reportKind: 'backtest',
|
||||||
evaluationAllocations,
|
evaluationAllocations,
|
||||||
reportSignalFilterNote:
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user