Source code for trader.persistence.backtests
"""Repository for backtest runs and their simulated trades (requirements §12)."""
import json
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from trader.backtest.engine import BacktestResult
from trader.persistence.models import BacktestRun, BacktestTrade
__all__ = ["BacktestRepository"]
def _quantity_to_decimal(quantity: object) -> Decimal:
"""Convert a whole-share quantity, refusing a float.
`DecimalText` rejects floats, but converting here first would hand it a
`Decimal` that had already absorbed the float's binary imprecision — the
guard would never see the original value. So check before converting.
"""
if isinstance(quantity, bool) or not isinstance(quantity, int):
raise TypeError(
f"trade quantity must be a whole number of shares, got "
f"{quantity!r} ({type(quantity).__name__})"
)
return Decimal(quantity)
[docs]
class BacktestRepository:
"""Persists a completed backtest so past runs stay comparable."""
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
[docs]
def save(
self,
result: BacktestResult,
start: datetime,
end: datetime,
parameters: dict[str, object],
) -> int:
"""Write the run and its trades in one transaction. Returns the run id."""
run = BacktestRun(
ran_at=datetime.now(UTC),
strategy_id=result.strategy_id,
ticker=result.symbol,
start_date=start,
end_date=end,
parameters_json=json.dumps(parameters, default=str),
starting_value=result.starting_value,
ending_value=result.ending_value,
total_return_pct=result.total_return_pct,
max_drawdown_pct=result.max_drawdown_pct,
win_count=result.win_count,
loss_count=result.loss_count,
benchmark_ending_value=result.benchmark_ending_value,
benchmark_return_pct=result.benchmark_return_pct,
)
with self._session_factory() as session:
session.add(run)
session.flush() # assign run.id before the children reference it
session.add_all(
BacktestTrade(
backtest_run_id=run.id,
ticker=result.symbol,
quantity=_quantity_to_decimal(t.quantity),
entry_at=t.entry_at,
entry_price=t.entry_price,
exit_at=t.exit_at,
exit_price=t.exit_price,
pnl=t.pnl,
)
for t in result.trades
)
session.commit()
return run.id
[docs]
def load_run(self, run_id: int) -> BacktestRun | None:
"""Fetch one run's summary row, or None."""
with self._session_factory() as session:
return session.get(BacktestRun, run_id)
[docs]
def load_trades(self, run_id: int) -> list[BacktestTrade]:
"""Fetch a run's simulated trades, oldest entry first."""
statement = (
select(BacktestTrade)
.where(BacktestTrade.backtest_run_id == run_id)
.order_by(BacktestTrade.entry_at.asc(), BacktestTrade.id.asc())
)
with self._session_factory() as session:
return list(session.scalars(statement).all())