Strategies¶
The strategy is what a backtest holds, whatever the
vehicle that holds it. An index definition passed to
run() is tracked in full. IndexTracking holds an index another way:
| Replication | What it holds |
|---|---|
FullReplication() |
Every constituent at its index weight (the default) |
OptimisedReplication(holdings=50) |
A subset weighted by the optimiser to minimise tracking error to the index |
SampledReplication(holdings=50) |
The largest names in each sector and size cell, each cell at its index weight |
The index itself is still calculated, and the run is measured against it:
tracking error and difference compare the portfolio with the index, not with
the replicated weights. At each rebalance the replication turns the index's
weights into the weights the portfolio trades to, and result.replication
records what it did.
Optimised replication¶
At each rebalance the covariance of the constituents is estimated from their
trailing daily returns in the book's currency (lookback_days=252 by
default), shrunk toward constant correlation. The optimiser then finds the
long-only, fully invested weights closest to the index in tracking error,
holding no more than holdings names and meeting any other constraints
(such as GroupBounds on sectors). The holdings limit is met by solving,
keeping the largest positions and solving again over them, so the answer is
feasible but not proven optimal.
A name with fewer than minimum_observations returns (126 by default), such
as a recent listing, cannot be measured, so it is held at its index weight
outside the optimisation and listed in that rebalance's unmeasured.
Each rebalance records the ex-ante tracking error: the annualised tracking error the solve expected, from that rebalance's covariance.
Sampled replication¶
Stratified sampling keeps the index's mix without an optimiser. The
constituents are split into cells by sector (the SECTOR classification by
default) and by size (size_buckets=3: terciles of market cap). Each cell is
given holdings in proportion to its index weight, at least one each, holds its
largest names by index weight, and is scaled to its index weight, so every
sector and size group keeps its weight. When the limit is smaller than the
number of cells, the heaviest cells are kept and the others' weight is spread
across them.
import logging
from beacon.backtest import Backtest
from beacon.index.constructor import IndexDefinition
from beacon.index.methodology import MarketCapWeighted
from beacon.strategy import IndexTracking, OptimisedReplication, SampledReplication
from beacon.testing import dataset
logging.getLogger("beacon").setLevel(logging.ERROR) # keep the output short
fetcher = dataset.data_fetcher()
definition = IndexDefinition(
index_id="SAMPLE", index_name="Sample Market-Cap Index",
base_date="2024-01-02", base_value=1000.0, currency="USD",
eligibility_rules=[], weighting_scheme=MarketCapWeighted(),
rebalancing_frequency="QUARTERLY", calendar="XNYS",
universe_identifiers=list(dataset.UNIVERSE),
)
backtest = Backtest(initial_capital=10_000_000.0, transaction_cost_bps=5.0,
data_provider=fetcher)
for replication in (OptimisedReplication(holdings=4), SampledReplication(holdings=4)):
result = backtest.run(IndexTracking(definition, replication),
start="2024-01-02", end="2025-12-31")
print(replication.name, result.replication[0].holdings, "names,",
"tracking error", round(result.get_tracking_error(), 4))
A Fund can hold its index through a replication too: pass the
IndexTracking as its strategy.
Active strategies¶
An ActiveStrategy builds its own portfolio from a signal and is measured
against a benchmark, an index calculated as any index is. At each rebalance
(the first session of each month by default) it:
- takes the benchmark's weights in force that day;
- picks its candidates: its
universe(the benchmark's constituents by default), less any failing itsscreen, a condition such asdata.market.market_cap > 1e9; - scores them with its signal;
- estimates the covariance from a year of returns, as optimised replication does, holding a name too new to measure at its benchmark weight;
- builds the long-only, fully invested portfolio with its construction method, within its constraints.
Signals. A signal's values are standardised across the candidates,
capped at three standard deviations, and negated when higher_is_better is
False; a name with no value scores 0.
| Signal | Its value for a name |
|---|---|
FieldSignal(field) |
A market, reference or feature field, such as data.features.fundamentals.earnings_yield |
Momentum(lookback_days=252, skip_days=21) |
The return over the lookback, leaving out the most recent month |
FunctionSignal(function) |
Whatever function(name, date, fetcher) returns |
Construction. Two methods share one interface, Construction, so more
can be added:
MaxAlpha(tracking_error=0.03)holds the most exposure to the scores that a tracking-error budget allows: the budget is the target, and the active bets are as large as it lets them be. It is solved through the equivalent mean-variance problem, with the risk aversion found by bisection so the tracking error meets the budget, or falls short of it when the other constraints keep the portfolio closer to the benchmark.MeanVariance(risk_aversion=10.0, alpha_per_score=0.02)maximises the expected active return (each score timesalpha_per_scorea year) lessrisk_aversiontimes the active variance. One setting trades return for risk, and the tracking error is whatever results.
Constraints, measured against the benchmark where it matters:
| Constraint | The portfolio must |
|---|---|
TrackingErrorBudget(maximum) |
Have an ex-ante tracking error of at most maximum |
ActiveShare(minimum, maximum) |
Differ from the benchmark by an active share in the range |
RelativeSectorBounds(within) |
Hold each sector within within of its benchmark weight |
RelativePositionBounds(within) |
Hold each name within within of its benchmark weight |
HoldingsLimit(maximum) |
Hold no more than maximum names |
TurnoverLimit(maximum) |
Trade at most maximum one way from the last rebalance's weights |
Active share is half the summed absolute differences from the benchmark's weights. The turnover limit is measured from the last rebalance's target weights, not from the drifted holdings.
Reporting. result.active records each rebalance's weights, the
benchmark's, the ex-ante tracking error, the active share and the scores.
The summary adds the information_ratio (annualised active return over
tracking error), the average active share and the average ex-ante tracking
error, and result.active_attribution() says which names produced the active
return: each name's active weight times its return, linked over the run, with
costs and cash in the residual.
from beacon.strategy import ActiveStrategy, MaxAlpha, Momentum, RelativeSectorBounds
momentum = ActiveStrategy(
benchmark=definition, signal=Momentum(),
construction=MaxAlpha(tracking_error=0.03),
constraints=[RelativeSectorBounds(within=0.10)])
active = backtest.run(momentum, start="2024-01-02", end="2025-12-31")
summary = active.summary()
print(round(summary["information_ratio"], 3),
round(summary["average_active_share"], 3))
print(active.active_attribution().contributions[0])