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.

5
Modules
5
Files
~0.5 k
Lines
7
Metrics

📚 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 ChoiceWhat Was ChosenWhat Was RejectedWhy
State encapsulationPrivate _portfolio dict with accessor methodsPublic dictPrevents external code from bypassing cost-basis or margin tracking invariants
Read-only viewsMappingProxyType on positions/gainsDeep-copy on every readZero-copy for reads; MappingProxyType raises TypeError on mutation attempts without copy overhead
Partial fillsAuto-downsize to affordable quantityReject-or-fill-allMatches real broker behavior; ensures the backtest doesn't stall when the agent over-requests
Metrics as pure functionsStateless compute_metrics(values)Metrics accumulated inside PortfolioPortfolio 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 / FunctionFilePurpose
Portfolioportfolio.pyEncapsulates portfolio state: cash, positions (long/short per ticker), margin, realized gains
Portfolio.__init__portfolio.pyInitializes all tickers with zero positions, sets initial cash and margin requirement
Portfolio.get_snapshotportfolio.pyReturns a deep-copied PortfolioSnapshot dict safe to pass to external callers
Portfolio.apply_long_buyportfolio.pyBuy shares: deducts cash, updates position count, recalculates weighted-average cost basis. Auto-downsizes if insufficient cash.
Portfolio.apply_long_sellportfolio.pySell long shares: calculates realized gain = (price - cost_basis) * qty, credits cash, reduces position
Portfolio.apply_short_openportfolio.pyOpen short: receives sale proceeds, reserves margin, updates short cost basis. Auto-downsizes if insufficient margin.
Portfolio.apply_short_coverportfolio.pyCover short: pays cover cost, releases proportional margin, calculates realized gain = (short_cost_basis - price) * qty
PerformanceMetricsCalculatormetrics.pyStateless calculator for Sharpe ratio, Sortino ratio, and max drawdown from an equity curve
PerformanceMetricsCalculator.compute_metricsmetrics.pyCore calculation: converts value points to DataFrame, computes daily returns, excess returns, risk ratios
calculate_portfolio_valuevaluation.pyPure function: NAV = cash + sum(long * price) - sum(short * price)
compute_exposuresvaluation.pyPure function: computes long/short/gross/net exposure and L/S ratio
compute_portfolio_summaryvaluation.pyPure function: assembles summary row data (return %, cash balance, position value, metrics)
BenchmarkCalculatorbenchmarks.pyComputes buy-and-hold return for a benchmark ticker (SPY) over the backtest period
BenchmarkCalculator.get_return_pctbenchmarks.pyReturns (last_close / first_close - 1) * 100, or None if data unavailable
OutputBuilderoutput.pyBuilds tabular display rows for each trading day and prints them via display utilities
OutputBuilder.build_day_rowsoutput.pyGenerates per-ticker rows (action, qty, price, position value) and a summary row (total value, return, metrics)

Data Structures

TypeNameDescription
TypedDictPositionStatePer-ticker state: long, short (share counts), long_cost_basis, short_cost_basis (avg price), short_margin_used
TypedDictTickerRealizedGainsPer-ticker realized P&L split by side: {long: float, short: float}
TypedDictPortfolioSnapshotFull state dump: cash, margin_used, margin_requirement, positions, realized_gains
TypedDictPerformanceMetricsComputed analytics: sharpe_ratio, sortino_ratio, max_drawdown, max_drawdown_date, exposure fields
TypedDictPortfolioValuePointDaily data point: Date, Portfolio Value, Long/Short/Gross/Net Exposure, L/S Ratio
DictExposures returncompute_exposures returns: Long Exposure, Short Exposure, Gross Exposure, Net Exposure, Long/Short Ratio
DictSummary returncompute_portfolio_summary returns: total_value, return_pct, cash_balance, total_position_value, sharpe, sortino, max_drawdown

Interactions

DirectionPartnerInterfaceDescription
←TradeExecutorapply_long_buy(), apply_long_sell(), apply_short_open(), apply_short_cover()Executor calls mutation methods to change position state
→AgentControllerget_snapshot()Portfolio provides a read-only copy of state for agent input
→valuation.pyget_cash(), get_positions()Valuation functions read state to compute NAV and exposures
→OutputBuilderget_positions()Output reads positions to format per-ticker rows
←BacktestEnginePerformanceMetricsCalculator.compute_metrics()Engine passes equity curve; calculator returns metrics dict
→API layerget_price_data()BenchmarkCalculator fetches SPY price data for return comparison
→Display utilsformat_backtest_row(), print_backtest_results()OutputBuilder delegates terminal formatting to shared utilities
←BacktestEngineOutputBuilder.build_day_rows()Engine passes daily data; builder returns formatted row lists

Configuration

Knob / ParameterDefaultDescription
initial_cash100000Starting cash balance for the portfolio
margin_requirement0.0Fraction of short proceeds reserved as margin (0.5 = 50%)
annual_trading_days252Used to annualize Sharpe and Sortino ratios
annual_rf_rate0.0434Risk-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_shares reconstructs 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 CaseWhat HappensWhere in CodeRisk Level
Sell more shares than heldCapped to current position size via min(quantity, position["long"])portfolio.py:apply_long_sell L116🟢 Low
Short with zero margin requirementMargin required = 0; proceeds go directly to cash; no margin reservedportfolio.py:apply_short_open L134-136🟡 Medium — unrealistic, allows unlimited shorting
Cover more shorts than openCapped 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.0metrics.py:compute_metrics L44-45🟢 Low
All positive excess returns (no down days)Sortino returns inf since downside deviation is zerometrics.py:compute_metrics L55🟡 Medium — inf may need special handling in output
SPY price data unavailableBenchmarkCalculator returns None; output row shows no benchmark comparisonbenchmarks.py:get_return_pct L15-17🟢 Low
Zero-price tickerDivision by zero in int(cash / price); guarded with if price > 0portfolio.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}%")