backtesting/portfolio/ — Portfolio Tracking & Performance Metrics
Position management, P&L calculation, and performance analytics
Tracks portfolio state across the backtest — cash, long/short positions, margin, and cost basis. Calculates comprehensive performance metrics (Sharpe ratio, Sortino ratio, maximum drawdown). Compares against benchmarks (S&P 500 via SPY). Generates formatted output reports with per-ticker and summary rows.
📚 What is this & why does it exist?
Without portfolio tracking and metrics, a backtest is just a list of decisions with no way to evaluate quality. This sub-module maintains the financial state of the simulated portfolio — tracking every position, cost basis, margin requirement, and realized/unrealized gain. The metrics module then analyzes this history using industry-standard measures. The Sharpe ratio tells you risk-adjusted return, maximum drawdown tells you worst-case loss, Sortino ratio penalizes only downside volatility, and benchmark comparison tells you whether the AI strategy beat simple index investing.
💡 Real-world analogy
Think of a sports team's season statistics tracker. The Portfolio class is the scoreboard — it shows the current score, who's on the field (positions), and how much time-out budget remains (cash and margin). The PerformanceMetricsCalculator is the analytics department — it computes win rate, points per game, and strength-of-schedule adjusted rankings (Sharpe, Sortino, drawdown). The BenchmarkCalculator is the league standings — it answers "are we doing better than average?" by comparing against the S&P 500. The OutputBuilder is the press release team — it formats all this data into tables that stakeholders can quickly scan. Without the analytics department, you'd know who won individual games but not whether the team is actually good.
🤔 Why Is It Designed This Way?
The portfolio sub-module follows an encapsulated-state + pure-functions pattern. The Portfolio class is the only mutable component — it owns all position state and provides atomic mutation methods (apply_long_buy, apply_short_cover, etc.) that enforce invariants (can't sell more shares than held, can't spend more cash than available). Everything else is a pure function or a stateless calculator: calculate_portfolio_value takes a portfolio and prices, returns a number; compute_metrics takes value history, returns metrics. This separation makes the financial math easy to unit-test without mocking any state. The MappingProxyType wrapper on get_positions() prevents callers from accidentally mutating internal state — mutations must go through the apply_* methods.
| Design Choice | What Was Chosen | What Was Rejected | Why |
|---|---|---|---|
| State encapsulation | Private _portfolio dict with accessor methods | Public dict | Prevents external code from bypassing cost-basis or margin tracking invariants |
| Read-only views | MappingProxyType on positions/gains | Deep-copy on every read | Zero-copy for reads; MappingProxyType raises TypeError on mutation attempts without copy overhead |
| Partial fills | Auto-downsize to affordable quantity | Reject-or-fill-all | Matches real broker behavior; ensures the backtest doesn't stall when the agent over-requests |
| Metrics as pure functions | Stateless compute_metrics(values) | Metrics accumulated inside Portfolio | Portfolio owns position state; metrics are a separate concern — portfolio shouldn't know about Sharpe ratios |
Contents
Architecture
flowchart TD Engine["BacktestEngine"] --> Portfolio["Portfolio -- Mutable State"] Engine --> Valuation["valuation.py -- NAV and Exposures"] Engine --> Metrics["PerformanceMetricsCalculator -- Sharpe, Sortino, Drawdown"] Engine --> Benchmark["BenchmarkCalculator -- SPY Comparison"] Engine --> Output["OutputBuilder -- Table Formatting"] Portfolio --> |get_snapshot| Engine Portfolio --> |apply_long_buy, apply_long_sell| Portfolio Portfolio --> |apply_short_open, apply_short_cover| Portfolio Valuation --> |calculate_portfolio_value| Engine Valuation --> |compute_exposures| Engine Valuation --> |compute_portfolio_summary| Output Metrics --> |compute_metrics| Engine Benchmark --> |get_return_pct| Output Output --> |build_day_rows| Engine Output --> |print_rows| Display["Terminal Output"]
Data Flow: Position Lifecycle
stateDiagram-v2 [*] --> NoPosition: Portfolio initialized NoPosition --> LongPosition: apply_long_buy(ticker, qty, price) LongPosition --> LongPosition: apply_long_buy (add to position) LongPosition --> NoPosition: apply_long_sell (sell all) LongPosition --> ReducedLong: apply_long_sell (partial) ReducedLong --> NoPosition: apply_long_sell (remaining) ReducedLong --> LongPosition: apply_long_buy (add back) NoPosition --> ShortPosition: apply_short_open(ticker, qty, price) ShortPosition --> ShortPosition: apply_short_open (add to short) ShortPosition --> NoPosition: apply_short_cover (cover all) ShortPosition --> ReducedShort: apply_short_cover (partial) ReducedShort --> NoPosition: apply_short_cover (remaining) ReducedShort --> ShortPosition: apply_short_open (add back)
Metrics Calculation Pipeline
flowchart LR Values["portfolio_values list"] --> DF["pd.DataFrame"] DF --> Returns["Daily Return = pct_change"] Returns --> Excess["excess = return - daily_rf"] Excess --> Sharpe["Sharpe = sqrt(252) * mean(excess) / std(excess)"] Excess --> Downside["downside = min(excess, 0)"] Downside --> Sortino["Sortino = sqrt(252) * mean(excess) / sqrt(mean(downside^2))"] DF --> CumMax["cummax of Portfolio Value"] CumMax --> Drawdown["drawdown = (value - cummax) / cummax"] Drawdown --> MaxDD["max_drawdown = min(drawdown) * 100"]
Key Classes & Functions
| Class / Function | File | Purpose |
|---|---|---|
Portfolio | portfolio.py | Encapsulates portfolio state: cash, positions (long/short per ticker), margin, realized gains |
Portfolio.__init__ | portfolio.py | Initializes all tickers with zero positions, sets initial cash and margin requirement |
Portfolio.get_snapshot | portfolio.py | Returns a deep-copied PortfolioSnapshot dict safe to pass to external callers |
Portfolio.apply_long_buy | portfolio.py | Buy shares: deducts cash, updates position count, recalculates weighted-average cost basis. Auto-downsizes if insufficient cash. |
Portfolio.apply_long_sell | portfolio.py | Sell long shares: calculates realized gain = (price - cost_basis) * qty, credits cash, reduces position |
Portfolio.apply_short_open | portfolio.py | Open short: receives sale proceeds, reserves margin, updates short cost basis. Auto-downsizes if insufficient margin. |
Portfolio.apply_short_cover | portfolio.py | Cover short: pays cover cost, releases proportional margin, calculates realized gain = (short_cost_basis - price) * qty |
PerformanceMetricsCalculator | metrics.py | Stateless calculator for Sharpe ratio, Sortino ratio, and max drawdown from an equity curve |
PerformanceMetricsCalculator.compute_metrics | metrics.py | Core calculation: converts value points to DataFrame, computes daily returns, excess returns, risk ratios |
calculate_portfolio_value | valuation.py | Pure function: NAV = cash + sum(long * price) - sum(short * price) |
compute_exposures | valuation.py | Pure function: computes long/short/gross/net exposure and L/S ratio |
compute_portfolio_summary | valuation.py | Pure function: assembles summary row data (return %, cash balance, position value, metrics) |
BenchmarkCalculator | benchmarks.py | Computes buy-and-hold return for a benchmark ticker (SPY) over the backtest period |
BenchmarkCalculator.get_return_pct | benchmarks.py | Returns (last_close / first_close - 1) * 100, or None if data unavailable |
OutputBuilder | output.py | Builds tabular display rows for each trading day and prints them via display utilities |
OutputBuilder.build_day_rows | output.py | Generates per-ticker rows (action, qty, price, position value) and a summary row (total value, return, metrics) |
Data Structures
| Type | Name | Description |
|---|---|---|
| TypedDict | PositionState | Per-ticker state: long, short (share counts), long_cost_basis, short_cost_basis (avg price), short_margin_used |
| TypedDict | TickerRealizedGains | Per-ticker realized P&L split by side: {long: float, short: float} |
| TypedDict | PortfolioSnapshot | Full state dump: cash, margin_used, margin_requirement, positions, realized_gains |
| TypedDict | PerformanceMetrics | Computed analytics: sharpe_ratio, sortino_ratio, max_drawdown, max_drawdown_date, exposure fields |
| TypedDict | PortfolioValuePoint | Daily data point: Date, Portfolio Value, Long/Short/Gross/Net Exposure, L/S Ratio |
| Dict | Exposures return | compute_exposures returns: Long Exposure, Short Exposure, Gross Exposure, Net Exposure, Long/Short Ratio |
| Dict | Summary return | compute_portfolio_summary returns: total_value, return_pct, cash_balance, total_position_value, sharpe, sortino, max_drawdown |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| ← | TradeExecutor | apply_long_buy(), apply_long_sell(), apply_short_open(), apply_short_cover() | Executor calls mutation methods to change position state |
| → | AgentController | get_snapshot() | Portfolio provides a read-only copy of state for agent input |
| → | valuation.py | get_cash(), get_positions() | Valuation functions read state to compute NAV and exposures |
| → | OutputBuilder | get_positions() | Output reads positions to format per-ticker rows |
| ← | BacktestEngine | PerformanceMetricsCalculator.compute_metrics() | Engine passes equity curve; calculator returns metrics dict |
| → | API layer | get_price_data() | BenchmarkCalculator fetches SPY price data for return comparison |
| → | Display utils | format_backtest_row(), print_backtest_results() | OutputBuilder delegates terminal formatting to shared utilities |
| ← | BacktestEngine | OutputBuilder.build_day_rows() | Engine passes daily data; builder returns formatted row lists |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
initial_cash | 100000 | Starting cash balance for the portfolio |
margin_requirement | 0.0 | Fraction of short proceeds reserved as margin (0.5 = 50%) |
annual_trading_days | 252 | Used to annualize Sharpe and Sortino ratios |
annual_rf_rate | 0.0434 | Risk-free rate for excess return calculation (4.34% default) |
Directory Map
Source tree for portfolio
backtesting/
├── portfolio.py ← Portfolio class: mutable state, position tracking, cost basis
├── metrics.py ← PerformanceMetricsCalculator: Sharpe, Sortino, max drawdown
├── valuation.py ← Pure functions: NAV, exposures, portfolio summary
├── benchmarks.py ← BenchmarkCalculator: SPY buy-and-hold comparison
├── output.py ← OutputBuilder: daily row formatting and terminal display
└── types.py ← TypedDicts: PositionState, PortfolioSnapshot, PerformanceMetrics
📑 Annotated Code Walkthrough
The most critical financial logic in this sub-module — the Sharpe ratio calculation and long-buy with cost-basis tracking:
PerformanceMetricsCalculator.compute_metrics() — Risk-Adjusted Returns
def compute_metrics(self, values: Sequence[PortfolioValuePoint]) -> PerformanceMetrics:
import pandas as pd
import numpy as np
if not values: # [1]
return {"sharpe_ratio": None, "sortino_ratio": None, "max_drawdown": None}
df = pd.DataFrame(values)
df = df.set_index("Date")
df["Daily Return"] = df["Portfolio Value"].pct_change() # [2]
clean_returns = df["Daily Return"].dropna()
if len(clean_returns) < 2: # [3]
return {"sharpe_ratio": None, "sortino_ratio": None, "max_drawdown": None}
daily_rf = self.annual_rf_rate / self.annual_trading_days # [4]
excess = clean_returns - daily_rf # [5]
mean_excess = excess.mean()
std_excess = excess.std()
if std_excess > 1e-12: # [6]
sharpe = float(np.sqrt(self.annual_trading_days) * (mean_excess / std_excess))
else:
sharpe = 0.0
downside_diff = np.minimum(excess, 0) # [7]
downside_dev = float(np.sqrt(np.mean(downside_diff**2)))
if downside_dev > 1e-12:
sortino = float(np.sqrt(self.annual_trading_days) * (mean_excess / downside_dev))
else:
sortino = float("inf") if mean_excess > 0 else 0.0 # [8]
rolling_max = df["Portfolio Value"].cummax() # [9]
drawdown = (df["Portfolio Value"] - rolling_max) / rolling_max
max_drawdown = float(drawdown.min() * 100.0) # [10]
Line-by-line:
- [1] Guard clause: Returns None metrics for empty input — prevents index errors downstream.
- [2] Daily returns:
pct_change()computes (V[t] - V[t-1]) / V[t-1]. The first row is NaN (no previous value), which is dropped. - [3] Minimum data check: At least 2 clean returns needed for standard deviation. With 0 or 1 points, std is undefined.
- [4] Daily risk-free rate: Annualized rate (4.34%) divided by 252 trading days. This is the daily "free money" baseline.
- [5] Excess returns: Subtracting risk-free rate answers "how much did we beat a treasury bill by?" each day.
- [6] Epsilon guard: If std_excess is nearly zero (flat line portfolio), division would explode. Returns 0 instead of infinity.
- [7] Downside deviation:
np.minimum(excess, 0)keeps only negative excess returns. Sortino only penalizes downside volatility, unlike Sharpe which penalizes all volatility. - [8] Infinite Sortino: If there are no down days but positive mean return, Sortino is infinite — all returns exceeded the risk-free rate.
- [9] Rolling maximum:
cummax()tracks the highest portfolio value seen so far. Drawdown is measured from this peak. - [10] Max drawdown: The worst peak-to-trough decline as a percentage. -5.2 means the portfolio fell 5.2% from its peak at the worst point.
Portfolio.apply_long_buy() — Weighted-Average Cost Basis
def apply_long_buy(self, ticker: str, quantity: int, price: float) -> int:
if quantity <= 0:
return 0
position = self._portfolio["positions"][ticker]
cost = quantity * price
if cost <= self._portfolio["cash"]: # [A]
old_shares = position["long"]
old_cost_basis = position["long_cost_basis"]
total_shares = old_shares + quantity
if total_shares > 0:
total_old_cost = old_cost_basis * old_shares # [B]
total_new_cost = cost
position["long_cost_basis"] = (total_old_cost + total_new_cost) / total_shares # [C]
position["long"] = old_shares + quantity
self._portfolio["cash"] -= cost
return quantity
max_quantity = int(self._portfolio["cash"] / price) # [D]
if max_quantity > 0:
# ... same cost-basis logic for partial fill ...
return max_quantity
return 0
Line-by-line:
- [A] Cash check: If the full order is affordable, execute in full. Otherwise fall through to partial fill logic.
- [B] Old total cost:
old_cost_basis * old_sharesreconstructs the total dollars invested in existing shares. This is needed for weighted averaging. - [C] Weighted-average cost basis: New basis = (old investment + new investment) / total shares. This correctly handles adding 100 shares at $50 to 200 shares bought at $40 → basis = (200*40 + 100*50) / 300 = $43.33.
- [D] Partial fill: Integer division gives the max whole shares affordable. Remaining cash stays available for other tickers. This mirrors real-broker behavior where you can't buy fractional shares.
⚠️ Edge Cases & Boundaries
| Edge Case | What Happens | Where in Code | Risk Level |
|---|---|---|---|
| Sell more shares than held | Capped to current position size via min(quantity, position["long"]) | portfolio.py:apply_long_sell L116 | 🟢 Low |
| Short with zero margin requirement | Margin required = 0; proceeds go directly to cash; no margin reserved | portfolio.py:apply_short_open L134-136 | 🟡 Medium — unrealistic, allows unlimited shorting |
| Cover more shorts than open | Capped to current short position via min(quantity, position["short"]) | portfolio.py:apply_short_cover L173 | 🟢 Low |
| Flat portfolio (all returns zero) | Sharpe returns 0.0 (std_excess < 1e-12 guard), Sortino returns 0.0 | metrics.py:compute_metrics L44-45 | 🟢 Low |
| All positive excess returns (no down days) | Sortino returns inf since downside deviation is zero | metrics.py:compute_metrics L55 | 🟡 Medium — inf may need special handling in output |
| SPY price data unavailable | BenchmarkCalculator returns None; output row shows no benchmark comparison | benchmarks.py:get_return_pct L15-17 | 🟢 Low |
| Zero-price ticker | Division by zero in int(cash / price); guarded with if price > 0 | portfolio.py:apply_long_buy L99 | 🟢 Low |
Code Examples
Example: Portfolio Lifecycle
from src.backtesting.portfolio import Portfolio
from src.backtesting.valuation import calculate_portfolio_value, compute_exposures
# Initialize with $100k and 2 tickers
portfolio = Portfolio(
tickers=["AAPL", "TSLA"],
initial_cash=100000.0,
margin_requirement=0.5,
)
# Buy 100 shares of AAPL at $150
executed = portfolio.apply_long_buy("AAPL", 100, 150.0)
# executed = 100, cash now = $85,000
# Price rises to $160 — check NAV
prices = {"AAPL": 160.0, "TSLA": 250.0}
nav = calculate_portfolio_value(portfolio, prices)
# nav = 85000 + (100 * 160) = $101,000
# Sell 50 shares — realize gain
sold = portfolio.apply_long_sell("AAPL", 50, 160.0)
# realized_gain = (160 - 150) * 50 = $500
# Check exposures
exp = compute_exposures(portfolio, prices)
# Long Exposure = 50 * 160 = $8,000
# Short Exposure = $0
Example: Computing Metrics from Equity Curve
from src.backtesting.metrics import PerformanceMetricsCalculator
from datetime import datetime
calc = PerformanceMetricsCalculator(annual_trading_days=252, annual_rf_rate=0.0434)
values = [
{"Date": datetime(2025, 1, 2), "Portfolio Value": 100000},
{"Date": datetime(2025, 1, 3), "Portfolio Value": 100500},
{"Date": datetime(2025, 1, 6), "Portfolio Value": 99800},
{"Date": datetime(2025, 1, 7), "Portfolio Value": 101200},
{"Date": datetime(2025, 1, 8), "Portfolio Value": 102000},
]
metrics = calc.compute_metrics(values)
print(f"Sharpe Ratio: {metrics['sharpe_ratio']:.2f}")
print(f"Sortino Ratio: {metrics['sortino_ratio']:.2f}")
print(f"Max Drawdown: {metrics['max_drawdown']:.2f}%")