Files
lowkey_backtest/engine/reporting.py

229 lines
7.9 KiB
Python
Raw Permalink Normal View History

"""
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)