Methodology¶
The methodology layer (src/beacon/index/methodology.py and
constructor.py) is the rules layer: IndexDefinition holds the static
configuration, and two families of pluggable rules — eligibility rules and
weighting schemes — decide, on any given date, which assets are in the index
and at what weight. IndexCalculator (the Calculator layer) is what
actually invokes these rules as it walks business days; see
Backtest for what happens downstream of the weights it
produces.
A methodology has four concerns, covered in turn below: Selection, Weighting, Scheduling (when the composition changes), and Treatment (of corporate actions and divisor continuity).
Selection¶
Selection narrows the universe down to the assets actually
eligible for inclusion, via EligibilityRuleBase subclasses.
EligibilityRuleBase.is_eligible(asset, current_date, market_data_provider,
context=None) is the abstract contract every rule implements, returning a
plain bool. IndexDefinition.eligibility_rules holds a list of these; an
asset must pass every rule to be selected.
Two concrete rules ship today:
MarketCapRule(min_market_cap=None, max_market_cap=None)— computesprice * shares_outstandingfor the asset oncurrent_dateand checks it against the configured bounds. Non-Equityassets pass through unconditionally.LiquidityRule(min_avg_daily_volume=None, min_avg_daily_value=None, lookback_days=60)— looks back over a trailing window of prices/volume and checks average daily volume and/or average daily traded value. Requires at least 80% of the lookback window's trading days to be present before it will pass an asset.
IndexCalculator.select_constituents(universe, current_date) runs each
universe member through every configured rule and returns the assets that
pass all of them. A rule that raises an exception is treated as a failure for
that asset (logged as an error), not propagated.
select_with_provenance() answers the harder question: why a name was
excluded. It walks the same rules once and records, per rule, which assets it
removed — so an editor can show the waterfall from universe to constituents
rather than only its two ends. Exclusions are attributed to the first rule
that dropped a name, which is what makes the counts sum: a name failing three
rules is excluded once, not three times.
GET /indices/rule-types publishes every registered rule with its parameters,
types, defaults and labels, derived from the classes themselves. A rule ships
with its catalogue entry by construction, so an editor cannot fall behind the
library.
Weighting¶
Weighting assigns a float weight to each selected constituent, via
WeightingSchemeBase subclasses.
WeightingSchemeBase.calculate_weights(constituents, current_date,
market_data_provider, context=None) returns a dict[Asset, float] whose
values should sum to 1.0. Two schemes ship today:
EqualWeighted()—1 / nfor each of thenconstituents.MarketCapWeighted(use_free_float=False)— each constituent's weight is its market cap (price * shares_outstanding, optionally scaled by a free-float factor fromDataFetcher.fetch_free_float_factor) divided by the total market cap of all constituents. If every constituent's market cap comes back zero, it falls back to equal weighting.
IndexCalculator.calculate_constituent_weights(constituents, current_date)
calls the configured scheme and then normalises the result to sum to
1.0 if it does not already (within a small tolerance), logging a warning
when it has to.
Capping¶
IndexDefinition.max_constituent_weight caps any single name, as a fraction.
IndexCalculator.cap_weights() applies it after the scheme and iterates:
weight pushed off a breaching name can carry another over the cap, so the pass
repeats until nothing breaches.
That iteration has a consequence worth knowing before you render it. A capped
name's raw weight is not necessarily above the cap — a name pulled over by
the first round's redistribution is capped in the second, and its pre-cap
weight sits below the cap it ends at. What holds is that a capped name ends
at the cap, and that the weight given up sums to redistributed.
Every rebalance keeps its uncapped_weights, so the counterfactual — what the
index would have held without the cap — is stored rather than estimated. That
is what makes cap drag computable in attribution.
An infeasible cap is arithmetic, not a solver outcome: 5% across ten names distributes at most 50%, and the server reports it as a validation finding while a user is still editing.
Scheduling¶
rebalancing_frequency (MONTHLY, QUARTERLY, SEMI-ANNUAL, ANNUAL) is the
cadence. Two further fields decide the rest.
rebalance_day_rule picks the day within a scheduled month:
FIRST_BUSINESS_DAY (the default, and what every index defined before this
existed used), LAST_BUSINESS_DAY, or THIRD_FRIDAY — the S&P and FTSE
convention.
calendar is an exchange MIC such as XNYS, and is required. It was
optional until BN-180, where null meant Monday to Friday — which treats 1
January, 4 July and Christmas Day as trading days and schedules rebalances the
data has no session for. Stored definitions without one were migrated to XNYS
by schema version 2, which redates their January rebalances from the 1st to the
2nd. A rebalance landing on a holiday rolls back to the previous session,
not forward, which is the convention and also the only choice that keeps a
month-end rebalance inside its month.
IndexDefinition requires it too, with no default: a default would let
IndexDefinition(currency="EUR") schedule a European index on New York's
holidays coherently and silently, which is the same failure in a different
costume. DEFAULT_CALENDAR exists only as the value the migration writes.
exchange_calendars is therefore a core dependency, not an extra: a required
input cannot sit behind an optional install. GET /indices/calendars publishes
every MIC this server accepts, read from the package at request time so the
list cannot drift from what the calculation takes. Each row carries code,
name, tz and region — the region being the first segment of the
calendar's own timezone (Europe/Oslo → Europe), derived rather than
curated, so a client can group its picker without a mapping that goes stale.
effective_lag_sessions separates announcement from effect. Real indices
publish a constituent list before it takes effect, which is what gives tracking
funds time to trade. Selection and weighting happen as of the announcement;
only the units and the divisor use the effective date's prices. Snapshots are
keyed by the effective date, since that is when the weights are in force.
return_type decides whether distributions accumulate: PRICE ignores
them, TOTAL_RETURN reinvests them across the index by shrinking the divisor,
and NET_TOTAL_RETURN does the same after a flat withholding_tax_rate. See
attribution for what reinvestment does to the level.
GET /indices/{id}/schedule computes the next rebalance and the days until it
from the schedule and the calendar — derived rather than stored, since a stored
date silently expires.
Treatment¶
Treatment covers what happens to the index's divisor — the value that converts raw aggregate market value into an index level — when composition or corporate actions change the underlying market value without a real change in the economic value of the index.
Two situations trigger a divisor adjustment, both implemented on
IndexCalculator (mixed in from market_values.py and
corporate_actions.py):
- Rebalance —
IndexCalculator.adjust_divisor_for_rebalance(old_divisor, old_market_value, new_market_value)rescales the divisor so that the index level is identical immediately before and after a reconstitution:new_divisor = old_divisor * (new_market_value / old_market_value). - Corporate actions —
CorporateActionsMixin.handle_corporate_action(...)adjusts the divisor for a single event it is handed.SPECIAL_DIVIDENDis implemented: the market-value reduction (dividend_per_share * shares_outstanding, adjusted for free float and FX) rescales the divisor the same way a rebalance does.RIGHTS_ISSUE,SPIN_OFF,STOCK_DIVIDENDandMERGERare recognised but are stubs — they log a warning and return the divisor unchanged.
handle_corporate_action is not called by run()
It is a public method a caller can invoke, but the run loop does not consult the action history through it. So a price index does not currently adjust for special dividends during a run, despite the method existing.
The one thing that does read the history during a run is total-return reinvestment, added later and independently — which is why there is no double-counting between the two. Worth knowing before assuming a price index handles distributions.
Both mechanisms exist to keep the index level continuous — a change in composition should not, by itself, cause the index to jump.