Files
stonks-oracle/services/trading/backtest_replay.py
T
Celes Renata ea6c2b3f54 fix: market data rate limiting and backtest price lookup
- Increase market_api polling cadence from 60s to 900s (15 min).
  The prev-day bar endpoint returns the same data all day, so polling
  every minute wastes API quota. 50 tickers at 15-min cadence = ~3.3
  req/min, well within the 5/min rate limit.
- Reduce market_api rate limit from 30/min to 5/min to match.
- Fix backtest replay to query market_snapshots with data->>'c' for
  close prices instead of nonexistent market_data.close_price column.
- Enrich backtest recommendations with prices from market_snapshots
  and sectors from companies table.
2026-04-15 22:19:44 +00:00

411 lines
16 KiB
Python

"""Backtest replay for the autonomous trading engine.
Task 32: Fetches historical recommendations from the database, simulates
the decision logic chronologically using evaluate_recommendation(), tracks
simulated positions and equity curve, and persists results to backtest_runs
and backtest_trades tables.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import date, datetime, timedelta, timezone
from services.trading.backtester import BacktestConfig, BacktestResult
from services.trading.correlation import CorrelationMatrix
from services.trading.engine import TradingEngine
from services.trading.models import (
RISK_TIER_DEFAULTS,
CircuitBreakerState,
ClosedTrade,
PortfolioState,
)
from services.trading.performance_tracker import PerformanceComputer
logger = logging.getLogger("trading_engine.backtest")
class BacktestReplay:
"""Replays historical recommendations through the trading engine logic.
Accepts an asyncpg pool for database access. The ``run()`` method
fetches historical data, simulates decisions chronologically, and
persists results.
"""
def __init__(self, pool: object) -> None:
self.pool = pool
self._perf = PerformanceComputer()
async def run(self, config: BacktestConfig) -> BacktestResult:
"""Execute a full backtest replay.
Args:
config: Backtest configuration (date range, capital, risk tier).
Returns:
BacktestResult with metrics, trade log, and equity curve.
"""
backtest_id = str(uuid.uuid4())
try:
# Fetch historical recommendations
recs = await self._fetch_recommendations(config.start_date, config.end_date)
# Set up simulated state
risk_tier = RISK_TIER_DEFAULTS.get(
config.risk_tier, RISK_TIER_DEFAULTS["moderate"]
)
portfolio_state = PortfolioState(
total_value=config.initial_capital,
cash=config.initial_capital,
active_pool=config.initial_capital,
reserve_pool=0.0,
)
cb_state = CircuitBreakerState()
correlation_matrix = CorrelationMatrix()
earnings_calendar: dict = {}
# Create a lightweight engine for evaluate_recommendation
from services.shared.config import TradingConfig
engine_config = TradingConfig(
risk_tier=config.risk_tier,
absolute_position_cap=config.initial_capital * 0.10,
active_pool_minimum=config.initial_capital * 0.20,
)
engine = TradingEngine(pool=None, redis=None, config=engine_config)
# Simulation state
simulated_positions: dict[str, dict] = {} # ticker -> position info
closed_trades: list[ClosedTrade] = []
equity_curve: list[dict] = []
daily_returns: list[float] = []
prev_value = config.initial_capital
trade_log: list[dict] = []
# Pre-load company sectors and latest prices for enrichment
company_sectors: dict[str, str] = {}
company_prices: dict[str, float] = {}
if self.pool is not None:
try:
sector_rows = await self.pool.fetch(
"SELECT ticker, sector FROM companies WHERE active = TRUE"
)
for sr in sector_rows:
company_sectors[sr["ticker"]] = sr["sector"] or "Unknown"
except Exception:
logger.debug("Could not load company sectors")
# Load latest market prices (use most recent close from market_snapshots JSONB)
try:
price_rows = await self.pool.fetch(
"SELECT DISTINCT ON (ticker) ticker, (data->>'c')::float as close_price "
"FROM market_snapshots WHERE snapshot_type = 'bar' "
"ORDER BY ticker, captured_at DESC"
)
for pr in price_rows:
if pr["close_price"]:
company_prices[pr["ticker"]] = float(pr["close_price"])
except Exception:
logger.debug("Could not load market prices — using portfolio_pct fallback")
# Group recommendations by date
recs_by_date: dict[date, list[dict]] = {}
for rec in recs:
rec_date = rec.get("generated_at", datetime.now(tz=timezone.utc))
if isinstance(rec_date, datetime):
d = rec_date.date()
else:
d = rec_date
# Enrich rec with price and sector if missing
ticker = rec.get("ticker", "")
if "current_price" not in rec or not rec.get("current_price"):
rec["current_price"] = company_prices.get(ticker, 50.0)
if "sector" not in rec or not rec.get("sector"):
rec["sector"] = company_sectors.get(ticker, "Unknown")
recs_by_date.setdefault(d, []).append(rec)
# Iterate through each trading day
current_date = config.start_date
while current_date <= config.end_date:
# Skip weekends
if current_date.weekday() > 4:
current_date += timedelta(days=1)
continue
day_recs = recs_by_date.get(current_date, [])
# Process recommendations for this day
for rec in day_recs:
# Set a timestamp within trading window for evaluation
# Use 11:00 AM ET (within trading window) for simulation
from services.trading.trading_window import ET
sim_time = datetime(
current_date.year,
current_date.month,
current_date.day,
11, 0, 0,
tzinfo=ET,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio_state,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=correlation_matrix,
earnings_calendar=earnings_calendar,
now=sim_time,
)
if decision.decision == "act":
ticker = decision.ticker
price = rec.get("current_price", 0.0)
qty = decision.computed_share_quantity or 0
if qty > 0 and price > 0:
cost = price * qty
if cost <= portfolio_state.active_pool:
simulated_positions[ticker] = {
"entry_price": price,
"quantity": qty,
"entry_date": current_date,
"sector": rec.get("sector", ""),
"recommendation_id": str(
rec.get("recommendation_id", rec.get("id", ""))
),
}
portfolio_state.active_pool -= cost
portfolio_state.open_position_count += 1
# Simulate simple exit logic: close positions held > 5 days
# (simplified — real engine uses stop-loss/take-profit)
tickers_to_close = []
for ticker, pos_info in simulated_positions.items():
hold_days = (current_date - pos_info["entry_date"]).days
if hold_days >= 5:
tickers_to_close.append(ticker)
for ticker in tickers_to_close:
pos_info = simulated_positions.pop(ticker)
# Simulate a small random-ish exit based on entry price
exit_price = pos_info["entry_price"] * 1.01 # simplified
qty = pos_info["quantity"]
pnl = (exit_price - pos_info["entry_price"]) * qty
pnl_pct = (
(exit_price - pos_info["entry_price"]) / pos_info["entry_price"]
if pos_info["entry_price"] > 0
else 0.0
)
hold_duration = timedelta(
days=(current_date - pos_info["entry_date"]).days
)
trade = ClosedTrade(
ticker=ticker,
entry_price=pos_info["entry_price"],
exit_price=exit_price,
quantity=qty,
pnl=pnl,
pnl_pct=pnl_pct,
hold_duration=hold_duration,
recommendation_id=pos_info.get("recommendation_id"),
)
closed_trades.append(trade)
trade_log.append(self._perf.compute_trade_metrics(trade))
# Return capital to active pool
portfolio_state.active_pool += exit_price * qty
portfolio_state.open_position_count = max(
0, portfolio_state.open_position_count - 1
)
# Compute daily portfolio value
positions_value = sum(
p["entry_price"] * p["quantity"]
for p in simulated_positions.values()
)
current_value = portfolio_state.active_pool + positions_value
portfolio_state.total_value = current_value
# Daily return
daily_ret = (
(current_value - prev_value) / prev_value
if prev_value > 0
else 0.0
)
daily_returns.append(daily_ret)
prev_value = current_value
equity_curve.append({
"date": current_date.isoformat(),
"portfolio_value": round(current_value, 2),
})
current_date += timedelta(days=1)
# Compute final metrics
metrics = self._perf.compute_metrics(
closed_trades=closed_trades,
portfolio_value=portfolio_state.total_value,
active_pool=portfolio_state.active_pool,
reserve_pool=portfolio_state.reserve_pool,
daily_pnl=0.0,
unrealized_pnl=0.0,
portfolio_heat=0.0,
daily_returns=daily_returns,
)
total_return = (
(portfolio_state.total_value - config.initial_capital)
/ config.initial_capital
if config.initial_capital > 0
else 0.0
)
result = BacktestResult(
backtest_id=backtest_id,
config=config,
total_return=total_return,
sharpe_ratio=metrics.sharpe_ratio,
max_drawdown=metrics.max_drawdown,
win_rate=metrics.win_rate,
profit_factor=metrics.profit_factor,
trade_count=len(closed_trades),
trade_log=trade_log,
equity_curve=equity_curve,
)
# Persist results
await self._persist_results(result, closed_trades)
return result
except Exception as exc:
logger.exception("Backtest %s failed", backtest_id)
# Persist partial results with failed status
await self._persist_failed_run(backtest_id, config, str(exc))
raise
# ------------------------------------------------------------------
# Database helpers
# ------------------------------------------------------------------
async def _fetch_recommendations(
self, start_date: date, end_date: date
) -> list[dict]:
"""Fetch historical recommendations for the date range."""
if self.pool is None:
return []
try:
start_dt = datetime(
start_date.year, start_date.month, start_date.day,
tzinfo=timezone.utc,
)
end_dt = datetime(
end_date.year, end_date.month, end_date.day,
23, 59, 59,
tzinfo=timezone.utc,
)
rows = await self.pool.fetch(
"SELECT * FROM recommendations "
"WHERE generated_at BETWEEN $1 AND $2 "
"AND action IN ('buy', 'sell') "
"ORDER BY generated_at ASC",
start_dt,
end_dt,
)
return [dict(r) for r in rows]
except Exception:
logger.debug("Could not fetch historical recommendations — table may not exist")
return []
async def _persist_results(
self, result: BacktestResult, trades: list[ClosedTrade]
) -> None:
"""Persist backtest results to backtest_runs and backtest_trades."""
if self.pool is None:
return
try:
await self.pool.execute(
"INSERT INTO backtest_runs "
"(id, start_date, end_date, initial_capital, risk_tier, "
"config, total_return, sharpe_ratio, max_drawdown, "
"win_rate, profit_factor, trade_count, equity_curve, "
"status, completed_at, created_at) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, "
"$11, $12, $13, $14, $15, $16)",
result.backtest_id,
result.config.start_date,
result.config.end_date,
result.config.initial_capital,
result.config.risk_tier,
json.dumps({}),
result.total_return,
result.sharpe_ratio,
result.max_drawdown,
result.win_rate,
result.profit_factor,
result.trade_count,
json.dumps(result.equity_curve),
"completed",
datetime.now(tz=timezone.utc),
datetime.now(tz=timezone.utc),
)
# Persist individual trades
for trade in trades:
await self.pool.execute(
"INSERT INTO backtest_trades "
"(backtest_id, ticker, side, entry_price, exit_price, "
"quantity, pnl, entry_date, exit_date, "
"hold_duration_days, recommendation_id) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
result.backtest_id,
trade.ticker,
"buy",
trade.entry_price,
trade.exit_price,
trade.quantity,
trade.pnl,
datetime.now(tz=timezone.utc), # simplified
datetime.now(tz=timezone.utc),
trade.hold_duration.days,
trade.recommendation_id,
)
except Exception:
logger.debug("Could not persist backtest results — tables may not exist")
async def _persist_failed_run(
self, backtest_id: str, config: BacktestConfig, error: str
) -> None:
"""Persist a failed backtest run."""
if self.pool is None:
return
try:
await self.pool.execute(
"INSERT INTO backtest_runs "
"(id, start_date, end_date, initial_capital, risk_tier, "
"config, status, created_at) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
backtest_id,
config.start_date,
config.end_date,
config.initial_capital,
config.risk_tier,
json.dumps({"error": error}),
"failed",
datetime.now(tz=timezone.utc),
)
except Exception:
logger.debug("Could not persist failed backtest run")