Portfolio Backtesting

AbaQuant’s backtesting layer evaluates periodic simple-return panels, applies transparent target weights, rebalances on a fixed calendar schedule, and reports performance, drawdown, turnover, and transaction-cost diagnostics. It is intentionally not an event-driven trading simulator.

Sections:

  1. Build an allocator and run the object-oriented backtest

  2. Run the same backtest through the functional helper

  3. Summaries: performance, costs, monthly returns, drawdowns, contributions, trades

  4. Visualizations

Setup

import abaquant
print(f"AbaQuant version: {abaquant.__version__}")
AbaQuant version: 1.0.0rc1
import numpy as np
import pandas as pd

from abaquant.portfolio import PortfolioAllocator, run_rebalanced_backtest
from abaquant.visualization import VisualizationError

def sample_returns() -> pd.DataFrame:
    dates = pd.date_range("2025-01-02", periods=36, freq="B")
    trend = np.linspace(0.0, 1.0, len(dates))
    seasonal = np.sin(np.linspace(0.0, 4.0 * np.pi, len(dates)))
    prices = pd.DataFrame(
        {
            "ALPHA": 100.0 + 9.0 * trend + 1.8 * seasonal,
            "BETA": 82.0 + 6.0 * trend - 1.2 * seasonal,
            "GAMMA": 54.0 + 3.0 * trend + 0.7 * np.cos(np.linspace(0.0, 3.0 * np.pi, len(dates))),
        },
        index=dates,
    )
    return prices.pct_change().dropna()

1. Build an allocator and run the object-oriented backtest

allocator.backtest(...) accepts a weight policy (here, inverse_volatility), a rebalance schedule, transaction-cost/slippage assumptions, and an optional benchmark.

allocator = PortfolioAllocator(sample_returns(), annual_risk_free_rate=0.02)

allocator_backtest = allocator.backtest(
    weights="inverse_volatility",
    rebalance="monthly",
    transaction_cost_bps=5.0,
    slippage_bps=1.0,
    fixed_transaction_cost=1.0,
    initial_capital=100_000.0,
    benchmark="equal_weight",
    lookback=4,
)
allocator_backtest.summary()
{'ending_value': 105670.59605154977,
 'total_return': 0.05670596051549781,
 'cagr': 0.4875443795575871,
 'annualized_return': 0.39759161617094785,
 'annualized_volatility': 0.01769693629033863,
 'downside_deviation': 0.0005270746229309016,
 'sharpe_ratio': 26.419509675967795,
 'sortino_ratio': 887.055379289018,
 'max_drawdown': -8.642685029525143e-05,
 'average_drawdown': -5.805475821304048e-05,
 'calmar_ratio': 5641.121687207597,
 'omega_ratio': 172.32947702415424,
 'best_period_return': 0.003515810977111844,
 'worst_period_return': -6.669185299079672e-05,
 'win_rate': 0.9428571428571428,
 'value_at_risk_95': 6.97823841958601e-06,
 'conditional_value_at_risk_95': -4.321408327329079e-05,
 'skewness': 0.3238484005937596,
 'kurtosis': -1.0722128125190267,
 'total_turnover': 0.4901790097829085,
 'average_turnover': 0.24508950489145426,
 'turnover_events': 1.0,
 'transaction_cost_drag': 0.00031700561324870266,
 'total_transaction_cost': 31.700561324870268,
 'benchmark_total_return': 0.06405143200151953,
 'benchmark_cagr': 0.5636187435678752,
 'active_total_return': -0.006903304920331532,
 'tracking_error': 0.005386616339461853,
 'information_ratio': -9.267156641584009,
 'beta': 1.122086166527091,
 'alpha': -0.1424426924647637,
 'up_capture': 0.8884525679130272,
 'down_capture': nan,
 'hit_rate_vs_benchmark': 0.37142857142857144}

2. Run the same backtest through the functional helper

Useful when you want fixed, user-specified weights instead of a policy name.

functional_backtest = run_rebalanced_backtest(
    sample_returns(),
    weights={"ALPHA": 0.45, "BETA": 0.35, "GAMMA": 0.20},
    rebalance="monthly",
    transaction_cost_bps=5.0,
    slippage_bps=1.0,
    fixed_transaction_cost=1.0,
    initial_capital=100_000.0,
    annual_risk_free_rate=0.02,
    benchmark="equal_weight",
)
functional_backtest.summary()
{'ending_value': 107202.72674408721,
 'total_return': 0.07202726744087218,
 'cagr': 0.6499925756051221,
 'annualized_return': 0.5014033290552501,
 'annualized_volatility': 0.01666781355297071,
 'downside_deviation': 0.0,
 'sharpe_ratio': 37.79695360780168,
 'sortino_ratio': nan,
 'max_drawdown': 0.0,
 'average_drawdown': 0.0,
 'calmar_ratio': nan,
 'omega_ratio': nan,
 'best_period_return': 0.0038228763419032585,
 'worst_period_return': 0.0006164770457144364,
 'win_rate': 1.0,
 'value_at_risk_95': 0.0006436517383136886,
 'conditional_value_at_risk_95': 0.000616575166763278,
 'skewness': 0.25439154939214204,
 'kurtosis': -1.2662410576131151,
 'total_turnover': 0.01933594365372518,
 'average_turnover': 0.00966797182686259,
 'turnover_events': 1.0,
 'transaction_cost_drag': 2.2165294349911445e-05,
 'total_transaction_cost': 2.2165294349911444,
 'benchmark_total_return': 0.06405143200151953,
 'benchmark_cagr': 0.5636187435678752,
 'active_total_return': 0.007495723608350158,
 'tracking_error': 0.005861632447920839,
 'information_ratio': 9.19421270050967,
 'beta': 1.0329682141700798,
 'alpha': 0.06845169287243147,
 'up_capture': 1.120428744321768,
 'down_capture': nan,
 'hit_rate_vs_benchmark': 0.6285714285714286}

3. Summaries

Cost summary, calendar return table, largest drawdown events, per-asset contribution summary, per-rebalance trade summary, and rolling metrics.

allocator_backtest.cost_summary()
{'transaction_cost_bps': 5.0,
 'slippage_bps': 1.0,
 'fixed_transaction_cost': 1.0,
 'total_transaction_cost': 31.700561324870268,
 'transaction_cost_drag': 0.00031700561324870266,
 'average_transaction_cost': 15.850280662435134}
allocator_backtest.return_table()
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Year
year
2025 0.043855 0.012311 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
allocator_backtest.drawdown_events(top=3)
start trough recovery drawdown duration_periods recovery_periods
0 2025-02-12 2025-02-13 2025-02-17 -0.000086 3 2
allocator_backtest.contribution_summary()
total_return_contribution absolute_return_contribution contribution_share
ALPHA 0.026473 0.042736 0.476776
BETA 0.019862 0.035834 0.357719
GAMMA 0.009190 0.026807 0.165504
allocator_backtest.trade_summary()
turnover transaction_cost largest_buy largest_sell
2025-01-03 0.000000 0.000000 0.00000 0.000000
2025-02-03 0.490179 31.700561 0.24509 -0.154315
allocator_backtest.rolling_metrics(window=4).tail()
annualized_return annualized_volatility sharpe_ratio sortino_ratio max_drawdown tracking_error
2025-02-14 0.005455 0.001512 -9.622288 NaN -0.000086 0.002771
2025-02-17 0.010272 0.002088 -4.658105 NaN -0.000086 0.002260
2025-02-18 0.046627 0.004413 6.033808 NaN -0.000086 0.001370
2025-02-19 0.112237 0.006615 13.943082 NaN -0.000068 0.000706
2025-02-20 0.202652 0.008401 21.741489 NaN 0.000000 0.001874

4. Visualizations

Equity curve, benchmark comparison, drawdown, weights, turnover, transaction costs, rolling Sharpe/volatility, a calendar return heatmap, contribution, and trade-weight charts.

try:
    figures = {
        "equity_curve": allocator_backtest.visualize(chart="equity_curve"),
        "benchmark": allocator_backtest.visualize(chart="benchmark"),
        "drawdown": allocator_backtest.visualize(chart="drawdown"),
        "weights": allocator_backtest.visualize(chart="weights"),
        "turnover": allocator_backtest.visualize(chart="turnover"),
        "transaction_costs": allocator_backtest.visualize(chart="transaction_costs"),
        "rolling_sharpe": allocator_backtest.visualize(chart="rolling_sharpe", rolling_window=4),
        "rolling_volatility": allocator_backtest.visualize(
            chart="rolling_volatility", rolling_window=4
        ),
        "return_heatmap": allocator_backtest.visualize(chart="return_heatmap"),
        "contributions": allocator_backtest.visualize(chart="contributions"),
        "trade_weights": allocator_backtest.visualize(chart="trade_weights"),
    }
    print(f"Created {len(figures)} figures: {list(figures)}")
except VisualizationError as exc:
    print(f"Visualization skipped (optional dependency missing): {exc}")
Created 11 figures: ['equity_curve', 'benchmark', 'drawdown', 'weights', 'turnover', 'transaction_costs', 'rolling_sharpe', 'rolling_volatility', 'return_heatmap', 'contributions', 'trade_weights']
../../_images/fd3cf9751b23d9da9ab3b6366fbc898effe7dd371072290503466b774447206c.png ../../_images/03e4827fb72f62e1d8b3bfdfa98a6c9266577fec9cf57a8757245b0c5c77ef20.png ../../_images/feeaf9fb8691f6bbde6c1ab2d8ec651092f401565fb89aeeb727b8f3cb91bd1b.png ../../_images/92aeaa3c593e41783971d090a29b81264d2663b334a6c98ec1092e51ad27ac48.png ../../_images/bc2b2ad89e2fccbc4fdf3ad4e284e40039aebab13b71750c914723eceae3cc85.png ../../_images/4871dbb7801fbdc37c9b16de9f766fe25c3df0766dc0ae0c4ebca93708b44eb6.png ../../_images/a75e094e81a55a868e5090420532f657e2d91c8e823d613f9ac1778ee72b93c7.png ../../_images/41789681cf0363891b17635738f2e3b514d5acff3a9d7fe97d579b6190f48cb0.png ../../_images/57059911b9b14692ff0f04901d3309680b93e7d1fd37277cfc14a91aa09df656.png ../../_images/8cc6d71523573e86d375d995343b5d26de87fa008e5af7d73b9dd994295c4a8f.png ../../_images/2a21b9efb71a128f5e4a18c22601187e814700c62706b8dd495b5b5222094642.png

Takeaway

Backtests here are deterministic, close-to-close simulations. They do not model intraday execution, taxes, financing, or market impact beyond the explicit slippage parameter — treat results as directional research, not a live-trading guarantee. See docs/domains/assumptions.rst for the full list of backtest failure modes.