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.
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:
- CLI Parsing — The user runs the CLI (
cli.py).argparsecollects tickers, date range, initial capital, and margin requirement. Interactive prompts viaquestionarylet the user pick analysts and an LLM model (OpenAI, Anthropic, Ollama, etc.). - Engine Construction — A
BacktestEngineis instantiated. It creates a freshPortfoliowith the given initial cash and margin requirement, plus aTradeExecutor,AgentController,PerformanceMetricsCalculator,OutputBuilder, andBenchmarkCalculator. - 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. - Day Loop Begins —
pd.date_range(start, end, freq="B")generates every business day. For each day, alookback_startis computed as one month prior, establishing the data window the agent will see (no future data). - 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. - Agent Invocation —
AgentController.run_agent()converts thePortfolioobject into a plainPortfolioSnapshotdict, then invokes the LangGraph agent pipeline. The agent sees only data fromlookback_starttocurrent_date. Returned decisions are normalized: actions coerced to theActionenum (buy/sell/short/cover/hold), quantities to floats, and missing tickers default tohold. - Trade Execution — For each ticker,
TradeExecutor.execute_trade()dispatches to the appropriatePortfoliomethod. 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. - 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. - 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). - Final Report — After all days are processed,
run_backtest()returns the finalPerformanceMetricsdict. 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 viaget_portfolio_values()for downstream charting.
🏗️ Design Decisions
| Decision | Choice Made | Alternatives Considered | Why 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 Mode | Trigger | Detection | Recovery / 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:
- LLM Agent Invocation — ~1–30 seconds per day depending on model and number of analysts. This dominates total runtime by orders of magnitude. A 6-month backtest with 5 tickers and 8 analysts can take hours. The engine itself adds negligible overhead.
- Data Prefetch — O(T) where T = number of tickers. Each ticker triggers 4 API calls (prices, financials, insider trades, news). With caching, subsequent days reuse prefetched data. SPY is also prefetched for benchmarking.
- Metrics Computation — O(D) where D = number of days elapsed. Sharpe/Sortino/drawdown iterate over the full portfolio value series each day. For typical backtests (60–250 days), this completes in microseconds.
Resource Usage
- Memory: Portfolio state is O(T) — one
PositionStatedict per ticker. Theportfolio_valueslist grows by one point per day (O(D)). Table rows accumulate for display (O(T × D)). No data is freed during the run; total memory is modest for typical backtests. - I/O: Financial data APIs are the primary I/O. Prefetching warms the internal cache. After prefetch, the day loop reads only from cache for market data. LLM calls are the dominant I/O cost, both in latency and in token spend.
Sub-Modules
engine/
Core simulation engine: BacktestController orchestrates day-by-day simulation, BacktestEngine runs agent pipeline per day, Trader executes decisions.
BacktestEngine— day loop + orchestrationAgentController— agent invocation + normalizationTradeExecutor— buy/sell/short/cover dispatchcli.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 statePerformanceMetricsCalculator— Sharpe, Sortino, drawdownValuation— portfolio value + exposure computationOutputBuilder— 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)