backtesting — Historical Backtesting Engine

Historical Backtesting Engine

Day-by-day historical simulation engine that runs the full agent pipeline against past market data, tracks positions and P&L, and calculates comprehensive performance metrics.

🎯 Why This Module Exists

Without backtesting, the system would be a black box — you’d have no way to evaluate whether the AI agents actually make profitable decisions or if their signals are noise. The backtesting module exists to provide empirical validation of the agent pipeline by replaying it against historical market data, day by day. Unlike simple “buy and check later” tests, this engine runs the full LangGraph agent workflow for each trading day, making decisions based only on data available at that point in time (no look-ahead bias). It tracks Portfolio Tracking & Performance Metrics" href="portfolio/index.html">portfolio positions (long and short), margin requirements, realized/unrealized P&L, and calculates industry-standard metrics (Sharpe ratio, maximum drawdown, cumulative returns). The module also compares performance against benchmarks (S&P 500). This isn’t just a testing tool — it’s how you discover which combinations of agents perform best in different market conditions.

💡 Real-world analogy

Imagine a flight simulator for pilots. Instead of risking a real plane (real money), pilots (the AI agents) fly through recorded weather conditions (historical market data) to practice their decision-making. The simulator tracks every maneuver (trade) and scores performance (Sharpe ratio, drawdown) so you can evaluate pilots before they fly real planes. Just as a flight simulator faithfully replays wind patterns and turbulence from archived weather logs without letting the pilot peek ahead, this backtester feeds the agents only the data they would have had on each historical day — ensuring the evaluation is honest and free of hindsight bias.

11
Files
2
Sub-Modules
~3.5k
Lines of Code
Python
Language

Day-by-Day Simulation No Look-Ahead Bias Benchmark Comparison

Architecture

flowchart LR
  subgraph Entry["Entry Points"]
    CLI["cli.py\nCLI Interface"]
    Web["WebApp\nStreamlit UI"]
  end
  subgraph Orchestration["Orchestration Layer"]
    Ctrl["BacktestEngine\nengine.py"]
    AC["AgentController\ncontroller.py"]
  end
  subgraph Execution["Trade Execution"]
    TE["TradeExecutor\ntrader.py"]
    Port["Portfolio\nportfolio.py"]
  end
  subgraph Analysis["Analysis Layer"]
    Val["Valuation\nvaluation.py"]
    Metrics["PerformanceMetrics\nmetrics.py"]
    Bench["BenchmarkCalculator\nbenchmarks.py"]
  end
  subgraph Output["Output Layer"]
    OB["OutputBuilder\noutput.py"]
  end
  subgraph External["External"]
    AP["Agent Pipeline\nLangGraph Workflow"]
    MD["Market Data\nFinancial APIs"]
  end
  CLI -->|"configure run"| Ctrl
  Web -->|"configure run"| Ctrl
  Ctrl -->|"each business day"| AC
  AC -->|"invoke agent"| AP
  AP -->|"decisions"| AC
  AC -->|"normalized decisions"| Ctrl
  Ctrl -->|"execute trades"| TE
  TE -->|"buy/sell/short/cover"| Port
  Ctrl -->|"fetch prices"| MD
  Ctrl -->|"calculate value"| Val
  Val -->|"total value"| Ctrl
  Ctrl -->|"compute metrics"| Metrics
  Ctrl -->|"benchmark comparison"| Bench
  Ctrl -->|"build rows"| OB

Data Flow

sequenceDiagram
    participant CLI as CLI / WebApp
    participant Engine as BacktestEngine
    participant API as Market Data API
    participant AC as AgentController
    participant Agent as Agent Pipeline
    participant TE as TradeExecutor
    participant Port as Portfolio
    participant Val as Valuation
    participant Metrics as Metrics Calculator
    participant Out as OutputBuilder

    CLI->>Engine: run_backtest(tickers, dates, capital)
    Engine->>API: prefetch_data(tickers, date range)
    API-->>Engine: cached price/news/metrics data

    loop Each Business Day
        Engine->>API: get_price_data(ticker, date)
        API-->>Engine: current_prices dict
        Engine->>AC: run_agent(agent, tickers, lookback, portfolio)
        AC->>Agent: invoke with snapshot + date window
        Agent-->>AC: AgentOutput (decisions + signals)
        AC-->>Engine: normalized decisions per ticker
        loop Each Ticker
            Engine->>TE: execute_trade(ticker, action, qty, price)
            TE->>Port: apply_long_buy / apply_long_sell / apply_short_open / apply_short_cover
            Port-->>TE: executed quantity
            TE-->>Engine: executed_qty
        end
        Engine->>Val: calculate_portfolio_value(portfolio, prices)
        Val-->>Engine: total_value
        Engine->>Val: compute_exposures(portfolio, prices)
        Val-->>Engine: long/short/gross/net exposure
        Engine->>Out: build_day_rows(date, trades, prices, portfolio)
        Out-->>Engine: formatted table rows
        Engine->>Metrics: compute_metrics(portfolio_values)
        Metrics-->>Engine: sharpe, sortino, max_drawdown
    end

    Engine-->>CLI: PerformanceMetrics summary

🚶 Step-by-Step Narrated Walkthrough

Follow a single complete backtest run as it flows through the entire backtesting pipeline:

  1. CLI Parsing — The user runs the CLI (cli.py). argparse collects tickers, date range, initial capital, and margin requirement. Interactive prompts via questionary let the user pick analysts and an LLM model (OpenAI, Anthropic, Ollama, etc.).
  2. Engine Construction — A BacktestEngine is instantiated. It creates a fresh Portfolio with the given initial cash and margin requirement, plus a TradeExecutor, AgentController, PerformanceMetricsCalculator, OutputBuilder, and BenchmarkCalculator.
  3. Data Prefetch — Before the day loop starts, _prefetch_data() calls the financial APIs to cache one year of price history, financial metrics, insider trades, and company news for every ticker—plus SPY for benchmarking. This avoids redundant API calls during the loop.
  4. Day Loop Begins — pd.date_range(start, end, freq="B") generates every business day. For each day, a lookback_start is computed as one month prior, establishing the data window the agent will see (no future data).
  5. Price Retrieval — For each ticker, the engine calls get_price_data() to fetch the closing price for the current day. If any ticker has missing data, the entire day is skipped—ensuring only complete market days are simulated.
  6. Agent Invocation — AgentController.run_agent() converts the Portfolio object into a plain PortfolioSnapshot dict, then invokes the LangGraph agent pipeline. The agent sees only data from lookback_start to current_date. Returned decisions are normalized: actions coerced to the Action enum (buy/sell/short/cover/hold), quantities to floats, and missing tickers default to hold.
  7. Trade Execution — For each ticker, TradeExecutor.execute_trade() dispatches to the appropriate Portfolio method. Long buys check available cash (partial fills if insufficient). Short opens check margin availability. Sells and covers are capped at current position size. Each method returns the actual executed quantity.
  8. Valuation & Exposure — calculate_portfolio_value() computes total value as cash + long market value − short market value. compute_exposures() calculates long exposure, short exposure, gross/net exposure, and the long/short ratio for risk monitoring.
  9. Output & Metrics — OutputBuilder.build_day_rows() formats each ticker’s action, position, and a summary row with total value, return percentage, Sharpe, Sortino, max drawdown, and benchmark return. Rows are prepended so the latest day appears first. After three days of data accumulate, PerformanceMetricsCalculator.compute_metrics() calculates annualized Sharpe (excess returns / std dev × √252), Sortino (excess / downside dev), and max drawdown (peak-to-trough percentage).
  10. Final Report — After all days are processed, run_backtest() returns the final PerformanceMetrics dict. The CLI prints total return, Sharpe ratio, Sortino ratio, and max drawdown with the date it occurred. The portfolio value time series is also available via get_portfolio_values() for downstream charting.

🏗️ Design Decisions

DecisionChoice MadeAlternatives ConsideredWhy This Approach
Portfolio as mutable object vs. immutable snapshots Portfolio class with internal mutable state; exposes read-only views via get_snapshot() and MappingProxyType Fully immutable portfolio passed through each step; event-sourced ledger Performance: mutating in-place avoids copying large position dicts every day. Safety: MappingProxyType prevents agents from accidentally mutating portfolio state. The agent receives a snapshot dict, not the live object.
Trade execution partial fills Automatically reduce quantity to what cash/margin allows; return actual executed quantity Reject the entire order if insufficient funds; queue unfilled orders Simulates realistic brokerage behavior: you get as many shares as you can afford. Rejecting entirely would lose signal from the agent. Queuing adds complexity with no clear benefit in a daily-resolution simulation.
One-month lookback window per day lookback_start = current_date - 1 month, sliding window Fixed window from backtest start; expanding window; configurable lookback Balances recency (agents see recent trends) with API cost (bounded data per call). A fixed or expanding window would grow request size linearly with backtest duration.
Decision normalization in AgentController Coerce actions to Action enum, quantities to float, default missing tickers to hold Trust agent output directly; raise exceptions on malformed output LLM outputs are inherently noisy. Normalizing prevents crashes from unexpected strings or missing keys. Defaulting to hold is the safest no-op when the agent fails for a ticker.
Data prefetching before loop _prefetch_data() loads all tickers’ data in bulk before simulation starts Lazy-load data on each day; parallel async fetching The financial API client caches responses internally. Prefetching warms the cache in a predictable pattern, avoiding redundant HTTP calls during the latency-sensitive day loop.
Metrics computation gated on 3+ data points Only compute Sharpe/Sortino/drawdown after len(portfolio_values) > 3 Compute from day one; require a full month Statistical meaningfulness: Sharpe ratio with one or two daily returns is noise. Three is the minimum for a meaningful standard deviation. Waiting longer delays feedback unnecessarily.

🔬 Algorithm Spotlight

Day-by-Day Simulation with Look-Ahead Prevention

The Problem: How do you evaluate an AI trading system without letting it “cheat” by seeing future data? A naive approach might feed the agent the entire historical dataset, but this produces unrealistically optimistic results.

The Approach: The engine iterates over business days using pd.date_range(freq="B"). For each day, it computes a lookback_start one month prior. The agent only receives data within [lookback_start, current_date]. Price data is fetched for [previous_date, current_date] to get the closing price. The portfolio snapshot passed to the agent reflects only the state before today’s trades execute. This three-layer isolation—date windowing, snapshot-before-trade, and sequential day processing—ensures no future information leaks into decisions.

Why This Algorithm: Vectorized backtesting (apply signals to a price matrix at once) is much faster but cannot capture the feedback loop where today’s portfolio state affects tomorrow’s decisions. Since the agent pipeline is stateful (it considers current positions, cash, and margin), day-by-day simulation is the only correct approach. The performance cost is dominated by LLM API calls, not the loop itself.

flowchart TD
    Start["Start: Generate business day range"] --> Loop{"Next business day?"}
    Loop -->|"Yes"| Window["Compute lookback window\ncurrent_date - 1 month"]
    Window --> Prices["Fetch closing prices\nfor current_date"]
    Prices --> Check{"All tickers\nhave data?"}
    Check -->|"No"| Skip["Skip day\nno partial data"] --> Loop
    Check -->|"Yes"| Snapshot["Create portfolio snapshot\nstate BEFORE today"]
    Snapshot --> Agent["Run agent with\nlookback window only"]
    Agent --> Normalize["Normalize decisions\ncoerce to Action enum"]
    Normalize --> Execute["Execute trades\nupdate portfolio"]
    Execute --> Value["Calculate portfolio value\nand exposures"]
    Value --> Metrics["Update performance\nmetrics if 3+ days"]
    Metrics --> Output["Print daily report\nlatest day first"]
    Output --> Loop
    Loop -->|"No more days"| Done["Return final\nPerformanceMetrics"]
Trace through an example

Given: Backtest AAPL from 2024-01-02 to 2024-01-05, initial capital $100,000

Day 1 (Jan 2): Lookback window is Dec 2 – Jan 2. Agent sees one month of AAPL data. Portfolio snapshot shows $100k cash, zero positions. Agent decides: buy 50 shares. At $185/share, TradeExecutor calls apply_long_buy(AAPL, 50, 185.0). Cash drops to $90,750. Portfolio value = $90,750 + 50×$185 = $100,000 (no gain yet).

Day 2 (Jan 3): Lookback is Dec 3 – Jan 3. Agent receives the updated snapshot (50 long shares, $90,750 cash). AAPL closes at $187. Agent says hold. No trades. Portfolio value = $90,750 + 50×$187 = $100,100. Return = +0.10%.

Day 3 (Jan 4): AAPL drops to $182. Agent says sell 20 shares. apply_long_sell(AAPL, 20, 182.0) realizes a loss of (182−185)×20 = −$60. Cash increases by $3,640. Now: 30 shares, $94,390 cash. Portfolio value = $94,390 + 30×$182 = $99,850. With 3 data points, Sharpe ratio is computed for the first time.

Result: Each day’s agent only saw data up to that day. The sell on Day 3 was based on the Day 3 snapshot, not knowledge of future recovery. If we had used vectorized backtesting, the agent might have “known” AAPL recovers on Day 4 and held instead.

⚠️ What Could Go Wrong

Failure ModeTriggerDetectionRecovery / Mitigation
Missing price data for a ticker Delisted stock, exchange holiday, data provider outage get_price_data() returns an empty DataFrame or raises an exception The entire day is skipped (missing_data = True; continue). No trades execute, preventing decisions based on stale or incomplete prices. The simulation resumes on the next day with data.
Agent returns malformed output LLM hallucination, prompt drift, model timeout returning partial JSON AgentController.run_agent() catches Action(action) ValueError and float(qty) exceptions Actions default to hold, quantities default to 0.0. The agent effectively does nothing for that ticker rather than crashing the backtest.
Insufficient cash or margin for a trade Agent requests buying more shares than cash allows, or shorting beyond margin capacity Portfolio.apply_long_buy() computes max_quantity = int(cash / price) Partial fill: the portfolio buys/shorts as many shares as it can afford. Returns actual executed quantity. The OutputBuilder shows the real quantity, not the requested one.
LLM API rate limiting or timeout Running many agents across many tickers on a tight schedule; API provider throttling Exception propagates from the agent callable; the agent returns incomplete output The outer try/except in the day loop catches failures. If the agent call fails, the day may be partially processed or skipped. Prefetching market data upfront reduces non-LLM API calls.
Division by zero in metrics All daily returns are identical (zero standard deviation); no short positions (long/short ratio) std_excess > 1e-12 guard in Sharpe calculation; short_exposure > 1e-9 guard in ratio Sharpe returns 0.0 when std dev is negligible. Long/short ratio returns inf when there are no shorts. Sortino returns inf when mean excess is positive but downside dev is zero.

⚡ Performance Profile

Hot Paths

The most performance-sensitive code paths in this module:

Resource Usage

Sub-Modules

engine/

Core simulation engine: BacktestController orchestrates day-by-day simulation, BacktestEngine runs agent pipeline per day, Trader executes decisions.

  • BacktestEngine — day loop + orchestration
  • AgentController — agent invocation + normalization
  • TradeExecutor — buy/sell/short/cover dispatch
  • cli.py — CLI entry point with interactive prompts

portfolio/

Portfolio tracking + metrics: Position management (long/short), P&L calculation, Sharpe ratio, drawdown, benchmark comparison.

  • Portfolio — mutable position + cash state
  • PerformanceMetricsCalculator — Sharpe, Sortino, drawdown
  • Valuation — portfolio value + exposure computation
  • OutputBuilder — daily report formatting

Directory Map

Source tree for backtesting
src/backtesting/
├── __init__.py
├── controller.py    ← Orchestrates agent invocation + normalizes output
├── engine.py        ← Core simulation engine (day loop + orchestration)
├── trader.py        ← Trade execution logic (buy/sell/short/cover dispatch)
├── portfolio.py     ← Position tracking, cash management, margin accounting
├── metrics.py       ← Performance metrics (Sharpe, Sortino, max drawdown)
├── valuation.py     ← Portfolio value + long/short/gross/net exposure
├── output.py        ← Report generation (daily rows + summary)
├── benchmarks.py    ← S&P 500 benchmark comparison
├── types.py         ← TypedDict definitions (PortfolioSnapshot, AgentOutput, etc.)
└── cli.py           ← Backtest CLI interface (argparse + questionary prompts)