PrimoGreedy-Agent / src /backtesting /portfolio_bridge.py
CiscsoPonce's picture
feat: major architecture refactor with yFinance screener, scoring, and insider feeds
8ed954c
"""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