beacon.backtest¶
Portfolio simulation: BacktestEngine consumes a target weight schedule and
simulates trading with configurable transaction costs, returning a
BacktestResult. See Backtest for the narrative
version.
backtest ¶
The init.py for the 'backtest' module.
This module provides an engine for backtesting index methodologies and ETF tracking strategies.
TradeInstruction
dataclass
¶
A single trade for a portfolio to record.
Produced by whatever decides trades — the backtest engine sizes, prices
and costs an order — and consumed by :meth:Portfolio.apply, which does
the accounting. It lives here rather than in the backtest layer because
the portfolio is the layer that accepts one, and a ledger's input type
belongs with the ledger (BN-151; previously in backtest/engine.py,
where it forced the codebase's one circular-import workaround).
Attributes:
| Name | Type | Description |
|---|---|---|
asset_id |
str
|
Asset identifier. |
side |
str
|
|
quantity |
float
|
Number of units to trade. |
price |
float
|
Execution price per unit. |
cost |
float
|
Transaction cost in currency terms. |
BacktestAssetView ¶
BacktestAssetView(
asset_id: str,
data_fetcher: DataFetcher,
portfolio: Portfolio,
index_book: object | None = None,
)
Bases: AssetView
AssetView with backtest context for a specific asset.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
asset_id
|
str
|
The identifier used to look up data in the DataFetcher. |
required |
data_fetcher
|
DataFetcher
|
The data provider instance. |
required |
portfolio
|
Portfolio
|
The run's books — positions, weights, transactions. |
required |
index_book
|
object | None
|
The tracked index's book, when the run tracked one.
Target weights come from its |
None
|
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
trades ¶
This asset's transactions.
Returns:
| Type | Description |
|---|---|
DataFrame
|
pd.DataFrame: DataFrame with columns: date, type, quantity, price, |
DataFrame
|
cost. Empty DataFrame if no trades exist for this asset. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
total_cost ¶
Sum of all transaction costs for this asset.
Returns:
| Name | Type | Description |
|---|---|---|
float |
float
|
Total transaction costs incurred for this asset. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
holding_periods ¶
Continuous periods when this asset was held.
Read from the positions panel — the record of quantities — rather than inferred from weights. Quantity above zero is the fact of holding; a weight of 0.0000 is a rounding statement about size.
Returns:
| Type | Description |
|---|---|
list[dict[str, Timestamp]]
|
list of dict: Each dict has |
list[dict[str, Timestamp]]
|
Timestamps. An open position at the end of the run has |
list[dict[str, Timestamp]]
|
set to the last recorded date. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
weight_series ¶
Time series of this asset's portfolio weight.
Returns:
| Type | Description |
|---|---|
Series
|
pd.Series: Weight at each date where the asset was held. Dates |
Series
|
where the asset had zero or no weight are excluded. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
target_weight_series ¶
Time series of this asset's target index weight.
Read from the rebalance snapshots — what each rebalance decided — rather than the index's daily panel: the target the portfolio traded to is the snapshot, and the daily drift between rebalances is the index's business, not the portfolio's instruction.
Returns:
| Type | Description |
|---|---|
Series
|
pd.Series: Target weight at each rebalance date. Rebalance dates |
Series
|
where the asset was not a constituent are excluded. Empty |
Series
|
Series if the run tracked no index. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
slippage_vs_target ¶
Difference between actual and target weights over time.
For each date the asset was held, finds the applicable target weight (most recent rebalance on or before that date) and computes actual - target.
Returns:
| Type | Description |
|---|---|
Series
|
pd.Series: Slippage series indexed by date. Positive values mean |
Series
|
the asset is overweight vs target. Empty Series if the run |
Series
|
tracked no index. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
weight_on_date ¶
This asset's portfolio weight on a specific date.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
date
|
Timestamp
|
The query date. |
required |
Returns:
| Type | Description |
|---|---|
float | None
|
float or None: The weight, or None if the asset was not held on |
float | None
|
that date. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/asset_view.py
BacktestEngine ¶
BacktestEngine(
start_date: str,
end_date: str,
initial_capital: float,
data_provider: DataFetcher,
index_result: IndexResult,
price_column: str = "CLOSE",
currency: str = "USD",
transaction_cost_bps: float = 0.0,
modifiers: list[BacktestModifier] | None = None,
benchmark: IndexResult | Series | None = None,
target_index: IndexResult | None = None,
calendar: str | None = None,
)
Bases: PricingMixin
Simulates portfolio execution against a target weight schedule.
The engine consumes target weights from an IndexResult — the sole
schedule source since BN-165, when the raw weight-dict mode was removed —
and simulates trading over a date range using prices from a
DataFetcher.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start_date
|
str
|
The start date of the backtest (YYYY-MM-DD). |
required |
end_date
|
str
|
The end date of the backtest (YYYY-MM-DD). |
required |
initial_capital
|
float
|
The starting capital for the backtest. |
required |
data_provider
|
DataFetcher
|
Data source for market prices. |
required |
index_result
|
IndexResult
|
The IndexResult whose weight_snapshots provide the rebalance schedule and target weights. |
required |
price_column
|
str
|
Column name to read from market data. Defaults to
|
'CLOSE'
|
transaction_cost_bps
|
float
|
Transaction cost in basis points applied to each trade's notional value. Defaults to 0 (no cost). |
0.0
|
modifiers
|
list[BacktestModifier] | None
|
Optional hooks that can skip rebalances or adjust trades. |
None
|
benchmark
|
IndexResult | Series | None
|
The benchmark of record, stored on the result so every reader quotes excess return against the same comparator. |
None
|
target_index
|
IndexResult | None
|
The calculated index the traded schedule was derived
from, when it differs from the schedule itself — the
derived-index shape (BN-167): index_result is an optimised
calculation and this is its parent, and they land in
|
None
|
calendar
|
str | None
|
The exchange MIC the traded index schedules on, which since BN-180 every definition carries. It decides which days the run steps onto at all (BN-186) and how a missing bar on one of them is read (BN-183): on a day the calendar says was closed the market was shut and the previous session's price is what the position was worth, while on a day it says was open the data is missing something and the carried price is recorded as a gap. None falls back to the data's own sessions — a day the store has bars for is treated as open — which is all a caller assembling an engine by hand can offer. |
None
|
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/engine.py
run ¶
Execute the backtest and return a :class:BacktestResult.
Returns:
| Type | Description |
|---|---|
BacktestResult
|
BacktestResult |
Raises:
| Type | Description |
|---|---|
CalculationError
|
If the dataset has no column to price positions from, checked before the first trade rather than discovered at it (BN-217). |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/engine.py
593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 | |
Backtest ¶
Backtest(
initial_capital: float,
transaction_cost_bps: float = 0.0,
price_column: str = "CLOSE",
currency: str = "USD",
modifiers: list[BacktestModifier] | None = None,
benchmark: IndexResult | Series | None = None,
data_provider: DataFetcher | None = None,
cache: IndexResultCache | None = None,
)
One-call backtests: assumptions on the object, the index per run.
The constructor mirrors :class:BacktestEngine's parameters — what stays
fixed across runs — and :meth:run takes the definition and window, so a
parameter sweep is one object per assumption set over one shared (cached)
calculation::
bt = Backtest(initial_capital=1_000_000, transaction_cost_bps=5.0)
result = bt.run(definition, start="2023-01-03", end="2023-12-29")
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
initial_capital
|
float
|
The starting capital for each run. |
required |
transaction_cost_bps
|
float
|
Transaction cost in basis points applied to each trade's notional value. Defaults to 0 (no cost). |
0.0
|
price_column
|
str
|
Market-data column both the calculator and the engine
read. Defaults to |
'CLOSE'
|
currency
|
str
|
The simulated book's currency. Defaults to |
'USD'
|
modifiers
|
list[BacktestModifier] | None
|
Optional hooks that can skip rebalances or adjust trades. |
None
|
benchmark
|
IndexResult | Series | None
|
The benchmark of record, stored on every result this object produces. |
None
|
data_provider
|
DataFetcher | None
|
Data source for both the calculation and the
simulation. None resolves the process's ambient source
(:func: |
None
|
cache
|
IndexResultCache | None
|
Where calculated IndexResults are kept between runs. None
uses the default location when |
None
|
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/main.py
run ¶
run(
definition: AnyIndexDefinition,
start: str | None = None,
end: str | None = None,
optimised: bool = False,
optimisation_config: OptimisationConfig | None = None,
) -> BacktestResult
Calculate (or reuse) the index, then simulate tracking it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
AnyIndexDefinition
|
The index to calculate and track — a plain
:class: |
required |
start
|
str | None
|
First date (YYYY-MM-DD). Defaults to the definition's base date. |
None
|
end
|
str | None
|
Last date (YYYY-MM-DD). Required. |
None
|
optimised
|
bool
|
Ad-hoc optimisation of definition: build an ephemeral derived index over it from optimisation_config — same solve, same chained levels as a stored one — and trade that. Requires the config; needs scipy only on this path. |
False
|
optimisation_config
|
OptimisationConfig | None
|
What the ad-hoc derivation is asked to do
(objective, constraints, reserved risk model). Only
meaningful — and only allowed — with |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
BacktestResult |
BacktestResult
|
The engine's result — portfolio kept whole, books |
BacktestResult
|
filled, data bound to the run's own source. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If end is not provided, or optimised and optimisation_config contradict each other (a flag with no config, or a config with no flag). |
CalculationError
|
If a calculation comes back empty (see
:func: |
DataSourceError
|
If no data source is bound and the process has no ambient one. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/main.py
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | |
BacktestResult
dataclass
¶
BacktestResult(
portfolio: Portfolio,
index: IndexBooks = IndexBooks(),
benchmark: Book | None = None,
unfilled: list[UnfilledOrder] = list(),
price_gaps: list[PriceGap] = list(),
rebalance_pricing: list[RebalancePricing] = list(),
_data_fetcher: DataFetcher | None = None,
)
The record of one backtest run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
portfolio
|
Portfolio
|
The books — positions, weights, cash, NAV, transactions — kept whole and frozen by the engine on completion. |
required |
index
|
IndexBooks
|
The run's calculated indices, as an :class: |
IndexBooks()
|
benchmark
|
Book | None
|
The benchmark of record, when one was given to the engine. |
None
|
unfilled
|
list[UnfilledOrder]
|
Buys the simulation could not execute in full. Empty for a run where every rebalance leg filled, so a non-empty list is itself the signal that the portfolio drifted off target for a reason other than price movement. |
list()
|
price_gaps
|
list[PriceGap]
|
Days a name had no bar on a session its calendar says was open, and was therefore marked at a carried-forward price (BN-183). Empty for a run with complete data — a market holiday is not a gap, since nothing is missing on a day nothing traded. |
list()
|
rebalance_pricing
|
list[RebalancePricing]
|
What each rebalance priced from, in date order.
|
list()
|
trading_nav
property
¶
NAV over the simulated days, with the day-zero row excluded.
The portfolio's own nav opens with initial capital on the eve of
the first trading day (decision 11) — the record of what the run
started with. Every metric derives from this series instead, which
matches the NAV the engine produced before the redesign exactly: the
eve row is a starting fact, not a day the simulation traded.
total_unfilled_value
property
¶
Total notional that went unfilled across the run.
with_data ¶
Bind a DataFetcher for asset-level queries. Returns self for chaining.
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
asset ¶
Return a BacktestAssetView for an asset the run ever held.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
asset_id
|
str
|
Identifier of the asset. |
required |
Returns:
| Type | Description |
|---|---|
BacktestAssetView
|
BacktestAssetView |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If no DataFetcher has been bound via
:meth: |
KeyError
|
If the run's books never held asset_id. Membership is judged from the positions panel — the record of holdings — rather than from a weight column, so a position too small to round to a visible weight still counts as held. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
against ¶
Compare this run's NAV against any comparator, after the fact.
The exploratory half of decision 13: the run-time benchmark is a fact about the run, this is a question asked later — so it computes and returns, and stores nothing. Ask against ten comparators and the result is byte-for-byte what it was.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
other
|
Comparable
|
Another result, a book, an index result, or a bare level series. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
RelativeMetrics |
RelativeMetrics
|
Excess return, tracking error, beta and |
RelativeMetrics
|
correlation over the common window, as |
|
RelativeMetrics
|
computes them. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
get_returns ¶
Derive a return series from portfolio NAV.
Returns:
| Type | Description |
|---|---|
Series
|
pd.Series: Percentage returns (first entry is dropped). |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
get_tracking_error ¶
Calculate annualised tracking error against the tracked index.
Tracking error is the annualised standard deviation of the difference between portfolio returns and index returns.
Returns:
| Type | Description |
|---|---|
float | None
|
float or None: Annualised tracking error, or None if the run |
float | None
|
tracked no index. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
get_tracking_difference ¶
Calculate cumulative tracking difference against the tracked index.
Tracking difference is the difference between the cumulative portfolio return and the cumulative index return over the full backtest period.
Returns:
| Type | Description |
|---|---|
float | None
|
float or None: Tracking difference, or None if the run tracked |
float | None
|
no index. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
summary ¶
Calculate key performance metrics for the backtest.
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict[str, float | None]
|
Dictionary containing: total_return, annualised_return, |
dict[str, float | None]
|
volatility, sharpe_ratio, max_drawdown, and optionally |
|
dict[str, float | None]
|
tracking_error and tracking_difference. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/result.py
PriceGap
dataclass
¶
A day a name should have traded on and had no bar (BN-183).
The engine prices from the last session on or before the date it is marking. Two different things can put it there, and only one of them is a fault: a day the index's calendar says was closed is a market that was shut, and the previous session's price is what the position was genuinely worth through it — no gap is recorded, because nothing is missing. A day the calendar says was open is data that is missing something, and the price carried forward is a stale quote.
Carrying it forward is standard practice and beats refusing: a backtest
over five hundred names must not die because one of them had one bad day.
Doing it silently is not — the mark is not what that day's market said, so
it is published here rather than absorbed, the way unfilled publishes the
legs a rebalance could not fill.
Attributes:
| Name | Type | Description |
|---|---|---|
date |
Timestamp
|
The simulated day whose bar was missing. |
asset_id |
str
|
The name with no bar. |
priced_from |
Timestamp
|
The session the carried price actually came from, always earlier than date. |
RebalancePricing
dataclass
¶
What one rebalance priced from (BN-183).
A rebalance scheduled on a day the market was shut still trades — it prices
from the session in force through the closure — and a record that only
carried the scheduled date left a reader unable to tell the two cases
apart. date and priced_from are equal for the ordinary rebalance, which
is what makes an unequal pair worth reading.
Attributes:
| Name | Type | Description |
|---|---|---|
date |
Timestamp
|
The rebalance date from the weight schedule. |
priced_from |
Timestamp
|
The session its prices were read from. |
UnfilledOrder
dataclass
¶
UnfilledOrder(
date: Timestamp,
asset_id: str,
requested_quantity: float,
filled_quantity: float,
price: float,
shortfall_value: float,
)
A buy the simulation could not execute in full.
Recorded on the result rather than only logged: a partially filled rebalance leaves the portfolio off its target weights, and a caller comparing tracking error against expectations needs to know that happened rather than reading it as a modelling result.
Attributes:
| Name | Type | Description |
|---|---|---|
date |
Timestamp
|
The rebalance date. |
asset_id |
str
|
Asset that could not be fully bought. |
requested_quantity |
float
|
Quantity the rebalance asked for. |
filled_quantity |
float
|
Quantity actually bought; 0.0 when nothing was. |
price |
float
|
Execution price used. |
shortfall_value |
float
|
Notional value that went unfilled, at price. |
BacktestModifier ¶
Bases: ABC
Abstract base class for modifiers that alter rebalance behaviour.
A modifier can veto a rebalance entirely via :meth:should_skip_rebalance
or adjust the trade list via :meth:adjust_trades.
should_skip_rebalance
abstractmethod
¶
should_skip_rebalance(
date: Timestamp,
portfolio: Portfolio,
target_weights: dict[str, float],
) -> bool
Return True to skip the scheduled rebalance on date.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
date
|
Timestamp
|
The rebalance date. |
required |
portfolio
|
Portfolio
|
Current portfolio state (prices already updated). |
required |
target_weights
|
dict[str, float]
|
Target weights for this rebalance. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
bool |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/rules.py
adjust_trades
abstractmethod
¶
adjust_trades(
trades: list[TradeInstruction],
date: Timestamp,
portfolio: Portfolio,
) -> list[TradeInstruction]
Optionally modify the trade list before execution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trades
|
list[TradeInstruction]
|
The trades generated by
:meth: |
required |
date
|
Timestamp
|
The rebalance date. |
required |
portfolio
|
Portfolio
|
Current portfolio state. |
required |
Returns:
| Type | Description |
|---|---|
list[TradeInstruction]
|
list of TradeInstruction: The (possibly modified) trade list. |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/rules.py
DriftThresholdModifier ¶
Bases: BacktestModifier
Only rebalance when max weight drift exceeds a threshold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
threshold
|
float
|
Maximum tolerable absolute drift between current and target weights. If every asset's drift is within this threshold the rebalance is skipped. For example, 0.05 means 5%. |
required |
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/backtest/rules.py
adjust_trades ¶
adjust_trades(
trades: list[TradeInstruction],
date: Timestamp,
portfolio: Portfolio,
) -> list[TradeInstruction]
Pass-through — no trade adjustment.