"""Tests for the mention-driven Kevin backtest mini-engine.""" from datetime import datetime, timedelta, timezone from decimal import Decimal import pandas as pd import pytest from backtester.kevin_backtest import ( KevinBacktestParams, KevinBacktestRunner, ) from backtester.metrics import BacktestResult from shared.strategies.kevin import KevinStrategy, KevinStrategyConfig class _StubPriceLoader: """In-memory bars; behaves like the real KevinPriceLoader.""" def __init__(self, bars_by_symbol: dict[str, pd.DataFrame]): self.bars = bars_by_symbol self.spy = bars_by_symbol.get("SPY") async def daily_bars(self, symbol, start, end): return self.bars.get(symbol, pd.DataFrame()) async def is_tradable(self, symbol): return symbol in self.bars async def benchmark_bars(self, start, end): return self.spy if self.spy is not None else pd.DataFrame() def _mention(symbol, action, conviction, horizon, days_ago): return type( "M", (), { "id": days_ago, "symbol": symbol, "action": type("A", (), {"value": action})(), "conviction": Decimal(conviction), "time_horizon": type("H", (), {"value": horizon})(), "created_at": datetime(2026, 5, 15, 14, 0, tzinfo=timezone.utc) + timedelta(days=days_ago), }, ) def _bars(symbol, start_date, prices): """Build a daily-bar DataFrame indexed by date.""" dates = pd.date_range(start_date, periods=len(prices), freq="B", tz="UTC") return pd.DataFrame( { "open": prices, "high": prices, "low": prices, "close": prices, }, index=dates, ) @pytest.fixture def cfg() -> KevinStrategyConfig: return KevinStrategyConfig( min_conviction=Decimal("0.6"), max_mention_age_hours=48 * 365, # effectively no age filter for backtest base_position_pct=Decimal("0.04"), min_trade_usd=Decimal("500"), max_trade_usd=Decimal("5000"), max_per_ticker_usd=Decimal("7500"), hold_days_by_horizon={ "days": 3, "weeks": 5, "months": 10, "long_term": 15, "unspecified": 5, }, avoid_closes_longs=True, avoid_blocks_days=7, ) async def test_backtest_emits_winning_trade(cfg): # NVDA: enters at $100 day 0, exits at $110 day 5 = +10% bars = { "NVDA": _bars("NVDA", "2026-05-15", [100, 102, 104, 106, 108, 110, 112]), "SPY": _bars("SPY", "2026-05-15", [500, 501, 502, 503, 504, 505, 506]), } strategy = KevinStrategy(cfg) mentions = [_mention("NVDA", "buy", "0.8", "weeks", 0)] runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) result = await runner.run( mentions, KevinBacktestParams( initial_capital=Decimal("100000"), slippage_pct=Decimal("0.0005"), ), ) assert isinstance(result, BacktestResult) assert result.trade_count == 1 assert result.total_return_pct > 0 # exit was triggered by holding period (5 trading days) async def test_backtest_filters_low_conviction(cfg): bars = { "NVDA": _bars("NVDA", "2026-05-15", [100, 105, 110, 115, 120, 125]), "SPY": _bars("SPY", "2026-05-15", [500] * 6), } strategy = KevinStrategy(cfg) mentions = [_mention("NVDA", "buy", "0.5", "weeks", 0)] # below 0.6 floor runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) result = await runner.run(mentions, KevinBacktestParams()) assert result.trade_count == 0 async def test_backtest_dedupe_roll_extends_exit(cfg): # Two BUYs on same ticker within hold window; exit should extend bars = { "NVDA": _bars("NVDA", "2026-05-15", [100] * 20), "SPY": _bars("SPY", "2026-05-15", [500] * 20), } strategy = KevinStrategy(cfg) mentions = [ _mention("NVDA", "buy", "0.7", "weeks", 0), _mention("NVDA", "buy", "0.7", "weeks", 3), ] runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) result = await runner.run( mentions, KevinBacktestParams(dedupe_policy="roll"), ) # Exit at day 3 + 5 = 8, not day 0 + 5 = 5 assert result.trade_count == 1 closed = result.trades[0] assert closed["holding_days_actual"] >= 5 async def test_backtest_sell_mid_position_closes_early(cfg): bars = { "NVDA": _bars("NVDA", "2026-05-15", [100, 105, 110, 95, 90, 85, 80]), "SPY": _bars("SPY", "2026-05-15", [500] * 7), } strategy = KevinStrategy(cfg) mentions = [ _mention("NVDA", "buy", "0.8", "weeks", 0), _mention("NVDA", "sell", "0.9", "days", 2), ] runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) result = await runner.run(mentions, KevinBacktestParams()) assert result.trade_count == 1 assert result.trades[0]["holding_days_actual"] <= 5 async def test_backtest_handles_missing_bars(cfg): bars = { "SPY": _bars("SPY", "2026-05-15", [500] * 5), # NVDA missing } strategy = KevinStrategy(cfg) mentions = [_mention("NVDA", "buy", "0.8", "weeks", 0)] runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) result = await runner.run(mentions, KevinBacktestParams()) # Mention skipped (no price data); no trade assert result.trade_count == 0 async def test_backtest_computes_alpha_vs_spy(cfg): # NVDA +10%, SPY flat -> positive alpha bars = { "NVDA": _bars("NVDA", "2026-05-15", [100, 100, 100, 100, 100, 110, 110]), "SPY": _bars("SPY", "2026-05-15", [500] * 7), } strategy = KevinStrategy(cfg) mentions = [_mention("NVDA", "buy", "0.8", "weeks", 0)] runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) result = await runner.run( mentions, KevinBacktestParams(initial_capital=Decimal("100000")), ) assert result.alpha_vs_spy_pct is not None assert result.alpha_vs_spy_pct > 0