Skip to content

Indices

Defining an index and calculating it: IndexDefinition holds the rules, the methodology module has the selection rules and weighting schemes, IndexCalculator walks the calendar's sessions, and trading calendars live in beacon.index.schedule. See Methodology.

index

Index methodologies and calculation.

Defining an index (IndexDefinition, eligibility rules and weighting schemes), selecting its constituents, weighting them, and computing its levels (IndexCalculator, which returns an IndexResult).

IndexAssetView

IndexAssetView(
    asset_id: str,
    data_fetcher: DataFetcher,
    weight_snapshots: dict[Timestamp, dict[str, float]],
    index_levels: Series,
)

Bases: AssetView

AssetView with index weight history and contribution analysis.

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
weight_snapshots dict[Timestamp, dict[str, float]]

Mapping of rebalance date -> dict of {asset_id: weight} from the parent IndexResult.

required
index_levels Series

Index level time series from the parent IndexResult.

required

weight_on_date

weight_on_date(date: Timestamp) -> float | None

Get this asset's index weight on a specific date.

Finds the most recent rebalance on or before date and returns the asset's weight. Returns None if the asset was not a constituent at that point.

Parameters:

Name Type Description Default
date Timestamp

The query date.

required

Returns:

Type Description
float | None

float or None

weight_series

weight_series() -> pd.Series

Return a Series of this asset's weight at each rebalance date.

Returns:

Type Description
Series

pd.Series: Indexed by rebalance date. Rebalance dates where the

Series

asset was not a constituent are excluded.

contribution

contribution(
    start: str, end: str, price_column: str = "CLOSE"
) -> pd.Series

Calculate this asset's contribution to index returns.

Contribution on day t = weight_{t-1} * asset_return_t.

Parameters:

Name Type Description Default
start str

Start date (YYYY-MM-DD).

required
end str

End date (YYYY-MM-DD).

required
price_column str

Column name for return calculation.

'CLOSE'

Returns:

Type Description
Series

pd.Series: Contribution series indexed by date.

IndexCalculator

IndexCalculator(
    index_definition: IndexDefinition,
    data_provider: DataFetcher,
    price_column: str = "CLOSE",
)

Bases: MarketValuesMixin, DeletionMixin, TotalReturnMixin, CorporateActionsMixin

Calculates an index from its definition and a data source.

Stateless between runs: it holds only its configuration (the definition, the data source, the price column and the index context), and everything a run produces is returned in its :class:IndexResult. It provides constituent selection, weighting, index level calculation, and corporate-action, deletion and distribution adjustments.

Initializes the IndexCalculator.

Parameters:

Name Type Description Default
index_definition IndexDefinition

The IndexDefinition object that specifies the index rules.

required
data_provider DataFetcher

A DataFetcher instance to access market and asset data.

required
price_column str

Market-data column read as the constituent price when computing market values. Defaults to "CLOSE".

'CLOSE'

resolve_universe

resolve_universe(date: Timestamp) -> list[Asset]

Resolve the definition's universe identifiers into Asset objects.

The public entry point for universe resolution, for callers outside the calculation loop (the constituent preview, for one). The reference data is read once for the whole universe, and each resolved name becomes an :class:~beacon.asset.equity.Equity built from its own row. An identifier the reference data does not know is skipped with a warning.

Parameters:

Name Type Description Default
date Timestamp

Point-in-time date for the reference-data lookup.

required

Returns:

Type Description
list[Asset]

list[Asset]: Assets for every identifier that resolved, in the

list[Asset]

order the definition names them.

Raises:

Type Description
CalculationError

If the definition names no universe at all.

select_constituents

select_constituents(
    universe: list[Asset], current_date: Timestamp
) -> list[Asset]

Select index constituents from a universe by the eligibility rules.

A thin projection of :meth:select_with_provenance: the survivors, with the record of which rule removed each excluded name discarded. Callers wanting that record (the preview waterfall, anything answering "why is this name missing") should use the fuller method rather than repeating the walk.

Parameters:

Name Type Description Default
universe list[Asset]

A list of potential Asset objects to consider for inclusion.

required
current_date Timestamp

The date for which selection is being made.

required

Returns:

Type Description
list[Asset]

A list of Asset objects that are eligible for the index.

select_with_provenance

select_with_provenance(
    universe: list[Asset], current_date: Timestamp
) -> SelectionResult

Select constituents, keeping the record of how the universe narrowed.

Parameters:

Name Type Description Default
universe list[Asset]

A list of potential Asset objects to consider for inclusion.

required
current_date Timestamp

The date for which selection is being made.

required

Returns:

Name Type Description
SelectionResult SelectionResult

Survivors, one step per rule, and the position of

SelectionResult

the rule that excluded each removed asset.

calculate_constituent_weights

calculate_constituent_weights(
    constituents: list[Asset], current_date: Timestamp
) -> dict[Asset, float]

Weight the constituents by the index's weighting scheme.

Parameters:

Name Type Description Default
constituents list[Asset]

A list of Asset objects that are part of the index.

required
current_date Timestamp

The date for which weights are calculated.

required

Returns:

Type Description
dict[Asset, float]

A dictionary mapping each Asset to its weight, summing to 1. Empty

dict[Asset, float]

(with a warning) when constituents is empty.

Raises:

Type Description
CalculationError

If the scheme refuses (an unpriced constituent, an unknown share count, a market cap of zero), in which case it propagates exactly as the scheme raised it, remedy and all. Also if the scheme's weights do not sum to 1: rescaling them would publish an allocation the scheme did not produce under the scheme's own name.

UnexpectedCalculationError

If the scheme raises anything else. A crash, not a decision, and it carries its own published code so a client does not read it as a refusal.

cap_weights

cap_weights(
    weights: dict[Asset, float],
) -> tuple[dict[Asset, float], CapReport]

Apply the definition's cap, returning the weights and a report.

Capping happens here rather than inside a weighting scheme so that it composes with every scheme, and it returns its report rather than storing one so the calculator stays stateless and run() stays idempotent.

Parameters:

Name Type Description Default
weights dict[Asset, float]

Normalised weights keyed by Asset.

required

Returns:

Name Type Description
tuple dict[Asset, float]

The capped weights and a CapReport. With no cap configured

CapReport

the weights are returned unchanged and the report is empty.

initialize_divisor

initialize_divisor(
    initial_total_market_value: float,
) -> float

Calculate the index's initial divisor on its base date.

divisor = initial_total_market_value / base_value.

Parameters:

Name Type Description Default
initial_total_market_value float

The sum of (price * shares * fx_rate * free_float_if_applicable) for all base constituents on the base_date, expressed in index currency.

required

Returns:

Type Description
float

The initial divisor as a float.

Raises:

Type Description
CalculationError

If initial_total_market_value or the definition's base value is not positive.

adjust_divisor_for_rebalance staticmethod

adjust_divisor_for_rebalance(
    old_divisor: float,
    old_market_value: float,
    new_market_value: float,
) -> float

Adjust the divisor to maintain index level continuity across a rebalance.

When index composition or weights change, the total market value shifts. To prevent an artificial jump in the index level the divisor is scaled:

new_divisor = old_divisor * (new_market_value / old_market_value)

This guarantees: level_before == level_after.

Parameters:

Name Type Description Default
old_divisor float

The divisor in effect before the rebalance.

required
old_market_value float

Aggregate market value under the old composition.

required
new_market_value float

Aggregate market value under the new composition.

required

Returns:

Type Description
float

The adjusted divisor.

Raises:

Type Description
ValueError

If old_divisor, old_market_value or new_market_value is zero or negative.

run

run(
    start_date: str | None = None,
    end_date: str | None = None,
) -> IndexResult

Run the full index calculation over a date range.

Iterates the index's own trading sessions (the definition's calendar, not Monday to Friday) from start_date to end_date, handling three day types:

  1. Base date: resolve universe, select constituents, compute weights, apply the cap, initialise divisor, set level = base_value. Rolled forward to the first session when the base date itself was not one.
  2. Rebalance date: reinvest the day's distributions (total-return indices), then reconstitute (re-resolve universe, re-select, re-weight, re-cap) and adjust the divisor for continuity. With an announcement lag, the composition is selected and weighted as of the announcement date and applied on the effective date.
  3. Regular day: drop any holding that stopped being listed, reinvest distributions (total-return indices), and compute the level from the units held.

The method is idempotent: it carries no state between calls. When the calendar covers only part of the window, the run is calculated over the covered part and the result's calendar_coverage says so; a window with no sessions at all (a single weekend, say) gives an empty result.

Parameters:

Name Type Description Default
start_date str | None

First calculation date (YYYY-MM-DD). Defaults to definition.base_date; an earlier date is moved up to it.

None
end_date str | None

Last calculation date (YYYY-MM-DD). Required.

None

Returns:

Name Type Description
An IndexResult

class:IndexResult containing index levels, divisor history,

IndexResult

constituent snapshots, weight snapshots, cap reports, announcement

IndexResult

dates and the daily weights panel (one row per constituent per

IndexResult

day, recorded as the loop goes, since the state it holds each day

IndexResult

is path-dependent and cannot be reconstructed from the rebalance

IndexResult

snapshots afterwards). The result is bound to the data source.

Raises:

Type Description
ValueError

If end_date is not provided or precedes the base date.

CalculationError

If the dataset lacks a column the definition reads (checked before any work, rather than discovered at the first read and reported as one company's problem), if the calendar can cover none of the window, if the index holds nothing on its base date, or if a rule, scheme or valuation refuses.

require_columns

require_columns() -> None

Refuse up front if the dataset cannot support this definition.

Public because the constituent preview runs a definition without calling run, and deserves the same answer: a preview of a market-cap index over a store with no share counts should say so, not fail on the first name it tries to price.

Raises:

Type Description
CalculationError

Naming each missing column and what needs it.

run_daily_calculation

run_daily_calculation(
    current_date: Timestamp,
    constituents: list[Asset],
    weights: dict[Asset, float],
    previous_index_level: float,
    previous_divisor: float,
) -> tuple[float, float]

Runs a single day's index calculation process.

Parameters:

Name Type Description Default
current_date Timestamp

The date for which to perform calculations.

required
constituents list[Asset]

Current index constituents.

required
weights dict[Asset, float]

Current constituent weights.

required
previous_index_level float

Index level from the previous period.

required
previous_divisor float

Divisor from the previous period.

required

Returns:

Type Description
tuple[float, float]

Tuple of (new_index_level, new_divisor).

IndexDefinition

IndexDefinition(
    index_id: str,
    index_name: str,
    base_date: str,
    base_value: float,
    currency: str,
    eligibility_rules: list[EligibilityRuleBase],
    weighting_scheme: WeightingSchemeBase,
    rebalancing_frequency: str,
    calendar: str,
    description: str | None = None,
    universe_identifiers: list[str] | None = None,
    max_constituent_weight: float | None = None,
    rebalance_day_rule: str = DEFAULT_DAY_RULE,
    return_type: str = PRICE,
    withholding_tax_rate: float = 0.0,
    effective_lag_sessions: int = 0,
)

Defines the static characteristics and rules for constructing an index.

Parameters:

Name Type Description Default
index_id str

A unique identifier for the index.

required
index_name str

The common name of the index.

required
base_date str

The date from which the index calculation begins (YYYY-MM-DD).

required
base_value float

The initial value of the index on its base_date. Must be positive.

required
currency str

The currency of the index (stored upper-cased).

required
eligibility_rules list[EligibilityRuleBase]

A list of EligibilityRuleBase objects that define criteria for constituent selection. An empty list is allowed but logged as a warning.

required
weighting_scheme WeightingSchemeBase

A WeightingSchemeBase object that defines how constituents are weighted.

required
rebalancing_frequency str

How often the index is rebalanced: "MONTHLY", "QUARTERLY", "SEMI-ANNUAL" or "ANNUAL" (stored upper-cased). An unsupported value is refused when rebalance dates are first computed.

required
calendar str

Exchange MIC backing trading-day arithmetic, e.g. "XNYS". Required, and deliberately without a default: a default would silently schedule, say, a European index on New York's holidays. Only the caller can choose it.

required
description str | None

Optional textual description of the index.

None
universe_identifiers list[str] | None

Optional list of string identifiers (e.g. tickers, ISINs) defining the asset universe from which constituents are selected. When given, it must not be empty. An index calculated with none is refused.

None
max_constituent_weight float | None

Optional cap on any single constituent's weight, as a fraction (0.1 is 10%), in (0, 1]. Applied after the weighting scheme and iterated until no constituent breaches it. None means uncapped.

None
rebalance_day_rule str

Which day of a scheduled month the rebalance falls on: "FIRST_BUSINESS_DAY" (the default), "LAST_BUSINESS_DAY" or "THIRD_FRIDAY".

DEFAULT_DAY_RULE
return_type str

"PRICE" (the default), "TOTAL_RETURN" or "NET_TOTAL_RETURN". The last two reinvest cash distributions across the index.

PRICE
withholding_tax_rate float

Fraction of each distribution withheld, for a net index, in [0, 1). Ignored unless the return type is NET_TOTAL_RETURN, so a definition carrying a rate it does not use cannot quietly apply it.

0.0
effective_lag_sessions int

Sessions between a composition being announced and its weights taking effect. Zero (the default) is same-day. Must not be negative.

0

Raises:

Type Description
ValueError

If a required argument is empty or out of range, or the day rule or return type is not supported.

get_rebalance_dates

get_rebalance_dates(
    start_date: str, end_date: str
) -> list[pd.Timestamp]

Every rebalance date within [start_date, end_date].

Follows the index's rebalancing frequency, day rule and calendar (see beacon.index.schedule). The calendar is always a real exchange calendar, so a date this returns is always a date the exchange has a session for. The cadence is anchored on the first scheduled date in the range.

Parameters:

Name Type Description Default
start_date str

Start of the range (YYYY-MM-DD), inclusive.

required
end_date str

End of the range (YYYY-MM-DD), inclusive.

required

Returns:

Type Description
list[Timestamp]

A chronologically sorted list of rebalance dates, each a session on

list[Timestamp]

the index's calendar.

Raises:

Type Description
ValueError

If the rebalancing frequency is unsupported.

next_rebalance

next_rebalance(as_of: str) -> pd.Timestamp | None

The first rebalance strictly after a date.

Anchored on the base date, as a calculation run from the base date is, so the answer names a day the index would genuinely rebalance on.

Parameters:

Name Type Description Default
as_of str

The date being asked from, YYYY-MM-DD.

required

Returns:

Type Description
Timestamp | None

The date, or None if none falls within the lookahead window.

OptimisedIndexDefinition

OptimisedIndexDefinition(
    index_id: str,
    index_name: str,
    source: AnyIndexDefinition,
    objective: str = MIN_TRACKING_ERROR,
    constraints: Sequence[Constraint] = (),
    base_date: str | None = None,
    base_value: float | None = None,
    currency: str | None = None,
    description: str | None = None,
    risk_model: RiskModel | None = None,
)

An optimised index: a derivation on a source index, plus identity.

The source stays first-class (referenced, never copied), so editing the parent changes its optimised children at their next calculation, which is what "optimise the index I built" means. Chained optimisation (a source that is itself optimised) falls out of the recursion for free.

Parameters:

Name Type Description Default
index_id str

A unique identifier for the derived index.

required
index_name str

The common name of the derived index.

required
source AnyIndexDefinition

The parent: a plain :class:IndexDefinition, or another :class:OptimisedIndexDefinition for a chain.

required
objective str

What to minimise. Only "min_tracking_error" exists; an unknown value fails the calculation loudly, naming the accepted ones.

MIN_TRACKING_ERROR
constraints Sequence[Constraint]

What the solved weights must satisfy, as :class:~beacon.optimise.constraints.Constraint instances. Empty means the solver's default of full investment alone.

()
base_date str | None

First calculation date (YYYY-MM-DD). None inherits the source's, which is the usual case: the child lives on the parent's calendar.

None
base_value float | None

The level the chained path starts at. None inherits the source's.

None
currency str | None

The derived index's currency. None inherits the source's.

None
description str | None

Optional textual description.

None
risk_model RiskModel | None

Reserved: carried but unused, mirroring :class:~beacon.optimise.config.OptimisationConfig. Setting one makes the calculation uncacheable (it cannot be keyed yet) and changes no result.

None

Raises:

Type Description
ValueError

If index_id or index_name is empty, source is None, or base_value is given and not positive.

base_date property

base_date: Timestamp

The first calculation date: own when given, else the source's.

base_value property

base_value: float

The starting level: own when given, else the source's.

currency property

currency: str

The index currency: own when given, else the source's.

calendar property

calendar: str

The trading calendar, which is always the source's.

No override, unlike the currency or the base date: the derivation reallocates on exactly the parent's rebalance dates, so a calendar of its own could only disagree with the days it actually has weights for.

universe_identifiers property

universe_identifiers: list[str] | None

The investable universe, which is always the source's.

The derivation holds no universe of its own (it reallocates over exactly the names the parent published), so the answer resolves through the chain to the root definition's.

from_config classmethod

from_config(
    index_id: str,
    index_name: str,
    source: AnyIndexDefinition,
    config: OptimisationConfig,
) -> OptimisedIndexDefinition

The derivation an :class:OptimisationConfig describes.

One vocabulary for ad-hoc and stored runs: the config is the stored derivation minus the source, so an ad-hoc Backtest.run builds an ephemeral definition through here and calculates it exactly as a stored one would be. The config's objective, constraints and risk model are carried over; base date, base value and currency inherit the source's.

ExpressionRule

ExpressionRule(
    expression: dict[str, Any],
    on_missing: str = EXCLUDE,
    max_age_days: int | None = MAX_AGE_DAYS,
)

Bases: EligibilityRuleBase

Select instruments that satisfy an expression.

Parameters:

Name Type Description Default
expression dict[str, Any]

The serialised expression tree (Expression.to_dict() output). Use :meth:from_expression to pass a live expression.

required
on_missing str

"exclude" (the default) or "include": what a comparison answers for a name with no value for its field.

EXCLUDE
max_age_days int | None

How old a feature value may be and still count. None means no limit.

MAX_AGE_DAYS

Raises:

Type Description
InvalidRuleError

If on_missing is not recognised, or expression is not a valid tree.

tree property

tree: Expression

The rebuilt expression.

required_columns

required_columns() -> frozenset[str]

The market columns the expression reads, derived from its tree.

An expression's needs are whatever it references, so they come from the fields in the tree. A derived field is expanded into what it is computed from: a screen on market_cap needs CLOSE and SHARES_OUTSTANDING, not a column called MARKET_CAP that no store has. Reference, action and feature fields read other tables and add nothing here.

from_expression classmethod

from_expression(
    expression: Expression,
    on_missing: str = EXCLUDE,
    max_age_days: int | None = MAX_AGE_DAYS,
) -> ExpressionRule

Build from a live expression rather than from its serialised form.

What a user writing Python calls. The stored params are identical either way, which is the point: one representation, two front doors.

is_eligible

is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether the asset passes, as of current_date.

The date is the rebalance date and is passed straight through to the point-in-time reads. A value published after it is invisible.

FeatureRule

FeatureRule(
    field: str,
    comparison: str = "gt",
    threshold: float = 0.0,
    feature_type: str | None = None,
    on_missing: str = EXCLUDE,
    max_age_days: int | None = MAX_AGE_DAYS,
)

Bases: EligibilityRuleBase

Select instruments whose feature value passes a threshold.

Parameters:

Name Type Description Default
field str

The feature to read, e.g. "revenue".

required
comparison str

How the value is tested against threshold: "gt", "ge", "lt", "le", "eq" or "ne".

'gt'
threshold float

The value compared against.

0.0
feature_type str | None

Which feature dataset (TYPE) to read from. None searches all, which picks arbitrarily between two datasets carrying the same field name.

None
on_missing str

"exclude" (the default) or "include": what happens to a name with no value knowable at the rebalance.

EXCLUDE
max_age_days int | None

How old a value may be and still count. None means no limit.

MAX_AGE_DAYS

Raises:

Type Description
InvalidRuleError

If comparison or on_missing is not recognised, or field is empty.

required_columns

required_columns() -> frozenset[str]

No market columns: a feature is read from the features table.

Stated rather than inherited, so the absence is visibly a decision. Whether the named feature exists is a real question with the same shape as a missing column, but it is asked of a different table, and the up-front column check covers market data only.

is_eligible

is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether the asset passes, as of current_date.

The date is the rebalance date, and it is passed straight through to the point-in-time accessor. A value published after it is invisible.

EligibilityRuleBase

EligibilityRuleBase(rule_name: str)

Bases: ABC

Abstract base class for an eligibility rule.

Eligibility rules determine whether an asset can be part of an index. Subclasses implement is_eligible, and may override required_columns and prepare.

Parameters:

Name Type Description Default
rule_name str

The rule's name, used in logs and error messages.

required

required_columns

required_columns() -> frozenset[str]

The market-data columns this rule reads, declared up front.

Checked against the dataset before a run does any work, so a store with no SHARES_OUTSTANDING column is refused at the start as "this rule needs SHARES_OUTSTANDING and the dataset has none", rather than at the first rebalance as "N0 has no SHARES_OUTSTANDING on 2024-01-02", which is true, and sends a reader to inspect one company whose data is fine.

Empty by default rather than abstract, so a rule written outside this package keeps working. It is then simply not checked up front, and fails at the first read instead. Every rule shipped here declares its own.

prepare

prepare(
    candidates: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> None

Read in one go whatever this rule is about to read per name.

Called once with the whole candidate set before is_eligible is asked about any of them. It decides nothing and returns nothing: a rule must give exactly the same answers whether or not it prepared, because this is a hint about how to read rather than about what is eligible. Doing nothing is therefore the right default, and it is the base implementation.

It exists because is_eligible is a predicate over one asset, so a rule reading market data reads it a name at a time, and each read slices a frame whose size is the whole store: the cost of one lookup grows with the universe around it rather than with the row it wants.

Parameters:

Name Type Description Default
candidates list[Asset]

Everything that reached this rung, in order. A rule that ranks rather than screens would want this set too; that is not what this is for, but it is the same set.

required
current_date Timestamp

The date selection is being made at.

required
market_data_provider DataFetcher

The data source the reads will go to.

required
context IndexContext | None

What the index settles for its rules, as for :meth:is_eligible.

None

is_eligible abstractmethod

is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Checks if a given asset is eligible based on this rule.

Parameters:

Name Type Description Default
asset Asset

The asset to check.

required
current_date Timestamp

The date on which eligibility is being assessed.

required
market_data_provider DataFetcher

A DataFetcher instance to get necessary market data (e.g., market cap, trading volume).

required
context IndexContext | None

What the index the rule is running inside reports in and settles. None when the rule is evaluated outside an index, in which case nothing here may assume a currency it was not told.

None

Returns:

Type Description
bool

True if the asset is eligible, False otherwise.

EqualWeighted

EqualWeighted()

Bases: WeightingSchemeBase

Equal weighting: every constituent gets 1 / n.

Reads no market data. An empty constituent list gets empty weights.

required_columns

required_columns() -> frozenset[str]

Nothing: equal weights are decided without reading the market.

Stated rather than inherited, so the absence is a decision a reader can see. The index still needs a price column to value its holdings daily, but that is the calculator's requirement, not this scheme's.

LiquidityRule

LiquidityRule(
    min_avg_daily_volume: int | None = None,
    min_avg_daily_value: float | None = None,
    lookback_days: int = 60,
)

Bases: EligibilityRuleBase

Eligibility by trading liquidity: average daily volume or value.

The averages are taken over the last lookback_days rows of market data on or before the date asked about. A name with fewer than 80% of that many rows, or with the needed column missing or empty, is excluded (and logged as a warning). Values are in the currency the name trades in, not converted.

Parameters:

Name Type Description Default
min_avg_daily_volume int | None

Lowest average shares traded per day. None for no volume floor.

None
min_avg_daily_value float | None

Lowest average close times volume per day. None for no value floor.

None
lookback_days int

Trading days the averages are taken over.

60

Raises:

Type Description
ValueError

If lookback_days is not positive.

required_columns

required_columns() -> frozenset[str]

Volume for either threshold, and the close too for a value one.

Declared from the thresholds actually set, because they read different things. The declaration matters here: is_eligible treats a missing VOLUME column as "not liquid enough" and excludes the name, so without the up-front check a store lacking the column would exclude every name and the run would fail as "index holds nothing on its base date" with no mention of volume.

is_eligible

is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether asset's traded volume and value over the lookback qualify.

No session resolution here, and none needed: this reads a window ending at current_date, so a closed day is already spanned by the days around it rather than being the single day everything hangs on.

Errors are not caught. A rule that throws has not said the asset is ineligible, and the two answers must not be spelled the same.

Raises:

Type Description
CalculationError

If asset is not an equity, so there is no ticker to read volume against.

MarketCapRule

MarketCapRule(
    min_market_cap: float | None = None,
    max_market_cap: float | None = None,
)

Bases: EligibilityRuleBase

Eligibility by market capitalisation, read from a resolved session.

The cap is price times shares outstanding, both read on the same session. A name with no price or no positive share count on that session is excluded (and logged as a warning).

Dates resolve backwards into the data. A weekend, a holiday or any date inside the data's coverage that carries no bar is read at the last session on or before it, because that is the universe the index actually held through the closure. This is the same resolution :class:MarketCapWeighted performs, through the same primitive, so selection and weighting cannot resolve a closed day differently.

The bounds are in the index's currency. The cap is converted at the session's rate before it meets either bound; a missing FX pair refuses rather than falling back to the local figure. Outside an index there is no context and so no currency to convert into, and the bounds are then read in the asset's own currency. Inside an index the calculator always supplies one.

Past the last bar it refuses rather than excluding. A rule that cannot evaluate has not found the asset ineligible, it has failed, and the two must not share an answer: "not in the index" is a published fact about a name, while "the data does not reach that date" is a fact about the store.

Parameters:

Name Type Description Default
min_market_cap float | None

Lowest cap admitted, in the index currency. None for no floor.

None
max_market_cap float | None

Highest cap admitted, in the index currency. None for no ceiling.

None

Raises:

Type Description
ValueError

If min_market_cap is greater than max_market_cap.

required_columns

required_columns() -> frozenset[str]

A cap is price times shares, so both, whichever bound is set.

prepare

prepare(
    candidates: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> None

Read the whole candidate set's session in one slice.

Every name this rule is about to be asked about is read on the same session, for the same two columns. Warming that session turns the per-name reads into dictionary lookups and, because the weighting scheme then reads the survivors on the same session, makes the second pricing of every surviving name free.

Raises:

Type Description
CalculationError

If current_date lies outside the data's coverage. That is the same refusal is_eligible makes over the same date, arriving one call earlier; a rule that cannot resolve its session cannot assess anything.

is_eligible

is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether asset's market cap at the resolved session clears the bounds.

Raises:

Type Description
CalculationError

If asset is not an equity, if current_date lies outside the data's coverage, or if the cap cannot be converted into the index currency, so the rule cannot be evaluated at all. Nothing here turns a failure into an exclusion (see the class docstring).

MarketCapWeighted

MarketCapWeighted(use_free_float: bool = False)

Bases: WeightingSchemeBase

Market capitalisation weighting, optionally free-float adjusted.

Each constituent's weight is its cap (price times shares outstanding, times the free-float factor when use_free_float is set) over the sum of all caps. A name is priced at its last close on or before the rebalance session, and its shares, free float and FX rate are read on that same day.

Every path either weights by real market caps or refuses. There is no equal-weight fallback: an index that comes out equal-weighted because the caps could not be read is not a degraded market-cap index, it is a different index published under the same heading, and nothing downstream looks wrong enough for anyone to ask (the levels are right, the weights sum, the backtest tracks).

Dates resolve backwards into the data. A request for a weekend, a holiday, or any date inside the data's coverage that carries no bar reads the last session on or before it, because that is the composition the index actually held through the closure rather than an approximation of one. Past the last bar it refuses, since there the same read would be a stale print presented as the current one. The bound is the data's own coverage, not a day count, which cannot tell those two apart.

Caps are compared in one currency. A universe spanning currencies is converted into the index currency before the caps are summed, and a missing FX pair refuses. A universe quoted in a single currency needs no conversion at all, since converting every cap by the same rate cannot move a weight. Called outside an index (no context) over several currencies, it refuses, since there is no currency to compare in.

Parameters:

Name Type Description Default
use_free_float bool

Weight by the freely traded portion of each cap rather than the full cap. Requires a FREE_FLOAT column.

False

required_columns

required_columns() -> frozenset[str]

Price and shares, and free float only when this scheme uses it.

use_free_float is the declaration: every free-float read in a run is behind it, so a scheme that is not float-adjusted never asks the store for the column and must not be refused for lacking it.

calculate_weights

calculate_weights(
    constituents: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> dict[Asset, float]

Weights proportional to market cap, or a refusal.

Raises:

Type Description
CalculationError

If current_date lies outside the data's coverage, if any constituent is unpriceable, unconvertible or is not an equity, has no positive shares outstanding or (when free-float adjusted) no usable free-float factor, if the universe spans currencies with no context to compare them in, or if the caps sum to nothing. Nothing here falls back to another methodology (see the class docstring).

WeightingSchemeBase

WeightingSchemeBase(scheme_name: str)

Bases: ABC

Abstract base class for a weighting scheme.

Weighting schemes determine the proportion of each constituent in an index. Subclasses implement calculate_weights, and may override required_columns.

Parameters:

Name Type Description Default
scheme_name str

The scheme's name, used in logs and error messages.

required

required_columns

required_columns() -> frozenset[str]

The market-data columns this scheme reads, declared up front.

The same contract as :meth:EligibilityRuleBase.required_columns, and derived from the scheme's own inputs where they change what it reads: a scheme's parameters are the declaration, so nothing asks a store for a column the configured scheme does not use.

calculate_weights abstractmethod

calculate_weights(
    constituents: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> dict[Asset, float]

Calculates the weight for each constituent asset.

Parameters:

Name Type Description Default
constituents list[Asset]

A list of assets that are eligible for the index.

required
current_date Timestamp

The date for which weights are being calculated.

required
market_data_provider DataFetcher

A DataFetcher instance.

required
context IndexContext | None

What the index the scheme is running inside reports in and settles. None when it is invoked outside an index.

None

Returns:

Type Description
dict[Asset, float]

A dictionary mapping each Asset object to its calculated weight (float).

dict[Asset, float]

The sum of weights should typically be 1.0.

IndexResult dataclass

IndexResult(
    index_id: str,
    index_levels: Series,
    divisor_history: Series,
    constituent_snapshots: dict[Timestamp, list[str]],
    weight_snapshots: dict[Timestamp, dict[str, float]],
    cap_reports: dict[Timestamp, CapReport] = dict(),
    announcement_dates: dict[Timestamp, Timestamp] = dict(),
    daily_weights: DataFrame = empty_daily_weights(),
    calendar_coverage: CalendarCoverage | None = None,
    _data_fetcher: DataFetcher | None = None,
)

Container holding the output of an index calculation run.

Parameters:

Name Type Description Default
index_id str

Identifier of the calculated index.

required
index_levels Series

Time series of index levels indexed by pd.DatetimeIndex.

required
divisor_history Series

Time series of divisor values indexed by pd.DatetimeIndex.

required
constituent_snapshots dict[Timestamp, list[str]]

Mapping of rebalance date -> list of asset_id strings.

required
weight_snapshots dict[Timestamp, dict[str, float]]

Mapping of rebalance date -> dict of {asset_id: weight}.

required
cap_reports dict[Timestamp, CapReport]

Mapping of rebalance date -> CapReport, for the rebalances where a weight cap actually bound. Empty for an uncapped index, so its presence is itself the signal that capping occurred.

dict()
announcement_dates dict[Timestamp, Timestamp]

Mapping of effective date -> the date that composition was announced. Snapshots are keyed by the effective date, because that is when the weights are in force and what every consumer (drift, attribution, the backtest engine) needs. The announcement is carried alongside rather than instead, since a client showing "rebalance of 18 Sep, effective 22 Sep" needs both. Empty for an index with no lag, where the two always coincide.

dict()
daily_weights DataFrame

Long-form panel of what the index held on every calculation day: DATE, IDENTIFIER, AMOUNT (units held) and WEIGHT (that holding's share of the day's aggregate value). Recorded by the calculator as it walks, not derived afterwards (see the note below). Defaults to an empty frame, so a result built by hand is still valid.

empty_daily_weights()
calendar_coverage CalendarCoverage | None

How much of the requested window the trading calendar covered, or None when it covered all of it (the ordinary case). Its presence is the signal that the run's range was narrowed to what the calendar covers.

None

The daily panel is recorded rather than re-derived because the index's daily state is path-dependent. It is not a forward-fill of the rebalance snapshot, and not even "amounts fixed between rebalances, repriced daily": a delisted name is dropped mid-period and the divisor adjusted, a split multiplies the units held on its ex-date, and a total-return index reinvests its cash, all on days that are not rebalances. A path is written down as it happens.

The rebalance snapshots are the record of what a rebalance decided. This panel is the record of what then happened.

capped_assets_on_date

capped_assets_on_date(date: Timestamp) -> dict[str, float]

Return the constituents held at the cap at the given rebalance.

Parameters:

Name Type Description Default
date Timestamp

A rebalance date.

required

Returns:

Name Type Description
dict dict[str, float]

{asset_id: uncapped_weight} for names the cap bound on

dict[str, float]

that date. Empty when nothing was capped, or when date is not a

dict[str, float]

rebalance date.

with_data

with_data(data_fetcher: DataFetcher) -> IndexResult

Bind a DataFetcher for asset-level queries. Returns self for chaining.

asset

asset(asset_id: str) -> IndexAssetView

Return an IndexAssetView for a constituent.

Parameters:

Name Type Description Default
asset_id str

Identifier of the constituent asset.

required

Returns:

Type Description
IndexAssetView

IndexAssetView

Raises:

Type Description
RuntimeError

If no DataFetcher has been bound via :meth:with_data.

KeyError

If asset_id is not found in any constituent snapshot.

get_returns

get_returns() -> pd.Series

Derive a return series from index levels.

Returns:

Type Description
Series

pd.Series: Percentage returns (first entry is dropped).

get_weights_on_date

get_weights_on_date(date: Timestamp) -> dict[str, float]

Get constituent weights effective on a given date.

Locates the most recent rebalance date on or before date.

Parameters:

Name Type Description Default
date Timestamp

The query date.

required

Returns:

Name Type Description
dict dict[str, float]

Mapping of asset_id to weight. Empty dict if no rebalance

dict[str, float]

has occurred on or before date.

weights_on

weights_on(date: Timestamp) -> dict[str, float]

Get the recorded constituent weights as of a given date.

Reads the daily panel rather than the rebalance snapshots, so the answer includes everything that happened since the last rebalance: price drift, a deletion, a divisor adjustment. Compare :meth:get_weights_on_date, which answers the different question of what the last rebalance decided.

Falls back to the latest recorded date on or before date, which covers a day the holdings could not be valued at all, since such a day records no rows.

Parameters:

Name Type Description Default
date Timestamp

The query date.

required

Returns:

Name Type Description
dict dict[str, float]

Mapping of identifier to weight. Empty when nothing was

dict[str, float]

recorded on or before date, including when no panel was captured

dict[str, float]

at all.

to_dataframe

to_dataframe() -> pd.DataFrame

Flatten index levels and divisor history into a DataFrame.

Returns:

Type Description
DataFrame

pd.DataFrame: Columns: index_level, divisor.

calculate_derived_index

calculate_derived_index(
    definition: OptimisedIndexDefinition,
    data_provider: DataFetcher,
    start_date: str | None = None,
    end_date: str | None = None,
    price_column: str = "CLOSE",
    parent_result: IndexResult | None = None,
) -> IndexResult

Calculate an optimised index into a standard :class:IndexResult.

Three steps: calculate the parent (or accept a pre-supplied calculation, as the Backtest integration does with its cached one), solve the parent's published weights at every rebalance under the definition's constraints, then chain the solved weights into the derived index's own daily levels.

Parameters:

Name Type Description Default
definition OptimisedIndexDefinition

The derivation to calculate.

required
data_provider DataFetcher

Data source for the parent calculation, prices and FX.

required
start_date str | None

First date (YYYY-MM-DD). Defaults to the definition's base date. Ignored when parent_result is supplied, whose own window governs.

None
end_date str | None

Last date (YYYY-MM-DD). Required unless parent_result is supplied.

None
price_column str

Market-data column read as the price.

'CLOSE'
parent_result IndexResult | None

The source's calculation, when the caller already has it. None calculates the source here (recursively, when the source is itself optimised).

None

Returns:

Name Type Description
IndexResult IndexResult

Daily levels, divisor history, constituent and weight

IndexResult

snapshots at exactly the parent's rebalance dates, and the daily

IndexResult

weights panel: a normal index result, data-bound to data_provider.

Raises:

Type Description
CalculationError

If the objective is unknown, the parent produced no rebalance snapshots to solve, or a solve is infeasible (the solver's own message names the binding conflict).

ValueError

If no window end is available to calculate the parent.

asset_view

IndexAssetView: an asset view extended with index-specific context such as weight history and contribution analysis.

IndexAssetView

IndexAssetView(
    asset_id: str,
    data_fetcher: DataFetcher,
    weight_snapshots: dict[Timestamp, dict[str, float]],
    index_levels: Series,
)

Bases: AssetView

AssetView with index weight history and contribution analysis.

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
weight_snapshots dict[Timestamp, dict[str, float]]

Mapping of rebalance date -> dict of {asset_id: weight} from the parent IndexResult.

required
index_levels Series

Index level time series from the parent IndexResult.

required
weight_on_date
weight_on_date(date: Timestamp) -> float | None

Get this asset's index weight on a specific date.

Finds the most recent rebalance on or before date and returns the asset's weight. Returns None if the asset was not a constituent at that point.

Parameters:

Name Type Description Default
date Timestamp

The query date.

required

Returns:

Type Description
float | None

float or None

weight_series
weight_series() -> pd.Series

Return a Series of this asset's weight at each rebalance date.

Returns:

Type Description
Series

pd.Series: Indexed by rebalance date. Rebalance dates where the

Series

asset was not a constituent are excluded.

contribution
contribution(
    start: str, end: str, price_column: str = "CLOSE"
) -> pd.Series

Calculate this asset's contribution to index returns.

Contribution on day t = weight_{t-1} * asset_return_t.

Parameters:

Name Type Description Default
start str

Start date (YYYY-MM-DD).

required
end str

End date (YYYY-MM-DD).

required
price_column str

Column name for return calculation.

'CLOSE'

Returns:

Type Description
Series

pd.Series: Contribution series indexed by date.

cache

A persistent, content-addressed cache of calculated :class:IndexResults.

A result is stored under a fingerprint of the four things the calculation is a pure function of (the definition, the store behind the fetcher, the window and the library version), so there is deliberately no invalidation logic: changed inputs never match, and stale entries age out by size-capped pruning.

The safety rule: cache only what can be keyed completely; anything else calculates fresh, every time. An incomplete key would mean silently stale numbers, so anything the key cannot capture (an unregistered rule class, a parameter that does not serialise, a fetcher with no on-disk store behind it) makes :func:fingerprint return None, and no key means no cache, at the cost of a recalculation the caller would have paid anyway. :func:explain_uncacheable answers why.

Storage is one directory per fingerprint: panels in the data store's own reproducible gzipped-CSV format, plus a manifest.json keeping the key parts in the clear so "why didn't this hit?" is answerable by looking. Entries are staged and renamed into place; on read, anything missing or unparseable is a miss and the corrupt entry is removed, so the cache never raises into a calculation. There is no cache schema version: the library version is part of every key, so a format change rides on the version bump that ships it. The cache is pruned to 512 MB (least recently used first) on every write.

IndexResultCache

IndexResultCache(root: Path | None = None)

Filesystem cache of IndexResults, keyed by :func:fingerprint.

Reading never raises: a corrupt or half-present entry is a miss, removed on sight. Writing never raises either: a cache write is a convenience, and failing one must not fail the calculation that produced the result.

Parameters:

Name Type Description Default
root Path | None

Directory the cache lives in, created on first write. None uses :func:default_root, which needs platformdirs.

None
root property
root: Path

Where this cache keeps its entries.

entry_path
entry_path(key: str) -> Path

The directory one key's entry occupies (which may not exist).

get
get(key: str) -> IndexResult | None

Read a cached result, or None on a miss.

A hit refreshes the last_used stamp pruning orders evictions by. The returned result has no DataFetcher bound; a consumer that needs asset views re-binds via :meth:IndexResult.with_data.

put
put(
    key: str,
    result: IndexResult,
    parts: dict[str, Any] | None = None,
) -> None

Store a result under its fingerprint, then prune to the size cap.

The caller owns the key/result pairing: this module cannot re-derive the inputs from the result. parts is the :func:key_parts payload, recorded in the entry's manifest in the clear so a stored entry can say what it was keyed on; optional, the fingerprint alone serves hits. An existing entry under key is left as it is, and an invalid key or a failed write is logged and skipped rather than raised.

clear
clear() -> int

Remove every entry. Returns how many were removed.

size_on_disk
size_on_disk() -> int

Total bytes the cache's entries occupy.

default_root

default_root() -> Path

The cache location used when no explicit root is given.

Requires platformdirs, imported inside the function for the same reason store.default_path does: only the default location is a platform question, and the core import path must not need an optional package.

fingerprint

fingerprint(
    definition: AnyIndexDefinition,
    fetcher: DataFetcher,
    start_date: str | None,
    end_date: str,
) -> str | None

The cache key for one calculation, or None when it cannot be keyed.

A sha256 hexdigest over the canonical JSON dump of the key parts. The window arguments are the calculator's own (start_date None means the base date) and are normalised, so two spellings of the same window share a key. None means uncacheable: the reason is logged at DEBUG and available from :func:explain_uncacheable.

key_parts

key_parts(
    definition: AnyIndexDefinition,
    fetcher: DataFetcher,
    start_date: str | None,
    end_date: str,
) -> dict[str, Any] | None

The four key parts in the clear, or None when uncacheable.

These are what :func:fingerprint hashes and what an entry's manifest records.

explain_uncacheable

explain_uncacheable(
    definition: AnyIndexDefinition,
    fetcher: DataFetcher,
    start_date: str | None,
    end_date: str,
) -> str | None

Why this calculation cannot be cached, or None when it can.

calculation

The index calculator.

Re-exports IndexCalculator, which composes constituent selection, weighting, market values, corporate actions, deletions and total-return reinvestment, and the selection result objects that record how a universe narrowed.

IndexCalculator

IndexCalculator(
    index_definition: IndexDefinition,
    data_provider: DataFetcher,
    price_column: str = "CLOSE",
)

Bases: MarketValuesMixin, DeletionMixin, TotalReturnMixin, CorporateActionsMixin

Calculates an index from its definition and a data source.

Stateless between runs: it holds only its configuration (the definition, the data source, the price column and the index context), and everything a run produces is returned in its :class:IndexResult. It provides constituent selection, weighting, index level calculation, and corporate-action, deletion and distribution adjustments.

Initializes the IndexCalculator.

Parameters:

Name Type Description Default
index_definition IndexDefinition

The IndexDefinition object that specifies the index rules.

required
data_provider DataFetcher

A DataFetcher instance to access market and asset data.

required
price_column str

Market-data column read as the constituent price when computing market values. Defaults to "CLOSE".

'CLOSE'
resolve_universe
resolve_universe(date: Timestamp) -> list[Asset]

Resolve the definition's universe identifiers into Asset objects.

The public entry point for universe resolution, for callers outside the calculation loop (the constituent preview, for one). The reference data is read once for the whole universe, and each resolved name becomes an :class:~beacon.asset.equity.Equity built from its own row. An identifier the reference data does not know is skipped with a warning.

Parameters:

Name Type Description Default
date Timestamp

Point-in-time date for the reference-data lookup.

required

Returns:

Type Description
list[Asset]

list[Asset]: Assets for every identifier that resolved, in the

list[Asset]

order the definition names them.

Raises:

Type Description
CalculationError

If the definition names no universe at all.

select_constituents
select_constituents(
    universe: list[Asset], current_date: Timestamp
) -> list[Asset]

Select index constituents from a universe by the eligibility rules.

A thin projection of :meth:select_with_provenance: the survivors, with the record of which rule removed each excluded name discarded. Callers wanting that record (the preview waterfall, anything answering "why is this name missing") should use the fuller method rather than repeating the walk.

Parameters:

Name Type Description Default
universe list[Asset]

A list of potential Asset objects to consider for inclusion.

required
current_date Timestamp

The date for which selection is being made.

required

Returns:

Type Description
list[Asset]

A list of Asset objects that are eligible for the index.

select_with_provenance
select_with_provenance(
    universe: list[Asset], current_date: Timestamp
) -> SelectionResult

Select constituents, keeping the record of how the universe narrowed.

Parameters:

Name Type Description Default
universe list[Asset]

A list of potential Asset objects to consider for inclusion.

required
current_date Timestamp

The date for which selection is being made.

required

Returns:

Name Type Description
SelectionResult SelectionResult

Survivors, one step per rule, and the position of

SelectionResult

the rule that excluded each removed asset.

calculate_constituent_weights
calculate_constituent_weights(
    constituents: list[Asset], current_date: Timestamp
) -> dict[Asset, float]

Weight the constituents by the index's weighting scheme.

Parameters:

Name Type Description Default
constituents list[Asset]

A list of Asset objects that are part of the index.

required
current_date Timestamp

The date for which weights are calculated.

required

Returns:

Type Description
dict[Asset, float]

A dictionary mapping each Asset to its weight, summing to 1. Empty

dict[Asset, float]

(with a warning) when constituents is empty.

Raises:

Type Description
CalculationError

If the scheme refuses (an unpriced constituent, an unknown share count, a market cap of zero), in which case it propagates exactly as the scheme raised it, remedy and all. Also if the scheme's weights do not sum to 1: rescaling them would publish an allocation the scheme did not produce under the scheme's own name.

UnexpectedCalculationError

If the scheme raises anything else. A crash, not a decision, and it carries its own published code so a client does not read it as a refusal.

cap_weights
cap_weights(
    weights: dict[Asset, float],
) -> tuple[dict[Asset, float], CapReport]

Apply the definition's cap, returning the weights and a report.

Capping happens here rather than inside a weighting scheme so that it composes with every scheme, and it returns its report rather than storing one so the calculator stays stateless and run() stays idempotent.

Parameters:

Name Type Description Default
weights dict[Asset, float]

Normalised weights keyed by Asset.

required

Returns:

Name Type Description
tuple dict[Asset, float]

The capped weights and a CapReport. With no cap configured

CapReport

the weights are returned unchanged and the report is empty.

initialize_divisor
initialize_divisor(
    initial_total_market_value: float,
) -> float

Calculate the index's initial divisor on its base date.

divisor = initial_total_market_value / base_value.

Parameters:

Name Type Description Default
initial_total_market_value float

The sum of (price * shares * fx_rate * free_float_if_applicable) for all base constituents on the base_date, expressed in index currency.

required

Returns:

Type Description
float

The initial divisor as a float.

Raises:

Type Description
CalculationError

If initial_total_market_value or the definition's base value is not positive.

adjust_divisor_for_rebalance staticmethod
adjust_divisor_for_rebalance(
    old_divisor: float,
    old_market_value: float,
    new_market_value: float,
) -> float

Adjust the divisor to maintain index level continuity across a rebalance.

When index composition or weights change, the total market value shifts. To prevent an artificial jump in the index level the divisor is scaled:

new_divisor = old_divisor * (new_market_value / old_market_value)

This guarantees: level_before == level_after.

Parameters:

Name Type Description Default
old_divisor float

The divisor in effect before the rebalance.

required
old_market_value float

Aggregate market value under the old composition.

required
new_market_value float

Aggregate market value under the new composition.

required

Returns:

Type Description
float

The adjusted divisor.

Raises:

Type Description
ValueError

If old_divisor, old_market_value or new_market_value is zero or negative.

run
run(
    start_date: str | None = None,
    end_date: str | None = None,
) -> IndexResult

Run the full index calculation over a date range.

Iterates the index's own trading sessions (the definition's calendar, not Monday to Friday) from start_date to end_date, handling three day types:

  1. Base date: resolve universe, select constituents, compute weights, apply the cap, initialise divisor, set level = base_value. Rolled forward to the first session when the base date itself was not one.
  2. Rebalance date: reinvest the day's distributions (total-return indices), then reconstitute (re-resolve universe, re-select, re-weight, re-cap) and adjust the divisor for continuity. With an announcement lag, the composition is selected and weighted as of the announcement date and applied on the effective date.
  3. Regular day: drop any holding that stopped being listed, reinvest distributions (total-return indices), and compute the level from the units held.

The method is idempotent: it carries no state between calls. When the calendar covers only part of the window, the run is calculated over the covered part and the result's calendar_coverage says so; a window with no sessions at all (a single weekend, say) gives an empty result.

Parameters:

Name Type Description Default
start_date str | None

First calculation date (YYYY-MM-DD). Defaults to definition.base_date; an earlier date is moved up to it.

None
end_date str | None

Last calculation date (YYYY-MM-DD). Required.

None

Returns:

Name Type Description
An IndexResult

class:IndexResult containing index levels, divisor history,

IndexResult

constituent snapshots, weight snapshots, cap reports, announcement

IndexResult

dates and the daily weights panel (one row per constituent per

IndexResult

day, recorded as the loop goes, since the state it holds each day

IndexResult

is path-dependent and cannot be reconstructed from the rebalance

IndexResult

snapshots afterwards). The result is bound to the data source.

Raises:

Type Description
ValueError

If end_date is not provided or precedes the base date.

CalculationError

If the dataset lacks a column the definition reads (checked before any work, rather than discovered at the first read and reported as one company's problem), if the calendar can cover none of the window, if the index holds nothing on its base date, or if a rule, scheme or valuation refuses.

require_columns
require_columns() -> None

Refuse up front if the dataset cannot support this definition.

Public because the constituent preview runs a definition without calling run, and deserves the same answer: a preview of a market-cap index over a store with no share counts should say so, not fail on the first name it tries to price.

Raises:

Type Description
CalculationError

Naming each missing column and what needs it.

run_daily_calculation
run_daily_calculation(
    current_date: Timestamp,
    constituents: list[Asset],
    weights: dict[Asset, float],
    previous_index_level: float,
    previous_divisor: float,
) -> tuple[float, float]

Runs a single day's index calculation process.

Parameters:

Name Type Description Default
current_date Timestamp

The date for which to perform calculations.

required
constituents list[Asset]

Current index constituents.

required
weights dict[Asset, float]

Current constituent weights.

required
previous_index_level float

Index level from the previous period.

required
previous_divisor float

Divisor from the previous period.

required

Returns:

Type Description
tuple[float, float]

Tuple of (new_index_level, new_divisor).

SelectionResult dataclass

SelectionResult(
    survivors: list[Asset],
    steps: list[SelectionStep],
    exclusions: dict[str, int] = dict(),
)

Which assets survived selection, and how each one fared.

Attributes:

Name Type Description
survivors list[Asset]

Assets that passed every rule, in universe order.

steps list[SelectionStep]

One entry per rung, starting with the universe, then the stale-price rung when any name was stale, then one per rule.

exclusions dict[str, int]

Identifier to the position of the rule that removed it (STALENESS_POSITION for a name dropped as stale). Each excluded asset appears exactly once: an asset leaves the surviving set the moment it fails, so no later rule ever sees it and no name can be blamed on two rules. That single-owner property is what makes the funnel answer "why is this name missing" rather than only "how many are left".

survivor_ids property
survivor_ids: list[str]

Identifiers of the surviving assets.

rule_steps property
rule_steps: list[SelectionStep]

Every rung after the universe, including any stale-price rung.

excluded_by
excluded_by(asset_id: str) -> SelectionStep | None

The rung that removed an asset.

Parameters:

Name Type Description Default
asset_id str

The identifier to look up.

required

Returns:

Type Description
SelectionStep | None

SelectionStep or None: The rung, or None if the asset survived or

SelectionStep | None

was never in the universe.

SelectionStep dataclass

SelectionStep(
    position: int,
    remaining: int,
    rule_name: str = "",
    excluded: list[str] = list(),
)

One rung of the selection funnel.

Attributes:

Name Type Description
position int

1-based index of the rule, UNIVERSE_POSITION (0) for the starting universe, or STALENESS_POSITION (-1) for the stale-price rung.

rule_name str

Type of the rule applied ("StalePrice" for the stale-price rung), empty for the universe rung.

remaining int

How many assets survived this rung.

excluded list[str]

Identifiers this rung removed, sorted. Empty for the universe rung.

is_universe property
is_universe: bool

Whether this is the starting rung rather than a rule.

select_with_provenance

select_with_provenance(
    universe: list[Asset],
    rules: list[EligibilityRuleBase],
    current_date: Timestamp,
    data_fetcher: DataFetcher,
    context: IndexContext | None = None,
) -> SelectionResult

Narrow a universe to its eligible constituents, recording each step.

Parameters:

Name Type Description Default
universe list[Asset]

Assets to select from.

required
rules list[EligibilityRuleBase]

Eligibility rules, applied in order. Each rule sees only what survived the ones before it.

required
current_date Timestamp

The date to evaluate at.

required
data_fetcher DataFetcher

Data source the rules read from.

required
context IndexContext | None

What the index settles for its rules: its currency, so a bound stated in it is compared against a converted figure rather than a local one. None outside an index, and then a rule that needs it says so rather than assuming one.

None

Returns:

Name Type Description
SelectionResult SelectionResult

Survivors, the funnel, and per-asset provenance.

Raises:

Type Description
Exception

Whatever a rule raises, unchanged. A rule that could not run has not excluded anything, so its failure propagates rather than being recorded as an exclusion.

calculator

The IndexCalculator: constituent selection, weighting, index level calculation, and corporate-action, deletion and distribution adjustments.

IndexCalculator
IndexCalculator(
    index_definition: IndexDefinition,
    data_provider: DataFetcher,
    price_column: str = "CLOSE",
)

Bases: MarketValuesMixin, DeletionMixin, TotalReturnMixin, CorporateActionsMixin

Calculates an index from its definition and a data source.

Stateless between runs: it holds only its configuration (the definition, the data source, the price column and the index context), and everything a run produces is returned in its :class:IndexResult. It provides constituent selection, weighting, index level calculation, and corporate-action, deletion and distribution adjustments.

Initializes the IndexCalculator.

Parameters:

Name Type Description Default
index_definition IndexDefinition

The IndexDefinition object that specifies the index rules.

required
data_provider DataFetcher

A DataFetcher instance to access market and asset data.

required
price_column str

Market-data column read as the constituent price when computing market values. Defaults to "CLOSE".

'CLOSE'
resolve_universe
resolve_universe(date: Timestamp) -> list[Asset]

Resolve the definition's universe identifiers into Asset objects.

The public entry point for universe resolution, for callers outside the calculation loop (the constituent preview, for one). The reference data is read once for the whole universe, and each resolved name becomes an :class:~beacon.asset.equity.Equity built from its own row. An identifier the reference data does not know is skipped with a warning.

Parameters:

Name Type Description Default
date Timestamp

Point-in-time date for the reference-data lookup.

required

Returns:

Type Description
list[Asset]

list[Asset]: Assets for every identifier that resolved, in the

list[Asset]

order the definition names them.

Raises:

Type Description
CalculationError

If the definition names no universe at all.

select_constituents
select_constituents(
    universe: list[Asset], current_date: Timestamp
) -> list[Asset]

Select index constituents from a universe by the eligibility rules.

A thin projection of :meth:select_with_provenance: the survivors, with the record of which rule removed each excluded name discarded. Callers wanting that record (the preview waterfall, anything answering "why is this name missing") should use the fuller method rather than repeating the walk.

Parameters:

Name Type Description Default
universe list[Asset]

A list of potential Asset objects to consider for inclusion.

required
current_date Timestamp

The date for which selection is being made.

required

Returns:

Type Description
list[Asset]

A list of Asset objects that are eligible for the index.

select_with_provenance
select_with_provenance(
    universe: list[Asset], current_date: Timestamp
) -> SelectionResult

Select constituents, keeping the record of how the universe narrowed.

Parameters:

Name Type Description Default
universe list[Asset]

A list of potential Asset objects to consider for inclusion.

required
current_date Timestamp

The date for which selection is being made.

required

Returns:

Name Type Description
SelectionResult SelectionResult

Survivors, one step per rule, and the position of

SelectionResult

the rule that excluded each removed asset.

calculate_constituent_weights
calculate_constituent_weights(
    constituents: list[Asset], current_date: Timestamp
) -> dict[Asset, float]

Weight the constituents by the index's weighting scheme.

Parameters:

Name Type Description Default
constituents list[Asset]

A list of Asset objects that are part of the index.

required
current_date Timestamp

The date for which weights are calculated.

required

Returns:

Type Description
dict[Asset, float]

A dictionary mapping each Asset to its weight, summing to 1. Empty

dict[Asset, float]

(with a warning) when constituents is empty.

Raises:

Type Description
CalculationError

If the scheme refuses (an unpriced constituent, an unknown share count, a market cap of zero), in which case it propagates exactly as the scheme raised it, remedy and all. Also if the scheme's weights do not sum to 1: rescaling them would publish an allocation the scheme did not produce under the scheme's own name.

UnexpectedCalculationError

If the scheme raises anything else. A crash, not a decision, and it carries its own published code so a client does not read it as a refusal.

cap_weights
cap_weights(
    weights: dict[Asset, float],
) -> tuple[dict[Asset, float], CapReport]

Apply the definition's cap, returning the weights and a report.

Capping happens here rather than inside a weighting scheme so that it composes with every scheme, and it returns its report rather than storing one so the calculator stays stateless and run() stays idempotent.

Parameters:

Name Type Description Default
weights dict[Asset, float]

Normalised weights keyed by Asset.

required

Returns:

Name Type Description
tuple dict[Asset, float]

The capped weights and a CapReport. With no cap configured

CapReport

the weights are returned unchanged and the report is empty.

initialize_divisor
initialize_divisor(
    initial_total_market_value: float,
) -> float

Calculate the index's initial divisor on its base date.

divisor = initial_total_market_value / base_value.

Parameters:

Name Type Description Default
initial_total_market_value float

The sum of (price * shares * fx_rate * free_float_if_applicable) for all base constituents on the base_date, expressed in index currency.

required

Returns:

Type Description
float

The initial divisor as a float.

Raises:

Type Description
CalculationError

If initial_total_market_value or the definition's base value is not positive.

adjust_divisor_for_rebalance staticmethod
adjust_divisor_for_rebalance(
    old_divisor: float,
    old_market_value: float,
    new_market_value: float,
) -> float

Adjust the divisor to maintain index level continuity across a rebalance.

When index composition or weights change, the total market value shifts. To prevent an artificial jump in the index level the divisor is scaled:

new_divisor = old_divisor * (new_market_value / old_market_value)

This guarantees: level_before == level_after.

Parameters:

Name Type Description Default
old_divisor float

The divisor in effect before the rebalance.

required
old_market_value float

Aggregate market value under the old composition.

required
new_market_value float

Aggregate market value under the new composition.

required

Returns:

Type Description
float

The adjusted divisor.

Raises:

Type Description
ValueError

If old_divisor, old_market_value or new_market_value is zero or negative.

run
run(
    start_date: str | None = None,
    end_date: str | None = None,
) -> IndexResult

Run the full index calculation over a date range.

Iterates the index's own trading sessions (the definition's calendar, not Monday to Friday) from start_date to end_date, handling three day types:

  1. Base date: resolve universe, select constituents, compute weights, apply the cap, initialise divisor, set level = base_value. Rolled forward to the first session when the base date itself was not one.
  2. Rebalance date: reinvest the day's distributions (total-return indices), then reconstitute (re-resolve universe, re-select, re-weight, re-cap) and adjust the divisor for continuity. With an announcement lag, the composition is selected and weighted as of the announcement date and applied on the effective date.
  3. Regular day: drop any holding that stopped being listed, reinvest distributions (total-return indices), and compute the level from the units held.

The method is idempotent: it carries no state between calls. When the calendar covers only part of the window, the run is calculated over the covered part and the result's calendar_coverage says so; a window with no sessions at all (a single weekend, say) gives an empty result.

Parameters:

Name Type Description Default
start_date str | None

First calculation date (YYYY-MM-DD). Defaults to definition.base_date; an earlier date is moved up to it.

None
end_date str | None

Last calculation date (YYYY-MM-DD). Required.

None

Returns:

Name Type Description
An IndexResult

class:IndexResult containing index levels, divisor history,

IndexResult

constituent snapshots, weight snapshots, cap reports, announcement

IndexResult

dates and the daily weights panel (one row per constituent per

IndexResult

day, recorded as the loop goes, since the state it holds each day

IndexResult

is path-dependent and cannot be reconstructed from the rebalance

IndexResult

snapshots afterwards). The result is bound to the data source.

Raises:

Type Description
ValueError

If end_date is not provided or precedes the base date.

CalculationError

If the dataset lacks a column the definition reads (checked before any work, rather than discovered at the first read and reported as one company's problem), if the calendar can cover none of the window, if the index holds nothing on its base date, or if a rule, scheme or valuation refuses.

require_columns
require_columns() -> None

Refuse up front if the dataset cannot support this definition.

Public because the constituent preview runs a definition without calling run, and deserves the same answer: a preview of a market-cap index over a store with no share counts should say so, not fail on the first name it tries to price.

Raises:

Type Description
CalculationError

Naming each missing column and what needs it.

run_daily_calculation
run_daily_calculation(
    current_date: Timestamp,
    constituents: list[Asset],
    weights: dict[Asset, float],
    previous_index_level: float,
    previous_divisor: float,
) -> tuple[float, float]

Runs a single day's index calculation process.

Parameters:

Name Type Description Default
current_date Timestamp

The date for which to perform calculations.

required
constituents list[Asset]

Current index constituents.

required
weights dict[Asset, float]

Current constituent weights.

required
previous_index_level float

Index level from the previous period.

required
previous_divisor float

Divisor from the previous period.

required

Returns:

Type Description
tuple[float, float]

Tuple of (new_index_level, new_divisor).

weight_rows
weight_rows(
    date: Timestamp,
    units: dict[Asset, float],
    values: dict[Asset, float],
) -> list[dict[str, object]]

One record per constituent for one day: what was held, and its share.

The weights are realised shares of the day's aggregate (value over total), so they drift with prices between rebalances, and they renormalise the moment a name is deleted. That is why the panel is recorded as the calculation runs rather than derived later from the rebalance snapshot: the two only agree on the rebalance date itself.

Plain dicts, appended during the run and converted once at the end with :func:~beacon.index.result.daily_weights_frame.

Parameters:

Name Type Description Default
date Timestamp

The calculation day.

required
units dict[Asset, float]

What the index holds after the day's events.

required
values dict[Asset, float]

Those holdings valued for date, from :meth:~.market_values.MarketValuesMixin.holding_values.

required

Returns:

Name Type Description
list list[dict[str, object]]

Records keyed by :data:~beacon.index.result.DAILY_WEIGHT_COLUMNS.

list[dict[str, object]]

A day whose holdings are worth nothing at all records nothing, because

list[dict[str, object]]

it has no weights to record: the level is carried forward on such a

list[dict[str, object]]

day, and a row of zeros would read as "held nothing" rather than

list[dict[str, object]]

"could not be valued".

corporate_actions

CorporateActionsMixin: adjusting the index divisor in response to corporate actions.

CorporateActionsMixin

Corporate-action logic, mixed into IndexCalculator: share-count changes to the units held, and divisor adjustment for a special dividend.

ratio_schedule
ratio_schedule() -> dict[pd.Timestamp, dict[str, float]]

Every split and stock dividend in the data, by ex-date and name.

Returns:

Name Type Description
dict dict[Timestamp, dict[str, float]]

ex-date -> {identifier: share-count multiplier}. Empty when

dict[Timestamp, dict[str, float]]

the data holds no action history.

apply_ratios staticmethod
apply_ratios(
    units: dict[Asset, float],
    schedule: dict[Timestamp, dict[str, float]],
    after: Timestamp,
    through: Timestamp,
) -> dict[Asset, float]

The units held, after any split or stock dividend since after.

On the ex-date the stored close falls by the ratio, so the units rise by it and the index's value, level and divisor are unchanged.

Parameters:

Name Type Description Default
units dict[Asset, float]

What the index holds. Not mutated.

required
schedule dict[Timestamp, dict[str, float]]

Output of :meth:ratio_schedule.

required
after Timestamp

The last day already calculated (excluded).

required
through Timestamp

Today (included).

required

Returns:

Name Type Description
dict dict[Asset, float]

The units, scaled where a ratio applied. The same mapping

dict[Asset, float]

when none did.

handle_corporate_action
handle_corporate_action(
    action: dict[str, Any],
    constituents: list[Asset],
    current_total_market_value_before_ca: float,
    current_divisor_before_ca: float,
) -> float

Adjust the index divisor for a corporate action to maintain continuity.

Supports SPECIAL_DIVIDEND. Other recognised types (RIGHTS_ISSUE, SPIN_OFF, STOCK_DIVIDEND, MERGER) are not implemented and are refused.

Returning the divisor unchanged is this method's answer only for an action that genuinely has no effect on the index: one affecting a name the index does not hold, or one whose adjustment rounds to nothing. A malformed action, an unimplemented type or an unknown type is refused instead, because an unadjustable action and a harmless one must not be spelled the same: the second is safe to publish and the first leaves the level wrong from that day onward.

For a special dividend the market-value reduction is::

reduction = dividend_per_share * shares_outstanding * ff * fx

where ff is the free-float factor (only when the weighting scheme is free-float adjusted) and fx converts the name's currency into the index currency on the ex-date under the dataset's FX policy. The new divisor is::

new_divisor = old_divisor * (mv_after / mv_before)

where mv_after = mv_before - reduction.

Parameters:

Name Type Description Default
action dict[str, Any]

Dictionary with keys type, asset, value, ex_date.

required
constituents list[Asset]

Current index constituents.

required
current_total_market_value_before_ca float

Aggregate market value of all constituents just before the action takes effect.

required
current_divisor_before_ca float

Divisor in effect before this action.

required

Returns:

Type Description
float

The (possibly adjusted) divisor.

Raises:

Type Description
CalculationError

If the action is malformed (no ex_date, asset or value), if its type is unknown or recognised but not implemented, if the affected asset is a constituent and is not an equity, or if a special dividend cannot be sized (no shares outstanding, no usable free-float factor, no FX rate) or would leave the index worth nothing.

deletions

Removing a constituent that stopped being one part-way through a period.

An index reconstitutes on its rebalance dates, and between them it holds fixed units. That works until a constituent is acquired, fails, or is otherwise delisted, because from that day on there is no price, and the holding cannot be valued at all.

Why a deletion is needed

A holding with no price is valued at 0.0, which is the right answer to "what is this worth today" and the wrong basis for an index level. A name that was 4% of the index would simply stop contributing, so the level would fall 4% on the day it delists and never recover it: a loss that no holder experienced, since in reality the position was sold, at a price, and the proceeds stayed in the fund.

What a deletion is

The same divisor adjustment a rebalance uses. Value the book on the last day the leaver had a price, once including it and once without:

divisor ← divisor × (aggregate without) / (aggregate with)

The level is then identical across the change, which is the entire purpose of a divisor. Holdings of the survivors are untouched, so their weights renormalise upward in proportion, which is exactly what reinvesting the proceeds pro rata across the remainder would have done.

A holding is removed on the first calculation day after its last listed date, including a rebalance day, where it leaves before the outgoing book is valued. If every holding would be removed, the holdings are kept (and an error is logged) rather than emptying the index. If the book cannot be valued on the last day, the leavers are removed without a divisor adjustment and the level steps (logged as a warning).

Why the reference data decides, not the price

A missing price and a delisting are different things. A gap in a feed is a data-quality problem, and carrying the last level forward (which level_from_units does) is the right response. A name whose reference record has ended is gone, and holding it forever is wrong. Reading the reference data's DATE_TO, rather than guessing from absent prices, keeps those two apart.

DeletionMixin

Intra-period constituent deletion, mixed into IndexCalculator.

aggregate_value
aggregate_value(
    units: dict[Asset, float], current_date: Timestamp
) -> float

Provided by MarketValuesMixin; declared so this one type-checks.

delisting_schedule
delisting_schedule() -> dict[str, pd.Timestamp]

The last date each identifier is listed, for those that end.

Built once per run rather than queried per holding per day: a five-thousand-name index over ten years would otherwise make twelve million reference lookups to find a few hundred deletions.

Returns:

Name Type Description
dict dict[str, Timestamp]

identifier -> last listed date. Names still listed at the

dict[str, Timestamp]

end of their record are absent, so an empty mapping means nothing

dict[str, Timestamp]

ever leaves and the daily check costs one if.

apply_deletions
apply_deletions(
    units: dict[Asset, float],
    divisor: float,
    date: Timestamp,
    schedule: dict[str, Timestamp],
    valuation_date: Timestamp,
) -> tuple[dict[Asset, float], float, list[str]]

Drop any holding whose listing ended, keeping the level continuous.

Parameters:

Name Type Description Default
units dict[Asset, float]

What the index holds. Not mutated.

required
divisor float

The divisor in force.

required
date Timestamp

Today.

required
schedule dict[str, Timestamp]

Output of :meth:delisting_schedule.

required
valuation_date Timestamp

The last date the leavers still had prices, normally the previous trading day. Both aggregates are taken here, so the ratio is a like-for-like comparison rather than one that mixes today's prices with yesterday's.

required

Returns:

Name Type Description
tuple dict[Asset, float]

The surviving holdings, the adjusted divisor, and the

float

identifiers removed. All three are unchanged when nothing left.

market_values

MarketValuesMixin: constituent market values, holding values and the index level.

MarketValuesMixin

Market-value and index-level calculation logic, mixed into IndexCalculator.

rate_on
rate_on(
    from_currency: str, to_currency: str, date: Timestamp
) -> float | None

An FX rate on a date, through :meth:DataFetcher.fx_rate_on.

The calculator's name for the library's one FX lookup, so the levels, the weights and the reference display all convert currency the same way.

Returns:

Type Description
float | None

float | None: The rate as of date, carried forward over gaps

float | None

under the dataset's FX policy, or None when the pair is unknown,

float | None

which callers treat as "cannot convert" rather than as a rate of

float | None

one.

asset_unit_value
asset_unit_value(
    asset: Asset, current_date: Timestamp
) -> float | None

Value of one unit of asset in the index currency: price times FX.

Distinct from a constituent's market value, which multiplies by shares outstanding and free float. Those belong to the weighting of the index; this is what one unit is worth, which is what the index's holdings are valued at day to day.

Parameters:

Name Type Description Default
asset Asset

The constituent. Must be an Equity.

required
current_date Timestamp

Valuation date.

required

Returns:

Name Type Description
float | None

float | None: The price in index currency, or None when the name

float | None

could not be priced on current_date. None rather than 0.0,

float | None

because "could not be priced" and "priced at zero" are different

answers float | None

:meth:index_units must refuse the first, while

float | None

meth:holding_values tolerates it (a feed gap is a flat day).

Raises:

Type Description
CalculationError

If asset is not an equity, or if its currency cannot be converted into the index's. Valuing either at 0.0 would make a constituent the index still holds contribute nothing, which is a silent restatement of the index rather than a missing price.

Errors from the data source are not caught: a fetcher that raised and a name that is genuinely unvaluable must not look the same.

index_units
index_units(
    weights: dict[Asset, float],
    aggregate: float,
    current_date: Timestamp,
) -> dict[Asset, float]

Units of each constituent the index holds to realise weights.

The index holds a fixed number of units of each constituent between rebalances, which is what makes weights drift with relative performance rather than being silently reset every day.

Units are set so that unit_value * units is weight of aggregate, which makes the weights exactly right on the rebalance date and lets them move from there.

For a market-capitalisation weighting this reduces to shares outstanding (the weight is itself the share of aggregate market value), so that methodology gives the same levels as holding shares outstanding directly.

Parameters:

Name Type Description Default
weights dict[Asset, float]

Target weight per constituent, summing to 1.

required
aggregate float

Total value the index represents on this date.

required
current_date Timestamp

Rebalance date.

required

Returns:

Name Type Description
dict dict[Asset, float]

Units per constituent. A constituent priced at exactly zero

dict[Asset, float]

gets zero units rather than an infinite position, and so does one

dict[Asset, float]

carried at a target weight of zero that could not be priced.

Raises:

Type Description
CalculationError

If a constituent the index allocates weight to could not be priced at all, or was priced below zero. A name genuinely quoted at zero is a fact about a market; a missing price is the absence of one. Holding zero units of a name with a target weight would leave the index short by that whole weight and publish the shortfall as the index's own level. The optimised (chained) path makes the same refusal.

holding_values
holding_values(
    units: dict[Asset, float], current_date: Timestamp
) -> dict[Asset, float]

What each holding is worth today: units times unit value.

Separate from :meth:aggregate_value because the daily weights panel needs the parts as well as the total, and a part costs a market-data lookup: computing them twice would double the lookups a run makes, which is its dominant cost.

Parameters:

Name Type Description Default
units dict[Asset, float]

What the index holds, asset to unit count.

required
current_date Timestamp

Valuation date.

required

Returns:

Name Type Description
dict dict[Asset, float]

Value per holding in the index currency. A name with no

dict[Asset, float]

price today is worth 0.0 (with a warning) and still appears,

dict[Asset, float]

because it is still held: a feed gap is a data-quality problem,

dict[Asset, float]

and carrying the level forward (which :meth:level_from_units

dict[Asset, float]

does) is the right response to one.

Raises:

Type Description
CalculationError

If a holding is not an equity, or a priced holding's currency cannot be converted into the index's.

aggregate_value
aggregate_value(
    units: dict[Asset, float], current_date: Timestamp
) -> float

Total value of the index's holdings: units times unit value.

level_from_units
level_from_units(
    units: dict[Asset, float],
    divisor: float,
    current_date: Timestamp,
    previous_index_level: float,
    values: dict[Asset, float] | None = None,
) -> float

Index level on an ordinary day: holdings value over the divisor.

Parameters:

Name Type Description Default
units dict[Asset, float]

What the index holds, fixed since the last rebalance.

required
divisor float

Current divisor.

required
current_date Timestamp

Valuation date.

required
previous_index_level float

Carried forward when the index cannot be valued today, so a missing price shows as a flat day rather than a collapse to zero.

required
values dict[Asset, float] | None

Holdings already valued for current_date, from :meth:holding_values. Passed by the run loop, which needs them anyway to record the day's weights; omitting it values the holdings here instead.

None

Returns:

Name Type Description
float float

The index level.

Raises:

Type Description
CalculationError

If the divisor is not positive.

The previous level is carried forward on a day on which the data has nothing to say: when there are no holdings (a rebalance that selected no constituents), or when the holdings are worth nothing (every holding unpriced on one date).

calculate_index_level
calculate_index_level(
    current_date: Timestamp,
    constituents: list[Asset],
    weights: dict[Asset, float],
    divisor: float,
    previous_index_level: float,
) -> tuple[float, float]

Calculate the index level from constituent market values.

A Laspeyres-type formula: the sum of the constituents' current market values (price times shares, times free float when the scheme is float-adjusted, converted into the index currency) over the divisor. run does not use this; it values the units the index holds, through :meth:level_from_units.

Parameters:

Name Type Description Default
current_date Timestamp

The date for which to calculate the index level.

required
constituents list[Asset]

Current index constituents.

required
weights dict[Asset, float]

Current constituent weights.

required
divisor float

The current index divisor.

required
previous_index_level float

The index level from the previous calculation period.

required

Returns:

Type Description
float

A tuple of (new_index_level, divisor). With no constituents the

float

previous level is returned.

Raises:

Type Description
CalculationError

If the divisor is not positive, a constituent is not an equity, or a currency cannot be converted.

selection

Constituent selection, and the record of how it happened.

One function, select_with_provenance, answers "which assets are eligible", and it answers it by walking the rules in order and narrowing the universe a rung at a time, keeping a note of which rule removed each name as it goes.

Before any rule runs, names with no trade within the data source's max_price_staleness_days are dropped at their own rung (STALENESS_POSITION, named StalePrice), so no rule evaluates a stale close.

Why provenance is the general form

Survivors fall out of the provenance for free; provenance cannot be recovered from a list of survivors. So the calculator and the preview waterfall both use this one walk, and a preview cannot disagree with the run it is previewing.

The rule-outer loop also leaves room for a rule that ranks (the largest hundred by market capitalisation, at most ten per sector), which needs to see the set it is choosing from. is_eligible is a per-asset predicate, so no rule ranks yet, but this loop structure can accommodate one where an asset-outer loop could not, because it never has a set in hand.

Rules are identified by position, not by name

A rule object carries a rule_name, which is its type ("MarketCapRule"), and an index definition may hold several of the same type. Stable per-rule identifiers exist only in the server's stored document, which is the server's concern and not this layer's. So provenance here is keyed by position in the rule list, and a caller that has its own identifiers maps position to them.

SelectionStep dataclass
SelectionStep(
    position: int,
    remaining: int,
    rule_name: str = "",
    excluded: list[str] = list(),
)

One rung of the selection funnel.

Attributes:

Name Type Description
position int

1-based index of the rule, UNIVERSE_POSITION (0) for the starting universe, or STALENESS_POSITION (-1) for the stale-price rung.

rule_name str

Type of the rule applied ("StalePrice" for the stale-price rung), empty for the universe rung.

remaining int

How many assets survived this rung.

excluded list[str]

Identifiers this rung removed, sorted. Empty for the universe rung.

is_universe property
is_universe: bool

Whether this is the starting rung rather than a rule.

SelectionResult dataclass
SelectionResult(
    survivors: list[Asset],
    steps: list[SelectionStep],
    exclusions: dict[str, int] = dict(),
)

Which assets survived selection, and how each one fared.

Attributes:

Name Type Description
survivors list[Asset]

Assets that passed every rule, in universe order.

steps list[SelectionStep]

One entry per rung, starting with the universe, then the stale-price rung when any name was stale, then one per rule.

exclusions dict[str, int]

Identifier to the position of the rule that removed it (STALENESS_POSITION for a name dropped as stale). Each excluded asset appears exactly once: an asset leaves the surviving set the moment it fails, so no later rule ever sees it and no name can be blamed on two rules. That single-owner property is what makes the funnel answer "why is this name missing" rather than only "how many are left".

survivor_ids property
survivor_ids: list[str]

Identifiers of the surviving assets.

rule_steps property
rule_steps: list[SelectionStep]

Every rung after the universe, including any stale-price rung.

excluded_by
excluded_by(asset_id: str) -> SelectionStep | None

The rung that removed an asset.

Parameters:

Name Type Description Default
asset_id str

The identifier to look up.

required

Returns:

Type Description
SelectionStep | None

SelectionStep or None: The rung, or None if the asset survived or

SelectionStep | None

was never in the universe.

select_with_provenance
select_with_provenance(
    universe: list[Asset],
    rules: list[EligibilityRuleBase],
    current_date: Timestamp,
    data_fetcher: DataFetcher,
    context: IndexContext | None = None,
) -> SelectionResult

Narrow a universe to its eligible constituents, recording each step.

Parameters:

Name Type Description Default
universe list[Asset]

Assets to select from.

required
rules list[EligibilityRuleBase]

Eligibility rules, applied in order. Each rule sees only what survived the ones before it.

required
current_date Timestamp

The date to evaluate at.

required
data_fetcher DataFetcher

Data source the rules read from.

required
context IndexContext | None

What the index settles for its rules: its currency, so a bound stated in it is compared against a converted figure rather than a local one. None outside an index, and then a rule that needs it says so rather than assuming one.

None

Returns:

Name Type Description
SelectionResult SelectionResult

Survivors, the funnel, and per-asset provenance.

Raises:

Type Description
Exception

Whatever a rule raises, unchanged. A rule that could not run has not excluded anything, so its failure propagates rather than being recorded as an exclusion.

total_return

Total-return and net-total-return accumulation.

On an ex-dividend date a constituent's price drops, the aggregate drops with it, and a price index (PRICE, the default return type) falls by the whole distribution. That is what a price index is supposed to do, and it is why a price index understates what an investor actually earned. TOTAL_RETURN and NET_TOTAL_RETURN indices reinvest cash distributions from the data source's corporate-action history.

The construction: a divisor adjustment, not a purchase

The tempting implementation is to buy more units of the paying constituent with its own dividend. That is wrong twice over: it silently re-weights the index towards whatever paid, and it makes the composition depend on the return type, so a price and a total-return version of one index would hold different things.

Index providers do it with the divisor instead. On an ex-date, with aggregate holdings value A and total cash received D:

divisor_new = divisor_old x A / (A + D)

The level that day is then A / divisor_new == (A + D) / divisor_old (the drop is exactly offset), and because the divisor is permanently smaller, every later level is scaled up in the same proportion. That is reinvestment across the index, and it leaves the units untouched. Distributions paid in another currency are converted into the index currency first; a missing FX pair refuses.

Only cash actions, and only once

A split is a ratio action: it changes the share count and the price together and distributes nothing. Reinvesting a split ratio as though it were cash would inflate the index by a factor of two. A rights issue, spin-off or merger is structural and carries no directly aggregable value at all. So the filter is kind == "cash", using the classification the data layer publishes rather than a list of type strings kept here. A name paying two cash distributions on one ex-date receives both.

The price path must already contain the drop for this to be right. It does for a real feed, which quotes prices unadjusted, and for Beacon's synthetic data, whose generator applies the ex-date drop when it builds the close. Adding the cash back to an aggregate that never fell would double-count the distribution.

Net return

The same, with a flat withholding rate applied to the cash: D x (1 - rate). A flat index-level rate rather than a per-country table, because a table keyed off reference data is only as good as the country field behind it, and an unpopulated table produces a number that looks precise and is not. The rate is a property of the index and stated on it.

TotalReturnMixin

Dividend reinvestment, mixed into IndexCalculator.

rate_on
rate_on(
    from_currency: str, to_currency: str, date: Timestamp
) -> float | None

Provided by MarketValuesMixin; declared so this one type-checks.

cash_distribution_schedule
cash_distribution_schedule() -> dict[
    pd.Timestamp, dict[str, float]
]

Every cash distribution the data source holds, by date and name.

Built once per run rather than queried per constituent per day: a five-hundred-name index over five years would otherwise make more than half a million lookups to find a few thousand dividends.

Returns:

Name Type Description
dict dict[Timestamp, dict[str, float]]

ex-date -> {identifier: cash per share}. Empty when the data

dict[Timestamp, dict[str, float]]

source holds no action history.

distribution_received staticmethod
distribution_received(
    units: dict[Asset, float],
    per_share: dict[str, float],
    withholding: float = 0.0,
    rates: dict[str, float] | None = None,
) -> float

Cash the index's holdings receive on one date, in index currency.

Parameters:

Name Type Description Default
units dict[Asset, float]

What the index holds, asset to unit count.

required
per_share dict[str, float]

Cash per share by identifier, for this date, quoted in the paying company's own currency.

required
withholding float

Fraction withheld; 0.0 for a gross index.

0.0
rates dict[str, float] | None

Identifier to FX rate into the index currency, as returned by :meth:distribution_rates. A missing entry converts at 1.0, which is correct for a name that already reports in the index currency, and that is the only thing a missing entry can mean: a name needing a rate that is not there makes :meth:distribution_rates refuse rather than leave a gap here for the 1.0 to fill.

None

Returns:

Name Type Description
float float

Total cash, net of withholding. Zero when nothing paid.

distribution_rates
distribution_rates(
    per_share: dict[str, float],
    units: dict[Asset, float],
    date: Timestamp,
    index_currency: str,
) -> dict[str, float]

FX rates into the index currency, for the names paying on a date.

Dividends are quoted in the paying company's currency while the aggregate they are reinvested into is in the index's, so the two cannot be combined until one is converted.

Only names the index holds that actually paid are looked up, so a quiet day costs nothing. A name already quoted in the index currency gets no entry (it converts at 1.0).

Parameters:

Name Type Description Default
per_share dict[str, float]

Cash per share by identifier, for this date.

required
units dict[Asset, float]

What the index holds, asset to unit count.

required
date Timestamp

The ex-date.

required
index_currency str

The currency the index reports in.

required

Returns:

Name Type Description
dict dict[str, float]

Identifier to FX rate into the index currency.

Raises:

Type Description
CalculationError

If a paying name's pair is unknown. A level cannot publish "unknown" for one constituent and stay a level, so the calculation refuses rather than reinvest the local number as though it were in the index currency.

reinvest staticmethod
reinvest(
    divisor: float, aggregate: float, distribution: float
) -> float

Shrink the divisor so a distribution is reinvested across the index.

Parameters:

Name Type Description Default
divisor float

The divisor in force.

required
aggregate float

Holdings value on the ex-date, after the price drop.

required
distribution float

Cash received, net of any withholding.

required

Returns:

Name Type Description
float float

The new divisor. Unchanged when nothing was distributed, or

float

when the aggregate or divisor is non-positive: an index with no

float

value cannot reinvest into itself, and scaling by zero would

float

destroy the divisor rather than adjust it.

withholding_for
withholding_for(return_type: str, rate: float) -> float

The fraction of each distribution withheld.

Zero for a gross total-return index however the rate is set, so a definition carrying a rate it does not use cannot quietly apply it.

capping

Weight capping.

Capping limits how much of an index any single constituent may represent, for UCITS 5/10/40, concentration control or investability. It is orthogonal to how the base weights were derived, so it lives here rather than inside any one weighting scheme, and composes with all of them.

Capping is iterative, not a single pass: reducing a breaching name to the cap redistributes its excess across the others, which can push a previously compliant name above the cap.

CapReport dataclass

CapReport(
    cap: float | None = None,
    capped: dict[str, float] = dict(),
    redistributed: float = 0.0,
    passes: int = 0,
    uncapped_weights: dict[str, float] = dict(),
)

What capping did, for the layers that need to show it.

Attributes:

Name Type Description
cap float | None

The maximum weight applied, or None when no cap was requested.

capped dict[str, float]

Identifiers held at the cap, mapped to the weight each would have had uncapped. The difference is what was redistributed.

redistributed float

Total weight moved off capped names and onto the rest.

passes int

Iterations the loop took to settle. 0 means nothing breached.

uncapped_weights dict[str, float]

The full weight vector before capping. Kept so the counterfactual (what the index would have returned uncapped) can be computed later. Reconstructing it from capped and redistributed alone is only possible for a single pass, and the loop routinely takes more.

was_capped property
was_capped: bool

Whether any constituent was held at the cap.

minimum_feasible_cap

minimum_feasible_cap(count: int) -> float

The smallest cap that can still distribute a full unit of weight.

Parameters:

Name Type Description Default
count int

Number of constituents.

required

Returns:

Name Type Description
float float

1 / count. Any cap below this is impossible to satisfy:

float

every name would sit at the cap and the total would still fall short

float

of 1.0.

Raises:

Type Description
ValueError

If count is not positive.

apply_cap

apply_cap(
    weights: dict[str, float], cap: float | None
) -> tuple[dict[str, float], CapReport]

Cap constituent weights, redistributing the excess pro rata.

Parameters:

Name Type Description Default
weights dict[str, float]

Base weights, expected to sum to 1.0. Keys are identifiers.

required
cap float | None

Maximum weight for any one constituent, or None for no capping. A cap of 1.0 is a no-op, as is any cap at or above the largest weight.

required

Returns:

Name Type Description
tuple dict[str, float]

The capped weights (summing to 1.0) and a CapReport describing

CapReport

what happened.

Raises:

Type Description
ValueError

If cap is not in (0, 1].

CalculationError

If the cap is infeasible for this many constituents, or if the iteration fails to settle within MAX_PASSES.

chaining

Weight-rebalanced level chaining, over identifiers.

The arithmetic that turns a schedule of target weights into a daily level path: units are fixed between rebalances so weights drift with relative performance, each rebalance rebuilds the units at the value the old holdings reached (which keeps the level continuous across it), and prices are converted into the index currency with as-of FX rates, exactly as the calculator and the engine convert theirs. The divisor is 1.0 from the first rebalance on, because the aggregate this represents is its own portfolio value and there is no market-value scale for a divisor to absorb.

Nothing here knows what a derived index is: chain_levels takes an index's identity, a parent calculation and a solved schedule, and serves any caller with weights to chain. It reimplements IndexCalculator's arithmetic over identifiers rather than reusing its mixins, which are built around Asset objects and per-day price lookups; the economics are the same.

chain_levels

chain_levels(
    index_id: str,
    base_value: float,
    currency: str,
    parent: IndexResult,
    solved: dict[Timestamp, dict[str, float]],
    data_provider: DataFetcher,
    price_column: str,
) -> IndexResult

Chain the solved weights into daily levels on the parent's calendar.

The weight-rebalanced arithmetic the calculator applies, restated over identifiers: units are fixed between rebalances, each rebalance rebuilds them at the value the old holdings reached (which keeps the level continuous), and the path starts at base_value on the first rebalance. The divisor is 1.0 from then on (0.0 on any earlier day): the aggregate this index represents is its own portfolio value, so there is no market-value scale for a divisor to absorb.

A day on which the holdings cannot be valued at all carries the level forward and records no weights, matching the calculator's behaviour.

Parameters:

Name Type Description Default
index_id str

Identifier of the resulting index.

required
base_value float

The level on the first rebalance.

required
currency str

The index currency; prices are converted into it.

required
parent IndexResult

The calculation whose days the path follows.

required
solved dict[Timestamp, dict[str, float]]

Target weights by rebalance date.

required
data_provider DataFetcher

Source of prices, reference data and FX rates.

required
price_column str

The market-data column holdings are valued with.

required

Returns:

Name Type Description
IndexResult IndexResult

Levels, divisors, rebalance snapshots and the daily

IndexResult

weights panel. It carries no cap reports or announcement dates.

Raises:

Type Description
CalculationError

If a name the schedule allocates to (at a non-zero weight) has no prices in the window, is quoted in a currency with no rate into the index currency, or has no usable price on a rebalance day. A name carried at a weight of zero is never held, so missing data for it is only logged.

constructor

IndexDefinition: the static rules of an index.

IndexDefinition

IndexDefinition(
    index_id: str,
    index_name: str,
    base_date: str,
    base_value: float,
    currency: str,
    eligibility_rules: list[EligibilityRuleBase],
    weighting_scheme: WeightingSchemeBase,
    rebalancing_frequency: str,
    calendar: str,
    description: str | None = None,
    universe_identifiers: list[str] | None = None,
    max_constituent_weight: float | None = None,
    rebalance_day_rule: str = DEFAULT_DAY_RULE,
    return_type: str = PRICE,
    withholding_tax_rate: float = 0.0,
    effective_lag_sessions: int = 0,
)

Defines the static characteristics and rules for constructing an index.

Parameters:

Name Type Description Default
index_id str

A unique identifier for the index.

required
index_name str

The common name of the index.

required
base_date str

The date from which the index calculation begins (YYYY-MM-DD).

required
base_value float

The initial value of the index on its base_date. Must be positive.

required
currency str

The currency of the index (stored upper-cased).

required
eligibility_rules list[EligibilityRuleBase]

A list of EligibilityRuleBase objects that define criteria for constituent selection. An empty list is allowed but logged as a warning.

required
weighting_scheme WeightingSchemeBase

A WeightingSchemeBase object that defines how constituents are weighted.

required
rebalancing_frequency str

How often the index is rebalanced: "MONTHLY", "QUARTERLY", "SEMI-ANNUAL" or "ANNUAL" (stored upper-cased). An unsupported value is refused when rebalance dates are first computed.

required
calendar str

Exchange MIC backing trading-day arithmetic, e.g. "XNYS". Required, and deliberately without a default: a default would silently schedule, say, a European index on New York's holidays. Only the caller can choose it.

required
description str | None

Optional textual description of the index.

None
universe_identifiers list[str] | None

Optional list of string identifiers (e.g. tickers, ISINs) defining the asset universe from which constituents are selected. When given, it must not be empty. An index calculated with none is refused.

None
max_constituent_weight float | None

Optional cap on any single constituent's weight, as a fraction (0.1 is 10%), in (0, 1]. Applied after the weighting scheme and iterated until no constituent breaches it. None means uncapped.

None
rebalance_day_rule str

Which day of a scheduled month the rebalance falls on: "FIRST_BUSINESS_DAY" (the default), "LAST_BUSINESS_DAY" or "THIRD_FRIDAY".

DEFAULT_DAY_RULE
return_type str

"PRICE" (the default), "TOTAL_RETURN" or "NET_TOTAL_RETURN". The last two reinvest cash distributions across the index.

PRICE
withholding_tax_rate float

Fraction of each distribution withheld, for a net index, in [0, 1). Ignored unless the return type is NET_TOTAL_RETURN, so a definition carrying a rate it does not use cannot quietly apply it.

0.0
effective_lag_sessions int

Sessions between a composition being announced and its weights taking effect. Zero (the default) is same-day. Must not be negative.

0

Raises:

Type Description
ValueError

If a required argument is empty or out of range, or the day rule or return type is not supported.

get_rebalance_dates
get_rebalance_dates(
    start_date: str, end_date: str
) -> list[pd.Timestamp]

Every rebalance date within [start_date, end_date].

Follows the index's rebalancing frequency, day rule and calendar (see beacon.index.schedule). The calendar is always a real exchange calendar, so a date this returns is always a date the exchange has a session for. The cadence is anchored on the first scheduled date in the range.

Parameters:

Name Type Description Default
start_date str

Start of the range (YYYY-MM-DD), inclusive.

required
end_date str

End of the range (YYYY-MM-DD), inclusive.

required

Returns:

Type Description
list[Timestamp]

A chronologically sorted list of rebalance dates, each a session on

list[Timestamp]

the index's calendar.

Raises:

Type Description
ValueError

If the rebalancing frequency is unsupported.

next_rebalance
next_rebalance(as_of: str) -> pd.Timestamp | None

The first rebalance strictly after a date.

Anchored on the base date, as a calculation run from the base date is, so the answer names a day the index would genuinely rebalance on.

Parameters:

Name Type Description Default
as_of str

The date being asked from, YYYY-MM-DD.

required

Returns:

Type Description
Timestamp | None

The date, or None if none falls within the lookahead window.

context

What a methodology rule knows about the index it is running inside.

IndexContext dataclass

IndexContext(currency: str)

The index's own settings, handed to its rules and weighting scheme.

The calculator passes one to every eligibility rule and weighting scheme as their context argument, so a rule can see what it needs about the index it runs inside. For example, a market-cap weighting needs the index currency to compare a yen cap with a dollar one.

Attributes:

Name Type Description
currency str

The currency the index reports in, upper-cased. Money amounts in a methodology (a market-cap bound, the caps a weighting compares) are denominated in it.

derived

Optimised indices: an index derived from an index you already built.

Create an index, then optimise it (an objective function and constraints), and that creates a new index. The derivation stores exactly three things, the source index, the objective and the constraints, plus the usual identity attributes. No weights are stored anywhere: definitions are rules, weights are calculated, and calculations are cached.

Two consequences shape this module:

  • Rebalancing follows the parent. The child solves exactly at the parent's published snapshots; a frequency of its own would have no parent weights at the extra dates. The derived definition therefore declares no schedule.
  • The calculation is a normal IndexResult. Solve the parent's weights at each rebalance, then chain the solved weights into daily levels on the parent's calendar, so the level path equals compounding the solved-weight portfolio's returns from the parent's price data.

:class:OptimisedIndexDefinition is a sibling of :class:~beacon.index.constructor.IndexDefinition rather than a subclass: a subclass would have to invent eligibility rules, a weighting scheme and a rebalancing frequency it does not have, and the calculator must never receive one by accident. The level chaining lives in beacon.index.chaining.

This module imports on the core install. scipy (the optimise extra) is needed only when a solve actually runs.

OptimisedIndexDefinition

OptimisedIndexDefinition(
    index_id: str,
    index_name: str,
    source: AnyIndexDefinition,
    objective: str = MIN_TRACKING_ERROR,
    constraints: Sequence[Constraint] = (),
    base_date: str | None = None,
    base_value: float | None = None,
    currency: str | None = None,
    description: str | None = None,
    risk_model: RiskModel | None = None,
)

An optimised index: a derivation on a source index, plus identity.

The source stays first-class (referenced, never copied), so editing the parent changes its optimised children at their next calculation, which is what "optimise the index I built" means. Chained optimisation (a source that is itself optimised) falls out of the recursion for free.

Parameters:

Name Type Description Default
index_id str

A unique identifier for the derived index.

required
index_name str

The common name of the derived index.

required
source AnyIndexDefinition

The parent: a plain :class:IndexDefinition, or another :class:OptimisedIndexDefinition for a chain.

required
objective str

What to minimise. Only "min_tracking_error" exists; an unknown value fails the calculation loudly, naming the accepted ones.

MIN_TRACKING_ERROR
constraints Sequence[Constraint]

What the solved weights must satisfy, as :class:~beacon.optimise.constraints.Constraint instances. Empty means the solver's default of full investment alone.

()
base_date str | None

First calculation date (YYYY-MM-DD). None inherits the source's, which is the usual case: the child lives on the parent's calendar.

None
base_value float | None

The level the chained path starts at. None inherits the source's.

None
currency str | None

The derived index's currency. None inherits the source's.

None
description str | None

Optional textual description.

None
risk_model RiskModel | None

Reserved: carried but unused, mirroring :class:~beacon.optimise.config.OptimisationConfig. Setting one makes the calculation uncacheable (it cannot be keyed yet) and changes no result.

None

Raises:

Type Description
ValueError

If index_id or index_name is empty, source is None, or base_value is given and not positive.

base_date property
base_date: Timestamp

The first calculation date: own when given, else the source's.

base_value property
base_value: float

The starting level: own when given, else the source's.

currency property
currency: str

The index currency: own when given, else the source's.

calendar property
calendar: str

The trading calendar, which is always the source's.

No override, unlike the currency or the base date: the derivation reallocates on exactly the parent's rebalance dates, so a calendar of its own could only disagree with the days it actually has weights for.

universe_identifiers property
universe_identifiers: list[str] | None

The investable universe, which is always the source's.

The derivation holds no universe of its own (it reallocates over exactly the names the parent published), so the answer resolves through the chain to the root definition's.

from_config classmethod
from_config(
    index_id: str,
    index_name: str,
    source: AnyIndexDefinition,
    config: OptimisationConfig,
) -> OptimisedIndexDefinition

The derivation an :class:OptimisationConfig describes.

One vocabulary for ad-hoc and stored runs: the config is the stored derivation minus the source, so an ad-hoc Backtest.run builds an ephemeral definition through here and calculates it exactly as a stored one would be. The config's objective, constraints and risk model are carried over; base date, base value and currency inherit the source's.

calculate_derived_index

calculate_derived_index(
    definition: OptimisedIndexDefinition,
    data_provider: DataFetcher,
    start_date: str | None = None,
    end_date: str | None = None,
    price_column: str = "CLOSE",
    parent_result: IndexResult | None = None,
) -> IndexResult

Calculate an optimised index into a standard :class:IndexResult.

Three steps: calculate the parent (or accept a pre-supplied calculation, as the Backtest integration does with its cached one), solve the parent's published weights at every rebalance under the definition's constraints, then chain the solved weights into the derived index's own daily levels.

Parameters:

Name Type Description Default
definition OptimisedIndexDefinition

The derivation to calculate.

required
data_provider DataFetcher

Data source for the parent calculation, prices and FX.

required
start_date str | None

First date (YYYY-MM-DD). Defaults to the definition's base date. Ignored when parent_result is supplied, whose own window governs.

None
end_date str | None

Last date (YYYY-MM-DD). Required unless parent_result is supplied.

None
price_column str

Market-data column read as the price.

'CLOSE'
parent_result IndexResult | None

The source's calculation, when the caller already has it. None calculates the source here (recursively, when the source is itself optimised).

None

Returns:

Name Type Description
IndexResult IndexResult

Daily levels, divisor history, constituent and weight

IndexResult

snapshots at exactly the parent's rebalance dates, and the daily

IndexResult

weights panel: a normal index result, data-bound to data_provider.

Raises:

Type Description
CalculationError

If the objective is unknown, the parent produced no rebalance snapshots to solve, or a solve is infeasible (the solver's own message names the binding conflict).

ValueError

If no window end is available to calculate the parent.

check_objective

check_objective(
    definition: OptimisedIndexDefinition,
) -> None

Refuse an objective this module cannot solve, naming the accepted set.

Checked before anything expensive happens (a full parent calculation, in the usual case), so a typo in a stored derivation fails in the time it takes to read the document rather than after a minute of arithmetic.

Raises:

Type Description
CalculationError

If the objective is not one of :data:OBJECTIVES.

calculate_source

calculate_source(
    source: AnyIndexDefinition,
    data_provider: DataFetcher,
    start_date: str | None = None,
    end_date: str | None = None,
    price_column: str = "CLOSE",
) -> IndexResult

The parent's calculation, recursive when the parent is itself derived.

The parent's published weights are what a derivation is defined against, so anything reasoning about a derivation (the derived calculation, the server's preview) needs them, and needs them from one place. A chain resolves here rather than at each caller.

Parameters:

Name Type Description Default
source AnyIndexDefinition

The parent definition: rule-driven, or another derivation.

required
data_provider DataFetcher

Data source for prices, reference data and FX.

required
start_date str | None

First date (YYYY-MM-DD). None uses the source's base date.

None
end_date str | None

Last date (YYYY-MM-DD). Required by the calculator.

None
price_column str

Market-data column read as the price.

'CLOSE'

Returns:

Name Type Description
IndexResult IndexResult

The source's own calculation over that window.

solve_snapshot

solve_snapshot(
    definition: OptimisedIndexDefinition,
    source_weights: dict[str, float],
) -> OptimisationResult

Solve one of the parent's snapshots under the derivation's constraints.

The single solve of this module, so the derived calculation and any caller asking "what would this derivation do at that date" (the server's preview) cannot disagree about what the answer is. The whole :class:~beacon.optimise.result.OptimisationResult is returned rather than only its weights, because which constraints bound and how much room the rest had left is the interesting half of the answer, and re-deriving it from the weights afterwards would be a second implementation of the rules.

scipy is required inside the solve. An infeasible constraint set raises there with a message naming the binding conflict, and nothing is caught here, so the failure is loud.

Parameters:

Name Type Description Default
definition OptimisedIndexDefinition

The derivation supplying the objective and constraints.

required
source_weights dict[str, float]

The parent's published weights at one rebalance.

required

Returns:

Name Type Description
OptimisationResult OptimisationResult

Solved weights, binding constraints, every

OptimisationResult

constraint's slack, and the solver's diagnostics.

Raises:

Type Description
CalculationError

If the objective is unknown, or the solve is infeasible or fails to converge.

expression_rules

The rule an expression compiles into.

rule = ExpressionRule.from_expression(
    (data.market.market_cap > 1e9)
    & (data.features.fundamentals.pe_ratio < 20))

IndexDefinition(..., eligibility_rules=[rule])
A rule type beside the others

ExpressionRule sits beside MarketCapRule, LiquidityRule, FeatureRule and the rest, and stores its tree in params, so a definition written in Python and one built in the client are the same document and neither has to know which produced it:

{"id": "r1", "type": "ExpressionRule",
 "params": {"expression": {"node": "all", "operands": [...]}}}

An expression that could not serialise could never reach a saved definition, which is most of what a rule is for.

Evaluated at the rebalance date

Every read goes through the point-in-time path (beacon.expressions.resolve), so a value published after the rebalance date is invisible. Reading the latest value instead would make the backtest look better and be wrong.

Missing coverage is a stated behaviour

A name with no value for a field is excluded by default, matching FeatureRule so the two do not disagree about the same situation.

The alternative (including it) means a screen for "revenue above a billion" silently admits every company the dataset has never heard of, which is the opposite of what the screen says. Excluding can be wrong too, so it is a parameter; the default is the one whose failure is visible, since an index that comes out too small prompts a question where one quietly full of uncovered names does not.

ExpressionRule

ExpressionRule(
    expression: dict[str, Any],
    on_missing: str = EXCLUDE,
    max_age_days: int | None = MAX_AGE_DAYS,
)

Bases: EligibilityRuleBase

Select instruments that satisfy an expression.

Parameters:

Name Type Description Default
expression dict[str, Any]

The serialised expression tree (Expression.to_dict() output). Use :meth:from_expression to pass a live expression.

required
on_missing str

"exclude" (the default) or "include": what a comparison answers for a name with no value for its field.

EXCLUDE
max_age_days int | None

How old a feature value may be and still count. None means no limit.

MAX_AGE_DAYS

Raises:

Type Description
InvalidRuleError

If on_missing is not recognised, or expression is not a valid tree.

tree property
tree: Expression

The rebuilt expression.

required_columns
required_columns() -> frozenset[str]

The market columns the expression reads, derived from its tree.

An expression's needs are whatever it references, so they come from the fields in the tree. A derived field is expanded into what it is computed from: a screen on market_cap needs CLOSE and SHARES_OUTSTANDING, not a column called MARKET_CAP that no store has. Reference, action and feature fields read other tables and add nothing here.

from_expression classmethod
from_expression(
    expression: Expression,
    on_missing: str = EXCLUDE,
    max_age_days: int | None = MAX_AGE_DAYS,
) -> ExpressionRule

Build from a live expression rather than from its serialised form.

What a user writing Python calls. The stored params are identical either way, which is the point: one representation, two front doors.

is_eligible
is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether the asset passes, as of current_date.

The date is the rebalance date and is passed straight through to the point-in-time reads. A value published after it is invisible.

feature_rules

Eligibility rules that screen on features.

A feature is any per-instrument datapoint that is not price, reference or action data (beacon.data.features), so this is the rule that lets an index select on a fundamental, an alternative dataset, or a value somebody derived and imported, without a new rule class per datapoint.

Resolved at the rebalance date, through the point-in-time accessor

An index rebalancing on 1 April screens on what was published by 1 April. Q1 revenue announced in mid-May is invisible, however completely the quarter had ended. The rule reads through DataFetcher.fetch_feature, which enforces this; reading the table directly would put look-ahead back in, and the resulting backtest would look better and be wrong. A value older than max_age_days at the rebalance counts as missing.

Missing coverage is a decision, not an accident

A name with no value for the field is excluded by default.

The alternative (including it) means a screen for "revenue above a billion" silently admits every company the dataset has never heard of, which is the opposite of what the screen says. Excluding can be wrong too: a universe with patchy coverage shrinks to the names the vendor happened to cover. So the behaviour is a parameter, on_missing, and the default is the one whose failure is visible: an index that comes out too small prompts a question, where one quietly containing uncovered names does not.

This is deliberately not the same as a name whose value is legitimately zero. Zero fails a > 0 test honestly; missing has no value to compare at all.

FeatureRule

FeatureRule(
    field: str,
    comparison: str = "gt",
    threshold: float = 0.0,
    feature_type: str | None = None,
    on_missing: str = EXCLUDE,
    max_age_days: int | None = MAX_AGE_DAYS,
)

Bases: EligibilityRuleBase

Select instruments whose feature value passes a threshold.

Parameters:

Name Type Description Default
field str

The feature to read, e.g. "revenue".

required
comparison str

How the value is tested against threshold: "gt", "ge", "lt", "le", "eq" or "ne".

'gt'
threshold float

The value compared against.

0.0
feature_type str | None

Which feature dataset (TYPE) to read from. None searches all, which picks arbitrarily between two datasets carrying the same field name.

None
on_missing str

"exclude" (the default) or "include": what happens to a name with no value knowable at the rebalance.

EXCLUDE
max_age_days int | None

How old a value may be and still count. None means no limit.

MAX_AGE_DAYS

Raises:

Type Description
InvalidRuleError

If comparison or on_missing is not recognised, or field is empty.

required_columns
required_columns() -> frozenset[str]

No market columns: a feature is read from the features table.

Stated rather than inherited, so the absence is visibly a decision. Whether the named feature exists is a real question with the same shape as a missing column, but it is asked of a different table, and the up-front column check covers market data only.

is_eligible
is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether the asset passes, as of current_date.

The date is the rebalance date, and it is passed straight through to the point-in-time accessor. A value published after it is invisible.

methodology

Index methodology: eligibility rules and weighting schemes.

EligibilityRuleBase and WeightingSchemeBase are the interfaces a rule or scheme implements. MarketCapRule and LiquidityRule screen constituents; MarketCapWeighted and EqualWeighted weight them.

EligibilityRuleBase

EligibilityRuleBase(rule_name: str)

Bases: ABC

Abstract base class for an eligibility rule.

Eligibility rules determine whether an asset can be part of an index. Subclasses implement is_eligible, and may override required_columns and prepare.

Parameters:

Name Type Description Default
rule_name str

The rule's name, used in logs and error messages.

required
required_columns
required_columns() -> frozenset[str]

The market-data columns this rule reads, declared up front.

Checked against the dataset before a run does any work, so a store with no SHARES_OUTSTANDING column is refused at the start as "this rule needs SHARES_OUTSTANDING and the dataset has none", rather than at the first rebalance as "N0 has no SHARES_OUTSTANDING on 2024-01-02", which is true, and sends a reader to inspect one company whose data is fine.

Empty by default rather than abstract, so a rule written outside this package keeps working. It is then simply not checked up front, and fails at the first read instead. Every rule shipped here declares its own.

prepare
prepare(
    candidates: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> None

Read in one go whatever this rule is about to read per name.

Called once with the whole candidate set before is_eligible is asked about any of them. It decides nothing and returns nothing: a rule must give exactly the same answers whether or not it prepared, because this is a hint about how to read rather than about what is eligible. Doing nothing is therefore the right default, and it is the base implementation.

It exists because is_eligible is a predicate over one asset, so a rule reading market data reads it a name at a time, and each read slices a frame whose size is the whole store: the cost of one lookup grows with the universe around it rather than with the row it wants.

Parameters:

Name Type Description Default
candidates list[Asset]

Everything that reached this rung, in order. A rule that ranks rather than screens would want this set too; that is not what this is for, but it is the same set.

required
current_date Timestamp

The date selection is being made at.

required
market_data_provider DataFetcher

The data source the reads will go to.

required
context IndexContext | None

What the index settles for its rules, as for :meth:is_eligible.

None
is_eligible abstractmethod
is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Checks if a given asset is eligible based on this rule.

Parameters:

Name Type Description Default
asset Asset

The asset to check.

required
current_date Timestamp

The date on which eligibility is being assessed.

required
market_data_provider DataFetcher

A DataFetcher instance to get necessary market data (e.g., market cap, trading volume).

required
context IndexContext | None

What the index the rule is running inside reports in and settles. None when the rule is evaluated outside an index, in which case nothing here may assume a currency it was not told.

None

Returns:

Type Description
bool

True if the asset is eligible, False otherwise.

MarketCapRule

MarketCapRule(
    min_market_cap: float | None = None,
    max_market_cap: float | None = None,
)

Bases: EligibilityRuleBase

Eligibility by market capitalisation, read from a resolved session.

The cap is price times shares outstanding, both read on the same session. A name with no price or no positive share count on that session is excluded (and logged as a warning).

Dates resolve backwards into the data. A weekend, a holiday or any date inside the data's coverage that carries no bar is read at the last session on or before it, because that is the universe the index actually held through the closure. This is the same resolution :class:MarketCapWeighted performs, through the same primitive, so selection and weighting cannot resolve a closed day differently.

The bounds are in the index's currency. The cap is converted at the session's rate before it meets either bound; a missing FX pair refuses rather than falling back to the local figure. Outside an index there is no context and so no currency to convert into, and the bounds are then read in the asset's own currency. Inside an index the calculator always supplies one.

Past the last bar it refuses rather than excluding. A rule that cannot evaluate has not found the asset ineligible, it has failed, and the two must not share an answer: "not in the index" is a published fact about a name, while "the data does not reach that date" is a fact about the store.

Parameters:

Name Type Description Default
min_market_cap float | None

Lowest cap admitted, in the index currency. None for no floor.

None
max_market_cap float | None

Highest cap admitted, in the index currency. None for no ceiling.

None

Raises:

Type Description
ValueError

If min_market_cap is greater than max_market_cap.

required_columns
required_columns() -> frozenset[str]

A cap is price times shares, so both, whichever bound is set.

prepare
prepare(
    candidates: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> None

Read the whole candidate set's session in one slice.

Every name this rule is about to be asked about is read on the same session, for the same two columns. Warming that session turns the per-name reads into dictionary lookups and, because the weighting scheme then reads the survivors on the same session, makes the second pricing of every surviving name free.

Raises:

Type Description
CalculationError

If current_date lies outside the data's coverage. That is the same refusal is_eligible makes over the same date, arriving one call earlier; a rule that cannot resolve its session cannot assess anything.

is_eligible
is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether asset's market cap at the resolved session clears the bounds.

Raises:

Type Description
CalculationError

If asset is not an equity, if current_date lies outside the data's coverage, or if the cap cannot be converted into the index currency, so the rule cannot be evaluated at all. Nothing here turns a failure into an exclusion (see the class docstring).

LiquidityRule

LiquidityRule(
    min_avg_daily_volume: int | None = None,
    min_avg_daily_value: float | None = None,
    lookback_days: int = 60,
)

Bases: EligibilityRuleBase

Eligibility by trading liquidity: average daily volume or value.

The averages are taken over the last lookback_days rows of market data on or before the date asked about. A name with fewer than 80% of that many rows, or with the needed column missing or empty, is excluded (and logged as a warning). Values are in the currency the name trades in, not converted.

Parameters:

Name Type Description Default
min_avg_daily_volume int | None

Lowest average shares traded per day. None for no volume floor.

None
min_avg_daily_value float | None

Lowest average close times volume per day. None for no value floor.

None
lookback_days int

Trading days the averages are taken over.

60

Raises:

Type Description
ValueError

If lookback_days is not positive.

required_columns
required_columns() -> frozenset[str]

Volume for either threshold, and the close too for a value one.

Declared from the thresholds actually set, because they read different things. The declaration matters here: is_eligible treats a missing VOLUME column as "not liquid enough" and excludes the name, so without the up-front check a store lacking the column would exclude every name and the run would fail as "index holds nothing on its base date" with no mention of volume.

is_eligible
is_eligible(
    asset: Asset,
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> bool

Whether asset's traded volume and value over the lookback qualify.

No session resolution here, and none needed: this reads a window ending at current_date, so a closed day is already spanned by the days around it rather than being the single day everything hangs on.

Errors are not caught. A rule that throws has not said the asset is ineligible, and the two answers must not be spelled the same.

Raises:

Type Description
CalculationError

If asset is not an equity, so there is no ticker to read volume against.

WeightingSchemeBase

WeightingSchemeBase(scheme_name: str)

Bases: ABC

Abstract base class for a weighting scheme.

Weighting schemes determine the proportion of each constituent in an index. Subclasses implement calculate_weights, and may override required_columns.

Parameters:

Name Type Description Default
scheme_name str

The scheme's name, used in logs and error messages.

required
required_columns
required_columns() -> frozenset[str]

The market-data columns this scheme reads, declared up front.

The same contract as :meth:EligibilityRuleBase.required_columns, and derived from the scheme's own inputs where they change what it reads: a scheme's parameters are the declaration, so nothing asks a store for a column the configured scheme does not use.

calculate_weights abstractmethod
calculate_weights(
    constituents: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> dict[Asset, float]

Calculates the weight for each constituent asset.

Parameters:

Name Type Description Default
constituents list[Asset]

A list of assets that are eligible for the index.

required
current_date Timestamp

The date for which weights are being calculated.

required
market_data_provider DataFetcher

A DataFetcher instance.

required
context IndexContext | None

What the index the scheme is running inside reports in and settles. None when it is invoked outside an index.

None

Returns:

Type Description
dict[Asset, float]

A dictionary mapping each Asset object to its calculated weight (float).

dict[Asset, float]

The sum of weights should typically be 1.0.

MarketCapWeighted

MarketCapWeighted(use_free_float: bool = False)

Bases: WeightingSchemeBase

Market capitalisation weighting, optionally free-float adjusted.

Each constituent's weight is its cap (price times shares outstanding, times the free-float factor when use_free_float is set) over the sum of all caps. A name is priced at its last close on or before the rebalance session, and its shares, free float and FX rate are read on that same day.

Every path either weights by real market caps or refuses. There is no equal-weight fallback: an index that comes out equal-weighted because the caps could not be read is not a degraded market-cap index, it is a different index published under the same heading, and nothing downstream looks wrong enough for anyone to ask (the levels are right, the weights sum, the backtest tracks).

Dates resolve backwards into the data. A request for a weekend, a holiday, or any date inside the data's coverage that carries no bar reads the last session on or before it, because that is the composition the index actually held through the closure rather than an approximation of one. Past the last bar it refuses, since there the same read would be a stale print presented as the current one. The bound is the data's own coverage, not a day count, which cannot tell those two apart.

Caps are compared in one currency. A universe spanning currencies is converted into the index currency before the caps are summed, and a missing FX pair refuses. A universe quoted in a single currency needs no conversion at all, since converting every cap by the same rate cannot move a weight. Called outside an index (no context) over several currencies, it refuses, since there is no currency to compare in.

Parameters:

Name Type Description Default
use_free_float bool

Weight by the freely traded portion of each cap rather than the full cap. Requires a FREE_FLOAT column.

False
required_columns
required_columns() -> frozenset[str]

Price and shares, and free float only when this scheme uses it.

use_free_float is the declaration: every free-float read in a run is behind it, so a scheme that is not float-adjusted never asks the store for the column and must not be refused for lacking it.

calculate_weights
calculate_weights(
    constituents: list[Asset],
    current_date: Timestamp,
    market_data_provider: DataFetcher,
    context: IndexContext | None = None,
) -> dict[Asset, float]

Weights proportional to market cap, or a refusal.

Raises:

Type Description
CalculationError

If current_date lies outside the data's coverage, if any constituent is unpriceable, unconvertible or is not an equity, has no positive shares outstanding or (when free-float adjusted) no usable free-float factor, if the universe spans currencies with no context to compare them in, or if the caps sum to nothing. Nothing here falls back to another methodology (see the class docstring).

EqualWeighted

EqualWeighted()

Bases: WeightingSchemeBase

Equal weighting: every constituent gets 1 / n.

Reads no market data. An empty constituent list gets empty weights.

required_columns
required_columns() -> frozenset[str]

Nothing: equal weights are decided without reading the market.

Stated rather than inherited, so the absence is a decision a reader can see. The index still needs a price column to value its holdings daily, but that is the calculator's requirement, not this scheme's.

requirements

Whether a dataset has the columns a definition needs, asked once up front.

Every rule and weighting scheme declares the market columns it reads (required_columns), and require_columns checks the union, plus the price column the calculation values holdings with, against the dataset before a run does any work. A missing column is refused with a CalculationError that names each column, what needs it, and what the dataset has instead.

Without this check, a run would get as far as the first read that needed the column and then fail in terms of one company on one day (for example, "N0 has no positive SHARES_OUTSTANDING on 2024-01-02"), sending the reader to inspect that company's data when the dataset has no share-count column at all. A missing VOLUME column is worse: every name fails a liquidity screen, and the run fails as "index holds nothing on its base date" with no mention of volume. A column cannot appear or vanish halfway through a run, so asking once is enough.

When the data provider cannot say which columns it has (it has no market_columns list), the check is skipped and the run fails, if it fails, at the first read.

required_by

required_by(
    definition: IndexDefinition, price_column: str
) -> dict[str, list[str]]

Every market column a definition reads, and what reads it.

Parameters:

Name Type Description Default
definition IndexDefinition

The index definition to check.

required
price_column str

The column the calculation values holdings with every day. It is a requirement of the calculation itself, separate from any rule or scheme.

required

Returns:

Name Type Description
dict dict[str, list[str]]

column -> the parts of the definition that need it, in the order

dict[str, list[str]]

they appear. The who is what makes the refusal actionable: "needs

dict[str, list[str]]

SHARES_OUTSTANDING" says what to load, and "for MarketCapWeighted" says

dict[str, list[str]]

what to change instead if loading it is not an option.

require_columns

require_columns(
    definition: IndexDefinition,
    fetcher: DataFetcher,
    price_column: str,
) -> None

Refuse a definition the dataset cannot support, before any work is done.

Parameters:

Name Type Description Default
definition IndexDefinition

The index definition about to run.

required
fetcher DataFetcher

The data it would run on.

required
price_column str

The calculation's daily valuation column.

required

Raises:

Type Description
CalculationError

If any column the definition reads is absent from the dataset's market data, naming each one, what needs it, and what the dataset has instead.

require_price_column

require_price_column(
    fetcher: DataFetcher, price_column: str, who: str
) -> None

The one requirement a run with no definition still has: a price.

Used by the backtest engine, which may be driven by a raw weight schedule with no definition behind it, and so has nothing to declare beyond the column it marks positions at. Skipped when the provider cannot list its columns.

Parameters:

Name Type Description Default
fetcher DataFetcher

The data the run would use.

required
price_column str

The column positions are priced from.

required
who str

What needs the column, for the error message.

required

Raises:

Type Description
CalculationError

If the dataset has no such column.

result

IndexResult: the output of an index calculation run.

IndexResult dataclass

IndexResult(
    index_id: str,
    index_levels: Series,
    divisor_history: Series,
    constituent_snapshots: dict[Timestamp, list[str]],
    weight_snapshots: dict[Timestamp, dict[str, float]],
    cap_reports: dict[Timestamp, CapReport] = dict(),
    announcement_dates: dict[Timestamp, Timestamp] = dict(),
    daily_weights: DataFrame = empty_daily_weights(),
    calendar_coverage: CalendarCoverage | None = None,
    _data_fetcher: DataFetcher | None = None,
)

Container holding the output of an index calculation run.

Parameters:

Name Type Description Default
index_id str

Identifier of the calculated index.

required
index_levels Series

Time series of index levels indexed by pd.DatetimeIndex.

required
divisor_history Series

Time series of divisor values indexed by pd.DatetimeIndex.

required
constituent_snapshots dict[Timestamp, list[str]]

Mapping of rebalance date -> list of asset_id strings.

required
weight_snapshots dict[Timestamp, dict[str, float]]

Mapping of rebalance date -> dict of {asset_id: weight}.

required
cap_reports dict[Timestamp, CapReport]

Mapping of rebalance date -> CapReport, for the rebalances where a weight cap actually bound. Empty for an uncapped index, so its presence is itself the signal that capping occurred.

dict()
announcement_dates dict[Timestamp, Timestamp]

Mapping of effective date -> the date that composition was announced. Snapshots are keyed by the effective date, because that is when the weights are in force and what every consumer (drift, attribution, the backtest engine) needs. The announcement is carried alongside rather than instead, since a client showing "rebalance of 18 Sep, effective 22 Sep" needs both. Empty for an index with no lag, where the two always coincide.

dict()
daily_weights DataFrame

Long-form panel of what the index held on every calculation day: DATE, IDENTIFIER, AMOUNT (units held) and WEIGHT (that holding's share of the day's aggregate value). Recorded by the calculator as it walks, not derived afterwards (see the note below). Defaults to an empty frame, so a result built by hand is still valid.

empty_daily_weights()
calendar_coverage CalendarCoverage | None

How much of the requested window the trading calendar covered, or None when it covered all of it (the ordinary case). Its presence is the signal that the run's range was narrowed to what the calendar covers.

None

The daily panel is recorded rather than re-derived because the index's daily state is path-dependent. It is not a forward-fill of the rebalance snapshot, and not even "amounts fixed between rebalances, repriced daily": a delisted name is dropped mid-period and the divisor adjusted, a split multiplies the units held on its ex-date, and a total-return index reinvests its cash, all on days that are not rebalances. A path is written down as it happens.

The rebalance snapshots are the record of what a rebalance decided. This panel is the record of what then happened.

capped_assets_on_date
capped_assets_on_date(date: Timestamp) -> dict[str, float]

Return the constituents held at the cap at the given rebalance.

Parameters:

Name Type Description Default
date Timestamp

A rebalance date.

required

Returns:

Name Type Description
dict dict[str, float]

{asset_id: uncapped_weight} for names the cap bound on

dict[str, float]

that date. Empty when nothing was capped, or when date is not a

dict[str, float]

rebalance date.

with_data
with_data(data_fetcher: DataFetcher) -> IndexResult

Bind a DataFetcher for asset-level queries. Returns self for chaining.

asset
asset(asset_id: str) -> IndexAssetView

Return an IndexAssetView for a constituent.

Parameters:

Name Type Description Default
asset_id str

Identifier of the constituent asset.

required

Returns:

Type Description
IndexAssetView

IndexAssetView

Raises:

Type Description
RuntimeError

If no DataFetcher has been bound via :meth:with_data.

KeyError

If asset_id is not found in any constituent snapshot.

get_returns
get_returns() -> pd.Series

Derive a return series from index levels.

Returns:

Type Description
Series

pd.Series: Percentage returns (first entry is dropped).

get_weights_on_date
get_weights_on_date(date: Timestamp) -> dict[str, float]

Get constituent weights effective on a given date.

Locates the most recent rebalance date on or before date.

Parameters:

Name Type Description Default
date Timestamp

The query date.

required

Returns:

Name Type Description
dict dict[str, float]

Mapping of asset_id to weight. Empty dict if no rebalance

dict[str, float]

has occurred on or before date.

weights_on
weights_on(date: Timestamp) -> dict[str, float]

Get the recorded constituent weights as of a given date.

Reads the daily panel rather than the rebalance snapshots, so the answer includes everything that happened since the last rebalance: price drift, a deletion, a divisor adjustment. Compare :meth:get_weights_on_date, which answers the different question of what the last rebalance decided.

Falls back to the latest recorded date on or before date, which covers a day the holdings could not be valued at all, since such a day records no rows.

Parameters:

Name Type Description Default
date Timestamp

The query date.

required

Returns:

Name Type Description
dict dict[str, float]

Mapping of identifier to weight. Empty when nothing was

dict[str, float]

recorded on or before date, including when no panel was captured

dict[str, float]

at all.

to_dataframe
to_dataframe() -> pd.DataFrame

Flatten index levels and divisor history into a DataFrame.

Returns:

Type Description
DataFrame

pd.DataFrame: Columns: index_level, divisor.

daily_weights_frame

daily_weights_frame(
    records: list[dict[str, object]],
) -> pd.DataFrame

Build the daily weights panel from records collected during a run.

Parameters:

Name Type Description Default
records list[dict[str, object]]

One dict per constituent per calculation day, with the keys in :data:DAILY_WEIGHT_COLUMNS.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Long-form DATE/IDENTIFIER/AMOUNT/WEIGHT,

DataFrame

in the storage dtypes. Empty records give an empty frame that still

DataFrame

carries the columns, so a consumer can slice it without checking.

empty_daily_weights

empty_daily_weights() -> pd.DataFrame

The panel an :class:IndexResult carries when nothing recorded one.

schedule

When an index rebalances, and which days it has a level on.

Two things, kept apart

A calendar says which days exist. A day rule says which of those days within a month is the one. Every schedule here is the product of the two, so "third Friday, on the New York calendar" (the S&P and FTSE convention) needs no special case: the third Friday is found, and if it is not a session it rolls back to the one before.

Frequencies are MONTHLY, QUARTERLY, SEMI-ANNUAL and ANNUAL; day rules are FIRST_BUSINESS_DAY (the default), LAST_BUSINESS_DAY and THIRD_FRIDAY. The cadence is anchored on the first scheduled date in the range, not on the calendar year: a quarterly index starting in February rebalances in February, May, August and November.

Rolling back, not forward

A rebalance landing on a holiday moves to the previous session. Forward would push it into the next month at a month end, which is the one case where the choice is visible, and the convention every index provider follows is back. Good Friday is the case that makes this concrete: the third Friday of April 2025 is the 18th, which is not a session on any US exchange, so the rebalance falls on Thursday the 17th.

The calendar is required

Every index schedules against a real exchange calendar, named by its MIC (for example "XNYS"), from the exchange_calendars package. An IndexDefinition always carries one, and stored documents without one are migrated to DEFAULT_CALENDAR (XNYS). A date this module schedules is therefore always a date the exchange has a session for.

sessions, rebalance_dates and next_rebalance take calendar as a required argument. Passing None asks for plain Monday-to-Friday business days (pd.bdate_range, holidays included), which a library caller may want, but must ask for in writing rather than get by omission.

Calendar bounds

A calendar covers a limited span. Its history is widened back to the requested start where the package allows, falling back to the earliest start it will build. The far end stops at the calendar's last published session. sessions clamps to those bounds; calendar_coverage reports how much of a window was covered, so an index calculation can refuse a window with no cover at all and report one with partial cover.

CalendarCoverage dataclass

CalendarCoverage(
    calendar: str | None,
    requested_start: Timestamp,
    requested_end: Timestamp,
    covered_start: Timestamp | None,
    covered_end: Timestamp | None,
    calendar_start: Timestamp | None = None,
    calendar_end: Timestamp | None = None,
)

What a calendar could offer of the window it was asked for.

Publishes the requested window beside the covered one, because a covered range alone cannot say what was asked for.

is_partial says whether the cover was narrowed, so a client never has to compare two dates to find out. trimmed_start and trimmed_end say which end moved, because the two have different causes and different remedies: the near end is a calendar whose history does not reach (change the calendar or the base date), the far end is one whose published sessions stop (wait, or ask for less).

Attributes:

Name Type Description
calendar str | None

The MIC asked for, or None for plain business days.

requested_start Timestamp

The window's first date, as asked for.

requested_end Timestamp

The window's last date, as asked for.

covered_start Timestamp | None

First date the calendar can speak for, or None when it can speak for none of the window.

covered_end Timestamp | None

Last such date, or None in the same case.

calendar_start Timestamp | None

The calendar's first session as resolved for this request (its history is widened back on demand, so the same MIC can answer differently depending on how far back it was asked to reach). None when no calendar was named.

calendar_end Timestamp | None

The calendar's last published session, or None when no calendar was named.

is_empty property
is_empty: bool

Whether the calendar covers none of the requested window.

starts_before_calendar property
starts_before_calendar: bool

Whether the window opens before anything the calendar knows.

Which end failed decides the remedy, so a refusal has to know: too early is a calendar whose history does not reach, too late is one whose published sessions stop, and the two are acted on differently.

trimmed_start property
trimmed_start: bool

Whether the calendar's history does not reach the requested start.

trimmed_end property
trimmed_end: bool

Whether the calendar's sessions stop before the requested end.

is_partial property
is_partial: bool

Whether either end was narrowed. False when the cover is complete.

describe_window
describe_window() -> str

The requested window beside the covered one, for a log line.

calendar_coverage

calendar_coverage(
    start: Timestamp, end: Timestamp, calendar: str | None
) -> CalendarCoverage

How much of a window a calendar can actually speak for.

A pure query: it refuses nothing and logs nothing, so the caller decides what a narrowing means. :func:sessions answers with what it has (and is_session depends on that, since a date outside a calendar's bounds is a date it cannot call open), while an index calculation asks this first and refuses on an empty answer. An inverted window (start after end) is reported as covering nothing.

Parameters:

Name Type Description Default
start Timestamp

First date of the window, inclusive.

required
end Timestamp

Last date, inclusive.

required
calendar str | None

Exchange MIC, or None for plain business days, which have no bounds and therefore always cover the window in full.

required

Returns:

Name Type Description
CalendarCoverage CalendarCoverage

The requested window beside the covered one.

earliest_available cached

earliest_available(calendar: str) -> pd.Timestamp

The earliest session the package will build for a calendar.

Found by bisection rather than read off an attribute, because there is no attribute: get_calendar(code) returns twenty years of history whatever the underlying data supports, and the true floor only shows up as the earliest start it will accept. Each calendar's floor differs and none is the default bound: XTKS and XBOM reach back to 1997, XHKG to 1960, while all three report a first session of 2006 until asked for more.

Worth the handful of calendar builds because it is only ever called to explain a refusal, and the alternative is a message that understates the reach: quoting the default bound would tell someone asking for a 1950 Hong Kong index that the calendar starts in 2006, which is both false and the wrong thing to act on.

Parameters:

Name Type Description Default
calendar str

Exchange MIC.

required

Returns:

Type Description
Timestamp

pd.Timestamp: First session of the widest calendar obtainable.

describe_bounds

describe_bounds(coverage: CalendarCoverage) -> str

Why a calendar could not cover a window, in terms of the end that failed.

Only the failing end is searched for. A window that opens before the calendar's history needs the true floor, and finding it costs a handful of calendar builds; a window that opens after the calendar's last published session needs no search at all, because that bound is already known.

Parameters:

Name Type Description Default
coverage CalendarCoverage

A coverage with a named calendar (not None).

required

Returns:

Name Type Description
str str

"XTKS (Tokyo Stock Exchange) has no sessions before ..." when

str

the window opens too early, otherwise "... has no published sessions

str

after ..." with the calendar's last session.

sessions

sessions(
    start: Timestamp, end: Timestamp, calendar: str | None
) -> pd.DatetimeIndex

The days an index has a level on, over a range.

Parameters:

Name Type Description Default
start Timestamp

First date, inclusive.

required
end Timestamp

Last date, inclusive.

required
calendar str | None

Exchange MIC, e.g. "XNYS". Required: passing None asks for Monday to Friday, holidays included, which no stored index can do and which a library caller must therefore state rather than fall into.

required

Returns:

Type Description
DatetimeIndex

pd.DatetimeIndex: Sessions in ascending order, clamped to the

DatetimeIndex

calendar's bounds (empty when the range lies wholly outside them).

is_session

is_session(date: Timestamp, calendar: str | None) -> bool

Whether the market was open on date.

The single-day face of :func:sessions, and the question that tells a holiday apart from a hole in the data: a day with no bar that the calendar says was closed is a market that was shut, while a day with no bar that the calendar says was open is data that is missing something.

Parameters:

Name Type Description Default
date Timestamp

The date asked about.

required
calendar str | None

Exchange MIC. None asks about Monday to Friday, holidays included (the same explicit request :func:sessions accepts).

required

Returns:

Name Type Description
bool bool

True when date is a trading session on calendar. A date past

bool

the calendar's published bounds answers False, since a day the

bool

calendar cannot speak for is not a day it says was open.

known_calendars

known_calendars() -> list[str]

Every MIC this installation can schedule against, sorted.

Read from the package rather than listed here, so the set a client is told about is the set the calculation actually accepts. A hand-kept copy of a hundred-odd MICs would be wrong the first time the package gained one.

calendar_region

calendar_region(calendar: str) -> tuple[str, str]

A calendar's region and IANA timezone, e.g. ("Europe", "Europe/Oslo").

Derived from the calendar's own timezone rather than from a table, for the same reason the code list is: a mapping kept here would be one release behind the package the schedule actually runs on. Every calendar has a timezone and splits into a region (Europe, America, Asia, Australia, Atlantic, Africa, Pacific); the few on bare UTC report "UTC" as their own region rather than being forced into a continent they do not have.

is_known_calendar

is_known_calendar(calendar: str) -> bool

Whether a MIC names a calendar this installation can use.

day_in_month

day_in_month(
    year: int,
    month: int,
    day_rule: str,
    available: DatetimeIndex,
) -> pd.Timestamp | None

The scheduled day within one month, or None if it has no sessions.

Parameters:

Name Type Description Default
year int

Calendar year.

required
month int

Calendar month, 1-12.

required
day_rule str

One of DAY_RULES.

required
available DatetimeIndex

Sessions covering at least this month.

required

Returns:

Type Description
Timestamp | None

The date, or None when the month holds no session at all.

rebalance_dates

rebalance_dates(
    frequency: str,
    start: str | Timestamp,
    end: str | Timestamp,
    calendar: str | None,
    day_rule: str = DEFAULT_DAY_RULE,
) -> list[pd.Timestamp]

Every rebalance date in a range.

Parameters:

Name Type Description Default
frequency str

One of FREQUENCIES.

required
start str | Timestamp

First date of the range, inclusive.

required
end str | Timestamp

Last date, inclusive.

required
calendar str | None

Exchange MIC. Required; None asks explicitly for business days, which no stored index does.

required
day_rule str

Which day within a scheduled month.

DEFAULT_DAY_RULE

Returns:

Name Type Description
list list[Timestamp]

Dates in ascending order, empty when the range holds none.

Raises:

Type Description
ValueError

If the frequency or day rule is unknown.

effective_date

effective_date(
    announced: Timestamp,
    lag_sessions: int,
    available: DatetimeIndex,
) -> pd.Timestamp

The date an announced rebalance takes effect.

Parameters:

Name Type Description Default
announced Timestamp

When the composition was published.

required
lag_sessions int

Sessions to wait. Zero means same-day.

required
available DatetimeIndex

Sessions covering the announcement and the lag.

required

Returns:

Type Description
Timestamp

The effective date. The announcement itself when the lag is zero, or

Timestamp

when the panel holds too few sessions after it (logged as a warning):

Timestamp

an index whose data ends mid-lag applies its last rebalance rather

Timestamp

than dropping it.

next_rebalance

next_rebalance(
    frequency: str,
    base_date: str | Timestamp,
    as_of: str | Timestamp,
    calendar: str | None,
    day_rule: str = DEFAULT_DAY_RULE,
) -> pd.Timestamp | None

The first rebalance strictly after a date.

Anchored on the base date, because that is what the calculator anchors on: a "next rebalance" computed from any other origin could name a date the index would never actually rebalance on.

Parameters:

Name Type Description Default
frequency str

One of FREQUENCIES.

required
base_date str | Timestamp

The index's base date, which anchors the cadence.

required
as_of str | Timestamp

The date being asked from.

required
calendar str | None

Exchange MIC. Required; None asks explicitly for business days.

required
day_rule str

Which day within a scheduled month.

DEFAULT_DAY_RULE

Returns:

Type Description
Timestamp | None

The next date, or None if none falls within the lookahead.