"""Bridge between the paper portfolio (whale_hunter/agent) and the backtesting engine. Converts paper_portfolio.json into Backtrader-compatible signals so you can measure how well the Graham/Deep-Value screening strategy performs against a simple Buy-and-Hold baseline. """ from __future__ import annotations import json from datetime import datetime, timedelta from pathlib import Path from typing import Optional import pandas as pd import yfinance as yf from src.core.logger import get_logger logger = get_logger(__name__) PORTFOLIO_FILE = "paper_portfolio.json" def load_paper_portfolio(path: str = PORTFOLIO_FILE) -> list[dict]: """Load the paper portfolio JSON into a list of trade records.""" p = Path(path) if not p.exists(): logger.warning("Portfolio file not found: %s", path) return [] try: with open(p) as f: return json.load(f) except (json.JSONDecodeError, OSError) as exc: logger.error("Could not read portfolio: %s", exc) return [] def portfolio_to_signals( portfolio: list[dict], hold_days: int = 30, ) -> dict[str, pd.DataFrame]: """Convert paper portfolio entries into per-symbol signal DataFrames. For each BUY / STRONG BUY entry, generates a BUY signal on the entry date and a SELL signal ``hold_days`` later. WATCH entries get a smaller position size. Returns: Mapping of symbol -> DataFrame with columns: date, trading_signal, position_size, confidence_level """ by_symbol: dict[str, list[dict]] = {} for trade in portfolio: ticker = trade.get("ticker", "") if not ticker: continue by_symbol.setdefault(ticker, []).append(trade) result: dict[str, pd.DataFrame] = {} for symbol, trades in by_symbol.items(): rows = [] for t in trades: entry_date = datetime.strptime(t["date"], "%Y-%m-%d") verdict = t.get("verdict", "BUY").upper() if "STRONG BUY" in verdict: size = 80 confidence = 0.9 elif "BUY" in verdict: size = 50 confidence = 0.7 elif "WATCH" in verdict: size = 20 confidence = 0.4 else: continue rows.append({ "date": entry_date, "trading_signal": "BUY", "position_size": size, "confidence_level": confidence, }) exit_date = entry_date + timedelta(days=hold_days) rows.append({ "date": exit_date, "trading_signal": "SELL", "position_size": 100, "confidence_level": confidence, }) if rows: df = pd.DataFrame(rows) df = df.sort_values("date").reset_index(drop=True) df = df.drop_duplicates(subset=["date"], keep="last") result[symbol] = df logger.info( "Converted %d portfolio entries into signals for %d symbols", len(portfolio), len(result), ) return result def backtest_portfolio( portfolio_path: str = PORTFOLIO_FILE, hold_days: int = 30, output_dir: str = "output/backtests", ) -> dict[str, dict]: """Run a backtest for every symbol in the paper portfolio. Uses the existing Backtrader-based engine with PrimoAgentStrategy. Returns: Mapping of symbol -> {primo: metrics_dict, buyhold: metrics_dict} """ from src.backtesting.engine import run_backtest from src.backtesting.strategies import PrimoAgentStrategy, BuyAndHoldStrategy from src.backtesting.plotting import plot_single_stock portfolio = load_paper_portfolio(portfolio_path) if not portfolio: logger.warning("No trades to backtest") return {} signals_map = portfolio_to_signals(portfolio, hold_days=hold_days) all_results: dict[str, dict] = {} for symbol, signals_df in signals_map.items(): logger.info("Backtesting %s (%d signals)...", symbol, len(signals_df)) try: start_date = signals_df["date"].min() - timedelta(days=5) end_date = signals_df["date"].max() + timedelta(days=5) ticker = yf.Ticker(symbol) ohlc = ticker.history(start=start_date, end=end_date) if ohlc.empty: logger.warning("No OHLC data for %s – skipping", symbol) continue ohlc = ohlc.reset_index() primo_results, primo_cerebro = run_backtest( ohlc, PrimoAgentStrategy, f"{symbol} PrimoAgent", signals_df=signals_df, ) buyhold_results, buyhold_cerebro = run_backtest( ohlc, BuyAndHoldStrategy, f"{symbol} Buy & Hold", ) all_results[symbol] = { "primo": primo_results, "buyhold": buyhold_results, } try: plot_single_stock( symbol, primo_cerebro, buyhold_cerebro, output_dir, f"portfolio_backtest_{symbol}.png", ) except Exception as exc: logger.warning("Chart generation failed for %s: %s", symbol, exc) primo_ret = primo_results["Cumulative Return [%]"] bh_ret = buyhold_results["Cumulative Return [%]"] diff = primo_ret - bh_ret logger.info( "%s: PrimoAgent %.2f%% vs Buy&Hold %.2f%% (%+.2f%%)", symbol, primo_ret, bh_ret, diff, ) except Exception as exc: logger.error("Backtest failed for %s: %s", symbol, exc, exc_info=True) continue if all_results: total = len(all_results) wins = sum( 1 for r in all_results.values() if r["primo"]["Cumulative Return [%]"] > r["buyhold"]["Cumulative Return [%]"] ) avg_primo = sum(r["primo"]["Cumulative Return [%]"] for r in all_results.values()) / total avg_bh = sum(r["buyhold"]["Cumulative Return [%]"] for r in all_results.values()) / total logger.info("=== PORTFOLIO BACKTEST SUMMARY ===") logger.info("Symbols tested: %d", total) logger.info("PrimoAgent wins: %d/%d (%.1f%%)", wins, total, wins / total * 100) logger.info("Avg PrimoAgent: %.2f%% | Avg Buy&Hold: %.2f%% | Alpha: %+.2f%%", avg_primo, avg_bh, avg_primo - avg_bh) return all_results