229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
"""
|
|
Reporting module for backtest results.
|
|
|
|
Handles summary printing, CSV exports, and plotting.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import plotly.graph_objects as go
|
|
import vectorbt as vbt
|
|
from plotly.subplots import make_subplots
|
|
|
|
from engine.logging_config import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class Reporter:
|
|
"""Reporter for backtest results with market-specific metrics."""
|
|
|
|
def __init__(self, output_dir: str = "backtest_logs"):
|
|
self.output_dir = Path(output_dir)
|
|
self.output_dir.mkdir(exist_ok=True)
|
|
|
|
def print_summary(self, result) -> None:
|
|
"""
|
|
Print backtest summary to console via logger.
|
|
|
|
Args:
|
|
result: BacktestResult or vbt.Portfolio object
|
|
"""
|
|
(portfolio, market_type, leverage, funding_paid,
|
|
liq_count, liq_loss, adjusted_return) = self._extract_result_data(result)
|
|
|
|
# Extract period info
|
|
idx = portfolio.wrapper.index
|
|
start_date = idx[0].strftime("%Y-%m-%d")
|
|
end_date = idx[-1].strftime("%Y-%m-%d")
|
|
|
|
# Extract price info
|
|
close = portfolio.close
|
|
start_price = close.iloc[0].mean() if hasattr(close.iloc[0], 'mean') else close.iloc[0]
|
|
end_price = close.iloc[-1].mean() if hasattr(close.iloc[-1], 'mean') else close.iloc[-1]
|
|
price_change = ((end_price - start_price) / start_price) * 100
|
|
|
|
# Extract fees
|
|
stats = portfolio.stats()
|
|
total_fees = stats.get('Total Fees Paid', 0)
|
|
|
|
raw_return = portfolio.total_return().mean() * 100
|
|
|
|
# Build summary
|
|
summary_lines = [
|
|
"",
|
|
"=" * 50,
|
|
"BACKTEST RESULTS",
|
|
"=" * 50,
|
|
f"Market Type: [{market_type.upper()}]",
|
|
f"Leverage: [{leverage}x]",
|
|
f"Period: [{start_date} to {end_date}]",
|
|
f"Price: [{start_price:,.2f} -> {end_price:,.2f} ({price_change:+.2f}%)]",
|
|
]
|
|
|
|
# Show adjusted return if liquidations occurred
|
|
if liq_count > 0 and adjusted_return is not None:
|
|
summary_lines.append(f"Raw Return: [%{raw_return:.2f}] (before liq adjustment)")
|
|
summary_lines.append(f"Adj Return: [%{adjusted_return:.2f}] (after liq losses)")
|
|
else:
|
|
summary_lines.append(f"Total Return: [%{raw_return:.2f}]")
|
|
|
|
summary_lines.extend([
|
|
f"Sharpe Ratio: [{portfolio.sharpe_ratio().mean():.2f}]",
|
|
f"Max Drawdown: [%{portfolio.max_drawdown().mean() * 100:.2f}]",
|
|
f"Total Trades: [{portfolio.trades.count().mean():.0f}]",
|
|
f"Win Rate: [%{portfolio.trades.win_rate().mean() * 100:.2f}]",
|
|
f"Total Fees: [{total_fees:,.2f}]",
|
|
])
|
|
|
|
if funding_paid != 0:
|
|
summary_lines.append(f"Funding Paid: [{funding_paid:,.2f}]")
|
|
if liq_count > 0:
|
|
summary_lines.append(f"Liquidations: [{liq_count}] (${liq_loss:,.2f} margin lost)")
|
|
|
|
summary_lines.append("=" * 50)
|
|
logger.info("\n".join(summary_lines))
|
|
|
|
def save_reports(self, result, filename_prefix: str) -> None:
|
|
"""
|
|
Save trade log, stats, and liquidation events to CSV files.
|
|
|
|
Args:
|
|
result: BacktestResult or vbt.Portfolio object
|
|
filename_prefix: Prefix for output filenames
|
|
"""
|
|
(portfolio, market_type, leverage, funding_paid,
|
|
liq_count, liq_loss, adjusted_return) = self._extract_result_data(result)
|
|
|
|
# Save trades
|
|
self._save_csv(
|
|
data=portfolio.trades.records_readable,
|
|
path=self.output_dir / f"{filename_prefix}_trades.csv",
|
|
description="trade log"
|
|
)
|
|
|
|
# Save stats with market-specific additions
|
|
stats = portfolio.stats()
|
|
stats['Market Type'] = market_type
|
|
stats['Leverage'] = leverage
|
|
stats['Total Funding Paid'] = funding_paid
|
|
stats['Liquidations'] = liq_count
|
|
stats['Liquidation Loss'] = liq_loss
|
|
if adjusted_return is not None:
|
|
stats['Adjusted Return'] = adjusted_return
|
|
|
|
self._save_csv(
|
|
data=stats,
|
|
path=self.output_dir / f"{filename_prefix}_stats.csv",
|
|
description="stats"
|
|
)
|
|
|
|
# Save liquidation events if any
|
|
if hasattr(result, 'liquidation_events') and result.liquidation_events:
|
|
liq_df = pd.DataFrame([
|
|
{
|
|
'entry_time': e.entry_time,
|
|
'entry_price': e.entry_price,
|
|
'liquidation_time': e.liquidation_time,
|
|
'liquidation_price': e.liquidation_price,
|
|
'actual_price': e.actual_price,
|
|
'direction': e.direction,
|
|
'margin_lost_pct': e.margin_lost_pct
|
|
}
|
|
for e in result.liquidation_events
|
|
])
|
|
self._save_csv(
|
|
data=liq_df,
|
|
path=self.output_dir / f"{filename_prefix}_liquidations.csv",
|
|
description="liquidation events"
|
|
)
|
|
|
|
def plot(self, portfolio: vbt.Portfolio, show: bool = True) -> None:
|
|
"""Display portfolio plot."""
|
|
if show:
|
|
portfolio.plot().show()
|
|
|
|
def plot_wfa(
|
|
self,
|
|
wfa_results: pd.DataFrame,
|
|
stitched_curve: pd.Series | None = None,
|
|
show: bool = True
|
|
) -> None:
|
|
"""
|
|
Plot Walk-Forward Analysis results.
|
|
|
|
Args:
|
|
wfa_results: DataFrame with WFA window results
|
|
stitched_curve: Stitched out-of-sample equity curve
|
|
show: Whether to display the plot
|
|
"""
|
|
fig = make_subplots(
|
|
rows=2, cols=1,
|
|
shared_xaxes=False,
|
|
vertical_spacing=0.1,
|
|
subplot_titles=(
|
|
"Walk-Forward Test Scores (Sharpe)",
|
|
"Stitched Out-of-Sample Equity"
|
|
)
|
|
)
|
|
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=wfa_results['window'],
|
|
y=wfa_results['test_score'],
|
|
name="Test Sharpe"
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
if stitched_curve is not None:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=stitched_curve.index,
|
|
y=stitched_curve.values,
|
|
name="Equity",
|
|
mode='lines'
|
|
),
|
|
row=2, col=1
|
|
)
|
|
|
|
fig.update_layout(height=800, title_text="Walk-Forward Analysis Report")
|
|
|
|
if show:
|
|
fig.show()
|
|
|
|
def _extract_result_data(self, result) -> tuple:
|
|
"""
|
|
Extract data from BacktestResult or raw Portfolio.
|
|
|
|
Returns:
|
|
Tuple of (portfolio, market_type, leverage, funding_paid, liq_count,
|
|
liq_loss, adjusted_return)
|
|
"""
|
|
if hasattr(result, 'portfolio'):
|
|
return (
|
|
result.portfolio,
|
|
result.market_type.value,
|
|
result.leverage,
|
|
result.total_funding_paid,
|
|
result.liquidation_count,
|
|
getattr(result, 'total_liquidation_loss', 0.0),
|
|
getattr(result, 'adjusted_return', None)
|
|
)
|
|
return (result, "unknown", 1, 0.0, 0, 0.0, None)
|
|
|
|
def _save_csv(self, data, path: Path, description: str) -> None:
|
|
"""
|
|
Save data to CSV with consistent error handling.
|
|
|
|
Args:
|
|
data: DataFrame or Series to save
|
|
path: Output file path
|
|
description: Human-readable description for logging
|
|
"""
|
|
try:
|
|
data.to_csv(path)
|
|
logger.info("Saved %s to %s", description, path)
|
|
except Exception as e:
|
|
logger.error("Could not save %s: %s", description, e)
|