Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/backtester.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def run_backtest(backtester: BacktestEngine) -> PerformanceMetrics | None:
model_provider=inputs.model_provider,
selected_analysts=inputs.selected_analysts,
initial_margin_requirement=inputs.margin_requirement,
generate_charts=True, # Enable chart generation by default
)

# Run the backtest with graceful exit handling
Expand Down
30 changes: 30 additions & 0 deletions src/backtesting/engine.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import warnings
from datetime import datetime
from typing import Sequence, Dict

Expand All @@ -14,6 +15,7 @@
from .valuation import calculate_portfolio_value, compute_exposures
from .output import OutputBuilder
from .benchmarks import BenchmarkCalculator
from src.utils.chart_generator import PerformanceChartGenerator

from src.tools.api import (
get_company_news,
Expand Down Expand Up @@ -44,6 +46,7 @@ def __init__(
model_provider: str,
selected_analysts: list[str] | None,
initial_margin_requirement: float,
generate_charts: bool = True,
) -> None:
self._agent = agent
self._tickers = tickers
Expand All @@ -53,6 +56,7 @@ def __init__(
self._model_name = model_name
self._model_provider = model_provider
self._selected_analysts = selected_analysts
self._generate_charts = generate_charts

self._portfolio = Portfolio(
tickers=tickers,
Expand All @@ -66,6 +70,10 @@ def __init__(

# Benchmark calculator
self._benchmark = BenchmarkCalculator()

# Chart generator (initialize only if needed)
if self._generate_charts:
self._chart_generator = PerformanceChartGenerator()

self._portfolio_values: list[PortfolioValuePoint] = []
self._table_rows: list[list] = []
Expand Down Expand Up @@ -186,6 +194,28 @@ def run_backtest(self) -> PerformanceMetrics:
if computed:
self._performance_metrics.update(computed)

# Generate charts if requested
if self._generate_charts and len(self._portfolio_values) > 1:
try:
# Generate portfolio performance chart
perf_chart_path = self._chart_generator.generate_portfolio_performance_chart(
self._portfolio_values,
filename=f"backtest_performance_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
)
print(f"Portfolio performance chart saved: {perf_chart_path}")

# Generate comprehensive dashboard if we have enough data
if len(self._portfolio_values) > 5:
dashboard_path = self._chart_generator.generate_combined_dashboard(
self._portfolio_values,
[], # Could extract backtest results if needed later
filename=f"backtest_dashboard_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
)
print(f"Performance dashboard saved: {dashboard_path}")

except Exception as e:
warnings.warn(f"Failed to generate performance charts: {e}")

return self._performance_metrics

def get_portfolio_values(self) -> Sequence[PortfolioValuePoint]:
Expand Down
249 changes: 249 additions & 0 deletions src/utils/chart_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from datetime import datetime
from typing import List, Dict, Optional
import os

class PerformanceChartGenerator:
"""Generate performance charts for backtesting results."""

def __init__(self, output_dir: str = "charts"):
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)

# Set matplotlib style for better looking charts
plt.style.use('seaborn-v0_8' if 'seaborn-v0_8' in plt.style.available else 'default')

def generate_portfolio_performance_chart(
self,
portfolio_values: List[Dict],
filename: Optional[str] = None,
show_drawdown: bool = True
) -> str:
"""
Generate a comprehensive portfolio performance chart.

Args:
portfolio_values: List of portfolio value points from backtesting
filename: Custom filename for the chart (optional)
show_drawdown: Whether to include drawdown subplot

Returns:
Path to the saved chart file
"""
if not portfolio_values:
raise ValueError("No portfolio values provided")

# Convert to DataFrame for easier manipulation
df = pd.DataFrame(portfolio_values)
df['Date'] = pd.to_datetime(df['Date'])
df = df.set_index('Date')

# Calculate additional metrics
df['Daily_Return'] = df['Portfolio Value'].pct_change()
df['Cumulative_Return'] = (df['Portfolio Value'] / df['Portfolio Value'].iloc[0] - 1) * 100

# Calculate drawdown
running_max = df['Portfolio Value'].cummax()
df['Drawdown'] = (df['Portfolio Value'] - running_max) / running_max * 100

# Create the chart
fig, axes = plt.subplots(2 if show_drawdown else 1, 1, figsize=(12, 8 if show_drawdown else 6))
if not show_drawdown:
axes = [axes]

# Portfolio value over time
axes[0].plot(df.index, df['Portfolio Value'], linewidth=2, color='#2E7D32', label='Portfolio Value')
axes[0].set_title('Portfolio Performance Over Time', fontsize=14, fontweight='bold')
axes[0].set_ylabel('Portfolio Value ($)', fontsize=12)
axes[0].grid(True, alpha=0.3)
axes[0].legend()

# Format y-axis to show currency
axes[0].yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))

# Add performance statistics as text box
total_return = df['Cumulative_Return'].iloc[-1]
max_drawdown = df['Drawdown'].min()
volatility = df['Daily_Return'].std() * np.sqrt(252) * 100 # Annualized volatility

stats_text = f'Total Return: {total_return:.1f}%\nMax Drawdown: {max_drawdown:.1f}%\nAnnual Volatility: {volatility:.1f}%'
axes[0].text(0.02, 0.98, stats_text, transform=axes[0].transAxes,
bbox=dict(boxstyle="round,pad=0.3", facecolor="lightblue", alpha=0.8),
verticalalignment='top', fontsize=10)

if show_drawdown:
# Drawdown chart
axes[1].fill_between(df.index, df['Drawdown'], 0, color='red', alpha=0.6, label='Drawdown')
axes[1].set_title('Portfolio Drawdown', fontsize=14, fontweight='bold')
axes[1].set_ylabel('Drawdown (%)', fontsize=12)
axes[1].set_xlabel('Date', fontsize=12)
axes[1].grid(True, alpha=0.3)
axes[1].legend()

# Highlight max drawdown point
max_dd_date = df['Drawdown'].idxmin()
axes[1].scatter(max_dd_date, max_drawdown, color='darkred', s=50, zorder=5)
axes[1].annotate(f'Max DD: {max_drawdown:.1f}%',
xy=(max_dd_date, max_drawdown),
xytext=(10, 10), textcoords='offset points',
bbox=dict(boxstyle="round,pad=0.3", facecolor="yellow", alpha=0.8),
fontsize=9)

plt.tight_layout()

# Save the chart
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"portfolio_performance_{timestamp}.png"

filepath = os.path.join(self.output_dir, filename)
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()

return filepath

def generate_agent_signals_chart(
self,
backtest_results: List[Dict],
filename: Optional[str] = None
) -> str:
"""
Generate a chart showing agent signal distribution over time.

Args:
backtest_results: List of daily backtest results
filename: Custom filename for the chart (optional)

Returns:
Path to the saved chart file
"""
if not backtest_results:
raise ValueError("No backtest results provided")

# Extract signal data
dates = []
bullish_counts = []
bearish_counts = []
neutral_counts = []

for result in backtest_results:
dates.append(pd.to_datetime(result['date']))

total_bullish = 0
total_bearish = 0
total_neutral = 0

for ticker_detail in result.get('ticker_details', []):
total_bullish += ticker_detail.get('bullish_count', 0)
total_bearish += ticker_detail.get('bearish_count', 0)
total_neutral += ticker_detail.get('neutral_count', 0)

bullish_counts.append(total_bullish)
bearish_counts.append(total_bearish)
neutral_counts.append(total_neutral)

# Create the chart
fig, ax = plt.subplots(figsize=(12, 6))

# Stacked area chart
ax.stackplot(dates, bullish_counts, bearish_counts, neutral_counts,
labels=['Bullish Signals', 'Bearish Signals', 'Neutral Signals'],
colors=['#4CAF50', '#F44336', '#FF9800'],
alpha=0.8)

ax.set_title('Agent Signal Distribution Over Time', fontsize=14, fontweight='bold')
ax.set_ylabel('Signal Count', fontsize=12)
ax.set_xlabel('Date', fontsize=12)
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)

plt.tight_layout()

# Save the chart
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"agent_signals_{timestamp}.png"

filepath = os.path.join(self.output_dir, filename)
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()

return filepath

def generate_combined_dashboard(
self,
portfolio_values: List[Dict],
backtest_results: List[Dict],
filename: Optional[str] = None
) -> str:
"""
Generate a comprehensive dashboard with multiple charts.

Args:
portfolio_values: List of portfolio value points
backtest_results: List of daily backtest results
filename: Custom filename for the dashboard (optional)

Returns:
Path to the saved dashboard file
"""
if not portfolio_values:
raise ValueError("Portfolio values are required")

fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 10))

# Convert portfolio data
df = pd.DataFrame(portfolio_values)
df['Date'] = pd.to_datetime(df['Date'])
df = df.set_index('Date')
df['Cumulative_Return'] = (df['Portfolio Value'] / df['Portfolio Value'].iloc[0] - 1) * 100
running_max = df['Portfolio Value'].cummax()
df['Drawdown'] = (df['Portfolio Value'] - running_max) / running_max * 100

# 1. Portfolio Value
ax1.plot(df.index, df['Portfolio Value'], linewidth=2, color='#2E7D32')
ax1.set_title('Portfolio Value', fontweight='bold')
ax1.set_ylabel('Value ($)')
ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))
ax1.grid(True, alpha=0.3)

# 2. Cumulative Returns
ax2.plot(df.index, df['Cumulative_Return'], linewidth=2, color='#1976D2')
ax2.set_title('Cumulative Returns', fontweight='bold')
ax2.set_ylabel('Return (%)')
ax2.axhline(y=0, color='black', linestyle='-', alpha=0.3)
ax2.grid(True, alpha=0.3)

# 3. Drawdown
ax3.fill_between(df.index, df['Drawdown'], 0, color='red', alpha=0.6)
ax3.set_title('Drawdown', fontweight='bold')
ax3.set_ylabel('Drawdown (%)')
ax3.grid(True, alpha=0.3)

# 4. Exposure over time (if available)
if 'Gross Exposure' in df.columns:
ax4.plot(df.index, df['Gross Exposure'], label='Gross', linewidth=2, color='purple')
ax4.plot(df.index, df['Net Exposure'], label='Net', linewidth=2, color='orange')
ax4.set_title('Portfolio Exposure', fontweight='bold')
ax4.set_ylabel('Exposure')
ax4.legend()
else:
ax4.text(0.5, 0.5, 'Exposure data\nnot available', ha='center', va='center',
transform=ax4.transAxes, fontsize=12)
ax4.set_title('Portfolio Exposure', fontweight='bold')
ax4.grid(True, alpha=0.3)

plt.tight_layout()

# Save the dashboard
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"portfolio_dashboard_{timestamp}.png"

filepath = os.path.join(self.output_dir, filename)
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()

return filepath