Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
Walks mentions chronologically, T+1 entry, time-based exit per KevinStrategy. Reuses backtester/metrics::compute_metrics for headline numbers. KevinPriceLoader fronts market_data + Alpaca.
187 lines
6 KiB
Python
187 lines
6 KiB
Python
"""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
|