Skip to content

Analysis

A run's performance, portfolio, risk and attribution analytics, and the older helpers for concentration, liquidity and ETF tracking. See Performance, Portfolio, Risk analysis and Attribution.

analysis

Analytics for indices, ETFs and portfolios.

Covers a run's performance (beacon.analysis.performance), risk (beacon.analysis.risk) and attribution (attribute, Attribution, cost and cap drag), concentration and drift from target weights, liquidity and ETF tracking analytics (tracking difference, tracking error, premium or discount to NAV), and RunData, everything a run's analysis reads, from a finished run or a stored one.

RiskMetricsCalculator, calculate_volatility, calculate_sharpe_ratio, calculate_max_drawdown and simple_performance_attribution are deprecated, to be removed in 0.11.0: their figures are in beacon.analysis.performance.formulas.

AttributionResult dataclass

AttributionResult(
    start: str,
    end: str,
    periods: int,
    total_return: float,
    contributions: list[Contribution],
    residual: float,
    cap_drag: float | None = None,
    cost_drag: float | None = None,
    _weights: DataFrame | None = None,
)

A decomposition of one return into per-constituent contributions.

Attributes:

Name Type Description
start str

First date of the window, ISO 8601.

end str

Last date, ISO 8601.

periods int

Return periods decomposed.

total_return float

The return being explained: a compounded return, or a compounded return less a comparator's.

contributions list[Contribution]

Per constituent, largest first. Sums to total_return up to residual.

residual float

total_return minus the sum of contributions. Reported always, expected to be at machine epsilon after linking. It is never folded into a constituent.

cap_drag float | None

Capped return minus uncapped return, when the index applies a cap. Negative when capping cost the index. None when uncapped.

cost_drag float | None

Portfolio return minus its gross return, when one was passed to attribute (see cost_drag). Negative, since costs only subtract. None otherwise.

explained property

explained: float

Sum of the contributions.

reconciles

reconciles(tolerance: float = 1e-09) -> bool

Whether the contributions account for the total return.

to_frame

to_frame() -> pd.DataFrame

Contributions as a DataFrame, largest first.

Contribution dataclass

Contribution(
    asset_id: str,
    contribution: float,
    average_weight: float,
    total_return: float,
)

One constituent's share of the total return.

Attributes:

Name Type Description
asset_id str

The constituent.

contribution float

Its linked contribution. These sum to the total return.

average_weight float

Mean weight across the window, for context: a large contribution from a small average weight is a different story from the same contribution from a large one.

total_return float

The constituent's own return over the window.

ConcentrationMetrics dataclass

ConcentrationMetrics(
    assets: int,
    herfindahl_index: float,
    effective_assets: float,
    largest_weight: float,
    largest_asset_id: str | None,
)

How concentrated a set of weights is.

Attributes:

Name Type Description
assets int

Number of weighted positions.

herfindahl_index float

Sum of squared weights. For weights summing to 1 this runs from 1/n (perfectly equal) to 1 (everything in one name).

effective_assets float

1 / herfindahl_index: the number of equally weighted positions that would be this concentrated. Reads more naturally than the index itself: "this 100-stock index behaves like 23 equal positions".

largest_weight float

The biggest single weight.

largest_asset_id str | None

Which asset holds it, or None when there are no positions.

DriftMetrics dataclass

DriftMetrics(
    per_asset: dict[str, float],
    max_absolute: float,
    max_absolute_asset_id: str | None,
    total_absolute: float,
    turnover: float,
)

How far current weights have moved from their targets.

Attributes:

Name Type Description
per_asset dict[str, float]

Current minus target for every asset in either set. Positive means overweight.

max_absolute float

Largest absolute drift across assets.

max_absolute_asset_id str | None

Which asset drifted most, or None when there is nothing to compare. Drifts equal to within floating-point noise count as tied, and a tie goes to the asset id that sorts first.

total_absolute float

Sum of absolute drifts.

turnover float

Half of total_absolute: the one-way trading needed to return to target, since every overweight funds an underweight.

ETFAnalytics

ETFAnalytics()

ETF analytics as methods. Each method calls the module-level function of the same name and returns its result unchanged.

Initializes the ETFAnalytics calculator.

calculate_tracking_difference

calculate_tracking_difference(
    etf_returns: Series, index_returns: Series
) -> float

Cumulative ETF return minus cumulative index return over the period.

See :func:calculate_tracking_difference.

calculate_tracking_error

calculate_tracking_error(
    etf_returns: Series,
    index_returns: Series,
    periods_per_year: int = 252,
) -> float

Annualised standard deviation of the ETF-minus-index return differences.

See :func:calculate_tracking_error.

calculate_premium_discount

calculate_premium_discount(
    etf_price: float, nav_price: float
) -> float

Market price divided by NAV, minus one.

See :func:calculate_premium_discount.

RiskMetricsCalculator

RiskMetricsCalculator()

Risk metrics as methods. Each method calls the module-level function of the same name and returns its result unchanged.

Deprecated: use the functions of beacon.analysis.performance.formulas.

Initializes the RiskMetricsCalculator.

calculate_volatility

calculate_volatility(
    price_series: Series, window: int = 252
) -> float

Calculates annualized volatility from a price series.

See :func:calculate_volatility.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required
window int

The number of trading periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized volatility as a float.

calculate_sharpe_ratio

calculate_sharpe_ratio(
    returns: Series,
    risk_free_rate: float,
    periods_per_year: int = 252,
) -> float

Calculates the annualized Sharpe Ratio.

Parameters:

Name Type Description Default
returns Series

A pandas Series of periodic returns.

required
risk_free_rate float

The annualized risk-free rate.

required
periods_per_year int

The number of return periods in a year (e.g., 252 for daily, 12 for monthly).

252

See :func:calculate_sharpe_ratio.

Returns:

Type Description
float

The annualized Sharpe Ratio as a float.

calculate_max_drawdown

calculate_max_drawdown(price_series: Series) -> float

Calculates the maximum drawdown from a price series.

See :func:calculate_max_drawdown.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required

Returns:

Type Description
float

The maximum drawdown as a non-positive float (e.g., -0.2 for a

float

20% drawdown).

attribute

attribute(
    period_returns: Series,
    weights: DataFrame,
    asset_returns: DataFrame,
    cap_drag: float | None = None,
    cost_drag: float | None = None,
    comparator: Series | None = None,
) -> AttributionResult

Decompose a return series into per-constituent contributions.

Parameters:

Name Type Description Default
period_returns Series

The return being explained, per period.

required
weights DataFrame

Weights per period, constituents on the columns. Aligned to period_returns; the weight used for a period is the one held at its start.

required
asset_returns DataFrame

Constituent returns per period.

required
cap_drag float | None

Optional capped-minus-uncapped return.

None
cost_drag float | None

Optional cost effect on the portfolio return.

None
comparator Series | None

A comparator's return per period. When given, weights are active weights (the portfolio's less the comparator's), and the return explained is the compounded period_returns less the compounded comparator's.

None

Returns:

Name Type Description
AttributionResult AttributionResult

The decomposition, with contributions summing to the

AttributionResult

compounded total return (or active return) and a residual reported

AttributionResult

separately.

Raises:

Type Description
CalculationError

If the inputs cannot be aligned, or a period wipes out the index.

cap_drag

cap_drag(
    capped_weights: dict[Timestamp, dict[str, float]],
    uncapped_weights: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
    window: Index | None = None,
) -> float

What capping cost, or gained, over the window.

The capped index's return minus the return of the same methodology left uncapped. Negative when the cap held back a name that went on to outperform, which is the usual case and the reason the number is worth reporting.

Both paths are built by drifting their own snapshots forward, so the comparison isolates the effect of the cap rather than of any other difference.

Parameters:

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

Rebalance date -> capped weights.

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

Rebalance date -> weights before capping.

required
prices DataFrame

Constituent prices, from the first rebalance on. Weights drift from the rebalance before each date, so the prices have to reach back to it even when window starts later.

required
window Index | None

The dates to measure over, as for :func:attribute: the return of each date after the first. None measures over every date in prices.

None

Returns:

Name Type Description
float float

Capped total return minus uncapped total return.

carino_factor

carino_factor(period_return: float) -> float

The Carino coefficient for one period.

ln(1+R)/R, with the removable singularity at R = 0 filled in with its limit of 1.

Parameters:

Name Type Description Default
period_return float

The period's total return.

required

Returns:

Name Type Description
float float

The coefficient.

Raises:

Type Description
CalculationError

If the return is -100% or worse, where the logarithm is undefined. An index that goes to zero cannot have its return attributed, and silently substituting a number would hide that.

cost_drag

cost_drag(
    total_costs: float, initial_capital: float
) -> float

Direct effect of transaction costs on a portfolio's return.

Costs paid as a fraction of starting capital, negated so it reads as a drag. This is the direct effect only: it excludes the compounding of the capital that was spent rather than invested, which is second-order but not zero over a long window. Reporting the direct figure keeps the number explainable (it is exactly the money that left the portfolio), and a caller wanting the full effect can difference a zero-cost run instead.

Parameters:

Name Type Description Default
total_costs float

Sum of transaction costs paid.

required
initial_capital float

Capital the portfolio started with.

required

Returns:

Name Type Description
float float

A non-positive drag.

Raises:

Type Description
CalculationError

If initial_capital is not positive.

drifted_weights

drifted_weights(
    snapshots: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
) -> pd.DataFrame

Reconstruct the index's daily weights from its rebalance snapshots.

Weights are set at a rebalance and then drift with relative performance until the next one, because the index holds fixed units in between. Given the weight at a rebalance and prices since, the drifted weight is

w_i,t ∝ w_i,rebalance × (p_i,t / p_i,rebalance)

normalised across constituents. The unit scale cancels, so nothing beyond the snapshot and prices is needed.

Parameters:

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

Rebalance date -> weights on that date.

required
prices DataFrame

Dates on the index, constituents on the columns.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Weights for every date at or after the first rebalance,

DataFrame

each row summing to 1.

Raises:

Type Description
CalculationError

If there are no snapshots to start from.

link_contributions(
    contributions: DataFrame,
    period_returns: Series,
    comparator: Series | None = None,
) -> pd.Series

Scale per-period contributions so they sum to the compounded return.

Parameters:

Name Type Description Default
contributions DataFrame

Periods on the index, constituents on the columns. Each row must sum to that period's return (or, with comparator, to that period's return less the comparator's).

required
period_returns Series

The total return of each period.

required
comparator Series | None

A comparator's return each period. When given, the contributions are linked by the active factor and sum to the compounded return less the comparator's.

None

Returns:

Type Description
Series

pd.Series: One linked contribution per constituent. Their sum equals

Series

the compounded total return (or the compounded active return)

Series

exactly.

Raises:

Type Description
CalculationError

If any period return is -100% or worse.

simple_performance_attribution

simple_performance_attribution(
    portfolio_returns: Series, benchmark_returns: Series
) -> dict[str, float]

Total return difference between a portfolio and a benchmark.

Deprecated, and to be removed in 0.11.0: use beacon.analysis.performance.formulas.compounded_return on each series, or a run's Attribution.active.

Parameters:

Name Type Description Default
portfolio_returns Series

Portfolio periodic returns.

required
benchmark_returns Series

Benchmark periodic returns, same length.

required

Each total return compounds its own periodic returns, prod(1 + r) - 1; the two series are not aligned by date.

Returns:

Name Type Description
dict dict[str, float]

total_portfolio_return, total_benchmark_return and

dict[str, float]

active_return (portfolio minus benchmark).

Raises:

Type Description
TypeError

If either input is not a Series.

ValueError

If the lengths differ or the inputs are empty.

drift_from_target

drift_from_target(
    current: dict[str, float], target: dict[str, float]
) -> DriftMetrics

Compare held weights against their targets.

Every asset appearing in either mapping is included, treating absence as a zero weight: a position that has been fully sold, or one the target wants but the portfolio does not hold, is precisely the drift worth seeing.

Parameters:

Name Type Description Default
current dict[str, float]

Held weights.

required
target dict[str, float]

Target weights.

required

Returns:

Name Type Description
DriftMetrics DriftMetrics

The comparison. Empty inputs give zeros and a None asset

DriftMetrics

rather than raising.

drift_history

drift_history(
    weight_history: dict[str, dict[str, float]],
    target: dict[str, float],
) -> dict[str, DriftMetrics]

Drift at each of several snapshots against one set of targets.

Parameters:

Name Type Description Default
weight_history dict[str, dict[str, float]]

Mapping of snapshot label (typically an ISO date) to the weights held at that point.

required
target dict[str, float]

The target weights to compare each snapshot against.

required

Returns:

Name Type Description
dict dict[str, DriftMetrics]

Snapshot label -> DriftMetrics, in the order the labels sort.

effective_number_of_assets

effective_number_of_assets(
    weights: dict[str, float],
) -> float

Number of equally weighted positions with the same concentration.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required

Returns:

Name Type Description
float float

1 / HHI. 0.0 when there are no positions, or when every

float

weight is zero: neither has a meaningful effective count, and

float

returning 0.0 keeps callers from having to guard against a division by

float

zero they cannot act on.

herfindahl_index

herfindahl_index(weights: dict[str, float]) -> float

Sum of squared weights.

Logs a warning when the weights do not sum to 1, since the measure then scales with the square of their sum.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight, expected to sum to 1.

required

Returns:

Name Type Description
float float

The index. 0.0 for no positions. Note that squaring makes this

float

blind to sign, so a short position concentrates the measure exactly as

float

a long one of the same size would.

top_n_weight

top_n_weight(
    weights: dict[str, float], count: int
) -> float

Combined weight of the count largest positions.

The measure a concentration limit is usually written against ("no more than 40% in the top five"), and not derivable from the Herfindahl index.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required
count int

How many of the largest positions to sum. Larger than the number of positions sums all of them.

required

Returns:

Name Type Description
float float

The combined weight.

Raises:

Type Description
CalculationError

If count is not positive.

calculate_premium_discount

calculate_premium_discount(
    etf_price: float, nav_price: float
) -> float

Calculates the premium/discount of an ETF's market price relative to its NAV.

Premium/Discount = (ETF Market Price / NAV) - 1.

Parameters:

Name Type Description Default
etf_price float

The current market price of the ETF.

required
nav_price float

The current Net Asset Value (NAV) per share of the ETF.

required

Returns:

Type Description
float

The premium/discount as a float (e.g., 0.01 for 1% premium, -0.005 for 0.5% discount).

Raises:

Type Description
TypeError

If either input is not an int or float.

ValueError

If nav_price is zero.

calculate_tracking_difference

calculate_tracking_difference(
    etf_returns: Series, index_returns: Series
) -> float

Calculates the tracking difference between ETF returns and index returns.

Tracking difference is the ETF's cumulative return minus the index's cumulative return over the whole period, where each cumulative return compounds its periodic returns: prod(1 + r) - 1. It is not annualised. Each Series is compounded on its own, so the two are not aligned by date; NaN returns are skipped.

Parameters:

Name Type Description Default
etf_returns Series

A pandas Series of ETF periodic returns.

required
index_returns Series

A pandas Series of benchmark index periodic returns. Must be of the same frequency and length as etf_returns.

required

Returns:

Type Description
float

The tracking difference over the period, as a float (e.g. -0.002

float

when the ETF returned 0.2 percentage points less than the index).

Raises:

Type Description
TypeError

If either input is not a pandas Series.

ValueError

If the input Series are not of the same length or are empty.

calculate_tracking_error

calculate_tracking_error(
    etf_returns: Series,
    index_returns: Series,
    periods_per_year: int = 252,
) -> float

Calculates the annualized tracking error between ETF returns and index returns.

Tracking error is the sample standard deviation of the periodic return differences (ETF minus index), multiplied by sqrt(periods_per_year). The difference is taken after aligning the two Series on their index, so they should share the same dates: a date present in only one Series produces NaN and is skipped, and fewer than two common dates gives NaN.

Parameters:

Name Type Description Default
etf_returns Series

A pandas Series of ETF periodic returns.

required
index_returns Series

A pandas Series of benchmark index periodic returns. Must be of the same frequency and length as etf_returns.

required
periods_per_year int

The number of periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized tracking error as a float.

Raises:

Type Description
TypeError

If either input is not a pandas Series.

ValueError

If the input Series are not of the same length or are empty, or periods_per_year is not positive.

average_daily_volume

average_daily_volume(
    market: DataFrame,
    as_of: Timestamp,
    months: int = TRAILING_MONTHS,
    column: str = VOLUME_COLUMN,
) -> pd.Series

Mean daily volume over the trailing window, per identifier.

Parameters:

Name Type Description Default
market DataFrame

Market data MultiIndexed by (IDENTIFIER, DATE), as DataFetcher.fetch_market_data returns for a list of identifiers.

required
as_of Timestamp

End of the window, inclusive.

required
months int

Length of the window in calendar months.

TRAILING_MONTHS
column str

Volume column to average.

VOLUME_COLUMN

Returns:

Type Description
Series

pd.Series: Indexed by identifier. Empty when the frame is empty,

Series

carries no volume column, or has no rows in the window (the last

Series

case logs a warning). An absent column is a property of the dataset,

Series

not a failure of the request, so it produces no answer rather than

Series

an error.

calculate_max_drawdown

calculate_max_drawdown(price_series: Series) -> float

Calculates the maximum drawdown from a price series.

Deprecated: use beacon.analysis.performance.formulas.max_drawdown, max_drawdown(prices).

The largest fall from a running peak, as a fraction of that peak.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required

Returns:

Type Description
float

The maximum drawdown as a non-positive float (e.g., -0.2 for a 20%

float

drawdown, 0.0 when the price never falls below a previous peak).

Raises:

Type Description
TypeError

If price_series is not a pandas Series.

ValueError

If price_series is empty or contains non-numeric data.

calculate_sharpe_ratio

calculate_sharpe_ratio(
    returns: Series,
    risk_free_rate: float,
    periods_per_year: int = 252,
) -> float

Calculates the annualized Sharpe Ratio.

Deprecated: use beacon.analysis.performance.formulas.sharpe_ratio, sharpe_ratio(returns, periods_per_year, risk_free_rate).

The mean return in excess of the risk-free rate over its sample standard deviation, multiplied by sqrt(periods_per_year): the Sharpe ratio a backtest reports, beacon.analysis.performance.formulas.sharpe_ratio. The annual risk-free rate is taken as the period rate that compounds to it. 0 when the returns do not vary.

Parameters:

Name Type Description Default
returns Series

A pandas Series of periodic returns.

required
risk_free_rate float

The annualized risk-free rate.

required
periods_per_year int

The number of return periods in a year (e.g., 252 for daily, 12 for monthly).

252

Returns:

Type Description
float

The annualized Sharpe Ratio as a float.

Raises:

Type Description
TypeError

If returns is not a pandas Series or risk_free_rate is not a number.

ValueError

If returns is empty or non-numeric, or periods_per_year is not positive.

calculate_volatility

calculate_volatility(
    price_series: Series, window: int = 252
) -> float

Calculates annualized volatility from a price series.

Deprecated: use beacon.analysis.performance.formulas.volatility on the returns, volatility(prices.pct_change().dropna(), periods_per_year).

The sample standard deviation of the simple period returns, multiplied by sqrt(window). Despite its name, window is the annualisation factor, not a rolling window: the whole series is used.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required
window int

The number of trading periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized volatility as a float.

Raises:

Type Description
TypeError

If price_series is not a pandas Series.

ValueError

If price_series is empty or contains non-numeric data, or window is not positive.

attribution

Performance attribution: which names, groups and decisions produced a return.

beacon.analysis.attribution.contribution decomposes one return into its names' contributions, linked over many periods (attribute), with the cap and cost drags beside it. Attribution does it for a run: by name or by group, for the fund or a comparator, and against the index or the benchmark.

AttributionResult dataclass

AttributionResult(
    start: str,
    end: str,
    periods: int,
    total_return: float,
    contributions: list[Contribution],
    residual: float,
    cap_drag: float | None = None,
    cost_drag: float | None = None,
    _weights: DataFrame | None = None,
)

A decomposition of one return into per-constituent contributions.

Attributes:

Name Type Description
start str

First date of the window, ISO 8601.

end str

Last date, ISO 8601.

periods int

Return periods decomposed.

total_return float

The return being explained: a compounded return, or a compounded return less a comparator's.

contributions list[Contribution]

Per constituent, largest first. Sums to total_return up to residual.

residual float

total_return minus the sum of contributions. Reported always, expected to be at machine epsilon after linking. It is never folded into a constituent.

cap_drag float | None

Capped return minus uncapped return, when the index applies a cap. Negative when capping cost the index. None when uncapped.

cost_drag float | None

Portfolio return minus its gross return, when one was passed to attribute (see cost_drag). Negative, since costs only subtract. None otherwise.

explained property
explained: float

Sum of the contributions.

reconciles
reconciles(tolerance: float = 1e-09) -> bool

Whether the contributions account for the total return.

to_frame
to_frame() -> pd.DataFrame

Contributions as a DataFrame, largest first.

Contribution dataclass

Contribution(
    asset_id: str,
    contribution: float,
    average_weight: float,
    total_return: float,
)

One constituent's share of the total return.

Attributes:

Name Type Description
asset_id str

The constituent.

contribution float

Its linked contribution. These sum to the total return.

average_weight float

Mean weight across the window, for context: a large contribution from a small average weight is a different story from the same contribution from a large one.

total_return float

The constituent's own return over the window.

attribute

attribute(
    period_returns: Series,
    weights: DataFrame,
    asset_returns: DataFrame,
    cap_drag: float | None = None,
    cost_drag: float | None = None,
    comparator: Series | None = None,
) -> AttributionResult

Decompose a return series into per-constituent contributions.

Parameters:

Name Type Description Default
period_returns Series

The return being explained, per period.

required
weights DataFrame

Weights per period, constituents on the columns. Aligned to period_returns; the weight used for a period is the one held at its start.

required
asset_returns DataFrame

Constituent returns per period.

required
cap_drag float | None

Optional capped-minus-uncapped return.

None
cost_drag float | None

Optional cost effect on the portfolio return.

None
comparator Series | None

A comparator's return per period. When given, weights are active weights (the portfolio's less the comparator's), and the return explained is the compounded period_returns less the compounded comparator's.

None

Returns:

Name Type Description
AttributionResult AttributionResult

The decomposition, with contributions summing to the

AttributionResult

compounded total return (or active return) and a residual reported

AttributionResult

separately.

Raises:

Type Description
CalculationError

If the inputs cannot be aligned, or a period wipes out the index.

cap_drag

cap_drag(
    capped_weights: dict[Timestamp, dict[str, float]],
    uncapped_weights: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
    window: Index | None = None,
) -> float

What capping cost, or gained, over the window.

The capped index's return minus the return of the same methodology left uncapped. Negative when the cap held back a name that went on to outperform, which is the usual case and the reason the number is worth reporting.

Both paths are built by drifting their own snapshots forward, so the comparison isolates the effect of the cap rather than of any other difference.

Parameters:

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

Rebalance date -> capped weights.

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

Rebalance date -> weights before capping.

required
prices DataFrame

Constituent prices, from the first rebalance on. Weights drift from the rebalance before each date, so the prices have to reach back to it even when window starts later.

required
window Index | None

The dates to measure over, as for :func:attribute: the return of each date after the first. None measures over every date in prices.

None

Returns:

Name Type Description
float float

Capped total return minus uncapped total return.

carino_factor

carino_factor(period_return: float) -> float

The Carino coefficient for one period.

ln(1+R)/R, with the removable singularity at R = 0 filled in with its limit of 1.

Parameters:

Name Type Description Default
period_return float

The period's total return.

required

Returns:

Name Type Description
float float

The coefficient.

Raises:

Type Description
CalculationError

If the return is -100% or worse, where the logarithm is undefined. An index that goes to zero cannot have its return attributed, and silently substituting a number would hide that.

cost_drag

cost_drag(
    total_costs: float, initial_capital: float
) -> float

Direct effect of transaction costs on a portfolio's return.

Costs paid as a fraction of starting capital, negated so it reads as a drag. This is the direct effect only: it excludes the compounding of the capital that was spent rather than invested, which is second-order but not zero over a long window. Reporting the direct figure keeps the number explainable (it is exactly the money that left the portfolio), and a caller wanting the full effect can difference a zero-cost run instead.

Parameters:

Name Type Description Default
total_costs float

Sum of transaction costs paid.

required
initial_capital float

Capital the portfolio started with.

required

Returns:

Name Type Description
float float

A non-positive drag.

Raises:

Type Description
CalculationError

If initial_capital is not positive.

drifted_weights

drifted_weights(
    snapshots: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
) -> pd.DataFrame

Reconstruct the index's daily weights from its rebalance snapshots.

Weights are set at a rebalance and then drift with relative performance until the next one, because the index holds fixed units in between. Given the weight at a rebalance and prices since, the drifted weight is

w_i,t ∝ w_i,rebalance × (p_i,t / p_i,rebalance)

normalised across constituents. The unit scale cancels, so nothing beyond the snapshot and prices is needed.

Parameters:

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

Rebalance date -> weights on that date.

required
prices DataFrame

Dates on the index, constituents on the columns.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Weights for every date at or after the first rebalance,

DataFrame

each row summing to 1.

Raises:

Type Description
CalculationError

If there are no snapshots to start from.

link_contributions(
    contributions: DataFrame,
    period_returns: Series,
    comparator: Series | None = None,
) -> pd.Series

Scale per-period contributions so they sum to the compounded return.

Parameters:

Name Type Description Default
contributions DataFrame

Periods on the index, constituents on the columns. Each row must sum to that period's return (or, with comparator, to that period's return less the comparator's).

required
period_returns Series

The total return of each period.

required
comparator Series | None

A comparator's return each period. When given, the contributions are linked by the active factor and sum to the compounded return less the comparator's.

None

Returns:

Type Description
Series

pd.Series: One linked contribution per constituent. Their sum equals

Series

the compounded total return (or the compounded active return)

Series

exactly.

Raises:

Type Description
CalculationError

If any period return is -100% or worse.

simple_performance_attribution

simple_performance_attribution(
    portfolio_returns: Series, benchmark_returns: Series
) -> dict[str, float]

Total return difference between a portfolio and a benchmark.

Deprecated, and to be removed in 0.11.0: use beacon.analysis.performance.formulas.compounded_return on each series, or a run's Attribution.active.

Parameters:

Name Type Description Default
portfolio_returns Series

Portfolio periodic returns.

required
benchmark_returns Series

Benchmark periodic returns, same length.

required

Each total return compounds its own periodic returns, prod(1 + r) - 1; the two series are not aligned by date.

Returns:

Name Type Description
dict dict[str, float]

total_portfolio_return, total_benchmark_return and

dict[str, float]

active_return (portfolio minus benchmark).

Raises:

Type Description
TypeError

If either input is not a Series.

ValueError

If the lengths differ or the inputs are empty.

analytics

A run's attribution: what each name, or each group, contributed to a book's return, or to the fund's return over a comparator.

Each day a name contributes its weight at the close before times its return that day (the run's own daily total return for it, in the book's currency), and the days are linked over the window by Carino's factors so the contributions add up to the compounded return (see beacon.analysis.attribution.contribution). A group contributes what its names did, each in the group it was in at that close.

Active attribution takes the fund's weights less the comparator's, both as they drift, against the fund's daily return less the comparator's, linked by the active Carino factor so the contributions add up to the fund's compounded return less the comparator's, the same total Brinson-Fachler, currency, factor and implementation attribution explain. What the names do not explain (cash, costs, fees, futures, the timing of dividends) is the residual, reported with every result and never folded into a name.

Attribution
Attribution(run: RunData)

What produced a run's returns, by name or group.

Parameters:

Name Type Description Default
run RunData

The run's data, with its weights, name returns and reference.

required
contribution
contribution(
    book: str = FUND,
    field: str | None = None,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> AttributionResult

Each name's (or, with field, each group's) contribution to a book's return (fund, index, optimised or benchmark) over the window, the whole run by default.

active
active(
    against: str = INDEX,
    field: str | None = None,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> AttributionResult

Each name's (or group's) contribution to the fund's return over against, from its active weight, over the window: the contributions add up to the fund's compounded return less against's.

brinson
brinson(
    field: str,
    against: str = INDEX,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> BrinsonResult

The fund's Brinson-Fachler attribution against against by the grouping field field, over the window, the whole run by default: each group's allocation, selection and interaction, cash a group of its own, linked over the days.

brinson_by_period
brinson_by_period(
    field: str, against: str = INDEX, frequency: str = "Y"
) -> pd.DataFrame

The Brinson-Fachler effects over each year (Y) or month (M).

currency
currency(
    against: str | None = None,
    by_currency: bool = False,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> EffectsResult

The fund's return (or, against a comparator, its active return) split into its names' local returns, their currencies' moves and the cross term; with by_currency, each by the names' currencies too.

factors
factors(
    analytics: FactorAnalytics,
    against: str | None = None,
    method: str = factor_model.HOLDINGS,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> EffectsResult

The fund's return (or its active return against a comparator) explained by the run's factors: from its holdings' exposures (holdings: market, each factor, specific) or from its returns' betas (returns: risk-free, alpha, each factor, residual).

Raises:

Type Description
ValueError

If the method is neither.

implementation
implementation(
    against: str = INDEX,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> EffectsResult

The fund's tracking difference against against split into the cap and screens, drift, cash drag, equitisation, each cost, fees, withholding tax, creation fees and dividend treatment, from the run's own records, linked over the window; the rest is the residual.

weights
weights(book: str = FUND) -> pd.DataFrame

A book's daily weights, by name: fund, index, optimised or benchmark.

Raises:

Type Description
KeyError

If the book is none of those, or the run kept no weights for it.

brinson

Brinson-Fachler attribution: how much of the fund's return over a comparator came from how it weighted each group (allocation), from what it held within each (selection), and from the two together (interaction).

Each day, with w and b the fund's and the comparator's weights at the close before, R and R^b their returns, by group g and in all:

allocation_g  = (w_g - b_g) (R^b_g - R^b)
selection_g   = b_g (R_g - R^b_g)
interaction_g = (w_g - b_g) (R_g - R^b_g)

They add up, over the groups, to the holdings' active return that day. A group the fund does not hold takes the comparator's group return as its own, so it has no selection or interaction; one the comparator does not hold takes the comparator's whole return, so its bet is all interaction. Cash is a group of its own: the fund's weight in it is one less its names', and its return the interest it earned over the cash it held.

The days are linked over the window by Carino's factors for an active return, (ln(1 + R_t) - ln(1 + R^b_t)) / (R_t - R^b_t) over the same for the window, so the effects add up to the holdings' compounded active return. What the holdings do not explain of the books' own returns (costs, fees, dividends not yet paid, a price index's missing dividends) is the residual.

BrinsonResult dataclass
BrinsonResult(
    start: str,
    end: str,
    periods: int,
    fund_return: float,
    comparator_return: float,
    active_return: float,
    explained: float,
    residual: float,
    groups: DataFrame,
    names: DataFrame = pd.DataFrame(),
)

The fund's return over a comparator, by group and effect.

Attributes:

Name Type Description
start str

The first day attributed.

end str

The last.

periods int

The days.

fund_return float

The fund's own compounded return.

comparator_return float

The comparator's.

active_return float

The fund's less the comparator's.

explained float

The holdings' active return the effects add up to.

residual float

The active return less what the effects explain.

groups DataFrame

By group: the mean weights and compounded returns of the fund's and the comparator's holdings, and each effect, linked.

names DataFrame

By group and name: each name's mean weights in the two books and its share of its group's selection and interaction, linked; a group's names add up to its own.

totals property
totals: Series

Each effect over all the groups.

reconciles
reconciles(tolerance: float = 1e-09) -> bool

Whether the effects account for the active return.

daily_effects
daily_effects(
    fund: DataFrame,
    comparator: DataFrame,
    fund_earned: DataFrame,
    comparator_earned: DataFrame,
) -> dict[str, pd.DataFrame]

Each day's effects by group, from each book's group weights at the close before and what each group earned (weight times return).

carino_factors
carino_factors(
    fund: Series, comparator: Series
) -> pd.Series

Each day's Carino factor over the window's, which link the days' effects so they add up to the compounded active return.

linked
linked(
    effects: dict[str, DataFrame],
    fund: Series,
    comparator: Series,
) -> dict[str, pd.Series]

Each effect by group over the days, scaled by Carino's factors so they add up to the compounded active return of fund over comparator.

brinson
brinson(
    run: RunData,
    weights: DataFrame,
    comparator_weights: DataFrame,
    comparator_levels: Series,
    name_returns: DataFrame,
    field: str,
    days: DatetimeIndex,
) -> BrinsonResult

The fund's Brinson-Fachler attribution against a comparator, by field, over days after the first, whose close the first day's weights are held at.

by_period
by_period(
    days: DatetimeIndex,
    compute: Callable[[DatetimeIndex], BrinsonResult],
    frequency: str = "Y",
) -> pd.DataFrame

The effects over each year (Y) or month (M), each period's days linked within it, from the close before its first: a row a period, the effects and the residual.

brinson_names

Brinson-Fachler selection and interaction split across each group's names, so a group's row opens into the names that make it up.

Each day, with W_g and B_g the fund's and the comparator's weights in group g at the close before, w_i and b_i a name's, r_i its return and R^b_g the comparator's group return:

d_i           = (w_i / W_g - b_i / B_g) (r_i - R^b_g)
selection_i   = B_g d_i
interaction_i = (W_g - B_g) d_i

The d_i of a group add up to R_g - R^b_g, so its names' selections and interactions add up to its own. A name the comparator holds and the fund does not has a negative share, so it shows what not holding it cost. A group the fund does not hold takes the comparator's return, so its names have none; in one the comparator does not hold, a name's share is its share of the fund's, measured against the comparator's whole return, as the group's is. The days are linked with the group effects' Carino factors. A name that changed group has a row in each.

name_effects
name_effects(
    fund_lagged: DataFrame,
    comparator_lagged: DataFrame,
    returns: DataFrame,
    groups: dict[Timestamp, Series],
    fund_held: DataFrame,
    comparator_held: DataFrame,
    comparator_earned: DataFrame,
    factors: Series,
) -> pd.DataFrame

Each name's linked selection and interaction within each group it was in, and its mean weights in each book, over the days of factors (the days' Carino factors over the window's).

Parameters:

Name Type Description Default
fund_lagged DataFrame

The fund's weights at the close before each day.

required
comparator_lagged DataFrame

The comparator's.

required
returns DataFrame

Each name's return each day.

required
groups dict[Timestamp, Series]

Each rebalance's group of every name.

required
fund_held DataFrame

The fund's weight in each group each day.

required
comparator_held DataFrame

The comparator's.

required
comparator_earned DataFrame

What each group earned the comparator each day.

required
factors Series

The linking factor of each day attributed.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by group and name, with fund_weight,

DataFrame

comparator_weight, selection, interaction and total.

contribution

Performance attribution: which constituents produced an index's return.

The identity, and why it needs care

Within a single period the decomposition is exact:

R_t = Σ_i w_{i,t-1} × r_{i,t}

where w are the index's own drifting weights. For a Beacon index this holds to better than 1e-12 for every weighting scheme, on rebalance days as well as ordinary ones.

Over multiple periods it does not carry over. Returns compound while contributions add, so the arithmetic sum of daily contributions falls short of the compounded total: by about one percentage point over a 130-day window in a modest example, which is far too large to write off as a residual.

The fix is Carino linking. Scale each period's contributions by k_t / K where k_t = ln(1+R_t)/R_t and K = ln(1+R)/R for the total return R. Then

Σ_i Σ_t (k_t/K) c_{i,t} = (1/K) Σ_t ln(1+R_t) = ln(1+R)/K = R

exactly. The residual is reported regardless, and should sit at machine epsilon; a residual that is not tiny means an assumption has broken.

A return over a comparator links the same way with the active factor k_t = (ln(1+R_t) - ln(1+B_t)) / (R_t - B_t), and K the same for the window, so the contributions add up to R - B: the fund's compounded return less the comparator's, as Brinson-Fachler explains it (beacon.analysis.attribution.brinson).

What the drags are, and what they are not

Cap drag and cost drag are comparisons, not terms in the identity above. Each is the difference between two returns (the index as built versus a counterfactual), so adding them to a decomposition of a single return would be mixing two different questions. They are reported alongside, each named for the counterfactual it implies.

Contribution dataclass
Contribution(
    asset_id: str,
    contribution: float,
    average_weight: float,
    total_return: float,
)

One constituent's share of the total return.

Attributes:

Name Type Description
asset_id str

The constituent.

contribution float

Its linked contribution. These sum to the total return.

average_weight float

Mean weight across the window, for context: a large contribution from a small average weight is a different story from the same contribution from a large one.

total_return float

The constituent's own return over the window.

AttributionResult dataclass
AttributionResult(
    start: str,
    end: str,
    periods: int,
    total_return: float,
    contributions: list[Contribution],
    residual: float,
    cap_drag: float | None = None,
    cost_drag: float | None = None,
    _weights: DataFrame | None = None,
)

A decomposition of one return into per-constituent contributions.

Attributes:

Name Type Description
start str

First date of the window, ISO 8601.

end str

Last date, ISO 8601.

periods int

Return periods decomposed.

total_return float

The return being explained: a compounded return, or a compounded return less a comparator's.

contributions list[Contribution]

Per constituent, largest first. Sums to total_return up to residual.

residual float

total_return minus the sum of contributions. Reported always, expected to be at machine epsilon after linking. It is never folded into a constituent.

cap_drag float | None

Capped return minus uncapped return, when the index applies a cap. Negative when capping cost the index. None when uncapped.

cost_drag float | None

Portfolio return minus its gross return, when one was passed to attribute (see cost_drag). Negative, since costs only subtract. None otherwise.

explained property
explained: float

Sum of the contributions.

reconciles
reconciles(tolerance: float = 1e-09) -> bool

Whether the contributions account for the total return.

to_frame
to_frame() -> pd.DataFrame

Contributions as a DataFrame, largest first.

carino_factor
carino_factor(period_return: float) -> float

The Carino coefficient for one period.

ln(1+R)/R, with the removable singularity at R = 0 filled in with its limit of 1.

Parameters:

Name Type Description Default
period_return float

The period's total return.

required

Returns:

Name Type Description
float float

The coefficient.

Raises:

Type Description
CalculationError

If the return is -100% or worse, where the logarithm is undefined. An index that goes to zero cannot have its return attributed, and silently substituting a number would hide that.

active_carino
active_carino(fund: float, comparator: float) -> float

The linking factor for an active return: (ln(1 + R) - ln(1 + R^b)) / (R - R^b), 1 / (1 + R) where the two are equal.

link_contributions(
    contributions: DataFrame,
    period_returns: Series,
    comparator: Series | None = None,
) -> pd.Series

Scale per-period contributions so they sum to the compounded return.

Parameters:

Name Type Description Default
contributions DataFrame

Periods on the index, constituents on the columns. Each row must sum to that period's return (or, with comparator, to that period's return less the comparator's).

required
period_returns Series

The total return of each period.

required
comparator Series | None

A comparator's return each period. When given, the contributions are linked by the active factor and sum to the compounded return less the comparator's.

None

Returns:

Type Description
Series

pd.Series: One linked contribution per constituent. Their sum equals

Series

the compounded total return (or the compounded active return)

Series

exactly.

Raises:

Type Description
CalculationError

If any period return is -100% or worse.

drifted_weights
drifted_weights(
    snapshots: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
) -> pd.DataFrame

Reconstruct the index's daily weights from its rebalance snapshots.

Weights are set at a rebalance and then drift with relative performance until the next one, because the index holds fixed units in between. Given the weight at a rebalance and prices since, the drifted weight is

w_i,t ∝ w_i,rebalance × (p_i,t / p_i,rebalance)

normalised across constituents. The unit scale cancels, so nothing beyond the snapshot and prices is needed.

Parameters:

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

Rebalance date -> weights on that date.

required
prices DataFrame

Dates on the index, constituents on the columns.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Weights for every date at or after the first rebalance,

DataFrame

each row summing to 1.

Raises:

Type Description
CalculationError

If there are no snapshots to start from.

attribute
attribute(
    period_returns: Series,
    weights: DataFrame,
    asset_returns: DataFrame,
    cap_drag: float | None = None,
    cost_drag: float | None = None,
    comparator: Series | None = None,
) -> AttributionResult

Decompose a return series into per-constituent contributions.

Parameters:

Name Type Description Default
period_returns Series

The return being explained, per period.

required
weights DataFrame

Weights per period, constituents on the columns. Aligned to period_returns; the weight used for a period is the one held at its start.

required
asset_returns DataFrame

Constituent returns per period.

required
cap_drag float | None

Optional capped-minus-uncapped return.

None
cost_drag float | None

Optional cost effect on the portfolio return.

None
comparator Series | None

A comparator's return per period. When given, weights are active weights (the portfolio's less the comparator's), and the return explained is the compounded period_returns less the compounded comparator's.

None

Returns:

Name Type Description
AttributionResult AttributionResult

The decomposition, with contributions summing to the

AttributionResult

compounded total return (or active return) and a residual reported

AttributionResult

separately.

Raises:

Type Description
CalculationError

If the inputs cannot be aligned, or a period wipes out the index.

cost_drag
cost_drag(
    total_costs: float, initial_capital: float
) -> float

Direct effect of transaction costs on a portfolio's return.

Costs paid as a fraction of starting capital, negated so it reads as a drag. This is the direct effect only: it excludes the compounding of the capital that was spent rather than invested, which is second-order but not zero over a long window. Reporting the direct figure keeps the number explainable (it is exactly the money that left the portfolio), and a caller wanting the full effect can difference a zero-cost run instead.

Parameters:

Name Type Description Default
total_costs float

Sum of transaction costs paid.

required
initial_capital float

Capital the portfolio started with.

required

Returns:

Name Type Description
float float

A non-positive drag.

Raises:

Type Description
CalculationError

If initial_capital is not positive.

cap_drag
cap_drag(
    capped_weights: dict[Timestamp, dict[str, float]],
    uncapped_weights: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
    window: Index | None = None,
) -> float

What capping cost, or gained, over the window.

The capped index's return minus the return of the same methodology left uncapped. Negative when the cap held back a name that went on to outperform, which is the usual case and the reason the number is worth reporting.

Both paths are built by drifting their own snapshots forward, so the comparison isolates the effect of the cap rather than of any other difference.

Parameters:

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

Rebalance date -> capped weights.

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

Rebalance date -> weights before capping.

required
prices DataFrame

Constituent prices, from the first rebalance on. Weights drift from the rebalance before each date, so the prices have to reach back to it even when window starts later.

required
window Index | None

The dates to measure over, as for :func:attribute: the return of each date after the first. None measures over every date in prices.

None

Returns:

Name Type Description
float float

Capped total return minus uncapped total return.

simple_performance_attribution
simple_performance_attribution(
    portfolio_returns: Series, benchmark_returns: Series
) -> dict[str, float]

Total return difference between a portfolio and a benchmark.

Deprecated, and to be removed in 0.11.0: use beacon.analysis.performance.formulas.compounded_return on each series, or a run's Attribution.active.

Parameters:

Name Type Description Default
portfolio_returns Series

Portfolio periodic returns.

required
benchmark_returns Series

Benchmark periodic returns, same length.

required

Each total return compounds its own periodic returns, prod(1 + r) - 1; the two series are not aligned by date.

Returns:

Name Type Description
dict dict[str, float]

total_portfolio_return, total_benchmark_return and

dict[str, float]

active_return (portfolio minus benchmark).

Raises:

Type Description
TypeError

If either input is not a Series.

ValueError

If the lengths differ or the inputs are empty.

currency

Currency attribution: how much of a book's return came from its names' own markets and how much from the exchange rates into the book's currency.

A name's daily return in the book's currency r is its return in its own l and the move of its currency x: 1 + r = (1 + l)(1 + x), so

r = l + x + l x

and each name contributes its weight at the close before times each part: the local return, the currency effect, and the cross term on a line of its own. Local returns are worked out from the run's own closes and corporate actions in each name's currency, as the book's are.

local_returns
local_returns(run: RunData) -> pd.DataFrame

Each name's daily total return in its own currency, on the days of the run's name returns.

currency_parts
currency_parts(
    lagged: DataFrame, book: DataFrame, local: DataFrame
) -> dict[str, pd.DataFrame]

Each name's daily contribution from each part: its weight at the close before times its local return, its currency effect and the cross term.

by_currency
by_currency(
    parts: dict[str, DataFrame], currencies: Series
) -> dict[str, pd.DataFrame]

Each part's daily contributions added up by the names' currencies.

effects

A return split into named effects each day (local, currency and cross term; market, factors and specific), linked over the days so the effects add up to the compounded return, with what they leave unexplained as the residual.

A book's own return links by Carino's factor ln(1 + R) / R; a return over a comparator by the active factor (ln(1 + R) - ln(1 + R^b)) / (R - R^b), as Brinson-Fachler links (beacon.analysis.attribution.brinson).

EffectsResult dataclass
EffectsResult(
    start: str,
    end: str,
    periods: int,
    total_return: float,
    effects: Series,
    explained: float,
    residual: float,
    table: DataFrame,
)

A return by effect, linked over the days.

Attributes:

Name Type Description
start str

The first day attributed.

end str

The last.

periods int

The days.

total_return float

The return explained: a book's compounded return, or the fund's less a comparator's.

effects Series

Each effect, linked, largest first.

explained float

What the effects add up to.

residual float

The return less what the effects explain.

table DataFrame

By group where one was asked for (each currency, say), each effect linked; else empty.

reconciles
reconciles(tolerance: float = 1e-09) -> bool

Whether the effects account for the return.

linked_effects
linked_effects(
    daily: DataFrame,
    ours: Series,
    theirs: Series | None = None,
    table: dict[str, DataFrame] | None = None,
) -> EffectsResult

Each of daily's effects (days by effect) linked over its days, as a share of ours' compounded return or, with theirs, of the return over it; table's groups (effect -> days by group) linked the same way.

groups

Which group each name was in on each day, and a table's columns added up by it, for attribution by group.

A name's weight at a close earns the next day's return, so a day's contributions belong to the groups the names were in at the close before: the groups of the last rebalance before the day. Memberships change only at rebalances, so the days sharing one are grouped at once.

memberships
memberships(
    run: RunData, field: str
) -> dict[pd.Timestamp, pd.Series]

Each rebalance's group of every name by field, by date.

Raises:

Type Description
KeyError

If the run kept no such field.

by_group
by_group(
    table: DataFrame, groups: dict[Timestamp, Series]
) -> pd.DataFrame

table's columns (names) added up by group, each day's by the groups of the last rebalance before it, or of the first for a day at or before it; Unknown for names with none.

implementation

The implementation breakdown: where a fund's tracking difference came from, day by day from the run's own records, linked over the run.

Each day, with V the base the fund's return per unit is measured on (the NAV at the close before, grown by any units dealt that day: today's units at yesterday's NAV per unit), w and b the fund's and the index's weights in the names at the close before (the fund's scaled onto V, so money dealt that day is held as cash), c = 1 - sum w the rest of the fund (its cash) and r the names' total returns:

  • cap and screens: sum g^R r, where g^R = w^R - (1 - c^R) b^R is the gap between the fund's invested weights and the index's at the last rebalance: what the cap, the screens, the smallest position and rounding left the fund holding differently;
  • drift: sum (g - g^R) r, how that gap has moved since, as flows, dividends and the cash band traded the fund and not the index;
  • cash drag: -c sum b r plus the interest earned over V: the return the cash gave up against the index, less what it earned;
  • equitisation: the futures' profit and loss before their costs, over V, which wins back the cash drag;
  • fixed costs, market impact, futures rolls and futures trading: each day's cost over V;
  • fees: the management fee charged over V;
  • withholding tax: the tax withheld from dividends over V;
  • creation fees: what dealing investors paid into the fund over V;
  • dividend treatment: sum b r less the index's own return: the dividends a price index leaves out, or a total return index's own timing.

They add up to the fund's return from its names, cash and futures, less its costs and fees, less the index's; what else moved the NAV (a currency hedge, trades dealt away from the close) is the residual.

daily_parts
daily_parts(
    run: RunData,
    fund: DataFrame,
    index: DataFrame,
    name_returns: DataFrame,
    index_returns: Series,
    days: DatetimeIndex,
) -> pd.DataFrame

Each day's parts of the tracking difference, by day and part: from the run's first day, whose close before is its starting capital all in cash, or else from the second of days.

model

Factor attribution: how much of a book's return its factor exposures explain, from the run's factors (beacon.analysis.risk.factors).

Holdings-based. Each day every name's return is the market's, plus its exposures at the last rebalance before the day times the factor returns, plus what is its own:

r_i = f_market + sum_k z_ik f_k + e_i

with e_i defined as what is left, so the split is exact. A book, holding w at the close before, earns (sum w) f_market from the market, X_k f_k from each factor (X_k = sum w_i z_ik) and sum w_i e_i of its own: the specific return.

Returns-based. The book's daily return over the risk-free rate, regressed on the factor returns over the window (alpha + sum beta_k f_k + e), splits into the risk-free return, the alpha, each beta times its factor's return, and the regression's residual.

On the active return the same holds on the active weights, or the active return's regression.

holdings_parts
holdings_parts(
    factors: FactorAnalytics,
    lagged: DataFrame,
    name_returns: DataFrame,
) -> pd.DataFrame

Each day's market, factor and specific contributions of a book held at lagged (its weights at the close before), by day and part.

Worked out a stretch between rebalances at a time, over which the exposures are fixed, as matrix products over its days.

returns_parts
returns_parts(
    target: Series,
    factor_returns: DataFrame,
    rate: float,
    periods_per_year: int,
) -> pd.DataFrame

Each day's risk-free, alpha, factor and residual parts of target (a book's daily return), from its regression on the factor returns.

daily_rate
daily_rate(
    risk_free_rate: float, periods_per_year: int
) -> float

The risk-free rate a day.

schedule

Contributions of an index's names over a window, from its weights at each rebalance and its names' prices, with what its cap and its costs took.

The weights drift from each rebalance with prices, so a rebalance before the window still sets the weights inside it: prices should cover the whole history, and the window is chosen afterwards from the drifted weights' days.

attribute_schedule
attribute_schedule(
    capped: dict[Timestamp, dict[str, float]],
    uncapped: dict[Timestamp, dict[str, float]],
    prices: DataFrame,
    start: str | None,
    end: str | None,
    total_costs: float = 0.0,
    initial_capital: float = 0.0,
) -> AttributionResult

Each name's contribution to the index's return between start and end.

Parameters:

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

The weights set at each rebalance, as applied.

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

The same before the cap; equal to capped when nothing was capped, and then no cap drag is given.

required
prices DataFrame

Every name's prices, names on the columns, in the index's currency.

required
start str | None

The window's first date; the first priced day when None.

required
end str | None

Its last; the last priced day when None.

required
total_costs float

Transaction costs paid; no cost drag is given at 0.

0.0
initial_capital float

What the costs are measured against.

0.0

Raises:

Type Description
DataNotFoundError

If the window has no days. A total return of zero over an empty window would read as a flat index rather than as a question that could not be answered.

class_panels

The tables a run with share classes keeps of each class: its days, its flows and its hedge's trades, a row each, with the class's name in CLASS.

Amounts are in the class's own currency, as its book records them, except BOOK_NET_ASSETS and the hedge's CARRY and COST, which are in the book's; a day's net assets in both give the rate between them.

class_tables

class_tables(
    result: BacktestResult,
) -> dict[str, pd.DataFrame]

The three tables, by name; none for a run without share classes.

concentration

Concentration and drift measures for a set of index or portfolio weights.

Two questions an index owner asks constantly: how concentrated is this thing, and how far has it wandered from its targets. Weights are passed as a mapping of asset id to weight.

ConcentrationMetrics dataclass

ConcentrationMetrics(
    assets: int,
    herfindahl_index: float,
    effective_assets: float,
    largest_weight: float,
    largest_asset_id: str | None,
)

How concentrated a set of weights is.

Attributes:

Name Type Description
assets int

Number of weighted positions.

herfindahl_index float

Sum of squared weights. For weights summing to 1 this runs from 1/n (perfectly equal) to 1 (everything in one name).

effective_assets float

1 / herfindahl_index: the number of equally weighted positions that would be this concentrated. Reads more naturally than the index itself: "this 100-stock index behaves like 23 equal positions".

largest_weight float

The biggest single weight.

largest_asset_id str | None

Which asset holds it, or None when there are no positions.

DriftMetrics dataclass

DriftMetrics(
    per_asset: dict[str, float],
    max_absolute: float,
    max_absolute_asset_id: str | None,
    total_absolute: float,
    turnover: float,
)

How far current weights have moved from their targets.

Attributes:

Name Type Description
per_asset dict[str, float]

Current minus target for every asset in either set. Positive means overweight.

max_absolute float

Largest absolute drift across assets.

max_absolute_asset_id str | None

Which asset drifted most, or None when there is nothing to compare. Drifts equal to within floating-point noise count as tied, and a tie goes to the asset id that sorts first.

total_absolute float

Sum of absolute drifts.

turnover float

Half of total_absolute: the one-way trading needed to return to target, since every overweight funds an underweight.

total_weight

total_weight(weights: dict[str, float]) -> float

The weights' sum.

herfindahl_index

herfindahl_index(weights: dict[str, float]) -> float

Sum of squared weights.

Logs a warning when the weights do not sum to 1, since the measure then scales with the square of their sum.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight, expected to sum to 1.

required

Returns:

Name Type Description
float float

The index. 0.0 for no positions. Note that squaring makes this

float

blind to sign, so a short position concentrates the measure exactly as

float

a long one of the same size would.

effective_number_of_assets

effective_number_of_assets(
    weights: dict[str, float],
) -> float

Number of equally weighted positions with the same concentration.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required

Returns:

Name Type Description
float float

1 / HHI. 0.0 when there are no positions, or when every

float

weight is zero: neither has a meaningful effective count, and

float

returning 0.0 keeps callers from having to guard against a division by

float

zero they cannot act on.

concentration

concentration(
    weights: dict[str, float],
) -> ConcentrationMetrics

Summarise how concentrated a set of weights is.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required

Returns:

Name Type Description
ConcentrationMetrics ConcentrationMetrics

The summary. An empty mapping yields zeros and a

ConcentrationMetrics

None largest asset rather than raising, since "no positions" is a

ConcentrationMetrics

legitimate state for an index between its inception and base date.

drift_from_target

drift_from_target(
    current: dict[str, float], target: dict[str, float]
) -> DriftMetrics

Compare held weights against their targets.

Every asset appearing in either mapping is included, treating absence as a zero weight: a position that has been fully sold, or one the target wants but the portfolio does not hold, is precisely the drift worth seeing.

Parameters:

Name Type Description Default
current dict[str, float]

Held weights.

required
target dict[str, float]

Target weights.

required

Returns:

Name Type Description
DriftMetrics DriftMetrics

The comparison. Empty inputs give zeros and a None asset

DriftMetrics

rather than raising.

drift_history

drift_history(
    weight_history: dict[str, dict[str, float]],
    target: dict[str, float],
) -> dict[str, DriftMetrics]

Drift at each of several snapshots against one set of targets.

Parameters:

Name Type Description Default
weight_history dict[str, dict[str, float]]

Mapping of snapshot label (typically an ISO date) to the weights held at that point.

required
target dict[str, float]

The target weights to compare each snapshot against.

required

Returns:

Name Type Description
dict dict[str, DriftMetrics]

Snapshot label -> DriftMetrics, in the order the labels sort.

top_n_weight

top_n_weight(
    weights: dict[str, float], count: int
) -> float

Combined weight of the count largest positions.

The measure a concentration limit is usually written against ("no more than 40% in the top five"), and not derivable from the Herfindahl index.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required
count int

How many of the largest positions to sum. Larger than the number of positions sums all of them.

required

Returns:

Name Type Description
float float

The combined weight.

Raises:

Type Description
CalculationError

If count is not positive.

data

Everything a run's analysis reads, from a finished run or a stored one.

data = RunData.from_result(result)
data.write(folder)
again = RunData.from_record(folder)

Every analysis of a run is built from a RunData, so it works the same on a run just finished and on one stored months ago: the stored one is read back exactly as it was written, whatever has happened to the data store since.

A run stored before its tables were kept loads with what it has, and missing says, for each table it lacks, why.

Written with a data store's shared folder (beacon.analysis.shared_inputs), a run keeps its market inputs there, once for every run on that store, and only the list of pieces it used itself; it reads them back from there, and says so when the store has since been deleted.

RunData dataclass

RunData(
    currency: str,
    returns: Series,
    nav: Series,
    units: Series,
    initial_capital: float,
    periods_per_year: int,
    risk_free_rate: float,
    comparators: Mapping[str, Series] = dict(),
    tables: Mapping[str, DataFrame] = dict(),
    missing: Mapping[str, str] = dict(),
    performance: Series | None = None,
    performance_start: float | None = None,
    investor_flows: Series | None = None,
    warnings: tuple[str, ...] = (),
)

A run, as its analysis reads it.

Attributes:

Name Type Description
currency str

The book's currency, which every amount is in.

returns Series

The fund's daily returns, as its metrics measure them: of a unit when the run had flows, with cash paid out added back.

nav Series

The fund's net assets at the end of each day, net of fees owed.

units Series

Units outstanding at the end of each day.

initial_capital float

What the fund started with.

periods_per_year int

How many daily returns make a year.

risk_free_rate float

The annual rate excess returns are measured over.

comparators Mapping[str, Series]

Each comparator's levels by name: index, optimised and benchmark, for those the run had.

tables Mapping[str, DataFrame]

The run's tables by name (beacon.analysis.panels), each built or read the first time it is asked for.

missing Mapping[str, str]

For each table or series a stored run lacks, why.

performance Series | None

The level the fund's total return and drawdown are measured along: its NAV, or, when it had flows or paid cash out, what it started with grown by its returns. None for a run stored before it was kept, whose NAV is used.

performance_start float | None

What performance starts from: the initial capital, or a unit's launch price.

investor_flows Series | None

The investors' cash flows, dated, from their side (money put in negative, the final NAV positive), for a run with flows; None for any other.

warnings tuple[str, ...]

What a stored run says as it opens: that the data store its market inputs came from was deleted, say.

from_result classmethod
from_result(result: BacktestResult) -> RunData

A finished run's data, with every table it keeps, built the first time any is asked for.

of_returns classmethod
of_returns(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
    comparators: Mapping[str, Series] | None = None,
    currency: str = "",
) -> RunData

A run known only by its daily returns (the first from its start) and its comparators' levels, for the figures that need nothing more: its headline figures, measured from a start of 1, its comparisons and its figures by period. It has no tables.

latest
latest(name: str, rows: int) -> pd.DataFrame

The last rows rows of table name. A stored run's daily weights are worked out for those days alone, unless already read whole, and are those rows of the whole table exactly.

write
write(
    folder: Path,
    shared: SharedInputs | None = None,
    store: Mapping[str, Any] | None = None,
) -> None

Keep this run in folder, replacing whatever it held: its tables and series as beacon.analysis.panels writes them, and the rest in run.json.

Parameters:

Name Type Description Default
folder Path

Where the run is kept.

required
shared SharedInputs | None

The shared folder of the data store the run read, which keeps its market inputs instead of folder; None to keep them in folder.

None
store Mapping[str, Any] | None

The data store the run read (id and name), kept with the run when shared is given.

None
from_record classmethod
from_record(
    folder: Path, record: Mapping[str, Any] | None = None
) -> RunData

A stored run, as it was written.

Parameters:

Name Type Description Default
folder Path

Where the run was written.

required
record Mapping[str, Any] | None

For a run stored before its data was kept this way, its JSON record (a backtest record's portfolio.nav and the comparators' levels), read for what the folder lacks.

None

Raises:

Type Description
ConfigurationError

If neither the folder nor record holds the run, or the folder was written by a newer version.

etf

Analytics for ETFs: tracking difference, tracking error and premium or discount to NAV.

analytics

Analytics specific to Exchange Traded Funds (ETFs): tracking difference, tracking error, and the premium or discount of market price to NAV.

Each is available as a plain function and as a method of ETFAnalytics.

ETFAnalytics
ETFAnalytics()

ETF analytics as methods. Each method calls the module-level function of the same name and returns its result unchanged.

Initializes the ETFAnalytics calculator.

calculate_tracking_difference
calculate_tracking_difference(
    etf_returns: Series, index_returns: Series
) -> float

Cumulative ETF return minus cumulative index return over the period.

See :func:calculate_tracking_difference.

calculate_tracking_error
calculate_tracking_error(
    etf_returns: Series,
    index_returns: Series,
    periods_per_year: int = 252,
) -> float

Annualised standard deviation of the ETF-minus-index return differences.

See :func:calculate_tracking_error.

calculate_premium_discount
calculate_premium_discount(
    etf_price: float, nav_price: float
) -> float

Market price divided by NAV, minus one.

See :func:calculate_premium_discount.

calculate_tracking_difference
calculate_tracking_difference(
    etf_returns: Series, index_returns: Series
) -> float

Calculates the tracking difference between ETF returns and index returns.

Tracking difference is the ETF's cumulative return minus the index's cumulative return over the whole period, where each cumulative return compounds its periodic returns: prod(1 + r) - 1. It is not annualised. Each Series is compounded on its own, so the two are not aligned by date; NaN returns are skipped.

Parameters:

Name Type Description Default
etf_returns Series

A pandas Series of ETF periodic returns.

required
index_returns Series

A pandas Series of benchmark index periodic returns. Must be of the same frequency and length as etf_returns.

required

Returns:

Type Description
float

The tracking difference over the period, as a float (e.g. -0.002

float

when the ETF returned 0.2 percentage points less than the index).

Raises:

Type Description
TypeError

If either input is not a pandas Series.

ValueError

If the input Series are not of the same length or are empty.

calculate_tracking_error
calculate_tracking_error(
    etf_returns: Series,
    index_returns: Series,
    periods_per_year: int = 252,
) -> float

Calculates the annualized tracking error between ETF returns and index returns.

Tracking error is the sample standard deviation of the periodic return differences (ETF minus index), multiplied by sqrt(periods_per_year). The difference is taken after aligning the two Series on their index, so they should share the same dates: a date present in only one Series produces NaN and is skipped, and fewer than two common dates gives NaN.

Parameters:

Name Type Description Default
etf_returns Series

A pandas Series of ETF periodic returns.

required
index_returns Series

A pandas Series of benchmark index periodic returns. Must be of the same frequency and length as etf_returns.

required
periods_per_year int

The number of periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized tracking error as a float.

Raises:

Type Description
TypeError

If either input is not a pandas Series.

ValueError

If the input Series are not of the same length or are empty, or periods_per_year is not positive.

calculate_premium_discount
calculate_premium_discount(
    etf_price: float, nav_price: float
) -> float

Calculates the premium/discount of an ETF's market price relative to its NAV.

Premium/Discount = (ETF Market Price / NAV) - 1.

Parameters:

Name Type Description Default
etf_price float

The current market price of the ETF.

required
nav_price float

The current Net Asset Value (NAV) per share of the ETF.

required

Returns:

Type Description
float

The premium/discount as a float (e.g., 0.01 for 1% premium, -0.005 for 0.5% discount).

Raises:

Type Description
TypeError

If either input is not an int or float.

ValueError

If nav_price is zero.

holding_replay

The fund's holdings worked out again from its trades.

A holding changes only when the fund trades the name or a split changes its share count, and the books add each trade to the holding before it, in order. So the fund's holdings on the days they changed (as beacon.analysis.holdings.holding_changes gives them) are worked out again by replaying the trades and the splits in the books' own arithmetic: a split multiplies the holding first, then each buy adds to it and each sell takes from it, a holding below 1e-9 ending.

What the replay does not give back to the last bit is kept: a patch of the recorded rows it gets wrong or misses, and the rows it gives that were not recorded (a split on a day nothing was recorded, say). A stored run keeps those and its trades rather than the holdings, and checks on writing that they come back exactly.

replayed

replayed(
    trades: DataFrame, actions: DataFrame
) -> pd.DataFrame

The fund's holdings on the days they changed, from its trades (a run's trades table) and the actions' splits, by day and then name.

pack_holdings

pack_holdings(
    changes: DataFrame,
    trades: DataFrame,
    actions: DataFrame,
) -> dict[str, pd.DataFrame] | None

What keeps changes given the trades and actions: the recorded rows the replay gets wrong or misses (patch) and the rows it gives that were not recorded (drops); None when they would not give changes back exactly.

unpack_holdings

unpack_holdings(
    trades: DataFrame,
    actions: DataFrame,
    kept: Mapping[str, DataFrame],
    dtypes: Mapping[str, object] | None = None,
) -> pd.DataFrame

The fund's holdings on the days they changed, from the replay with kept's rows dropped and patched, by day and then name.

holdings

Daily weights worked out again from what was held and the closes.

A book's daily weights repeat information its holdings and the prices already carry: a weight is a holding's value over the day's total, and a holding changes only when the book trades or the index rebalances. So a stored run keeps the holdings on the days they change, and the weights are worked out again from them and the closes, in the same arithmetic as the code that first recorded them.

Wherever a weight worked out this way is not the recorded one to the last bit, the recorded value is kept in a patch and put back, so the weights always come back exactly. The fund's weights need no patch; an index's, recorded at single precision from units that were themselves rounded to single precision, need one for about a quarter of their cells.

wide_of

wide_of(
    frame: DataFrame, index: str, columns: str, values: str
) -> pd.DataFrame

A long frame as dates (its index column) by name (its columns column) of values, each pair's value as it is, missing where a pair has none; what a pivot gives for pairs that occur once, without its cost.

holding_changes

holding_changes(amounts: DataFrame) -> pd.DataFrame

The days each name's holding changed, from amounts (dates by name, missing where not held).

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with NAME, AMOUNT (missing on

DataFrame

the day a holding ends) and HELD, a row for each name on each day

DataFrame

its holding began, changed or ended.

held_amounts

held_amounts(
    changes: DataFrame,
    dates: DatetimeIndex,
    names: list[str],
) -> pd.DataFrame

changes as amounts held on every one of dates, by name in names, missing where a name was not held; a holding that began before the first of dates is carried into them.

fund_weights_from

fund_weights_from(
    changes: DataFrame,
    totals: Series,
    prices: DataFrame,
    dates: DatetimeIndex,
    names: list[str],
) -> pd.DataFrame

The fund's daily weights: each holding's quantity times its price in the book's currency, over the day's total, as the books recorded them.

Parameters:

Name Type Description Default
changes DataFrame

The fund's quantities on the days they changed.

required
totals Series

The books' total value (holdings and cash) on each day.

required
prices DataFrame

Each name's close in the book's currency, carried over gaps.

required
dates DatetimeIndex

The days the books held anything.

required
names list[str]

The names, in the recorded weights' order.

required

index_weights_from

index_weights_from(
    changes: DataFrame,
    closes: DataFrame,
    rates: DataFrame,
    dates: DatetimeIndex,
    names: list[str],
    dtype: str,
) -> pd.DataFrame

An index's daily weights: each holding's units times its close times its rate, over the day's total, in the index's own order of operations and the precision it records them at, then in dtype.

Parameters:

Name Type Description Default
changes DataFrame

The index's units on the days they changed.

required
closes DataFrame

Each name's close in its own currency, carried over gaps.

required
rates DataFrame

Each name's rate into the book's currency, by date and name.

required
dates DatetimeIndex

The days the index held anything.

required
names list[str]

The names, in the recorded weights' order.

required
dtype str

The weights table's dtype.

required

rate_grid

rate_grid(
    currencies: DataFrame,
    rates: DataFrame,
    dates: DatetimeIndex,
    names: list[str],
) -> pd.DataFrame

Each name's rate into the book's currency on each of dates: its currency's kept rate, or 1 for a name in the book's own currency.

patch_of

patch_of(
    rebuilt: DataFrame, recorded: DataFrame
) -> pd.DataFrame

Every cell where rebuilt is not recorded to the last bit, with the recorded value: indexed by date, with NAME and WEIGHT.

patched

patched(
    rebuilt: DataFrame, patch: DataFrame
) -> pd.DataFrame

rebuilt with patch's recorded values put back.

liquidity

Liquidity measures computed from held market data.

Average daily volume (ADV) is the figure a universe table shows for every name.

The trailing window is calendar months, not a day count

"ADV 3M" means the last three calendar months, so the window is as_of - 3 months < date <= as_of. The two differ by several days across a quarter, and quoting a 63-trading-day average as "3M" makes two vendors disagree for no reason anybody can see. This matches the trailing-twelve-month dividend convention in beacon.data.corporate_actions, including the half-open boundary: a day exactly three months old has rolled out.

Missing data is NaN, not zero

A name whose volume is missing on every day in the window gets NaN rather than 0.0, and a name with no rows in the window is left out of the result. Zero is a claim (that it traded and nobody bought) and a liquidity screen reading it would exclude the name for the wrong reason. NaN says the question has no answer here, and serialises to null.

trailing_start

trailing_start(
    end: Timestamp, months: int = TRAILING_MONTHS
) -> pd.Timestamp

The first day of the trailing months ending on end.

average_daily_volume

average_daily_volume(
    market: DataFrame,
    as_of: Timestamp,
    months: int = TRAILING_MONTHS,
    column: str = VOLUME_COLUMN,
) -> pd.Series

Mean daily volume over the trailing window, per identifier.

Parameters:

Name Type Description Default
market DataFrame

Market data MultiIndexed by (IDENTIFIER, DATE), as DataFetcher.fetch_market_data returns for a list of identifiers.

required
as_of Timestamp

End of the window, inclusive.

required
months int

Length of the window in calendar months.

TRAILING_MONTHS
column str

Volume column to average.

VOLUME_COLUMN

Returns:

Type Description
Series

pd.Series: Indexed by identifier. Empty when the frame is empty,

Series

carries no volume column, or has no rows in the window (the last

Series

case logs a warning). An absent column is a property of the dataset,

Series

not a failure of the request, so it produces no answer rather than

Series

an error.

packing

A run's market inputs, kept small, and the data source they stand in for.

A run keeps the closes it read, in each name's own currency, rather than the tables worked out from them: a close is quoted to a few decimals, so it packs into a small whole number, where a return or a weight is a full-precision float that barely compresses. The tables are worked out again from these when first asked for, by the same code a finished run used, on the same numbers, so they come back exactly.

  • Closes are kept per name as whole numbers at the fewest decimals that hold every one of that name's closes exactly (whole cents for most), with the scale beside them. A name whose closes need more than six decimals is kept as it is.
  • StoredSource serves the kept closes' currencies, exchange rates and corporate actions to the code that reads them from a data store.

StoredSource

StoredSource(
    currencies: DataFrame,
    rates: DataFrame,
    actions: DataFrame,
    book: str,
)

The parts of a data store a run's kept market inputs stand in for: each name's currency, the exchange rates on the kept days and the corporate actions, for the code that reads them from a DataFetcher.

Parameters:

Name Type Description Default
currencies DataFrame

Each name's currency, indexed by name, in a CURRENCY column.

required
rates DataFrame

Each currency's rate into the book's currency on the kept days, a column per currency.

required
actions DataFrame

The kept names' corporate actions.

required
book str

The book's currency.

required
fetch_market_data
fetch_market_data(
    identifier: str | list[str],
    start_date: str | None = None,
    end_date: str | None = None,
    columns: list[str] | None = None,
) -> pd.DataFrame

No market data: the kept closes are handed to the calculation directly, so this is never what it reads.

fetch_reference_data
fetch_reference_data(
    identifier: str | list[str],
    date: str | None = None,
    columns: list[str] | None = None,
) -> pd.DataFrame

Each kept name's currency, as reference data gives it.

fx_rates_on
fx_rates_on(
    from_currency: str, to_currency: str, days: Index
) -> pd.Series | None

The kept rate from from_currency into the book's currency on days, or None when none was kept.

decimals_of

decimals_of(values: ndarray) -> int | None

The fewest decimals that hold every one of values exactly, or None when more than MAX_DECIMALS would be needed.

pack_closes

pack_closes(closes: DataFrame) -> dict[str, pd.DataFrame]

closes (dates by name) as the tables that keep them.

Returns:

Name Type Description
dict dict[str, DataFrame]

closes_packed, each packable name as whole numbers at its

dict[str, DataFrame]

scale; closes_scale, each of those names' decimals; and

dict[str, DataFrame]

closes_float, the names that are not packable, as they are.

unpack_closes

unpack_closes(
    tables: Mapping[str, DataFrame],
    order: list[str] | None = None,
) -> pd.DataFrame

The closes pack_closes kept, exactly as they were, names in order (or packed names first).

market_inputs

market_inputs(
    source: Any, closes: DataFrame, currency: str
) -> dict[str, pd.DataFrame]

What a run keeps beside its closes so its returns can be worked out again: each name's currency, the rates the conversion read, and the names' corporate actions.

Parameters:

Name Type Description Default
source Any

The data the run used.

required
closes DataFrame

The closes kept, dates by name.

required
currency str

The book's currency.

required

panels

The large tables a run's analysis reads, kept as Parquet files in a folder.

A run's record is a JSON document: its summary and its small series. The tables that grow with the number of names and days, such as each day's weights in every name held, are too large for JSON and slow to read back from it, so they are kept beside the record as Parquet files, one per table, with a small manifest saying what is there.

Each table is wide, dates by name, with a date index. It reads back exactly: the same values, names and dates. A record kept before tables were written has no folder, and reading one gives no tables rather than an error.

run_panels

run_panels(
    result: BacktestResult,
) -> dict[str, pd.DataFrame]

The tables a finished run keeps beside its record, by name:

  • fund_weights: the fund's weight in each name held on each day it was recorded, unabridged.
  • name_returns: each day's total return, in the book's currency, of every name the fund held or its index or benchmark held, as beacon.data.returns.name_returns measures it. Left out, with a warning, when the run kept no data to read them from or they cannot be measured (a currency with no rate into the book's).
  • market: for an ETF, its quote on the exchange each day: NAV per share, premium, spread, market price, bid, ask, and what creating and redeeming cost.
  • cash_band_events: for a fund with a cash band, each time its cash left the band and was brought back, as the run recorded it.
  • class_days, class_flows and class_hedges: for a run with share classes, each class's days, flows and hedge trades, as beacon.analysis.class_panels.class_tables gives them.
  • futures_trades and futures_days: for a fund holding index futures, each trade of them with its cost, and each day's position, price, fair value and index level.
  • trades: every trade, why it was made and its tags, as beacon.backtest.breakdown.trades gives them.
  • costs: what the run cost each day, by kind, as beacon.backtest.breakdown.daily_costs gives it.
  • cash: its cash, income, fees and flows each day, as beacon.backtest.breakdown.daily_cash gives them.
  • index_weights, optimised_weights and benchmark_weights: each comparator's daily weights in its names, for those the run has and that hold names (a benchmark given as a bare level series has none).
  • reference: the grouping fields (sector, industry, region, country, currency, exchange) of every name held, as they stood at each rebalance; see beacon.analysis.snapshots.
  • characteristics: their market cap, P/E, P/B, dividend yield and momentum at each rebalance, as beacon.analysis.snapshots.characteristics_snapshots gives them; left out, with a warning, when they cannot be measured.
  • closes, currencies, rates and actions: the closes the name returns are worked out from, in each name's own currency, and what converts them and accounts for splits and cash; see beacon.analysis.packing.
  • traded_values: their average daily traded value at each rebalance and month end, as the liquidity screens measure it (beacon.analysis.snapshots.traded_value_snapshots).

Parameters:

Name Type Description Default
result BacktestResult

The finished run.

required

Returns:

Name Type Description
dict dict[str, DataFrame]

Each table by name, as above.

name_returns_from

name_returns_from(
    tables: Mapping[str, DataFrame],
    currency: str,
    days: DatetimeIndex,
) -> pd.DataFrame

The name returns, worked out from a run's kept closes, currencies, rates and corporate actions, on its NAV days from the first kept close: a year before the fund's first holding, enough for a risk model's window.

The same calculation as beacon.data.returns.name_returns on the data store, on the same numbers, so a finished run and a stored one give the same table to the last bit.

on_sessions

on_sessions(
    returns: DataFrame, days: DatetimeIndex
) -> pd.DataFrame

returns, measured on every day a name has a close, as returns from one of days to the next: a day with a close but no session (a holiday's print) is compounded into the session after it, rather than dropped with its move. Days that follow no such day are as they were.

weights_inputs

weights_inputs(
    result: BacktestResult, tables: Mapping[str, DataFrame]
) -> dict[str, pd.DataFrame]

What each book's daily weights are worked out again from: the fund's quantities and daily totals, each index's units, and for every book its days, its names and the cells that differ from what was recorded.

weights_from

weights_from(
    name: str,
    tables: Mapping[str, DataFrame],
    currency: str,
    dtype: str,
    last: int | None = None,
) -> pd.DataFrame

A book's daily weights, worked out again from what was kept, with the recorded cells put back, so they are exactly what was recorded; for its last days alone when given, which are those days of the whole table.

held_names

held_names(result: BacktestResult) -> list[str]

Every name the fund, its index (both books) or its benchmark held at any point in the run, sorted.

write_panels

write_panels(
    folder: Path, panels: Mapping[str, DataFrame]
) -> dict[str, Any]

Write panels to folder, replacing whatever it held.

The tables are written to a new folder beside it, which then takes its place, so a failure part way leaves the previous tables as they were.

Parameters:

Name Type Description Default
folder Path

Where the tables go.

required
panels Mapping[str, DataFrame]

Each table by name: dates by name, with a date index.

required

Returns:

Name Type Description
dict dict[str, Any]

The manifest written beside them.

Raises:

Type Description
ConfigurationError

If a table's name is not lower-case letters, digits and underscores, or its columns are not text.

read_manifest

read_manifest(folder: Path) -> dict[str, Any] | None

What folder holds, or None when there is no such folder.

Raises:

Type Description
ConfigurationError

If the manifest cannot be read, or was written by a newer version than this one reads.

read_panels

read_panels(
    folder: Path, names: Iterable[str] | None = None
) -> dict[str, pd.DataFrame]

Read tables back from folder.

Parameters:

Name Type Description Default
folder Path

Where the tables were written.

required
names Iterable[str] | None

The tables wanted; every one there when None.

None

Returns:

Name Type Description
dict dict[str, DataFrame]

Each table by name, as it was written. Empty when there is no

dict[str, DataFrame]

folder, as for a record kept before tables were.

Raises:

Type Description
DataNotFoundError

If a table asked for by name is not there.

ConfigurationError

If the folder cannot be read.

performance

A run's performance: its returns, risk and figures against its comparators.

PerformanceAnalytics measures a run's figures from its RunData, with the AnalysisSettings they are measured with; RelativeAnalytics its figures against one comparator; CrisisAnalytics how it held up in crises and RegimeAnalytics in each kind of market; PortfolioAnalytics what it held. beacon.analysis.performance.formulas holds the formulas, one definition each, that every figure is computed with.

AnalysisSettings dataclass

AnalysisSettings(
    start: Timestamp | str | None = None,
    end: Timestamp | str | None = None,
    periods_per_year: int | None = None,
    risk_free_rate: float | None = None,
    tracked: str | None = None,
)

How a run's figures are measured. Each setting left as None takes the run's own.

Attributes:

Name Type Description
start Timestamp | str | None

The first day measured; the run's first when None. A later start measures from the previous day's close.

end Timestamp | str | None

The last day measured; the run's last when None.

periods_per_year int | None

How many daily returns make a year.

risk_free_rate float | None

The annual rate the Sharpe ratio is in excess of.

tracked str | None

The comparator tracking is measured against, by name (index, optimised or benchmark): the index the run traded toward when None, which is the optimised index when it solved one.

analytics

A run's performance figures, from its data and the settings it is measured with.

analytics = PerformanceAnalytics(RunData.from_record(folder))
analytics.summary()

Every figure is one of beacon.analysis.performance.formulas, so a finished run and a stored one give the same figures, and a backtest's summary is these.

PerformanceAnalytics
PerformanceAnalytics(
    run: RunData, settings: AnalysisSettings | None = None
)

A run's performance figures.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
settings AnalysisSettings | None

What the figures are measured over and against; the run's own when None.

None
periods_per_year property
periods_per_year: int

How many daily returns make a year.

risk_free_rate property
risk_free_rate: float

The annual rate the Sharpe ratio is in excess of.

returns property
returns: Series

The fund's daily returns over the days measured.

before property
before: Timestamp | None

The run's close before the days measured, when they start after the run's first day; None when they start with it.

tracked property
tracked: Series | None

The levels of the comparator tracking is measured against, or None when the run has none.

headline
headline() -> dict[str, float]

The five headline figures: total and annualised return, volatility, Sharpe ratio and maximum drawdown.

Over the whole run, the total return and drawdown run from what the fund started with, so the cost of buying in on the first day counts; over a window, from the close before its first day.

hit_rate_monthly
hit_rate_monthly() -> float | None

The share of calendar months with a positive return.

tracking_error
tracking_error() -> float | None

The annualised spread of the daily returns less the tracked comparator's; None without one or with fewer than two days.

tracking_difference
tracking_difference() -> float | None

The compounded return less the tracked comparator's; None without one.

information_ratio
information_ratio() -> float | None

The mean daily return less the tracked comparator's, over its spread, annualised; None without one or without active risk.

money_weighted_return
money_weighted_return() -> float | None

The annual internal rate of return of the investors' money; None for a run without flows, or when no rate exists.

comparison
comparison(comparator: str | Series) -> Comparison | None

The run against a comparator, given by name or by its levels, over the days both have: the comparator's own figures and the run's relative to it. None for a comparator the run does not have, or one sharing fewer than two days with it.

periods
periods(
    frequency: str, comparators: list[str] | None = None
) -> list[Period]

The run's figures in each calendar year, quarter or month, and its comparators' in each, by name: the index and the benchmark, of those the run has, unless comparators names others.

Raises:

Type Description
ValueError

If the frequency is not annual, quarterly or monthly.

trailing_returns
trailing_returns() -> dict[
    str, return_figures.WindowReturn | None
]

The returns over the last 1, 3 and 6 months and 1, 3, 5 and 10 years, for the month, quarter and year to date, and since inception, by name (1M ... 10Y, MTD, QTD, YTD, inception). Each is annualised as well when it is longer than a year. None for a window longer than the run.

Since inception is the headline's total and annualised return, so over the whole run it starts from what the fund started with.

best_and_worst
best_and_worst() -> dict[
    str,
    tuple[
        return_figures.PeriodReturn,
        return_figures.PeriodReturn,
    ]
    | None,
]

The best and worst day, week, month, quarter and year, by frequency (daily ... annual), with their dates and whether the run covered all of each.

positive_shares
positive_shares() -> dict[str, float | None]

The share of days, months and years with a positive return, by frequency.

gains_and_losses
gains_and_losses(
    frequency: str = return_figures.DAILY,
) -> return_figures.GainsAndLosses

The average gain and loss over days (or months, ...), and their ratio.

longest_streaks
longest_streaks() -> tuple[
    return_figures.Streak | None,
    return_figures.Streak | None,
]

The longest run of months that all gained, and of months that all lost.

means
means() -> dict[str, float | None]

The arithmetic and geometric mean daily return, and each a year.

levels
levels() -> pd.Series

The level the figures are measured along, dated, from its start: over the whole run what the fund started with, then its NAV or a unit's value; over a window, 1 grown by its returns. The start is dated the business day before the first return.

drawdowns
drawdowns(worst: int = WORST) -> DrawdownFigures

The run's drawdowns: the deepest worst with their peak, trough and recovery dates, the current one, and the figures over all of them. The deepest is the headline's maximum drawdown.

drawdown_series
drawdown_series() -> pd.Series

How far below its peak the level stood each day.

ratios
ratios(
    sharpe_benchmark: float = 0.0,
    trials: int | None = None,
    trial_variance: float | None = None,
) -> dict[str, float | None]

The risk-adjusted ratios beyond the Sharpe ratio, by name.

sortino, kappa_3 (daily), upside_potential and omega measure return against the shortfall below the risk-free rate; calmar, martin, sterling and burke against drawdowns; gain_to_pain against losing months. probabilistic_sharpe is the probability the true Sharpe ratio is above sharpe_benchmark (a year); deflated_sharpe the same against the best of trials strategies whose annual Sharpe ratios vary by trial_variance, None unless both are given; lo_sharpe the Sharpe ratio corrected for serial correlation.

distribution
distribution(bins: int = shape.BINS) -> dict[str, Any]

The shape of the daily returns, by name: downside_deviation (below the risk-free rate) and semi_variance (below the mean), both annualised; skew and excess_kurtosis; tail_ratio; serial_correlation by lag in days; and a histogram of bins bins with the normal distribution to compare.

bootstrap
bootstrap(
    samples: int = shape.SAMPLES,
    confidence: float = shape.CONFIDENCE,
    block: int = 1,
    seed: int = shape.SEED,
) -> dict[str, shape.Interval] | None

How far the annualised return and the Sharpe ratio could have come out otherwise, from runs made by drawing the run's own days again; see beacon.analysis.performance.distribution.bootstrap.

rolling
rolling(
    window: int = moving.WINDOWS[1],
    comparator: str | Series | None = None,
) -> pd.DataFrame

Each day's return, volatility, Sharpe ratio, Sortino ratio and maximum drawdown over the window days ending that day, blank until the first full window; with a comparator (by name or levels), its relative return, tracking error, information ratio, beta and correlation too, on the days both have.

rolling_outcomes
rolling_outcomes(
    window: int = moving.WINDOWS[1],
    comparator: str | Series | None = None,
) -> dict[str, float | None]

Over every full window: the worst, middle and best return, the share that gained, and the share ahead of the comparator when one is given.

monthly_grid
monthly_grid(of: str = tables.FUND) -> pd.DataFrame

The calendar grid of monthly returns, a row a year, with each year's: of the fund, of a comparator by name, or of the fund less a comparator (difference index).

Raises:

Type Description
KeyError

If the run has no such comparator.

period_table
period_table(
    frequency: str = formulas.ANNUAL,
) -> pd.DataFrame

Each year's (or quarter's, month's) return, volatility, Sharpe ratio and maximum drawdown for the fund, with its share of positive days, for its index and benchmark, and the fund's less each's.

trailing_table
trailing_table() -> pd.DataFrame

The trailing returns (trailing_returns) of the fund, its index and benchmark and the fund's less each's, a row a window.

month_of_year
month_of_year() -> pd.Series

The fund's mean return in each calendar month over the years.

summary
summary() -> dict[str, float | None]

The headline figures, the monthly hit rate, and, when the run has them, the tracking error and difference against the tracked comparator and the money-weighted return.

cash_management

How a fund managed its cash: each time the cash left its band and was raised or spent back down to its target, how often and when that happened, what it cost and how close it brought the cash to its target; and every trade made to raise cash or spend it, whatever it was raised or spent for.

An event's gap is how far the cash stood from its target; the share of the gap closed is one less the gap after over the gap before, so 1 is a cash brought exactly to its target.

band_events
band_events(events: DataFrame) -> pd.DataFrame

Each event as the run recorded it, with the gap to the target before and after, the share of it closed, and the cost in basis points of the value traded.

band_summary
band_summary(events: DataFrame) -> pd.DataFrame

For raises, spend-downs and both: how many, the value traded, the total and mean cost, the cost in basis points of the value traded, and the mean share of the gap closed and gap left, a row each.

band_calendar
band_calendar(
    events: DataFrame, frequency: str = "M"
) -> pd.DataFrame

How many raises and spend-downs each month (M) or year (Y) that had one, and what they cost.

cash_trading
cash_trading(trades: DataFrame) -> pd.DataFrame

Every trade made to raise cash or spend it, by the tag and the reason it was made for (a redemption, a distribution, the band...): how many, the value traded and its cost.

comparisons

A run against a comparator, and its figures by calendar period.

A run is given as its daily returns, the first running from its starting point (a backtest's initial capital), and a comparator as its levels. Over the run's whole window the comparator's first day returns nothing, so its figures run from its level that day, as a run's tracking does; over a later window both start there. Every figure is one of beacon.analysis.performance.formulas, so a comparison against a run's own index agrees with its tracking error and tracking difference.

Figures by period split the same returns into calendar years, quarters or months, each period from the previous period's close and the first from the start, so the periods compound to the whole window's figures.

Comparison dataclass
Comparison(
    start: Timestamp,
    end: Timestamp,
    observations: int,
    level: Series,
    own: dict[str, float | None],
    relative: dict[str, float | None],
)

A run against one comparator over the window they share.

Attributes:

Name Type Description
start Timestamp

The window's first day.

end Timestamp

Its last day.

observations int

Its days.

level Series

The comparator's level over it, rebased to 100 on its first day.

own dict[str, float | None]

The comparator's figures: total_return, annualised_return, volatility, sharpe_ratio, max_drawdown and hit_rate_monthly.

relative dict[str, float | None]

The run against it: relative_return, annualised_relative_return, tracking_error, information_ratio, correlation, beta and hit_rate_monthly.

Period dataclass
Period(
    period: str,
    start: Timestamp,
    end: Timestamp,
    returns: Series,
    figures: dict[str, float],
    against: dict[
        str, dict[str, dict[str, float | None]] | None
    ] = dict(),
)

A run's figures in one calendar period.

Attributes:

Name Type Description
period str

Its name: 2024, 2024 Q1 or 2024-01.

start Timestamp

Its first day.

end Timestamp

Its last day.

returns Series

The run's returns in it.

figures dict[str, float]

The run's headline figures in it, and the share of its days with a positive return (positive_days).

against dict[str, dict[str, dict[str, float | None]] | None]

Each comparator's own and relative figures in it, by name; None for a comparator with no days in it.

comparison_of
comparison_of(
    returns: Series,
    comparator_levels: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> Comparison | None

A run against a comparator over the days both have; None when they share fewer than two.

Parameters:

Name Type Description Default
returns Series

The run's daily returns, the first from its starting point.

required
comparator_levels Series

The comparator's levels.

required
periods_per_year int

How many daily returns make a year.

required
risk_free_rate float

The annual rate Sharpe ratios are in excess of.

0.0
aligned
aligned(
    returns: Series,
    comparator_levels: Series,
    before: Timestamp | None = None,
) -> tuple[pd.Series, pd.Series]

A run's and a comparator's daily returns on the days both have, empty when they share fewer than two: the comparator's first day returns nothing from its level that day, and so, when the comparator starts after the run, does the run's.

For returns over a window that starts after the run, before is the run's close before the window's first day: the comparator's first return is then from its own close on or before it, as the run's is.

periods_of
periods_of(
    returns: Series,
    frequency: str,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
    comparators: Mapping[str, Series] | None = None,
) -> list[Period]

A run's figures in each calendar period, and each comparator's in it.

Each period's returns are its own days', so the first runs from the run's starting point and each later one from the previous close.

Parameters:

Name Type Description Default
returns Series

The run's daily returns, the first from its starting point.

required
frequency str

"annual", "quarterly" or "monthly".

required
periods_per_year int

How many daily returns make a year.

required
risk_free_rate float

The annual rate the Sharpe ratio is in excess of.

0.0
comparators Mapping[str, Series] | None

Each comparator's daily returns, by name, its first day's nothing.

None

Raises:

Type Description
ValueError

If the frequency is not one of the three.

period_against
period_against(
    ours: Series,
    theirs: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> dict[str, dict[str, float | None]] | None

A comparator's own figures and the run's relative ones over a period, from both's daily returns in it; None with no common days.

own_figures
own_figures(
    returns: Series,
    path: Series,
    periods_per_year: int,
    risk_free_rate: float,
) -> dict[str, float | None]

A series' own figures, from its returns and the level they move.

relative_figures
relative_figures(
    ours: Series, theirs: Series, periods_per_year: int
) -> dict[str, float | None]

The run against the comparator, over the same days.

costs

What a run cost: its costs by kind (the fixed cost and the market impact of its trades, the cost of rolling and of trading futures) and the creation fees dealing investors paid into it, in money, in basis points of its assets a year and per unit it traded, over the run, each year and by why it traded.

Creation fees are received by the fund, so they are set against its costs: the net cost is the costs less the fees.

cost_summary
cost_summary(
    costs: DataFrame, nav: Series, periods_per_year: int
) -> pd.DataFrame

Each kind's total over the run and in basis points of the mean NAV a year, a row a kind.

yearly_costs
yearly_costs(costs: DataFrame, nav: Series) -> pd.DataFrame

Each calendar year's costs by kind, and the net in basis points of the year's mean NAV (for the year's days alone, not annualised), a row a year.

per_traded
per_traded(trades: DataFrame) -> dict[str, float | None]

The fixed cost, the market impact and both, in basis points of the value traded; None without trading.

by_reason
by_reason(trades: DataFrame) -> pd.DataFrame

The trading costs by why the trade was made: the cost, its market impact, the value traded and the cost in basis points of it, a row a reason, largest cost first. They add up to the run's trading costs.

crises

How a run held up in crises: each crisis window's returns, outperformance, drawdowns, beta and alpha, what came after the trough and how long the fund took to recover; and the figures across every crisis.

crises = CrisisAnalytics(RunData.from_record(folder), market=sp500_levels)
crises.crises(), crises.summary()

The windows come from a broad market series by default: each fall of the market deeper than a threshold (15%), from its peak to its trough. Or they are the user's own named episodes, each a start and an end date, instead. A window is measured over the days after its start through its end; one the run covers only part of is marked partial.

Episode dataclass
Episode(name: str, start: Timestamp, end: Timestamp)

A crisis window: a name, its start (the market's peak) and its end (its trough).

Crisis dataclass
Crisis(
    name: str,
    start: Timestamp,
    end: Timestamp,
    partial: bool,
    days: int,
    fund: float,
    comparator: float,
    outperformance: float,
    fund_drawdown: float,
    comparator_drawdown: float,
    beta: float,
    alpha: float,
    after: dict[str, float | None],
    recovery_days: int | None,
)

How the fund held up in one crisis.

Attributes:

Name Type Description
name str

The episode's name, or the market's peak and trough dates.

start Timestamp

The window's start (the market's peak).

end Timestamp

Its end (the trough).

partial bool

Whether the run covered only part of it.

days int

The days measured.

fund float

The fund's return over it.

comparator float

The comparator's.

outperformance float

The fund's less the comparator's.

fund_drawdown float

The fund's deepest fall inside it.

comparator_drawdown float

The comparator's.

beta float

The fund's beta to the comparator over it.

alpha float

The fund's return beyond what the whole run's beta predicts.

after dict[str, float | None]

The fund's and the comparator's returns over the months after the end, by fund 3m ... comparator 6m; None where the run ends too soon.

recovery_days int | None

Trading days from the end until the fund was back at its value at the start; None if it had not been by the run's end.

CrisisAnalytics
CrisisAnalytics(
    run: RunData,
    comparator: str | Series = INDEX,
    market: str | Series | None = None,
    threshold: float = THRESHOLD,
    named: Sequence[Episode] | None = None,
    settings: AnalysisSettings | None = None,
)

How a run held up in crises, against one comparator.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
comparator str | Series

What the fund is measured against in each crisis, by name or levels; the run's index by default.

INDEX
market str | Series | None

The broad market series the crises are found on, by name of one of the run's comparators or by its levels; the run's benchmark when it has one, else its index.

None
threshold float

The market's fall that makes a crisis.

THRESHOLD
named Sequence[Episode] | None

The user's own episodes, used instead of the market's falls.

None
settings AnalysisSettings | None

What the figures are measured over and with.

None

Raises:

Type Description
KeyError

If the run has no comparator of a name given.

crises
crises() -> list[Crisis]

Each crisis the run saw any of, in order.

summary
summary() -> dict[str, float | None]

The figures across every crisis.

market_crises
market_crises(
    market: Series, threshold: float = THRESHOLD
) -> list[Episode]

Each fall of the market deeper than threshold, peak to trough.

crisis_of
crisis_of(
    episode: Episode,
    ours: Series,
    theirs: Series,
    run_beta: float,
) -> Crisis | None

How the fund held up over one episode, on the days the run has; None when the run has none of them.

across
across(
    crises: Sequence[Crisis],
    ours: Series,
    theirs: Series,
    run_beta: float,
    periods_per_year: int,
) -> dict[str, float | None]

The figures across every crisis: the average outperformance, the share outperformed, crisis capture, and the pooled crisis alpha (the mean daily return beyond the whole run's beta over every crisis day, a year).

dealing

What dealing in a fund cost: each subscription or redemption (each creation or redemption, for an ETF), and each share class's costs.

A flow's cost to the fund is the trading it caused, as fund_figures attributes it (every trading cost of a day with flows and no rebalance; on a rebalance day, the flow's share of the day's trading), less what the dealing investors paid into the fund toward it through the price or a levy. For an ETF, its cost to the AP is the flow times the quote's cost of creating or redeeming that day: the AP's fee, and the basket's trading or the creation fee.

A class's costs are its fees; its hedge's trading costs, less the carry its forwards earned (each forward's locked-in carry, for the share of its term it was held); the trading its own flows caused, less what its dealing investors paid; and its share of the rest of the fund's trading (rebalances, dividends, the cash band, futures rolls), by its share of the fund's assets at the close before. All are in the book's currency, so the classes' trading adds up to the fund's.

dealing_costs
dealing_costs(
    trades: DataFrame,
    cash: DataFrame,
    costs: DataFrame,
    market: DataFrame | None = None,
) -> pd.DataFrame

Each day's flow: whether money came in or went out, its size, the trading it caused, what the dealing investors paid, the net cost to the fund and in basis points of the flow, and, for an ETF, its cost to the AP.

dealing_summary
dealing_summary(
    table: DataFrame, by: str = "run"
) -> pd.DataFrame

The flows' costs added up by kind, over the run (run) or each year too (year): how many, their size, the trading they caused, what dealing investors paid, the net cost and in basis points of the flows, and the AP's cost.

class_costs
class_costs(
    tables: dict[str, DataFrame],
    trades: DataFrame,
    cash: DataFrame,
    costs: DataFrame,
    periods_per_year: int,
    by: str = "run",
) -> pd.DataFrame

Each class's costs in the book's currency, over the run (run, with the net in basis points of its mean assets a year) or each year (year, with the net in basis points of the year's mean assets).

distribution

The shape of a run's daily returns: their spread below a target and below their mean, their skew and tails, how each day's return relates to the days before, a histogram to draw, and, by resampling, how far its annualised return and Sharpe ratio could have come out otherwise.

Histogram dataclass
Histogram(
    edges: list[float],
    counts: list[int],
    normal: list[float],
)

The daily returns counted into bins, with the normal distribution of the same mean and spread to compare them with.

Attributes:

Name Type Description
edges list[float]

The bins' edges, one more than the bins.

counts list[int]

The returns in each bin.

normal list[float]

The returns a normal distribution would put in each bin.

Interval dataclass
Interval(
    value: float, low: float, high: float, confidence: float
)

A figure and the range the resampled runs put it in.

Attributes:

Name Type Description
value float

The figure on the run's own returns.

low float

The lower end of the interval.

high float

The upper end.

confidence float

The share of resampled runs between them.

downside_deviation
downside_deviation(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> float

The root mean square of the shortfalls below the daily risk-free rate, every day counted, annualised: the Sortino ratio's risk.

semi_variance
semi_variance(
    returns: Series, periods_per_year: int
) -> float

The mean square of the shortfalls below the mean return, every day counted, annualised.

skew
skew(returns: Series) -> float | None

The sample skew, bias-corrected as a spreadsheet's is; None with fewer than three returns or no spread.

excess_kurtosis
excess_kurtosis(returns: Series) -> float | None

The sample kurtosis over a normal distribution's, bias-corrected as a spreadsheet's is; None with fewer than four returns or no spread.

tail_ratio
tail_ratio(returns: Series) -> float | None

The 95th percentile of the returns over the size of the 5th: how much larger the right tail is than the left.

serial_correlation
serial_correlation(
    returns: Series, lags: int = SERIAL_LAGS
) -> dict[int, float | None]

Each day's return's correlation with the return lag days before, for lags 1 to lags.

histogram
histogram(returns: Series, bins: int = BINS) -> Histogram

The daily returns counted into bins bins of equal width.

bootstrap
bootstrap(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
    samples: int = SAMPLES,
    confidence: float = CONFIDENCE,
    block: int = 1,
    seed: int = SEED,
) -> dict[str, Interval] | None

How far the annualised return and the Sharpe ratio could have come out otherwise: each over samples runs of the same length made by drawing the run's own days again, in blocks of block days to keep any dependence between neighbouring days, with the interval holding confidence of them. The same seed draws the same runs. None with fewer than two blocks' worth of returns.

drawdowns

A run's drawdowns: each fall from a peak, how deep it went, how long it took to reach its trough and to recover, and the figures over all of them.

A drawdown starts on a peak (the level's high so far), reaches its trough (the lowest level before it recovers) and ends when the level first gets back to the peak. One still open on the last day has no recovery date: it is the current drawdown. Lengths are counted in trading days, from the peak.

Drawdown dataclass
Drawdown(
    peak: Timestamp,
    trough: Timestamp,
    recovery: Timestamp | None,
    depth: float,
    to_trough: int,
    to_recover: int | None,
    length: int,
)

One fall from a peak.

Attributes:

Name Type Description
peak Timestamp

The day of the high it fell from.

trough Timestamp

The day of its lowest level.

recovery Timestamp | None

The first day the level got back to the peak; None when it had not by the end.

depth float

The fall to the trough, as a fraction of the peak (negative).

to_trough int

Trading days from the peak to the trough.

to_recover int | None

Trading days from the trough to the recovery; None while open.

length int

Trading days from the peak to the recovery, or to the last day while open.

DrawdownFigures dataclass
DrawdownFigures(
    worst: list[Drawdown],
    current: float,
    current_drawdown: Drawdown | None,
    average_depth: float,
    average_length: float,
    time_below_peak: float,
    longest_below_peak: int,
    recovery_factor: float | None,
    ulcer_index: float,
    pain_index: float,
)

The figures over a run's drawdowns.

Attributes:

Name Type Description
worst list[Drawdown]

The deepest drawdowns, deepest first.

current float

How far below its peak the level ended (0 at a high).

current_drawdown Drawdown | None

The drawdown open on the last day; None at a high.

average_depth float

The mean depth of every drawdown; 0 without any.

average_length float

Their mean length in trading days; 0 without any.

time_below_peak float

The share of days spent below a peak.

longest_below_peak int

The longest drawdown, in trading days.

recovery_factor float | None

The total return over the size of the deepest drawdown; None without a drawdown.

ulcer_index float

The root mean square of the daily drawdown.

pain_index float

The mean size of the daily drawdown.

episodes
episodes(levels: Series) -> list[Drawdown]

Every drawdown of a dated level series, in order.

drawdown_figures
drawdown_figures(
    levels: Series, total_return: float, worst: int = WORST
) -> DrawdownFigures

The figures over a level series' drawdowns.

Parameters:

Name Type Description Default
levels Series

The level, dated, from its start.

required
total_return float

The return over the whole series, for the recovery factor.

required
worst int

How many of the deepest drawdowns to list.

WORST

formulas

The formulas behind a run's performance figures: pure functions of returns and levels, one definition each.

Returns are simple daily returns, and a level series is what they move. A comparator's returns over a run start from its level on the first day, so its first return is nothing (returns_from_start), as a run's first return runs from its starting capital. Figures annualise over periods_per_year returns a year.

A figure that cannot be measured (too few returns) is None, unless its definition says otherwise. The ratios of a mean return to its spread, the Sharpe and information ratios, are the mean daily return over its standard deviation, annualised by the square root of periods_per_year: both halves from the same daily returns, so the ratio does not change with leverage, and times the square root of the years it is the t-statistic of the mean.

returns_from_start
returns_from_start(levels: Series) -> pd.Series

Daily returns of a level series, the first day's being nothing: its returns over a run, measured from its level on the first day.

total_return
total_return(start: float, end: float) -> float

The return from start to end.

compounded_return
compounded_return(returns: Series) -> float

The return of a run of returns, compounded.

annualised_return
annualised_return(
    total: float, periods: int, periods_per_year: int
) -> float

total, earned over periods returns, as a rate a year; 0 over no time.

volatility
volatility(returns: Series, periods_per_year: int) -> float

The annualised standard deviation of returns; 0 with fewer than two.

daily_rate
daily_rate(
    annual_rate: float, periods_per_year: int
) -> float

The rate a period that compounds to annual_rate over a year.

sharpe_ratio
sharpe_ratio(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> float

The mean daily return in excess of the risk-free rate, over its standard deviation, annualised; 0 with fewer than two returns, or when they do not vary (NEGLIGIBLE_SPREAD).

The annual risk_free_rate is taken as the daily rate that compounds to it (daily_rate).

drawdown
drawdown(levels: Series) -> pd.Series

How far below its running peak a level series stands each day.

max_drawdown
max_drawdown(path: Series) -> float

The largest fall of path from a running peak; 0 for no path.

path_of
path_of(returns: Series) -> pd.Series

The level a run of returns moves, from 1 before its first.

headline_figures
headline_figures(
    returns: Series,
    total: float,
    path: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> dict[str, float]

The five headline figures of a performance.

Parameters:

Name Type Description Default
returns Series

The daily returns.

required
total float

The return over the whole performance.

required
path Series

The level the drawdown is measured along, from its start.

required
periods_per_year int

How many returns make a year.

required
risk_free_rate float

The annual rate the Sharpe ratio is in excess of.

0.0

Returns:

Name Type Description
dict dict[str, float]

total_return, annualised_return, volatility, sharpe_ratio and

dict[str, float]

max_drawdown.

period_returns
period_returns(
    returns: Series, frequency: str
) -> pd.Series

Returns compounded within each calendar year, quarter or month, by its name (2024, 2024 Q1, 2024-01), in order.

grouped
grouped(
    returns: Series, frequency: str
) -> list[tuple[str, pd.Series]]

returns split into calendar periods, each named, in order.

Raises:

Type Description
ValueError

If the frequency is not one of FREQUENCIES.

hit_rate_monthly
hit_rate_monthly(returns: Series) -> float | None

The share of calendar months whose compounded return was positive.

relative_hit_rate_monthly
relative_hit_rate_monthly(
    ours: Series, theirs: Series
) -> float | None

The share of calendar months in which ours compounded to more than theirs, over the same days.

positive_share
positive_share(returns: Series) -> float

The share of returns above zero.

tracking_error
tracking_error(
    active: Series, periods_per_year: int
) -> float | None

The annualised standard deviation of the active returns (ours less theirs, day by day); None with fewer than two.

tracking_difference
tracking_difference(ours: Series, theirs: Series) -> float

Our compounded return less theirs.

annualised_relative_return
annualised_relative_return(
    our_total: float,
    their_total: float,
    periods: int,
    periods_per_year: int,
) -> float

Our annualised growth less theirs, each total earned over periods returns; 0 over no time.

information_ratio
information_ratio(
    active: Series, periods_per_year: int
) -> float | None

The mean active return over its standard deviation, annualised; None with fewer than two, or when they do not vary (NEGLIGIBLE_SPREAD), as there is no active risk to measure it against.

correlation
correlation(ours: Series, theirs: Series) -> float

The correlation of two return series over the same days; 0 with fewer than two, or when either does not vary (NEGLIGIBLE_SPREAD), as nothing relates them.

beta
beta(ours: Series, theirs: Series) -> float

The sensitivity of ours to theirs: their covariance over the variance of theirs; 0 with fewer than two, or when theirs does not vary (NEGLIGIBLE_SPREAD), as there is nothing to be sensitive to.

market_return
market_return(first_ask: float, last_bid: float) -> float

An exchange investor's return: bought at first_ask, sold at last_bid.

money_weighted_return
money_weighted_return(
    flows: list[tuple[Timestamp, float]],
) -> float | None

The annual rate that discounts an investor's cash flows to zero.

Parameters:

Name Type Description Default
flows list[tuple[Timestamp, float]]

(date, amount) from the investor's side: money put in is negative, money taken out (and the final value) positive.

required

Returns:

Type Description
float | None

float or None: The internal rate of return a year, over calendar days

float | None

(ACT/365), or None when the flows do not change sign, so no rate

float | None

exists.

fund_figures

A fund's own figures: what it held looked like (its characteristics against its index's), what it earned (its income), how big it was and how money moved in and out (its AUM and flows), and, for an ETF, how its price on the exchange stood against its NAV.

Characteristics are weighted over the names with a value, the weights scaled to them: market cap and dividend yield as weighted means, P/E and P/B as weighted harmonic means, through earnings and book yields, which a loss or a tiny book does not blow up.

weighted_characteristics
weighted_characteristics(
    characteristics: DataFrame, weights: Series
) -> dict[str, float | None]

A book's characteristics on one day, from its weights by name and each name's characteristics (a row a name, IDENTIFIER and a column each).

characteristics_over_time
characteristics_over_time(
    characteristics: DataFrame, books: dict[str, DataFrame]
) -> pd.DataFrame

Each book's characteristics at each date the characteristics were taken (the rebalances), from its weights at the end of that day: a row a date, columns by book and characteristic.

trailing_yields
trailing_yields(
    cash: DataFrame, nav: Series
) -> pd.DataFrame

Each day's distribution yield (what was paid out over the year to it, over that day's NAV) and trailing dividend yield (the dividends the fund became owed over the year to it, gross, over its mean NAV over the year), from a year after the run's start.

dividend_growth
dividend_growth(cash: DataFrame) -> pd.DataFrame

Each calendar year's dividends (gross, as the fund became owed them) and their growth on the year before's.

net_flows
net_flows(
    cash: DataFrame, nav: Series, frequency: str = "M"
) -> pd.DataFrame

Each month's (M) or year's (Y) net flows (money in less money out, as dealt) and their share of the AUM at the close before it began.

flow_costs
flow_costs(
    trades: DataFrame, cash: DataFrame, costs: DataFrame
) -> pd.DataFrame

The cost of trading each day's flows: on a day with flows and no rebalance, every trading cost that day; on a rebalance day, the share of the day's trading costs the flow's size is of what was traded; with the creation fees dealing investors paid set against it. A row a day with flows.

premium_figures
premium_figures(
    market: DataFrame,
) -> dict[str, float | None]

An ETF's premium (its price over its NAV, less one) on the exchange: its mean, its standard deviation, and its widest above and below; and the mean bid-ask spread.

futures_figures

How a fund holding index futures traded them, and the basis risk of holding them.

Trading, by what each trade did (opened, rolled, resized): how many trades, the contracts and notional traded, the cost, the cost per contract and in basis points of the notional, and the notional traded a year over the fund's mean assets.

The basis each day: the futures' price over the level of the index they are on, less one, which splits into the carry (fair value over the level) and the mispricing (the price over fair value).

Basis risk splits the gap between the futures' daily return and the fund's index's into two: the pricing basis, the futures against their own index (carry and any mispricing), and the underlying mismatch, their index against the fund's, which brings tracking error even at fair value with no costs. A day the position rolled is left out of the futures' returns, since its price moved to another contract.

futures_trading
futures_trading(
    trades: DataFrame, nav: Series, periods_per_year: int
) -> pd.DataFrame

The futures trading by kind (opened, rolled, resized), between rolls, and all of it: the trades, the contracts and notional traded, the cost, per contract and in basis points of the notional, and the notional a year over the mean NAV.

daily_basis
daily_basis(days: DataFrame) -> pd.DataFrame

Each day's basis (the price over the level, less one), its carry (fair value over the level) and its mispricing (the price over fair value).

basis_summary
basis_summary(days: DataFrame) -> pd.DataFrame

The basis, carry and mispricing in basis points: their mean, standard deviation, and lowest and highest, a row each.

basis_returns
basis_returns(days: DataFrame) -> pd.DataFrame

Each day's pricing basis (the futures' return less their index's), underlying mismatch (their index's less the fund's) and both, on days the contract held did not change.

basis_risk
basis_risk(
    days: DataFrame, periods_per_year: int
) -> pd.DataFrame

For the pricing basis, the underlying mismatch and both: the mean a year and the tracking error each causes (its daily standard deviation, annualised), and the days measured.

levels

Level series as they are shown: rebased to 100, their daily returns, their returns by calendar year, and several on one axis over the dates they share.

LevelComparison dataclass
LevelComparison(
    window: DatetimeIndex,
    levels: dict[str, Series],
    total_returns: dict[str, float],
)

Several level series over the dates they all have, each rebased to 100 on the first.

Attributes:

Name Type Description
window DatetimeIndex

The shared dates.

levels dict[str, Series]

Each series over them, rebased.

total_returns dict[str, float]

Each series' return over them.

rebased
rebased(
    series: Series, base: float = BASE_LEVEL
) -> pd.Series

series rebased to base at its first value; as it is when empty or when that value is 0.

daily_returns
daily_returns(levels: Series) -> pd.Series

Each day's return of levels, from the day before; none for the first.

total_return
total_return(levels: Series) -> float

The last level over the first, less one; 0 when there is none or the first is 0.

headline
headline(
    levels: Series,
    periods_per_year: int,
    risk_free_rate: float,
) -> dict[str, float]

The five headline figures of a level series alone; see formulas.headline_figures.

annual_returns
annual_returns(level: Series) -> dict[str, float]

Return per calendar year, keyed by the year.

Each year runs from the previous year's closing level to its own, so the product of (1 + r) telescopes to last / first - 1. The first value is the starting point, not a year of its own: a run that starts on 2 January begins from the capital held on the eve, which may fall in the year before.

Parameters:

Name Type Description Default
level Series

The level series, indexed by date.

required

Returns:

Name Type Description
dict dict[str, float]

Year (as a string) -> return for that year.

compared
compared(levels: dict[str, Series]) -> LevelComparison

levels on one axis, over the dates every one of them has.

Intersected rather than clipped to the latest start and earliest end, so a gap in one series drops that date for all of them instead of leaving a hole only one line has.

Raises:

Type Description
DataNotFoundError

If they share no date.

portfolio

What a run held: how many names, and its weight in each group of a grouping field (sector, region, currency, ...) each day, against its index's and its benchmark's.

portfolio = PortfolioAnalytics(RunData.from_record(folder))
portfolio.group_weights("SECTOR")

A name's group on a day is the one it had at the latest rebalance on or before that day, as the run's reference table recorded it. A name with no group recorded is in Unknown. A book's group weights add up to its invested weight each day: the fund's cash is in none of them.

PortfolioAnalytics
PortfolioAnalytics(run: RunData)

What a run held, and in which groups.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
weights
weights(book: str = FUND) -> pd.DataFrame | None

A book's daily weights, dates by name: fund, index or benchmark; None for a book the run did not keep weights for (a benchmark given as a level series holds no names).

holdings_count
holdings_count(book: str = FUND) -> pd.Series | None

How many names a book held each day.

group_weights
group_weights(
    field: str, book: str = FUND
) -> pd.DataFrame | None

A book's weight in each group of field each day, dates by group, the groups sorted; None for a book without weights.

Raises:

Type Description
KeyError

If the run kept no reference data, or none for field.

active_group_weights
active_group_weights(
    field: str, against: str = INDEX
) -> pd.DataFrame | None

The fund's weight in each group less against's (index or benchmark), on the days the fund has; None when the comparator holds no names.

turnover
turnover(by: str = 'day') -> pd.DataFrame

The fund's buys, sells, number of trades and their mean value, and its one-way turnover (the lesser of buys and sells) and two-way turnover (all of it), over its assets: each trading day (day, with whether it was a rebalance), each rebalance's own trades (rebalance) or each year (year).

Raises:

Type Description
KeyError

If the run kept no trades (one stored before 0.10.0).

ValueError

If by is none of the three.

trading_by_reason
trading_by_reason() -> pd.DataFrame

The fund's trading by why it was made, a row a reason.

costs
costs(by: str = 'run') -> pd.DataFrame

The fund's costs by kind (fixed, impact, futures rolls and futures trading), the creation fees it received and the net: over the run (run), with each in basis points of its mean NAV a year, or each year (year), with the net in basis points of the year's mean NAV.

Raises:

Type Description
ValueError

If by is neither.

cost_per_traded
cost_per_traded() -> dict[str, float | None]

The trading costs in basis points of the value traded.

costs_by_reason
costs_by_reason() -> pd.DataFrame

The trading costs by why the trade was made.

characteristics
characteristics() -> pd.DataFrame

The weighted market cap, P/E, P/B and dividend yield of the fund, its index and its benchmark (when it holds names) at each rebalance: a row a rebalance, columns by book and figure.

income
income() -> pd.DataFrame

Each day's distribution yield and trailing dividend yield, over the year to it.

dividend_growth
dividend_growth() -> pd.DataFrame

Each calendar year's dividends and their growth.

aum
aum() -> pd.DataFrame

The fund's assets under management and units outstanding each day.

net_flows
net_flows(frequency: str = 'M') -> pd.DataFrame

Each month's (M) or year's (Y) net flows and their share of the AUM it began with.

flow_costs
flow_costs() -> pd.DataFrame

The cost of trading each day's flows, less the creation fees received.

etf_premium
etf_premium() -> dict[str, float | None] | None

An ETF's premium to NAV on the exchange: its mean, volatility and widest each way, and the mean spread; None for another vehicle.

cash_band_events
cash_band_events() -> pd.DataFrame

Each time the cash left its band and was raised or spent back to its target: the cash before and after, the target and band, the gap closed, what was traded and its cost. Empty for a run without a band.

cash_band_summary
cash_band_summary() -> pd.DataFrame

The raises, the spend-downs and both: how many, what they traded and cost, and how close they brought the cash to its target.

cash_band_calendar
cash_band_calendar(frequency: str = 'M') -> pd.DataFrame

How many raises and spend-downs each month (M) or year (Y) had, and their cost.

cash_trading
cash_trading() -> pd.DataFrame

Every trade made to raise or spend cash, by tag and reason.

dealing_costs
dealing_costs() -> pd.DataFrame

Each day's subscriptions or redemptions (an ETF's creations or redemptions): the trading they caused, what the dealing investors paid toward it, the net cost to the fund, and for an ETF the cost to the AP.

dealing_summary
dealing_summary(by: str = 'run') -> pd.DataFrame

The flows' costs added up by kind, over the run (run) or each year (year).

class_costs
class_costs(by: str = 'run') -> pd.DataFrame

Each share class's fees, hedging costs less the carry earned, the trading its flows caused less what its dealing investors paid, and its share of the rest of the fund's trading, in the book's currency: over the run (run) or each year (year).

Raises:

Type Description
KeyError

If the run had no share classes.

futures_trading
futures_trading() -> pd.DataFrame

The fund's futures trading by why each trade was made, between rolls and all of it: trades, contracts, notional, cost per contract and in basis points, and turnover.

Raises:

Type Description
KeyError

If the run held no index futures.

futures_basis
futures_basis(daily: bool = False) -> pd.DataFrame

The futures' basis to their index, its carry and its mispricing: each day (daily), or their mean, volatility and extremes in basis points.

futures_basis_risk
futures_basis_risk() -> pd.DataFrame

The tracking error the futures bring, split into the pricing basis (against their own index) and the underlying mismatch (their index against the fund's).

ratios

Risk-adjusted ratios beyond the Sharpe ratio: return over the downside (Sortino, Kappa 3, upside potential, Omega), over drawdowns (Calmar, Martin, Sterling, Burke) and over losing months (gain to pain), and the probability that a Sharpe ratio is real (probabilistic and deflated), with the Sharpe ratio corrected for serial correlation (Lo).

Excess returns are over the daily rate that compounds to the risk-free rate, as the Sharpe ratio's are. A ratio over a risk that is not there (no day below the target, no drawdown) is None.

excess_returns
excess_returns(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float,
) -> pd.Series

returns less the daily rate that compounds to risk_free_rate.

downside_deviation
downside_deviation(excess: Series) -> float

The root mean square of the shortfalls below 0, over every day.

sortino
sortino(
    excess: Series, periods_per_year: int
) -> float | None

The mean excess return over the downside deviation, annualised.

kappa_3
kappa_3(excess: Series) -> float | None

The mean daily excess return over the cube root of the third lower moment: Sortino with the shortfalls cubed, which weighs the larger ones more. Daily: a cube root does not annualise by a square root.

upside_potential
upside_potential(excess: Series) -> float | None

The mean gain above 0 over the downside deviation.

omega
omega(excess: Series) -> float | None

The sum of the gains above 0 over the sum of the shortfalls below it.

gain_to_pain
gain_to_pain(monthly: Series) -> float | None

The sum of the monthly returns over the size of the losing months' sum.

calmar
calmar(annualised: float, deepest: float) -> float | None

The annualised return over the size of the deepest drawdown.

martin
martin(
    excess: Series, ulcer: float, periods_per_year: int
) -> float | None

The annualised mean excess return over the ulcer index.

sterling
sterling(
    annualised: float, depths: Sequence[float]
) -> float | None

The annualised return over the mean size of the deepest drawdowns (STERLING_DRAWDOWNS of them).

burke
burke(
    annualised: float, depths: Sequence[float]
) -> float | None

The annualised return over the root of the sum of every drawdown's depth squared.

moments
moments(excess: Series) -> tuple[float, float]

The skew and kurtosis (not excess kurtosis) of the daily excess returns, from their population moments.

probabilistic_sharpe
probabilistic_sharpe(
    excess: Series,
    periods_per_year: int,
    benchmark: float = 0.0,
) -> float | None

The probability that the true Sharpe ratio is above benchmark (a year), allowing for the skew and fat tails of the daily returns (Bailey and Lopez de Prado). None with fewer than three returns or no spread.

expected_best_sharpe
expected_best_sharpe(trials: int, variance: float) -> float

The largest Sharpe ratio (in the units of variance's) expected from trials independent strategies with no skill, whose Sharpe ratios vary by variance (Bailey and Lopez de Prado).

deflated_sharpe
deflated_sharpe(
    excess: Series,
    periods_per_year: int,
    trials: int | None,
    trial_variance: float | None,
) -> float | None

The probabilistic Sharpe ratio against the best Sharpe ratio trials strategies would show by luck, their annual Sharpe ratios varying by trial_variance. None unless both are given and there were at least two trials.

lo_sharpe
lo_sharpe(
    excess: Series,
    periods_per_year: int,
    lags: int = LO_LAGS,
) -> float | None

The Sharpe ratio a year corrected for the serial correlation of the daily returns (Lo, 2002), which annualising by the square root of the days assumes away. Correlations up to lags days are counted.

regimes

How a run did in each kind of market: months the market rose and fell, calm and volatile stretches, and bear markets and corrections.

regimes = RegimeAnalytics(RunData.from_record(folder), market=sp500_levels)
regimes.regimes()["volatile"]

Each regime is a set of days, found on a broad market series as the crises are; over its days the fund is measured against its comparator. A regime's return compounds its days, as if invested on those days alone, and is annualised from their mean so regimes of different lengths compare.

Regime dataclass
Regime(
    days: int,
    fund: float,
    comparator: float,
    difference: float,
    fund_annual: float,
    comparator_annual: float,
    beta: float,
    alpha: float,
)

The fund against its comparator over one regime's days.

Attributes:

Name Type Description
days int

The regime's days the run had.

fund float

The fund's return compounded over them.

comparator float

The comparator's.

difference float

The fund's less the comparator's.

fund_annual float

The fund's mean daily return, a year.

comparator_annual float

The comparator's.

beta float

The fund's beta to the comparator over them.

alpha float

The fund's mean daily return beyond its beta, in excess of the risk-free rate, a year (Jensen's alpha over the regime).

RegimeAnalytics
RegimeAnalytics(
    run: RunData,
    comparator: str | Series = INDEX,
    market: str | Series | None = None,
    settings: AnalysisSettings | None = None,
)

How a run did in each kind of market, against one comparator.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
comparator str | Series

What the fund is measured against, by name or levels; the run's index by default.

INDEX
market str | Series | None

The broad market series the regimes are found on, by name of one of the run's comparators or by its levels; the run's benchmark when it has one, else its index.

None
settings AnalysisSettings | None

What the figures are measured over and with.

None

Raises:

Type Description
KeyError

If the run has no comparator of a name given.

days
days() -> dict[str, pd.Series]

Which days fall in each regime, by name.

regimes
regimes() -> dict[str, Regime | None]

The fund against its comparator in each regime, by name; None for a regime the run never saw.

up_and_down_months
up_and_down_months(
    market: Series,
) -> tuple[pd.Series, pd.Series]

Which days fall in a month the market rose, and in one it fell, from its daily returns.

calm_and_volatile
calm_and_volatile(
    market: Series,
    window: int = VOLATILITY_WINDOW,
    fifth: float = FIFTH,
) -> tuple[pd.Series, pd.Series]

Which days the market's rolling volatility was in its lowest fifth, and in its highest; days before the first full window are neither.

falls
falls(
    levels: Series,
    deeper_than: float,
    no_deeper_than: float | None = None,
) -> pd.Series

Which days fall in one of the market's declines, peak to trough, of more than deeper_than (and at most no_deeper_than).

regime_of
regime_of(
    ours: Series,
    theirs: Series,
    days: Series,
    daily_rate: float,
    periods_per_year: int,
) -> Regime | None

The fund against its comparator over days; None without any.

relative_analytics

A run against one comparator, its index or its benchmark: how much of its return the comparator explains and what is left (alpha, beta, R-squared, and the t-statistics that say whether the rest is more than luck), how it behaved when the comparator rose and fell, how often and by how much it got ahead, and its stretches of falling behind.

relative = RelativeAnalytics(RunData.from_record(folder), "benchmark")
relative.figures()

The two are compared on the days both have, as the run's comparisons are: the comparator's first day returns nothing from its level that day, and so, when the comparator starts after the run, does the run's.

Regression dataclass
Regression(
    alpha: float,
    beta: float,
    r_squared: float,
    alpha_t: float | None,
    alpha_p: float | None,
    residual_risk: float,
)

The fund's daily excess returns regressed on the comparator's.

Attributes:

Name Type Description
alpha float

The intercept, a year: the return the comparator does not explain.

beta float

The slope: the fund's sensitivity to the comparator.

r_squared float

The share of the fund's variance the comparator explains.

alpha_t float | None

Alpha over its standard error.

alpha_p float | None

The two-sided probability of an alpha this far from 0 by chance.

residual_risk float

The spread of what the comparator does not explain, a year.

YearDifference dataclass
YearDifference(
    year: str,
    fund: float,
    comparator: float,
    difference: float,
)

One calendar year's returns and their difference.

Attributes:

Name Type Description
year str

The year.

fund float

The fund's return in it.

comparator float

The comparator's.

difference float

The fund's less the comparator's, as a fraction (0.019 is 1.9 percentage points).

RelativeAnalytics
RelativeAnalytics(
    run: RunData,
    comparator: str | Series = INDEX,
    settings: AnalysisSettings | None = None,
)

A run against one comparator.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
comparator str | Series

The comparator, by name (index, optimised or benchmark) or by its levels.

INDEX
settings AnalysisSettings | None

What the figures are measured over and with; the run's own when None.

None

Raises:

Type Description
KeyError

If the run has no comparator of that name.

periods_per_year property
periods_per_year: int

How many daily returns make a year.

active property
active: Series

The fund's daily return less the comparator's.

regression
regression() -> Regression | None

Jensen's regression of the fund's daily excess returns on the comparator's; None with fewer than three days. Against a comparator that does not vary there is nothing to be sensitive to, so the beta is 0, as beacon.analysis.performance.formulas.beta gives it, and the alpha is the fund's mean excess return.

treynor
treynor() -> float | None

The annual mean excess return per unit of beta; None without a beta.

information_ratio_t
information_ratio_t() -> dict[str, float | None]

The information ratio's t-statistic, IR times the root of the years, and its two-sided probability by chance.

capture
capture() -> dict[str, float | None]

Up capture, down capture and their ratio, on months: the fund's geometric mean monthly return in the months the comparator rose (fell) over the comparator's in them.

batting_average
batting_average() -> float | None

The share of months the fund's return beat the comparator's.

relative_levels
relative_levels() -> pd.Series

The fund's wealth over the comparator's, from 1 the business day before the first day compared.

relative_drawdowns
relative_drawdowns(worst: int = WORST) -> DrawdownFigures

The drawdowns of the fund's wealth relative to the comparator's: each a stretch of falling behind.

downside_tracking_error
downside_tracking_error() -> float

The root mean square of the days the fund fell behind, every day counted, a year.

tracking_difference_by_year
tracking_difference_by_year() -> tuple[
    list[YearDifference], float | None
]

Each calendar year's return less the comparator's, and the standard deviation of those differences (None with fewer than two years).

figures
figures() -> dict[str, Any]

Every relative figure, by name: today's comparison figures (relative return, tracking error, information ratio, correlation, beta, monthly hit rate) and the rest.

return_figures

A run's return figures: trailing returns and returns to date, its best and worst periods, how often it gained, its average gain and loss, its longest runs of winning and losing months, and its mean returns.

Every figure is worked out from daily returns, the first from the run's starting point. A period's return compounds its days. A trailing return runs from the close on or before the same date that many months or years before the last day; a window the run is too short for has none. Returns to date run from the close before the month, quarter or year began, or from the run's start when it began within it.

WindowReturn dataclass
WindowReturn(
    start: Timestamp,
    end: Timestamp,
    days: int,
    total: float,
    annualised: float | None,
)

A return over a window ending on the run's last day.

Attributes:

Name Type Description
start Timestamp

The window's first day with a return.

end Timestamp

Its last day.

days int

Its returns.

total float

Its compounded return.

annualised float | None

The total as a rate a year, for a window longer than a year (3 years and more, or since inception over more than a calendar year); None otherwise, as a shorter return annualised overstates what was earned.

PeriodReturn dataclass
PeriodReturn(
    period: str,
    start: Timestamp,
    end: Timestamp,
    value: float,
    partial: bool,
)

One calendar period's compounded return.

Attributes:

Name Type Description
period str

Its name: a date, 2024-W05, 2024-01, 2024 Q1 or 2024.

start Timestamp

Its first day with a return.

end Timestamp

Its last.

value float

Its compounded return.

partial bool

Whether the run covered only part of it, at its start or end.

GainsAndLosses dataclass
GainsAndLosses(
    average_gain: float | None,
    average_loss: float | None,
    ratio: float | None,
    gains: int,
    losses: int,
)

The average gain and loss over periods of one length.

Attributes:

Name Type Description
average_gain float | None

The mean of the positive returns; None without any.

average_loss float | None

The mean of the negative returns; None without any.

ratio float | None

The average gain over the size of the average loss; None without both.

gains int

How many returns were positive.

losses int

How many were negative.

Streak dataclass
Streak(months: int, start: str, end: str, total: float)

A run of consecutive months that all gained, or all lost.

Attributes:

Name Type Description
months int

Its length.

start str

Its first month, 2024-01.

end str

Its last month.

total float

Its compounded return.

window_return
window_return(
    returns: Series,
    start: Timestamp,
    periods_per_year: int,
    annualise: bool = False,
) -> WindowReturn | None

The return over returns from start (the first day counted) to their end, annualised as well when annualise; None when there are no such days.

longer_than_a_year
longer_than_a_year(
    start: Timestamp, end: Timestamp
) -> bool

Whether a window from start to end spans more than a calendar year.

trailing_returns
trailing_returns(
    returns: Series, periods_per_year: int
) -> dict[str, WindowReturn | None]

The returns over the last 1, 3 and 6 months and 1, 3, 5 and 10 years, by name; None for a window longer than the run.

returns_to_date
returns_to_date(
    returns: Series, periods_per_year: int
) -> dict[str, WindowReturn | None]

The returns for the month, quarter and year to date, by name.

period_returns
period_returns(
    returns: Series, frequency: str
) -> list[PeriodReturn]

Each calendar day, week, month, quarter or year's compounded return, in order, with whether the run covered all of it.

best_and_worst
best_and_worst(
    returns: Series, frequency: str
) -> tuple[PeriodReturn, PeriodReturn] | None

The best and the worst of frequency's periods; None with none. The first of equals is taken.

positive_share
positive_share(
    returns: Series, frequency: str
) -> float | None

The share of frequency's periods with a positive return; None with none.

gains_and_losses
gains_and_losses(
    returns: Series, frequency: str = DAILY
) -> GainsAndLosses

The average gain and average loss over frequency's periods, and their ratio.

longest_streaks
longest_streaks(
    returns: Series,
) -> tuple[Streak | None, Streak | None]

The longest run of months that all gained, and of months that all lost; the earlier of equals, and None without such a month. A month with no return breaks both.

means
means(
    returns: Series, periods_per_year: int
) -> dict[str, float | None]

The arithmetic and geometric mean daily return, and each a year: the arithmetic one times the days a year, the geometric one compounded over them, which is the annualised return.

rolling

A run's figures over a window that moves a day at a time: each day's figure is the one its formula gives over the window ending that day, blank until the first full window. And over every window, the worst, middle and best return, and how often it gained or got ahead.

rolling_figures
rolling_figures(
    returns: Series,
    window: int,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> pd.DataFrame

Each day's return, volatility, Sharpe ratio, Sortino ratio and maximum drawdown over the window days ending that day.

rolling_relative
rolling_relative(
    ours: Series,
    theirs: Series,
    window: int,
    periods_per_year: int,
) -> pd.DataFrame

Each day's relative return, tracking error, information ratio, beta and correlation against a comparator over the window days ending that day, the two on the same days.

outcomes
outcomes(
    window_returns: Series,
    relative_returns: Series | None = None,
) -> dict[str, float | None]

Over every full window: the worst, middle and best return, the share of windows that gained, and the share ahead of the comparator.

settings

What a run's analysis is measured over and against.

AnalysisSettings dataclass
AnalysisSettings(
    start: Timestamp | str | None = None,
    end: Timestamp | str | None = None,
    periods_per_year: int | None = None,
    risk_free_rate: float | None = None,
    tracked: str | None = None,
)

How a run's figures are measured. Each setting left as None takes the run's own.

Attributes:

Name Type Description
start Timestamp | str | None

The first day measured; the run's first when None. A later start measures from the previous day's close.

end Timestamp | str | None

The last day measured; the run's last when None.

periods_per_year int | None

How many daily returns make a year.

risk_free_rate float | None

The annual rate the Sharpe ratio is in excess of.

tracked str | None

The comparator tracking is measured against, by name (index, optimised or benchmark): the index the run traded toward when None, which is the optimised index when it solved one.

tables

A run's figures laid out as tables: the calendar grid of monthly returns with each year's, the figures for each quarter or year, and the trailing returns, each for the fund, its comparators and the differences.

Differences are the fund's figure less the comparator's, as fractions like the figures themselves (0.019 is 1.9 percentage points). A period's return compounds its days, so a year's months compound to the year, and the years to the whole run.

monthly_grid
monthly_grid(returns: Series) -> pd.DataFrame

Each month's compounded return, a row a year and a column a month, with the year's own return (to date, for the last) in Year. A month the run did not cover is blank.

difference_grid
difference_grid(
    ours: Series, theirs: Series
) -> pd.DataFrame

The fund's monthly grid less a comparator's, cell by cell, the two on the same days.

period_table
period_table(
    returns: Series,
    frequency: str,
    periods_per_year: int,
    risk_free_rate: float,
    comparators: Mapping[str, Series],
) -> pd.DataFrame

Each quarter's or year's figures (return, volatility, Sharpe ratio, maximum drawdown) for the fund, with its share of positive days, and for each comparator (given by its daily returns, its first day's nothing), and the fund's less each comparator's: a row a period, columns by whom and figure.

trailing_table
trailing_table(
    fund: Mapping[str, WindowReturn | None],
    comparators: Mapping[
        str, Mapping[str, WindowReturn | None]
    ],
) -> pd.DataFrame

The trailing returns as a table: a row a window, the total and annualised return for the fund, each comparator and the fund's less each comparator's. A difference is given only when the two cover the same days: a comparator that starts after the run has a shorter inception.

month_of_year
month_of_year(returns: Series) -> pd.Series

The mean return of each calendar month over the years, January to December; blank for a month the run never covered.

comparator_returns
comparator_returns(
    returns: Series, levels: Mapping[str, Series]
) -> dict[str, tuple[pd.Series, pd.Series]]

For each comparator, the fund's and its daily returns on the days both have (beacon.analysis.performance.comparisons.aligned); a comparator sharing fewer than two days is left out.

trading

How much a run traded: its turnover each trading day, at each rebalance and each year, and its trading by why it was made.

One-way turnover is the lesser of what was bought and what was sold, over the fund's assets: the SEC's measure, which money arriving or leaving does not inflate, as it is bought or sold on one side only. Two-way turnover is all of it, bought and sold, over the same assets. A day's are over the NAV at the close before its trades; a year's over its mean daily NAV.

nav_before
nav_before(
    nav: Series, initial_capital: float
) -> pd.Series

The NAV at the close before each day: the day before's, the first's the starting capital.

daily_turnover
daily_turnover(
    trades: DataFrame, nav: Series, initial_capital: float
) -> pd.DataFrame

Each trading day's buys, sells, number of trades and their mean value, one-way and two-way turnover, and whether it was a rebalance.

rebalance_turnover
rebalance_turnover(
    trades: DataFrame, nav: Series, initial_capital: float
) -> pd.DataFrame

Each rebalance's own trades: its buys, sells, number of trades and their mean value, and its one-way and two-way turnover.

yearly_turnover
yearly_turnover(
    trades: DataFrame, nav: Series
) -> pd.DataFrame

Each calendar year's buys, sells, number of trades and their mean value, and its one-way and two-way turnover over its mean daily NAV.

by_reason
by_reason(trades: DataFrame) -> pd.DataFrame

The run's trading by why it was made: the trades, what was bought and sold, and the share of all it traded, a row a reason, largest first. Each trade has one reason, so the rows add up to the run's trading.

relative

Performance of one level series relative to another.

BacktestResult.get_tracking_error compares a portfolio against the index it was built to track, which is the right question for a replication mandate. It is not the only question: a portfolio is also measured against benchmarks it was never trying to replicate. That comparison needs a general pair of level series rather than a target IndexResult, which is what lives here.

Alignment is the part that matters. Two series rarely cover exactly the same dates (a benchmark may start earlier, or trade on a holiday the portfolio does not), and silently comparing mismatched observations produces numbers that look fine and mean nothing. Every comparison here uses only the dates both series share.

RelativeMetrics dataclass

RelativeMetrics(
    observations: int,
    start: str,
    end: str,
    total_return: float,
    benchmark_return: float,
    excess_return: float,
    tracking_error: float,
    correlation: float,
    beta: float,
)

How one level series performed against another.

Attributes:

Name Type Description
observations int

Aligned dates the comparison used, which may be fewer than either input carried.

start str

First aligned date, ISO 8601.

end str

Last aligned date, ISO 8601.

total_return float

Portfolio return over the aligned window.

benchmark_return float

Benchmark return over the same window.

excess_return float

Portfolio minus benchmark. Also known as the tracking difference: both names mean the arithmetic difference of two total returns, and each is idiomatic in a different context.

tracking_error float

Annualised sample standard deviation of the per-period return differences.

correlation float

Correlation of the two return series. 0.0 when either series barely varies (standard deviation at or below 1e-12).

beta float

Sensitivity of portfolio returns to benchmark returns: covariance over benchmark variance. 0.0 when the benchmark's returns barely vary.

align_on_common_window

align_on_common_window(
    portfolio: Series, benchmark: Series
) -> tuple[pd.Series, pd.Series]

Restrict two level series to the dates they share.

Parameters:

Name Type Description Default
portfolio Series

Level series of the portfolio.

required
benchmark Series

Level series of the benchmark.

required

Returns:

Name Type Description
tuple tuple[Series, Series]

The two series over their shared dates, in ascending order.

Raises:

Type Description
CalculationError

If either series is empty, or the two share fewer than MINIMUM_ALIGNED_OBSERVATIONS (3) dates. Comparing series that barely overlap would return a number rather than an error, and nothing downstream would reveal that it rests on two data points.

relative_metrics

relative_metrics(
    portfolio: Series,
    benchmark: Series,
    periods_per_year: int = PERIODS_PER_YEAR,
) -> RelativeMetrics

Compare a portfolio level series against a benchmark's.

Parameters:

Name Type Description Default
portfolio Series

Level series of the portfolio. Need not be rebased: returns are scale-invariant, so only the shape matters.

required
benchmark Series

Level series of the benchmark.

required
periods_per_year int

Annualisation factor for the tracking error.

PERIODS_PER_YEAR

Returns:

Name Type Description
RelativeMetrics RelativeMetrics

The comparison, computed over the shared window only.

Raises:

Type Description
CalculationError

If the series cannot be aligned, or a level of zero makes a return undefined.

reports

A run's analysis as reports: the statistics, the reports they are read in, and every choice they make.

catalogue

Every statistic a run's analysis gives, in the order the categories are read.

from beacon.analysis.reports.catalogue import CATALOGUE

CATALOGUE["sharpe_ratio"].label  # "Sharpe ratio"
attribution

The statistics of where the fund's return came from: by name and group, against the tracked comparator, by Brinson-Fachler effect, by currency, by factor, and the implementation breakdown of the tracking difference.

statistics
statistics() -> list[Statistic]

The attribution statistics, in order.

build

The shapes most statistics take: a figure of each book's returns, a figure of the fund against each comparator, and tables from one call.

per_book
per_book(
    read: Reader, active: bool = False
) -> Callable[[Context], dict[str, Any]]

A statistic of each book's returns: the fund's, each comparator's and each share class's; with active, the fund's less the tracked comparator's too.

per_comparator
per_comparator(
    read: RelativeReader,
) -> Callable[[Context], dict[str, Any]]

A statistic of the fund against each comparator the run has.

memo
memo(
    analytics: Any, name: str, build: Callable[[], Any]
) -> Any

build's answer, worked out once for analytics (a book's or a comparator's), so statistics read from the same call share it.

fund_only
fund_only(
    read: Reader,
) -> Callable[[Context], dict[str, Any]]

A statistic of the fund alone.

frame
frame(table: DataFrame) -> pd.DataFrame

table with two-level column and row names joined with /, ready to send.

performance

The statistics of a run's own returns: returns, risk, drawdowns, the shape of the distribution and the risk-adjusted ratios, each given for the fund, its comparators and its share classes.

statistics
statistics() -> list[Statistic]

The statistics of a run's own returns, in order.

portfolio

The statistics of what the fund held and did: its holdings and groups, its trading and costs, its cash and flows, its income, and an ETF's premium and a fund's futures.

needs
needs(
    code: str, read: Callable[[Context], Any]
) -> Callable[[Context], Any]

read, or Unavailable with code when the run lacks the table it reads (the portfolio figures raise KeyError naming it).

statistics
statistics() -> list[Statistic]

The portfolio statistics, in order.

relative

The statistics of the fund against its comparators, the spread of its rolling returns, and how it held up in crises and regimes.

statistics
statistics() -> list[Statistic]

The relative, rolling, crisis and regime statistics, in order.

risk

The statistics of the fund's risk: its factors, its value at risk, where its risk comes from, how concentrated it is, and how fast it could be sold.

The factor, expected-risk and liquidity figures read the data store the run was made from; without it they are unavailable.

long_enough
long_enough(
    read: Callable[[Context], Any],
) -> Callable[[Context], Any]

read, or Unavailable when the run is too short for the risk model it needs.

statistics
statistics() -> list[Statistic]

The risk statistics, in order.

context

What a run's statistics are worked out from: the run, the choices the analysis makes, and each analysis of it, built the first time a statistic asks for it.

context = Context(run, options, source)
context.book("index").headline()
context.relative("benchmark").regression()

A statistic asks the context for what it needs; one the run cannot give, such as a comparator it does not have, raises Unavailable saying why.

Context
Context(
    run: RunData,
    options: AnalysisOptions | None = None,
    source: DataFetcher | None = None,
    factors: Sequence[Factor] | None = None,
    folder: Path | None = None,
)

A run and every analysis of it a statistic can read.

Parameters:

Name Type Description Default
run RunData

The run.

required
options AnalysisOptions | None

The choices the analysis makes.

None
source DataFetcher | None

The data store the run was made from, which factor, liquidity and risk figures read; None when it is not loaded.

None
factors Sequence[Factor] | None

The factors risk and attribution use; the defaults when None.

None
folder Path | None

Where a stored run is kept: its factor work is read from there when it was kept for these factors.

None
performance cached property
performance: PerformanceAnalytics

The fund's performance.

comparators cached property
comparators: list[str]

The comparators the run has, in order.

tracked cached property
tracked: str | None

The comparator relative figures are measured against: the one asked for, else the optimised index when the run solved one, else the index; None when the run has none of them.

classes cached property
classes: list[str]

The run's share classes, by name; none for a run without them.

crises cached property
crises: CrisisAnalytics

The run's crises, against the tracked comparator.

regimes cached property
regimes: RegimeAnalytics

The run's regimes, against the tracked comparator.

portfolio cached property
portfolio: PortfolioAnalytics

The run's portfolio figures.

factor_analytics cached property
factor_analytics: FactorAnalytics

The run's factor exposures, returns and betas: as kept with a stored run for these factors, else worked out when first read.

loss cached property
loss: RiskOfLoss

The run's value at risk, expected shortfall and stress windows.

risk_over_time cached property
risk_over_time: RiskOverTime

The run's expected and measured risk.

concentration cached property
concentration: ConcentrationAnalytics

The run's concentration; by risk only when the data store is loaded.

liquidity cached property
liquidity: LiquidityAnalytics

How fast the run's books could be sold.

attribution cached property
attribution: Attribution

The run's attribution.

kept
kept(key: tuple[Any, ...], build: Callable[[], Any]) -> Any

What build gives, worked out once under key, so statistics read from the same call share it.

require
require(comparator: str) -> str

comparator, or Unavailable when the run lacks it.

require_tracked
require_tracked() -> str

The tracked comparator, or Unavailable when the run has none.

books
books() -> list[str]

Every book a figure of returns is given for: the fund, each comparator and each share class.

book
book(name: str) -> PerformanceAnalytics

The performance of book name: the fund, a comparator, or a share class (class/<name>).

relative
relative(
    comparator: str | None = None,
) -> RelativeAnalytics

The fund against comparator: the tracked one when None.

market
market() -> str

The comparator crises and regimes are read from.

require_source
require_source() -> DataFetcher

The data store, or Unavailable when it is not loaded.

keep_work
keep_work(folder: Path) -> bool

Keep the factor work this context did with the run in folder, so a later analysis of it does not do it again; False when it did none, or its factors cannot be kept.

window
window() -> tuple[Any, Any]

The analysis window's start and end, None where open.

definitions

The reports a run's analysis is read in, each at two levels: core, the statistics shown by default with others offered, and extended, everything the report covers.

REPORTS["factsheet"].level("core").included

A level lists its statistics in sections, by category, in the order the categories are read; each statistic is included (shown by default) or optional (offered, off by default), with the time view it is shown in by default: point, rolling or both. Everything in a report's extended view is optional for its core.

Entry dataclass
Entry(id: str, state: str, view: str)

One statistic in a report level.

Attributes:

Name Type Description
id str

The statistic's id.

state str

included or optional.

view str

The time view it is shown in by default.

Section dataclass
Section(
    category: str, label: str, entries: tuple[Entry, ...]
)

A level's statistics of one category, in order.

Level dataclass
Level(name: str, sections: tuple[Section, ...])

A report at one level of detail.

entries property
entries: list[Entry]

Every statistic in the level, in order.

included property
included: list[str]

The statistics shown by default.

optional property
optional: list[str]

The statistics offered, off by default.

entry
entry(statistic_id: str) -> Entry | None

statistic_id's entry, or None when the level does not cover it.

Report dataclass
Report(
    id: str,
    label: str,
    order: int,
    levels: dict[str, Level],
)

One report, with its two levels.

level
level(name: str) -> Level

The report at name (core or extended).

Raises:

Type Description
ValueError

If name is neither.

load_reports
load_reports(path: Path = _FILE) -> dict[str, Report]

The reports kept in path, in order.

options

Every modelling choice a run's analysis makes, in sections, each with its default, and described well enough for a form to draw.

options = AnalysisOptions(rolling=RollingOptions(window=126))

A section left out takes its defaults. describe gives each section's settings in the shape GET /backtest/options describes a backtest's.

RollingOptions dataclass
RollingOptions(window: int = 252, step: str | None = None)

How rolling figures are measured.

Attributes:

Name Type Description
window int

The trailing window, in days.

step str | None

How often a window ends: every day or every month end; by each statistic's cost when None (month end for the heavy ones).

ReturnsOptions dataclass
ReturnsOptions(
    worst_drawdowns: int = 5,
    gains_frequency: str = "daily",
    sharpe_benchmark: float = 0.0,
    trials: int | None = None,
    trial_variance: float | None = None,
    bootstrap_samples: int = 1000,
    bootstrap_confidence: float = 0.95,
    bootstrap_block: int = 1,
    bootstrap_seed: int = 0,
    histogram_bins: int = 50,
)

Choices the return, drawdown and distribution figures make.

Attributes:

Name Type Description
worst_drawdowns int

How many of the deepest drawdowns are listed.

gains_frequency str

The returns average gains and losses are measured on.

sharpe_benchmark float

The Sharpe ratio the probabilistic Sharpe ratio is the chance of beating.

trials int | None

How many strategies were tried, for the deflated Sharpe ratio; the run alone when None.

trial_variance float | None

The variance of the tried strategies' Sharpe ratios; the run's own when None.

bootstrap_samples int

How many resamples the ranges are drawn from.

bootstrap_confidence float

The share of resamples the ranges hold.

bootstrap_block int

Consecutive days drawn together.

bootstrap_seed int

The random seed, so a range is the same each time.

histogram_bins int

How many bins the return histogram has.

CrisesOptions dataclass
CrisesOptions(
    market: str | None = None, threshold: float = 0.15
)

How crises and regimes are found.

Attributes:

Name Type Description
market str | None

The comparator crises and regimes are read from: the benchmark when the run has one, else the index, when None.

threshold float

The fall from a peak that makes a crisis.

LossOptions dataclass
LossOptions(
    levels: tuple[float, ...] = (0.95, 0.99),
    horizons: tuple[int, ...] = (1, 10),
)

How value at risk and expected shortfall are given.

Attributes:

Name Type Description
levels tuple[float, ...]

The confidence levels.

horizons tuple[int, ...]

The horizons, in days; longer ones scale the day's by the square root of time.

RiskModelOptions dataclass
RiskModelOptions(
    window: int = 252,
    structure: str = "auto",
    country_field: str = "COUNTRY_DOMICILE",
    minimum_names: int = 10,
    sectors: bool = True,
    currencies: bool = True,
    weekly: bool = True,
    weeks: int = 104,
)

How expected risk is worked out.

Attributes:

Name Type Description
window int

The days of returns before each rebalance the factor model is fitted to, when it is fitted on daily returns.

structure str

global (the styles, with country, sector and currency factors), styles (the market and the styles alone), or auto: global when the names span more than one country.

country_field str

The reference field each name's country is read from; empty for no country factors.

minimum_names int

The fewest names a country needs for a factor of its own; one with fewer joins its region's.

sectors bool

Whether sectors are factors in the global model.

currencies bool

Whether currencies are factors in the global model.

weekly bool

Whether the global model is fitted on weekly returns.

weeks int

The weeks of returns before each rebalance the weekly model is fitted to.

settings
settings() -> Any

These choices as the risk model's settings (beacon.analysis.risk.model.RiskModelSettings).

LiquidityOptions dataclass
LiquidityOptions(
    participation: float = 0.2,
    lookback_days: int = 20,
    horizons: tuple[int, ...] = (1, 5, 20),
)

How fast the book could be sold.

Attributes:

Name Type Description
participation float

The share of a name's daily traded value the book could sell.

lookback_days int

The days a name's average traded value is taken over.

horizons tuple[int, ...]

The days the sellable share is given at.

AttributionOptions dataclass
AttributionOptions(
    grouping: str = "SECTOR", frequency: str = "Y"
)

How returns are attributed.

Attributes:

Name Type Description
grouping str

The reference field names are grouped by.

frequency str

The periods attribution is also given by: Y, Q or M.

AnalysisOptions dataclass
AnalysisOptions(
    window: AnalysisSettings = AnalysisSettings(),
    rolling: RollingOptions = RollingOptions(),
    returns: ReturnsOptions = ReturnsOptions(),
    crises: CrisesOptions = CrisesOptions(),
    value_at_risk: LossOptions = LossOptions(),
    risk_model: RiskModelOptions = RiskModelOptions(),
    liquidity: LiquidityOptions = LiquidityOptions(),
    attribution: AttributionOptions = AttributionOptions(),
)

Every choice a run's analysis makes, by section.

Described dataclass
Described(
    label: str,
    help: str,
    unit: str | None = None,
    choices: tuple[Any, ...] | None = None,
    choice_labels: dict[str, str] | None = None,
    suggestions: tuple[Any, ...] | None = None,
    minimum: float | None = None,
    maximum: float | None = None,
    items: str | None = None,
    items_unit: str | None = None,
)

How one setting is shown: what describe adds to its name, type and default.

describe
describe(
    options: AnalysisOptions | None = None,
) -> list[dict[str, Any]]

Each section's settings, in order, with their defaults from options (the defaults when None).

rerun

A stored run's analysis asked for again: with statistics added, a time view changed, or other choices.

block = rerun(run, request, source, folder, previous=stored_block)

With the same choices as the block it is given, only the statistics that block lacks (or lacks in a view now wanted) are worked out, and the answer is the whole block, the earlier statistics and reports with the new ones. With other choices everything asked for is worked out afresh. The run's factor work is read from its folder when it was kept for these factors, and kept there when it is worked out.

rerun
rerun(
    run: RunData,
    request: AnalysisRequest,
    source: DataFetcher | None,
    folder: Path | None = None,
    previous: Mapping[str, Any] | None = None,
    progress: Callable[[str], None] | None = None,
) -> dict[str, Any]

The analysis request asks for of a stored run.

Parameters:

Name Type Description Default
run RunData

The run.

required
request AnalysisRequest

What to give.

required
source DataFetcher | None

The data store, which factor, liquidity and risk figures read; None when it is not loaded.

required
folder Path | None

Where the run is kept, which its factor work is read from and kept in.

None
previous Mapping[str, Any] | None

The run's block so far, whose statistics are kept when it was made with the same choices.

None
progress Callable[[str], None] | None

Told as each part starts.

None

Returns:

Name Type Description
dict dict[str, Any]

The block, as beacon.analysis.request.analyse gives it.

same_choices
same_choices(
    previous: Mapping[str, Any], request: AnalysisRequest
) -> bool

Whether previous was made with the choices request makes: the same options and factors.

reusable
reusable(
    previous: Mapping[str, Any] | None,
    request: AnalysisRequest,
) -> dict[str, Any]

The statistics of previous that request wants and need not be worked out again: made with the same choices, in every view now wanted.

rolling

Statistics over each trailing window: the same calculation as each one's figure over the whole window, run on each window in turn, so a rolling figure is the point figure of its window.

tables = rolling_all([CATALOGUE["sharpe_ratio"], CATALOGUE["beta"]], context)

A window ends every day, or every month end; a statistic that is heavy to roll steps month end unless asked otherwise. A window is a number of daily returns, measured from the close before its first.

The most read figures (return, volatility, Sharpe and Sortino ratios, maximum drawdown, against each comparator the tracking difference and error, information ratio, beta and correlation, and value at risk and expected shortfall) roll in one pass over the whole series instead, which the tests check gives the window-by-window figures. Every other statistic is worked out window by window, all of them together, so those that read the same calculation share it.

step_of
step_of(statistic: Statistic, context: Context) -> str

How often statistic's windows end: as asked, else by its cost.

step_for
step_for(
    statistic: Statistic, options: AnalysisOptions
) -> str

How often statistic's windows end under options: as they ask, else by its cost.

window_ends
window_ends(
    days: DatetimeIndex, window: int, step: str
) -> list[int]

The positions in days each full window ends at.

rolling
rolling(
    statistic: Statistic, context: Context
) -> pd.DataFrame

statistic's figures over each trailing window: dates by figure name, empty when the window is longer than the run.

Raises:

Type Description
ValueError

If the statistic cannot roll.

rolling_all
rolling_all(
    statistics: Sequence[Statistic], context: Context
) -> dict[str, pd.DataFrame]

Each of statistics over each trailing window, by id.

Raises:

Type Description
ValueError

If one of them cannot roll.

window_by_window
window_by_window(
    statistics: Sequence[Statistic],
    context: Context,
    step: str,
) -> dict[str, pd.DataFrame]

statistics worked out on each window in turn, sharing each window's analyses.

rolling_loss

Value at risk and expected shortfall over each trailing window, every window at once: the windows stacked as rows, each method worked out along them, as beacon.analysis.risk.var works it out for one window.

rolling_loss
rolling_loss(
    measure: str,
) -> Callable[[Context], pd.DataFrame]

Each day's value at risk (var) or expected shortfall (es) over the window ending that day, by book, method, level and horizon.

statistics

What a statistic is: its id and label, what it measures, the kind of value it gives, how it is worked out, and whether it can roll.

A statistic gives one of three kinds of value:

  • figures: named numbers, by book (fund, index, benchmark, class/<name>), by comparator, or by the parts of a compound name joined with / (historical/0.95/1);
  • series: one series, dated or not;
  • table: one table.

One that cannot be worked out for a run raises Unavailable with a short code and a sentence saying why.

Unavailable
Unavailable(code: str, reason: str | None = None)

Bases: Exception

Why a statistic cannot be worked out for a run.

Attributes:

Name Type Description
code

One of REASONS.

reason

A plain sentence saying why, fit to show as it is.

Column dataclass
Column(
    name: str,
    label: str,
    unit: str | None = None,
    labels: Mapping[str, str] | None = None,
)

One column of a table statistic or a large table.

Attributes:

Name Type Description
name str

As it is keyed.

label str

As it is shown.

unit str | None

What it is measured in; None for text.

labels Mapping[str, str] | None

For a column of coded text, each value's label.

Statistic dataclass
Statistic(
    id: str,
    label: str,
    category: str,
    kind: str,
    part: str,
    description: str,
    compute: Callable[[Any], Any],
    unit: str | None = None,
    decimals: int | None = None,
    rolling: str | None = None,
    settings: tuple[str, ...] = (),
    columns: tuple[Column, ...] = (),
    figure_names: tuple[str, ...] = ("book",),
    roll: Callable[[Any], DataFrame] | None = None,
    signed: tuple[str, ...] = (),
)

One statistic.

Attributes:

Name Type Description
id str

Its id, which requests and stored choices name it by.

label str

Its name, as shown.

category str

What it is a figure of, which reports are sectioned by.

kind str

figures, series or table.

part str

The analysis that works it out: performance, portfolio, risk or attribution.

description str

One line saying what it measures.

compute Callable[[Any], Any]

Works it out from a Context.

unit str | None

What its figures (or series) are measured in.

decimals int | None

How many decimals it is shown to; by unit when None.

rolling str | None

What rolling it costs, when it can roll; None when it cannot.

settings tuple[str, ...]

The settings it reads, as section.setting.

columns tuple[Column, ...]

For a table, its columns in order.

figure_names tuple[str, ...]

What its figure names are made of, part by part.

roll Callable[[Any], DataFrame] | None

Works out its rolling figures itself, for a statistic that cannot be worked out window by window; dates by figure name.

signed tuple[str, ...]

The names of its figures that are returns or differences, whose sign says better or worse: the last part of a figure's, a column's or a summary figure's name; * for every number.

Summarised dataclass
Summarised(table: DataFrame, summary: Mapping[str, Any])

A table statistic's table, with the figures that sum it up (its totals, its residual).

Catalogue dataclass
Catalogue(statistics: Mapping[str, Statistic] = dict())

Every statistic, by id, in order.

decimals_of
decimals_of(statistic: Statistic) -> int | None

How many decimals statistic is shown to.

figures_of
figures_of(value: Any, prefix: str = '') -> dict[str, Any]

value as named figures: a mapping or a dataclass flattened, nested names joined with /, each figure a number, text or None.

scalar
scalar(value: Any) -> Any

One figure as plain data: a number, text, true or false, or None for a missing one; a date as YYYY-MM-DD.

wire

A statistic's value as plain data, ready to send as JSON: figures as a map of names to numbers, a series as {index, name, data}, a table as {index, columns, data} (rows), with dates as YYYY-MM-DD and missing values as None.

series_payload
series_payload(series: Series) -> dict[str, Any]

A series as {index, name, data}.

table_payload
table_payload(table: DataFrame) -> dict[str, Any]

A table as {index, columns, data}, a list of values a row.

payload
payload(kind: str, value: Any) -> dict[str, Any]

A statistic's value of kind: {"point": ...}, with summary for a table that has one.

request

What a run's analysis should give: the reports wanted, each at a level, with statistics added to or taken from them, the time view each is shown in, and every choice the analysis makes.

Backtest(...).run(definition, start, end,
                  analysis=AnalysisRequest(reports={"factsheet": "core"},
                                           add={"factsheet": ["sortino_ratio"]}))

The run then carries the analytics block (analyse): each statistic once, however many reports show it, and the reports as lists of statistic ids. Only what the statistics asked for need is worked out, so a factsheet's core never builds the risk model.

AnalysisRequest dataclass

AnalysisRequest(
    reports: Mapping[str, str] = (
        lambda: {"factsheet": "core"}
    )(),
    add: Mapping[str, Sequence[str]] = dict(),
    remove: Mapping[str, Sequence[str]] = dict(),
    time_views: Mapping[str, Mapping[str, str]] = dict(),
    statistics: Sequence[str] = (),
    options: AnalysisOptions = AnalysisOptions(),
    factors: Sequence[Factor] | None = None,
)

What a run's analysis should give.

Attributes:

Name Type Description
reports Mapping[str, str]

Each report wanted, by id, with its level: core or extended.

add Mapping[str, Sequence[str]]

By report, optional statistics to show.

remove Mapping[str, Sequence[str]]

By report, included statistics not to show.

time_views Mapping[str, Mapping[str, str]]

By report, statistic ids with the time view to show them in: point, rolling or both.

statistics Sequence[str]

Statistics wanted outside any report, over the window.

options AnalysisOptions

Every choice the analysis makes; the defaults when left out.

factors Sequence[Factor] | None

The factors risk and attribution use; the defaults (size, value, momentum) when None.

Raises:

Type Description
ValueError

If a report, level or statistic is unknown, a statistic added is not optional at the report's level or one taken out is not in it, or a time view is unknown or rolls a statistic that does not roll.

settings property
settings: AnalysisSettings

The window the figures are measured over and against.

selection
selection() -> dict[str, list[tuple[str, str]]]

Each report's statistics to show, in order, with their views: its level's included ones, with those added and without those taken out.

wanted
wanted() -> dict[str, set[str]]

Each statistic to work out, with the views it is wanted in.

parts
parts() -> list[str]

The parts the statistics wanted are worked out by, in order.

as_data
as_data() -> dict[str, Any]

The request as plain data, every default filled in.

analyse

analyse(
    result: Any,
    request: AnalysisRequest,
    progress: Callable[[str], None] | None = None,
) -> dict[str, Any]

The analytics block request asks for, of the finished run result.

Each statistic is worked out once, part by part, with a message as each part starts; one that cannot be worked out for the run is named under unavailable with a code and a reason.

Returns:

Name Type Description
dict dict[str, Any]

request, reports (each report's level and statistics,

dict[str, Any]

by id, with their views), statistics (each one's kind, its

dict[str, Any]

point value and, where wanted, its rolling table, and a

dict[str, Any]

table's summary), unavailable, active_against,

dict[str, Any]

groupings (the reference fields the run kept, which attribution

dict[str, Any]

can group by) and timings (seconds, the run's tables first).

keep_analysis_work

keep_analysis_work(result: Any, folder: Path) -> bool

Keep the factor work a finished run's analysis did in folder, where the run is stored, so a later analysis of it does not do it again; False when there was none.

analyse_run

analyse_run(
    context: Any,
    request: AnalysisRequest,
    progress: Callable[[str], None] | None = None,
    skip: Collection[str] = (),
) -> dict[str, Any]

analyse on a run's Context, for a stored run as for a finished one; the statistics in skip are left out, as already worked out.

risk

Risk analysis of a run, and the single-series risk metrics.

beacon.analysis.risk.model chooses and sets up the risk model an analysis uses, and estimates it once at each rebalance. The single-series metrics (volatility, Sharpe ratio, maximum drawdown) in beacon.analysis.risk.single_series, importable from here as before, are deprecated: use beacon.analysis.performance.formulas.

RiskMetricsCalculator

RiskMetricsCalculator()

Risk metrics as methods. Each method calls the module-level function of the same name and returns its result unchanged.

Deprecated: use the functions of beacon.analysis.performance.formulas.

Initializes the RiskMetricsCalculator.

calculate_volatility
calculate_volatility(
    price_series: Series, window: int = 252
) -> float

Calculates annualized volatility from a price series.

See :func:calculate_volatility.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required
window int

The number of trading periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized volatility as a float.

calculate_sharpe_ratio
calculate_sharpe_ratio(
    returns: Series,
    risk_free_rate: float,
    periods_per_year: int = 252,
) -> float

Calculates the annualized Sharpe Ratio.

Parameters:

Name Type Description Default
returns Series

A pandas Series of periodic returns.

required
risk_free_rate float

The annualized risk-free rate.

required
periods_per_year int

The number of return periods in a year (e.g., 252 for daily, 12 for monthly).

252

See :func:calculate_sharpe_ratio.

Returns:

Type Description
float

The annualized Sharpe Ratio as a float.

calculate_max_drawdown
calculate_max_drawdown(price_series: Series) -> float

Calculates the maximum drawdown from a price series.

See :func:calculate_max_drawdown.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required

Returns:

Type Description
float

The maximum drawdown as a non-positive float (e.g., -0.2 for a

float

20% drawdown).

calculate_max_drawdown

calculate_max_drawdown(price_series: Series) -> float

Calculates the maximum drawdown from a price series.

Deprecated: use beacon.analysis.performance.formulas.max_drawdown, max_drawdown(prices).

The largest fall from a running peak, as a fraction of that peak.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required

Returns:

Type Description
float

The maximum drawdown as a non-positive float (e.g., -0.2 for a 20%

float

drawdown, 0.0 when the price never falls below a previous peak).

Raises:

Type Description
TypeError

If price_series is not a pandas Series.

ValueError

If price_series is empty or contains non-numeric data.

calculate_sharpe_ratio

calculate_sharpe_ratio(
    returns: Series,
    risk_free_rate: float,
    periods_per_year: int = 252,
) -> float

Calculates the annualized Sharpe Ratio.

Deprecated: use beacon.analysis.performance.formulas.sharpe_ratio, sharpe_ratio(returns, periods_per_year, risk_free_rate).

The mean return in excess of the risk-free rate over its sample standard deviation, multiplied by sqrt(periods_per_year): the Sharpe ratio a backtest reports, beacon.analysis.performance.formulas.sharpe_ratio. The annual risk-free rate is taken as the period rate that compounds to it. 0 when the returns do not vary.

Parameters:

Name Type Description Default
returns Series

A pandas Series of periodic returns.

required
risk_free_rate float

The annualized risk-free rate.

required
periods_per_year int

The number of return periods in a year (e.g., 252 for daily, 12 for monthly).

252

Returns:

Type Description
float

The annualized Sharpe Ratio as a float.

Raises:

Type Description
TypeError

If returns is not a pandas Series or risk_free_rate is not a number.

ValueError

If returns is empty or non-numeric, or periods_per_year is not positive.

calculate_volatility

calculate_volatility(
    price_series: Series, window: int = 252
) -> float

Calculates annualized volatility from a price series.

Deprecated: use beacon.analysis.performance.formulas.volatility on the returns, volatility(prices.pct_change().dropna(), periods_per_year).

The sample standard deviation of the simple period returns, multiplied by sqrt(window). Despite its name, window is the annualisation factor, not a rolling window: the whole series is used.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required
window int

The number of trading periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized volatility as a float.

Raises:

Type Description
TypeError

If price_series is not a pandas Series.

ValueError

If price_series is empty or contains non-numeric data, or window is not positive.

analytics

A run's risk analytics in one place: its factors, value at risk, risk over time, concentration and liquidity, each built when first asked for and kept.

RiskAnalytics
RiskAnalytics(
    run: RunData,
    source: DataFetcher,
    factors: Sequence[Factor] | None = None,
    settings: AnalysisSettings | None = None,
    risk_model: RiskModelSettings | None = None,
    participation: float = PARTICIPATION,
    lookback_days: int = LOOKBACK_DAYS,
)

A run's risk.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
source DataFetcher

The data the run read, which the factors and traded values are measured from.

required
factors Sequence[Factor] | None

The factors; beacon.strategy.default_factors when None.

None
settings AnalysisSettings | None

The window and conventions the value at risk is measured with.

None
risk_model RiskModelSettings | None

How expected risk is worked out; the defaults when None.

None
participation float

The share of a name's daily traded value the liquidity figures assume could be sold.

PARTICIPATION
lookback_days int

The days a name's average traded value is taken over.

LOOKBACK_DAYS
factors cached property
factors: FactorAnalytics

The run's factor exposures, factor returns and betas.

loss cached property
loss: RiskOfLoss

The run's value at risk, expected shortfall and stress windows.

over_time cached property
over_time: RiskOverTime

The run's expected and measured risk, and where it came from.

concentration cached property
concentration: ConcentrationAnalytics

The run's concentration by weight, risk and outcome.

liquidity cached property
liquidity: LiquidityAnalytics

The run's liquidity at each rebalance and month end.

concentration

How concentrated a book is: in its weights, in its risk and in what earned its return.

By weight, over the names held (weight above 0), scaled to add to 1:

  • the Herfindahl index H = sum w_i^2, and its form normalised between an equally weighted book (0) and a single name (1), (H - 1/N) / (1 - 1/N);
  • the effective number of holdings 1 / H;
  • the largest weight and the top 5, 10 and 20 together;
  • the fewest names making 50%, 80% and 90% of the book;
  • optionally, the Gini coefficient from the Lorenz curve (0 equal, toward 1 all in one name) and the entropy -sum w_i ln w_i, with its effective number exp(E).

The same figures on group weights (a book's weight in each sector, say) measure how concentrated it is across the groups.

By risk, from each name's share of the expected volatility at each rebalance (beacon.analysis.risk.contributions): the Herfindahl of the shares and the effective number of risk sources 1 / sum p_i^2, the top names' share of the risk, the diversification ratio and the average correlation; optionally the absorption ratio, the share of the held names' total variance in the risk model's largest eigenvalues, one per factor.

By outcome, over a window: each name's contribution to the return (its weight at the close before times its return, added up day by day), the top contributors' share of the total, and breadth, the share of the names held that added to it.

Drift is how far the weights have moved since the last rebalance: half the sum of the gaps, the share of the book that would trade to put it back.

The concentration curve takes the names largest first, position by position: the k-th name's weight, the cumulative weight of the top k (as a share of the book), and the concentration of the top k on their own, their Herfindahl and effective number once their weights are scaled to add to 1, with how much adding the k-th changed each.

ConcentrationAnalytics
ConcentrationAnalytics(
    run: RunData,
    risk: RiskOverTime | None = None,
    settings: AnalysisSettings | None = None,
)

A run's concentration by weight, risk and outcome.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
risk RiskOverTime | None

The run's risk over time, for the figures by risk.

None
settings AnalysisSettings | None

The window the figures in crises are measured over, and the conventions the crises are found with.

None
weights
weights(
    book: str = "fund", field: str | None = None
) -> pd.DataFrame

A book's daily weights, by name or, with field, by group.

rebalances
rebalances() -> list[pd.Timestamp]

The run's rebalances.

by_weight
by_weight(
    book: str = "fund",
    field: str | None = None,
    daily: bool = False,
    optional: bool = False,
) -> pd.DataFrame

The book's concentration by weight at each rebalance's close, or each day; across groups with field.

against
against(
    other: str = "index",
    field: str | None = None,
    daily: bool = False,
) -> pd.DataFrame

The fund's figures by weight less other's, on the days both have.

curve
curve(
    date: str | Timestamp,
    book: str = "fund",
    field: str | None = None,
) -> pd.DataFrame

The concentration curve of a book's weights (or, with field, its group weights) at the close of date: names largest first, with cumulative weight and the top k's concentration.

by_period
by_period(
    frequency: str = "Y",
    book: str = "fund",
    field: str | None = None,
) -> pd.DataFrame

Each month's (M) or year's (Y) mean daily figures by weight.

in_crises
in_crises(
    book: str = "fund",
    field: str | None = None,
    market: str | Series | None = None,
    threshold: float = THRESHOLD,
    named: Sequence[Episode] | None = None,
) -> pd.DataFrame

The mean daily figures by weight inside each crisis and on the calm days outside all of them, within the analysis window.

drift
drift(book: str = 'fund') -> pd.Series

Each day's drift from the last rebalance's weights.

by_risk
by_risk(book: str = 'fund') -> pd.DataFrame

The book's concentration by risk at each rebalance the risk model covers, with its diversification ratio and average correlation.

Raises:

Type Description
ValueError

If no risk was given.

absorption_ratio
absorption_ratio(
    date: str | Timestamp, components: int | None = None
) -> float

The share of the total variance of the names held at the rebalance on date in the risk model's components largest eigenvalues: by default one per independent factor, so it reads how much of the risk the portfolio's common drivers absorb.

Over a sample covariance, which has no factors, it counts the largest fifth of the eigenvalues instead.

by_outcome
by_outcome(
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
    top: int = 10,
) -> dict[str, float | None]

The top contributors' share of the fund's return from its names over the window (the whole run by default), and breadth.

weight_figures
weight_figures(
    weights: DataFrame, optional: bool = False
) -> pd.DataFrame

The concentration figures of each row of weights (a book's weights by date and name), a row each.

lorenz
lorenz(weights: Series) -> pd.DataFrame

The Lorenz curve of the names held: the share of the names, smallest first, against the share of the book they make together.

concentration_curve
concentration_curve(weights: Series) -> pd.DataFrame

The names held, largest first, a row a position from 1: each name, its weight (a share of the names held), the cumulative weight to it, and the top k's Herfindahl and effective number on their own, with the change the k-th made to each.

risk_figures
risk_figures(
    shares: Series, tops: Sequence[int] = TOPS
) -> dict[str, float]

The concentration of a book's risk, from each name's share of it.

outcome_figures
outcome_figures(
    contributions: Series, held: int, top: int = 10
) -> dict[str, float | None]

The top contributors' share of the return and breadth, from each name's contribution to it over a window.

contributions

Where a run's risk came from: expected at each rebalance, from the factor model, and measured over the run, from its returns.

Expected. At each rebalance the factor model (see beacon.analysis.risk.model) is estimated on the year of daily returns before it, on the factors' exposures that day, and the book is held at its weights at that day's close. Its covariance is B F B' + D, so the book's variance splits exactly into a factor part, x' F x with x = B' w, and a specific part, w' D w; so does the active variance, on the active weights. Each name's marginal contribution is (S w)_i / sigma and its contribution w_i times that, which add up to the volatility; a group's is the sum of its names'.

The diversification ratio is the weighted sum of the names' volatilities over the book's, and the average correlation the one the weights and volatilities imply:

DR = sum w_i s_i / s,    rho = (s^2 - sum w_i^2 s_i^2) / ((sum w_i s_i)^2 - sum w_i^2 s_i^2)

Measured. Each name's daily contribution to the fund's return is its weight at the close before times its return that day; whatever else moved the fund (cash, costs, futures) is other. Each one's share of the fund's measured variance is the covariance of its contributions with the fund's returns over their variance, and the shares add up to 1 exactly.

RiskOverTime
RiskOverTime(
    factors: FactorAnalytics,
    window: int = 252,
    settings: RiskModelSettings | None = None,
)

A run's expected risk at each rebalance and its measured risk over the run.

Parameters:

Name Type Description Default
factors FactorAnalytics

The run's factors, which the risk model is fitted on.

required
window int

The sessions of daily returns before each rebalance the model is estimated on.

252
settings RiskModelSettings | None

How the factor model is estimated, its structure among them (see beacon.analysis.risk.model); its kind is always a factor model, and window is used when None.

None
dates
dates() -> list[pd.Timestamp]

The rebalances with enough returns before them for their model.

weights
weights(date: Timestamp, book: str = 'fund') -> pd.Series

A book's weights at the close of date, by name.

contributions
contributions(
    date: str | Timestamp,
    book: str = "fund",
    against: str | None = None,
) -> pd.DataFrame

Each name's weight, marginal contribution, contribution and share of the book's expected volatility (or, against a comparator, of its expected tracking error) at the rebalance on date. A tracking error below a millionth of the book's own volatility is rounding in the weights, and its shares are given as 0.

group_contributions
group_contributions(
    date: str | Timestamp,
    field: str,
    book: str = "fund",
    against: str | None = None,
) -> pd.DataFrame

The contributions added up by the group each name was in on the day, by the grouping field field.

expected
expected(
    date: str | Timestamp,
    book: str = "fund",
    against: str = "index",
) -> dict[str, float]

The book's expected risk at the rebalance on date: its volatility, split into factor and specific; its tracking error against against, split the same way; its diversification ratio and average correlation; and the share of its weight the model covers.

summary
summary(
    book: str = "fund", against: str = "index"
) -> pd.DataFrame

expected at every rebalance with a year of returns before it.

forecast_check
forecast_check(
    book: str = "fund", against: str | None = "index"
) -> pd.DataFrame

Each rebalance's expected volatility, and tracking error against against (none when None), beside the ones measured over the days until the next rebalance, and the measured over the expected: see beacon.analysis.risk.forecast.

bias_statistics
bias_statistics(
    book: str = "fund", against: str | None = "index"
) -> dict[str, float | None]

The bias statistic of the expected volatility, and of the expected tracking error against against: the standard deviation of the daily returns over the forecast in force, 1 when the forecasts were right on average. See beacon.analysis.risk.forecast.

measured
measured(group: str | None = None) -> pd.DataFrame

Each name's (or, with group, each group's) share of the fund's measured variance over the run, with other for the rest of its return, and its contribution to its measured volatility a year.

factor_store

A run's factor work kept beside it, so it is worked out once: each rebalance's exposures, the market caps they were centred and fitted with, the groups a small group fell back to, and the daily factor returns.

keep(analytics, folder)      # after working them out
restore(analytics, folder)   # before, on a stored run

What is kept is keyed by the factors' definitions and the universe they were standardised over, so a run asked about other factors works them out again. Factors that read a Python function cannot be told apart that way and are never kept.

fingerprint
fingerprint(
    factors: list[Factor], universe: list[str] | None
) -> str | None

A key for factors standardised over universe; None when one of them reads something that cannot be described, such as a function.

keep
keep(analytics: FactorAnalytics, folder: Path) -> bool

Keep what analytics has worked out under folder; False, keeping nothing, when its factors cannot be keyed or it has worked out nothing.

restore
restore(analytics: FactorAnalytics, folder: Path) -> bool

Give analytics the work kept under folder for its factors; False, leaving it to work them out, when nothing was kept for them.

factors

A run's factors: each name's exposures at each rebalance, the factors' daily returns, and the betas of the fund, its comparators and its active return to them.

Exposures are each factor's scores (see beacon.strategy.Factor) at each rebalance, for every name the fund, its index or its benchmark held, standardised over the universe: the index's names that day by default, or names given.

Factor returns are estimated each day by a cross-sectional regression of the universe's returns on their exposures at the last rebalance before the day, with a market intercept, each name weighted by its market cap:

r_i = f_market + sum_k z_ik f_k + e_i

so a factor's return is what a unit of exposure earned that day with the others held fixed. A factor naming its own series (returns) takes that series' daily change instead.

Betas regress a book's daily return over the risk-free rate on the factor returns, with an intercept, by ordinary least squares; the active return (the fund's less its index's) is regressed on the same factors.

Holdings-based exposures are a book's weights times its names' exposures: at each rebalance, from its weights at the close that day, or each day, from that day's weights and the last rebalance's exposures. Cash and names without exposures add nothing. A book's active exposure is its exposure less its index's, or another comparator's.

FactorRegression dataclass
FactorRegression(
    coefficients: DataFrame,
    r_squared: float,
    observations: int,
    periods_per_year: int,
)

A book's returns regressed on the factors.

Attributes:

Name Type Description
coefficients DataFrame

For the intercept (alpha, a day) and each factor, its coefficient, standard error and t-statistic.

r_squared float

The share of the returns' variance the factors explain.

observations int

The days fitted on.

periods_per_year int

The days in the run's year.

betas property
betas: Series

Each factor's beta.

alpha_a_year property
alpha_a_year: float

The intercept over a year: what the book earned beyond its exposures, a day times the days in a year.

FactorAnalytics
FactorAnalytics(
    run: RunData,
    source: DataFetcher,
    factors: Sequence[Factor] | None = None,
    universe: Sequence[str] | None = None,
)

A run's factor exposures, factor returns and betas.

Parameters:

Name Type Description Default
run RunData

The run's data, with its name returns, weights and reference.

required
source DataFetcher

The data the run read, which the factors are scored from.

required
factors Sequence[Factor] | None

The factors; beacon.strategy.default_factors when None.

None
universe Sequence[str] | None

The names exposures are standardised over; the index's names at each rebalance when None.

None

Raises:

Type Description
ValueError

If two factors share a name, or one is called market.

names property
names: list[str]

The factors' names, in order.

dates property
dates: list[Timestamp]

The run's rebalances.

exposures
exposures(date: str | Timestamp) -> pd.DataFrame

Each held name's exposure to each factor at the rebalance on date: names on the index, factors on the columns.

caps
caps(date: str | Timestamp) -> pd.Series

Each held name's market cap at the rebalance on date, which its exposures were centred with.

fallbacks
fallbacks(
    date: str | Timestamp,
) -> dict[str, dict[tuple[str, ...], tuple[str, ...]]]

For each factor centred within groups, the groups at the rebalance on date too small to centre on, and the wider group used.

universe_on
universe_on(date: Timestamp) -> list[str]

The names standardised over at the rebalance on date.

factor_returns
factor_returns() -> pd.DataFrame

Each day's factor returns, the market's first, from the day after the first rebalance.

regression
regression(book: str = FUND) -> FactorRegression

The book's returns (fund, a comparator's name such as index, or active) regressed on the factor returns over the whole run.

rolling_betas
rolling_betas(
    book: str = FUND, window: int = 252
) -> pd.DataFrame

Each factor's beta over the window days to each day.

holdings_exposures
holdings_exposures(
    book: str = FUND, daily: bool = False
) -> pd.DataFrame

A book's exposure to each factor (fund, index or benchmark): at each rebalance, or each day when daily.

Raises:

Type Description
KeyError

If the run kept no weights for the book.

active_exposures
active_exposures(
    against: str = INDEX, daily: bool = False
) -> pd.DataFrame

The fund's exposures less a comparator's, on the days both have.

rolling_fits
rolling_fits(
    target: Series, factors: DataFrame, window: int
) -> pd.DataFrame

target regressed with an intercept on factors over each trailing window of days: each factor's beta, a row a window's last day.

Every window's cross-products are summed at once and its normal equations solved, rather than each window fitted by least squares; a window whose equations are too near singular to solve that way is fitted on its own.

regressed
regressed(
    target: Series,
    factors: DataFrame,
    periods_per_year: int,
) -> FactorRegression

target regressed on factors with an intercept, by ordinary least squares.

forecast

How well each rebalance's expected risk forecast what followed.

At each rebalance with a model (RiskOverTime.dates), the expected volatility and tracking error (RiskOverTime.expected) are set beside the ones measured over the days after it, up to and including the next rebalance (or the run's last day): the standard deviation of the daily returns (or of the fund's less the comparator's) over those days, a year. Their ratio is above 1 where the model under-forecast.

The bias statistic pools the days: each day's return is divided by the daily risk forecast in force at the close before (the expected figure over the root of the days in a year), and the bias is the standard deviation of those standardised returns. It is 1 when the forecasts were right on average, above 1 when the risk was under-forecast. With few days it is noisy: its standard error is about one over the root of twice the days.

book_returns
book_returns(run: RunData, book: str) -> pd.Series

A book's daily returns: the fund's, or a comparator's from its levels.

active_returns
active_returns(
    run: RunData, book: str, against: str
) -> pd.Series

A book's daily returns less against's, on the days both have.

forecast_check
forecast_check(
    expected: dict[Timestamp, dict[str, float]],
    returns: Series,
    active: Series | None,
    periods_per_year: int,
) -> pd.DataFrame

Each rebalance's expected volatility (and tracking error, when active is given) beside the ones measured until the next rebalance.

Parameters:

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

Each rebalance's expected figures, as RiskOverTime.expected gives them.

required
returns Series

The book's daily returns.

required
active Series | None

Its daily returns less the comparator's; None for no tracking error.

required
periods_per_year int

The days in a year the figures are annualised by.

required
bias_statistic
bias_statistic(
    forecasts: Series,
    returns: Series,
    periods_per_year: int,
) -> float | None

The standard deviation of the daily returns after the first forecast, each over the daily forecast in force at the close before; None with fewer than two such days or a forecast of 0.

global_model

The global factor model of a run whose names span more than one country: the styles, with country, sector and currency factors beside them, fitted on weekly returns (see beacon.risk.model.global_fit).

At each rebalance the model is fitted on the weeks whole weeks before it, each ending on a Friday, for the names held whose returns cover every one of them. Each name's country is its reference field (its domicile by default) as it stood on the rebalance, its sector likewise, and its currency the one its closes were converted from. Turned off, weekly returns give way to the daily returns of the model's window, and countries, sectors or currencies to no factor at all.

GlobalModels
GlobalModels(
    run: RunData,
    country_field: str | None,
    minimum_names: int,
    sectors: bool,
    currencies: bool,
    weekly: bool,
)

A run's returns and the reference it is grouped by, read once for every rebalance's global model.

Parameters:

Name Type Description Default
run RunData

The run's data, with its name returns, reference, currencies and rates.

required
country_field str | None

The reference field each name's country is read from, or None for no country factors.

required
minimum_names int

The fewest names a country needs for a factor of its own; one with fewer joins its region's.

required
sectors bool

Whether sectors are factors.

required
currencies bool

Whether currencies are factors.

required
weekly bool

Whether the model is fitted on weekly returns rather than daily.

required
periods_per_year property
periods_per_year: int

The periods in the model's year.

reference_on
reference_on(day: Timestamp) -> pd.DataFrame

The names' reference fields at the rebalance on day, by name.

spans_countries
spans_countries(day: Timestamp, names: list[str]) -> bool

Whether names were in more than one country on day.

periods_before
periods_before(day: Timestamp) -> pd.DataFrame

The returns of the periods that ended before day: whole weeks, or sessions.

estimate
estimate(
    day: Timestamp,
    names: list[str],
    periods: int,
    styles: Loadings | None,
    caps: Caps | None,
) -> tuple[FactorRiskModel, list[str]]

The global model at the rebalance on day over its last periods periods, and the names held left out of it for want of returns.

Raises:

Type Description
CalculationError

If fewer than periods periods come before day, or no name's returns cover them.

liquidity

How quickly a book could be sold: each holding's days to sell, and the share of the book sellable within 1, 5 and 20 days, at each rebalance and month end.

A holding worth V is sold at no more than participation of the name's average daily traded value ADV a day, so it takes

days = V / (participation x ADV)

and within d days the book can sell sum min(V, d x participation x ADV) of itself. A name with no traded value in the window cannot be sold at all and is counted apart.

The average daily traded value is the close times the volume, in the book's currency, over the name's last lookback_days days with a volume, as the liquidity screens measure it (beacon.analysis.snapshots), read from the data the run used. Without it, a stored run's own traded values are used, which the run kept over the screens' 63 days.

LiquidityAnalytics
LiquidityAnalytics(
    run: RunData,
    source: DataFetcher | None = None,
    participation: float = PARTICIPATION,
    lookback_days: int = LOOKBACK_DAYS,
)

A run's liquidity at each rebalance and month end.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
source DataFetcher | None

The data the run read, which traded values are measured from; without it, the run's own stored traded values.

None
participation float

The share of a name's daily traded value sold a day.

PARTICIPATION
lookback_days int

The days with a volume its traded value is averaged over, when measured from source.

LOOKBACK_DAYS

Raises:

Type Description
ValueError

If participation is not above 0 and at most 1.

dates
dates() -> list[pd.Timestamp]

The rebalances and month ends the run kept traded values on.

traded_values
traded_values() -> pd.DataFrame

Each name's average daily traded value on each date, dates by name.

days_to_sell
days_to_sell(
    date: str | Timestamp, book: str = "fund"
) -> pd.DataFrame

Each holding's value, average daily traded value and days to sell on date, slowest first; infinite for a name with no traded value.

profile
profile(
    book: str = "fund", horizons: Sequence[int] = HORIZONS
) -> pd.DataFrame

At each date: the share of the book sellable within each horizon, the days to sell all of it but the names with no traded value, the value-weighted mean days, and the share with no traded value.

model

The risk model an analysis of a run uses: which kind, estimated over how long, and estimated once at each rebalance for every figure that needs it.

Kinds. sample is the sample covariance of the names' daily returns, shrunk toward a structured target (beacon.risk.estimate_risk_model). factor is the factor model fitted by cross-sectional regression on the names' exposures (beacon.risk.fit_factor_model), which splits risk into factor and specific parts; its exposures at each rebalance are given by the caller.

Structure. A factor model is global or styles. A global model adds country, sector and currency factors to the styles and is fitted on weekly returns (beacon.analysis.risk.global_model); a styles model is the market and the styles alone, on daily returns. auto, the default, takes the global model at a rebalance whose names span more than one country and the styles model otherwise.

Each rebalance's model is estimated on the run's daily name returns over the window sessions before that day, never including it, for every name the fund, its index or its benchmark held on it. A name whose returns do not cover the whole window (one that listed during it, say) is left out of that rebalance's model rather than shortening the window for every name, and is reported by uncovered.

RiskModelSettings dataclass
RiskModelSettings(
    kind: str = SAMPLE,
    window: int = DEFAULT_WINDOW,
    target: str = CONSTANT_CORRELATION,
    intensity: float | None = None,
    structure: str = AUTO,
    country_field: str | None = DOMICILE,
    minimum_names: int = MINIMUM_NAMES,
    sectors: bool = True,
    currencies: bool = True,
    weekly: bool = True,
    weeks: int = DEFAULT_WEEKS,
)

How an analysis estimates its risk model.

Attributes:

Name Type Description
kind str

sample or factor.

window int

The sessions of daily returns before each rebalance it is estimated on.

target str

For a sample model, the structured target it is shrunk toward; see beacon.risk.TARGETS.

intensity float | None

For a sample model, the weight on the target, from 0 to 1; None takes the heuristic from the panel's shape.

structure str

For a factor model, global, styles or auto.

country_field str | None

For a global model, the reference field each name's country is read from: its domicile by default, its listing (COUNTRY_LISTING), or another such as REGION; None for no country factors.

minimum_names int

For a global model, the fewest names a country needs for a factor of its own; one with fewer joins its region's.

sectors bool

For a global model, whether sectors are factors.

currencies bool

For a global model, whether currencies are factors.

weekly bool

For a global model, whether it is fitted on weekly returns; on the window's daily returns when not.

weeks int

For a global model on weekly returns, the weeks before each rebalance it is estimated on.

Raises:

Type Description
ValueError

If the kind, target or structure is unknown, the window is shorter than 20 sessions or the weeks than 8, the minimum names below 1, or the intensity outside 0 to 1.

RebalanceRiskModels
RebalanceRiskModels(
    run: RunData,
    settings: RiskModelSettings | None = None,
    exposures: Exposures | None = None,
    caps: Caps | None = None,
)

A run's risk model at each rebalance, each estimated once when first asked for.

Parameters:

Name Type Description Default
run RunData

The run's data, with its name returns.

required
settings RiskModelSettings | None

How the models are estimated.

None
exposures Exposures | None

For a factor model, each rebalance's style exposures of the names asked about.

None
caps Caps | None

For a global model, each rebalance's market caps of the names asked about, which weight its regressions; every name the same when None.

None

Raises:

Type Description
ValueError

If a factor model is asked for without exposures.

dates property
dates: list[Timestamp]

The run's rebalances, in order.

at
at(date: str | Timestamp) -> RiskModel | FactorRiskModel

The model estimated for the rebalance on date.

Raises:

Type Description
KeyError

If date is not a rebalance.

CalculationError

If fewer than the window's sessions come before it, or no name's returns cover them.

uncovered
uncovered(date: str | Timestamp) -> list[str]

The names held on the rebalance on date whose returns did not cover the window, and so are not in its model.

is_global
is_global(date: str | Timestamp) -> bool

Whether the model at the rebalance on date is the global one.

has_history
has_history(date: str | Timestamp) -> bool

Whether enough returns come before the rebalance on date for its model: the weeks of a weekly global model, the window's sessions otherwise.

held
held(date: Timestamp) -> list[str]

Every name the fund, its index or its benchmark held on date.

read_once

A data source read once for a run's factor analytics: every name's closes over the run, and each feature as of every rebalance, cut to what each rebalance asks for, which is what the source itself would give.

FeaturesReadOnce
FeaturesReadOnce(
    features: Any,
    names: Sequence[str],
    dates: Sequence[Timestamp],
)

A source's features, whose as-of tables for a set of names on a set of dates are read once a field: a read of fewer names on some of the dates is cut from it, which is the same answer, since a name's value on a date reads only that name's rows; every other read goes to the source.

table_as_of
table_as_of(
    identifiers: list[str],
    field: str,
    dates: list[Timestamp],
    feature_type: str | None = None,
    max_age_days: int | None = MAX_AGE_DAYS,
) -> pd.DataFrame

The table, cut from the one read when it covers the names and dates.

PricesReadOnce
PricesReadOnce(
    source: DataFetcher,
    names: Sequence[str],
    start: Timestamp,
    end: Timestamp,
    currency: str,
    dates: Sequence[Timestamp] = (),
)

A data source whose closes for a set of names, in one currency, over one window, are read once; a read of fewer names or a shorter window is cut from it as a read of its own would find them (only the days and names with a price in it), and every other read goes to the source. Its features' as-of tables on the given dates are read once a field too (FeaturesReadOnce).

Parameters:

Name Type Description Default
source DataFetcher

The data.

required
names Sequence[str]

The names read once.

required
start Timestamp

The window's first day.

required
end Timestamp

Its last.

required
currency str

The currency the closes are in.

required
dates Sequence[Timestamp]

The dates features are read as of once.

()
fetch_prices
fetch_prices(
    identifiers: list[str],
    start_date: str | None = None,
    end_date: str | None = None,
    currency: str | None = None,
    column: str = "CLOSE",
) -> pd.DataFrame

The closes, cut from the one read when it covers them.

single_series

Risk metrics for a price or return series: annualised volatility, Sharpe ratio and maximum drawdown.

Deprecated, and to be removed in 0.11.0: each figure is in beacon.analysis.performance.formulas (volatility, sharpe_ratio, max_drawdown), and a run's are its analytics' headline().

RiskMetricsCalculator
RiskMetricsCalculator()

Risk metrics as methods. Each method calls the module-level function of the same name and returns its result unchanged.

Deprecated: use the functions of beacon.analysis.performance.formulas.

Initializes the RiskMetricsCalculator.

calculate_volatility
calculate_volatility(
    price_series: Series, window: int = 252
) -> float

Calculates annualized volatility from a price series.

See :func:calculate_volatility.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required
window int

The number of trading periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized volatility as a float.

calculate_sharpe_ratio
calculate_sharpe_ratio(
    returns: Series,
    risk_free_rate: float,
    periods_per_year: int = 252,
) -> float

Calculates the annualized Sharpe Ratio.

Parameters:

Name Type Description Default
returns Series

A pandas Series of periodic returns.

required
risk_free_rate float

The annualized risk-free rate.

required
periods_per_year int

The number of return periods in a year (e.g., 252 for daily, 12 for monthly).

252

See :func:calculate_sharpe_ratio.

Returns:

Type Description
float

The annualized Sharpe Ratio as a float.

calculate_max_drawdown
calculate_max_drawdown(price_series: Series) -> float

Calculates the maximum drawdown from a price series.

See :func:calculate_max_drawdown.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required

Returns:

Type Description
float

The maximum drawdown as a non-positive float (e.g., -0.2 for a

float

20% drawdown).

calculate_volatility
calculate_volatility(
    price_series: Series, window: int = 252
) -> float

Calculates annualized volatility from a price series.

Deprecated: use beacon.analysis.performance.formulas.volatility on the returns, volatility(prices.pct_change().dropna(), periods_per_year).

The sample standard deviation of the simple period returns, multiplied by sqrt(window). Despite its name, window is the annualisation factor, not a rolling window: the whole series is used.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required
window int

The number of trading periods in a year (e.g., 252 for daily).

252

Returns:

Type Description
float

The annualized volatility as a float.

Raises:

Type Description
TypeError

If price_series is not a pandas Series.

ValueError

If price_series is empty or contains non-numeric data, or window is not positive.

calculate_sharpe_ratio
calculate_sharpe_ratio(
    returns: Series,
    risk_free_rate: float,
    periods_per_year: int = 252,
) -> float

Calculates the annualized Sharpe Ratio.

Deprecated: use beacon.analysis.performance.formulas.sharpe_ratio, sharpe_ratio(returns, periods_per_year, risk_free_rate).

The mean return in excess of the risk-free rate over its sample standard deviation, multiplied by sqrt(periods_per_year): the Sharpe ratio a backtest reports, beacon.analysis.performance.formulas.sharpe_ratio. The annual risk-free rate is taken as the period rate that compounds to it. 0 when the returns do not vary.

Parameters:

Name Type Description Default
returns Series

A pandas Series of periodic returns.

required
risk_free_rate float

The annualized risk-free rate.

required
periods_per_year int

The number of return periods in a year (e.g., 252 for daily, 12 for monthly).

252

Returns:

Type Description
float

The annualized Sharpe Ratio as a float.

Raises:

Type Description
TypeError

If returns is not a pandas Series or risk_free_rate is not a number.

ValueError

If returns is empty or non-numeric, or periods_per_year is not positive.

calculate_max_drawdown
calculate_max_drawdown(price_series: Series) -> float

Calculates the maximum drawdown from a price series.

Deprecated: use beacon.analysis.performance.formulas.max_drawdown, max_drawdown(prices).

The largest fall from a running peak, as a fraction of that peak.

Parameters:

Name Type Description Default
price_series Series

A pandas Series of prices.

required

Returns:

Type Description
float

The maximum drawdown as a non-positive float (e.g., -0.2 for a 20%

float

drawdown, 0.0 when the price never falls below a previous peak).

Raises:

Type Description
TypeError

If price_series is not a pandas Series.

ValueError

If price_series is empty or contains non-numeric data.

var

Value at risk and expected shortfall, and how each book fared in the run's stress windows.

Both are losses, reported as positive numbers: a one-day 99% value at risk of 0.02 says a day's loss is worse than 2% one day in a hundred.

  • Historical: from the returns themselves. Value at risk is minus their 1 - level quantile (linearly interpolated); expected shortfall is minus the mean of the returns at or below that quantile.
  • Normal: from their mean and standard deviation, as if normal. VaR = -(mean + z sd) with z the normal's 1 - level quantile, and ES = -mean + sd phi(z) / (1 - level).
  • Cornish-Fisher: the normal's, with z moved by the returns' skew S and excess kurtosis K, z + (z^2 - 1) S / 6 + (z^3 - 3z) K / 24 - (2z^3 - 5z) S^2 / 36, so fat tails and a lean to one side count. The expansion holds for moderate tails (a skew within about 1 either side and an excess kurtosis up to about 6); far beyond them it can fall below even the normal's figure, and the historical one is the better guide.

Ten days is the square root of ten times one day, the Basel convention.

Stress windows are the run's crises (beacon.analysis.performance.crises: the market's falls of 15% or more, or named episodes), and each book's return and deepest fall over each.

RiskOfLoss
RiskOfLoss(
    run: RunData, settings: AnalysisSettings | None = None
)

A run's value at risk, expected shortfall and stress windows.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
settings AnalysisSettings | None

The window and conventions figures are measured with.

None
books
books() -> list[str]

The books the run can be measured on: the fund, each comparator, and the active return when it has an index.

returns_of
returns_of(book: str = FUND) -> pd.Series

A book's daily returns over the window: fund, a comparator by name, or active (the fund's less its index's).

Raises:

Type Description
KeyError

If the run has no such book.

table
table(
    book: str = FUND,
    levels: Sequence[float] = LEVELS,
    horizons: Sequence[int] = HORIZONS,
) -> pd.DataFrame

The book's value at risk and expected shortfall, every method, level and horizon.

stress_windows
stress_windows(
    market: str | Series | None = None,
    threshold: float = THRESHOLD,
    named: Sequence[Episode] | None = None,
) -> pd.DataFrame

Each crisis the run saw: its window, and each book's return and deepest fall over it, a row a crisis.

value_at_risk
value_at_risk(
    returns: Series,
    level: float = 0.95,
    method: str = HISTORICAL,
    horizon: int = 1,
) -> float | None

The loss worse than which a horizon-day period falls with probability 1 - level; None with fewer than 20 returns.

Raises:

Type Description
ValueError

If the method is unknown or the level is not between 0 and 1.

expected_shortfall
expected_shortfall(
    returns: Series,
    level: float = 0.95,
    method: str = HISTORICAL,
    horizon: int = 1,
) -> float | None

The mean loss on the periods worse than the value at risk; None with fewer than 20 returns.

Raises:

Type Description
ValueError

If the method is unknown or the level is not between 0 and 1.

risk_of_loss
risk_of_loss(
    returns: Series,
    levels: Sequence[float] = LEVELS,
    horizons: Sequence[int] = HORIZONS,
) -> pd.DataFrame

Every value at risk and expected shortfall of returns: a row a measure and method, a column a level and horizon.

shared_indices

An index's results within a run, kept once for every run that tracked it.

Strategies tracking the same index on the same data store over the same days work out the same index: its target book's holdings, days, names and patch (beacon.analysis.holdings), and its optimised book's when it has one. Kept in the store's shared folder (beacon.analysis.shared_inputs) under indices/<fingerprint>, they are kept once. The fingerprint is of the tables themselves, so a changed definition, other data or other days give another entry, and an older run keeps the index it ran against.

index_tables

index_tables(
    tables: Mapping[str, DataFrame],
) -> dict[str, pd.DataFrame]

The tables of tables that are the index's results.

fingerprint

fingerprint(tables: Mapping[str, DataFrame]) -> str

A fingerprint of tables, their names, columns, dtypes, index and every value.

keep

keep(
    shared_folder: Path, tables: Mapping[str, DataFrame]
) -> str

Keep tables (an index's results) in the shared folder, unless an entry with their fingerprint is there already; the fingerprint.

read

read(
    shared_folder: Path, key: str, names: Iterable[str]
) -> dict[str, pd.DataFrame]

The index's results kept under key.

Raises:

Type Description
KeyError

If the folder holds no such entry.

keys

keys(shared_folder: Path) -> list[str]

Every entry the shared folder holds.

prune

prune(
    shared_folder: Path, in_use: Iterable[str]
) -> list[str]

Remove the entries no run uses, sparing those written in the last GRACE_SECONDS; those removed.

shared_inputs

A data store's market inputs, kept once for every run on it.

A run keeps the market inputs it read (closes, exchange rates, corporate actions, currencies, traded values, characteristics and reference data), and runs on the same data read mostly the same ones. Kept here, each is kept once:

  • Pieces. Each table is cut into pieces of one name's year (a currency's for the exchange rates; a name's whole entry for its currency), each named by a fingerprint of its contents. A run keeps the list of pieces it used. A repeat run on the same data adds nothing; a longer run adds the earlier years; a wider one the new names; a revised price is a new piece, so an older run keeps the numbers it ran on.
  • On disk. One folder per data store: a catalogue (table, name, year, fingerprint, file, first row, rows) and a few files per table. Everything a run adds goes into one new file per table. merge rewrites the files as one per table, keeping only the pieces still in use, beside the old ones, and swaps the catalogue in; runs name fingerprints, not files, so a merge does not touch them.

A table read back from its pieces equals the one written, to the last bit: the run keeps its days and names, or the order of its rows, beside its list.

SharedInputs

SharedInputs(folder: Path)

A data store's shared folder of market inputs.

Parameters:

Name Type Description Default
folder Path

The folder; created when the first pieces are added.

required
add
add(
    tables: Mapping[str, DataFrame],
) -> tuple[pd.DataFrame, dict[str, Any]]

Keep the pieces of tables (those of SHARED) the folder does not hold yet.

Returns:

Name Type Description
tuple DataFrame

The pieces the tables are made of (table and

dict[str, Any]

fingerprint, a row each), and what rebuilds each table from

tuple[DataFrame, dict[str, Any]]

them (its days and names, or the order of its rows), for the run

tuple[DataFrame, dict[str, Any]]

to keep.

read
read(
    table: str,
    fingerprints: Iterable[int],
    layout: Mapping[str, Any],
) -> pd.DataFrame

table put back together from its pieces fingerprints, as the run that kept layout wrote it.

Raises:

Type Description
KeyError

If a piece is not in the folder.

catalogue
catalogue() -> pd.DataFrame

Every piece the folder holds: its table, name, year, fingerprint, file, first row and rows.

files
files() -> list[str]

The files of pieces the folder holds.

due_a_merge
due_a_merge() -> bool

Whether the folder holds more files than MERGE_AFTER.

merge
merge(in_use: Iterable[tuple[str, int]]) -> None

Rewrite the folder as one file per table holding only the pieces in_use (table and fingerprint), and those in files written in the last GRACE_SECONDS: written beside the old files, the catalogue swapped in, then the old files removed.

mark_deleted
mark_deleted(store: Mapping[str, Any]) -> None

Mark the folder as kept for a data store that was deleted.

deleted
deleted() -> dict[str, Any] | None

The deleted data store this folder was kept for, or None.

folder_lock

folder_lock(folder: Path) -> threading.Lock

The lock that runs adding to, merging or reading folder take.

deleted_warning

deleted_warning(store: Mapping[str, Any]) -> str

The warning a run on the deleted data store store opens with.

to_long

to_long(
    table: str, frame: DataFrame
) -> tuple[pd.DataFrame, dict[str, Any]]

frame as rows of a name, a day and its values, and what puts it back: its days and names, or how its rows are ordered, and its dtypes.

from_long

from_long(
    table: str, long: DataFrame, layout: Mapping[str, Any]
) -> pd.DataFrame

The table to_long was given, from its rows and layout.

shared_runs

The stored runs that keep their market inputs in a data store's shared folder (beacon.analysis.shared_inputs): which runs read a store, the pieces they use, what deleting the store leaves, and moving a run that keeps its inputs itself into the shared layout.

Each function takes the stored runs as a mapping of a name (a strategy, say) to the folder the run was written to.

shared_folder

shared_folder(root: Path, store_id: str) -> SharedInputs

The shared folder of the data store store_id under root.

store_of

store_of(folder: Path) -> str | None

The data store whose shared folder the run in folder keeps its inputs in, or None for a run that keeps them itself.

runs_on

runs_on(
    runs: Mapping[str, Path], store_id: str
) -> list[str]

The runs that keep their inputs in store_id's folder.

in_use

in_use(
    runs: Mapping[str, Path], store_id: str
) -> set[tuple[str, int]]

Every piece (table and fingerprint) the runs on store_id use.

indices_in_use

indices_in_use(
    runs: Mapping[str, Path], store_id: str
) -> set[str]

Every index result the runs on store_id use.

merge_if_due

merge_if_due(
    runs: Mapping[str, Path],
    shared: SharedInputs,
    store_id: str,
) -> bool

Merge shared, keeping the pieces runs use, when it holds too many files, and remove the index results no run uses when it holds too many of those; whether it did either.

forget_store

forget_store(
    runs: Mapping[str, Path],
    root: Path,
    store: Mapping[str, Any],
    cascade: bool = False,
) -> dict[str, Any]

What deleting the data store store leaves of its shared folder: with runs on it, kept and marked as for a deleted store, unless cascade, which removes it, as does no run using it.

Returns:

Name Type Description
dict dict[str, Any]

on_it, the runs on the store (the caller deletes them

dict[str, Any]

under cascade), and warning, said when they are kept.

warnings_of

warnings_of(folder: Path) -> list[str]

What the run in folder says as it opens, without opening it: that its data store was deleted, say.

migrate

migrate(runs: Mapping[str, Path], root: Path) -> list[str]

Move each of runs that keeps its market inputs itself to the shared layout, under unattributed (the run did not record its store), checking every table reads back the same first; the runs moved.

snapshots

What a run's names looked like at each rebalance, kept with the run.

A rerun of a run's analysis months later should give the same answer even if the data store has been refreshed since, so what the analysis groups and weighs names by is read as it stood at each rebalance and kept with the run.

Each snapshot is a long table indexed by date, a row per name per rebalance, with an IDENTIFIER column and a column per field. A name with no record in force on a date has no row for it. Between rebalances, the latest snapshot on or before a day is the one in force.

rebalance_dates

rebalance_dates(
    result: BacktestResult,
) -> list[pd.Timestamp]

Every day the fund or its index rebalanced, within the run, sorted.

reference_snapshots

reference_snapshots(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
) -> pd.DataFrame

The grouping fields of names as they stood on each of dates.

Parameters:

Name Type Description Default
source DataFetcher

The data the run used.

required
names list[str]

The names to describe.

required
dates list[Timestamp]

The days to describe them on.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with IDENTIFIER and each of

DataFrame

GROUPING_FIELDS the reference data has, as text; missing where a

DataFrame

name has no value for a field.

characteristics_snapshots

characteristics_snapshots(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
    currency: str,
    market: dict[str, DataFrame] | None = None,
) -> pd.DataFrame

Each name's characteristics as they stood on each of dates.

  • MARKET_CAP: the close times shares outstanding, in currency.
  • PE_RATIO and PB_RATIO: the pe_ratio and pb_ratio features in force on the date, by DataFetcher.fetch_feature's rules.
  • DIVIDEND_YIELD: ordinary dividends over the trailing year over the close, as DataFetcher.fetch_trailing_dividend_yield gives it.
  • MOMENTUM: the total return, in the name's own currency, from 252 sessions before the date to 21 sessions before it.

A value the data does not give is missing. A name only has a row while it trades: one whose last close is more than five sessions old by the date has stopped, and is left out.

Parameters:

Name Type Description Default
source DataFetcher

The data the run used.

required
names list[str]

The names to describe.

required
dates list[Timestamp]

The days to describe them on.

required
currency str

The currency market caps are in.

required
market dict[str, DataFrame] | None

CLOSE and SHARES_OUTSTANDING by date and name, from at least 400 days before the first date, for a caller that has read them already (market_history); read from source when None.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with IDENTIFIER and each of

DataFrame

CHARACTERISTICS.

market_history

market_history(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
    until: Timestamp | None = None,
) -> dict[str, pd.DataFrame]

The market data characteristics_snapshots reads, by column: closes, shares outstanding and volumes from 400 days before the first of dates to the last of them, or to until when later, in one read.

liquidity_dates

liquidity_dates(
    result: BacktestResult,
) -> list[pd.Timestamp]

Each rebalance and the last day of each month of the run, sorted.

traded_value_snapshots

traded_value_snapshots(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
    currency: str,
    market: dict[str, DataFrame] | None = None,
    lookback_days: int = DEFAULT_LIQUIDITY_DAYS,
) -> pd.DataFrame

Each name's average daily traded value on each of dates, as the liquidity screens measure it.

The close times the volume, in currency converted day by day, averaged over the name's last lookback_days days with a volume in the window the screens read (beacon.backtest.screens.average_traded).

Parameters:

Name Type Description Default
source DataFetcher

The data the run used.

required
names list[str]

The names to describe.

required
dates list[Timestamp]

The days to describe them on.

required
currency str

The currency traded values are in.

required
market dict[str, DataFrame] | None

CLOSE and VOLUME by date and name, from at least 400 days before the first date (market_history); read from source when None.

None
lookback_days int

How many days with a volume to average.

DEFAULT_LIQUIDITY_DAYS

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with IDENTIFIER and

DataFrame

TRADED_VALUE; a name with no volume in the window has no row.

stored_tables

A stored run's tables: each read from its folder, from its data store's shared folder, or worked out again from what it kept, the first time it is asked for. beacon.analysis.data.RunData writes and opens a run with these.

StoredTables

StoredTables(
    kept: dict[str, DataFrame],
    rebuilds: dict[str, Callable[[], DataFrame]],
)

Bases: Mapping[str, DataFrame]

A stored run's tables, each read from its folder, or worked out again from the ones it kept, the first time it is asked for.

add
add(
    name: str,
    rebuild: Callable[[], DataFrame],
    window: Callable[[int], DataFrame] | None = None,
) -> None

Have name read or worked out the first time it is asked for; window works out only its last rows, when it can.

latest
latest(name: str, rows: int) -> pd.DataFrame

The last rows rows of name: worked out for those alone when it is worked out again and has not been already.

relative_folder

relative_folder(target: Path, start: Path) -> str

target relative to start, so a run and its store's folder can move together; absolute when the two are on different drives.

add_shared

add_shared(
    tables: StoredTables,
    folder: Path,
    inputs: Mapping[str, Any] | None,
) -> tuple[str, ...]

Add to tables the market inputs a stored run keeps in its data store's shared folder, each read when first asked for; the warnings the run opens with.

packed_trades

packed_trades(
    tables: Mapping[str, DataFrame], currency: str
) -> tuple[dict[str, pd.DataFrame], float] | None

A run's trades packed, and the fixed rate; None when the run has no trades or lacks what they are worked out from, or they would not come back exactly.

packed_holdings

packed_holdings(
    tables: Mapping[str, DataFrame],
) -> dict[str, pd.DataFrame] | None

The fund's holdings as what its trades do not give back; None when the run lacks them or what they are worked out from, or they would not come back exactly.

holdings_from

holdings_from(
    folder: Path,
    tables: Mapping[str, DataFrame],
    run: Mapping[str, Any],
) -> pd.DataFrame

A stored run's fund holdings, worked out again from its trades.

trades_from

trades_from(
    folder: Path,
    tables: Mapping[str, DataFrame],
    run: Mapping[str, Any],
) -> pd.DataFrame

A stored run's trades, worked out again from what it kept.

read_one

read_one(folder: Path, name: str) -> pd.DataFrame

One stored table.

rebuilds_of

rebuilds_of(
    tables: Mapping[str, DataFrame], run: Mapping[str, Any]
) -> dict[str, Callable[[], pd.DataFrame]]

How each table a stored run worked out again is rebuilt from the ones it kept.

window_of

window_of(
    name: str,
    tables: Mapping[str, DataFrame],
    run: Mapping[str, Any],
) -> Callable[[int], pd.DataFrame] | None

How a stored run's daily weights are worked out for their last days alone; None for any other table.

latest_weights

latest_weights(
    name: str,
    tables: Mapping[str, DataFrame],
    currency: str,
    dtype: str,
    rows: int,
) -> pd.DataFrame

name's daily weights on its last rows days.

column_of

column_of(table: DataFrame) -> pd.Series

A series kept as a table of one column.

trade_packing

A run's trades, kept small: what was traded, and the rest worked out again.

A trade's price is its name's close in the book's currency that day, its value is quantity times price, and its cost is the run's fixed rate on that value, with any market impact. So a stored run keeps each trade's date, name, side, quantity, reason and tags, and the fixed rate; the price, value, cost and impact are worked out again from the closes it keeps, in the arithmetic that first recorded them.

Wherever a value worked out this way is not the recorded one to the last bit, the recorded value is kept in a patch and put back, so the trades come back exactly. A column the patch would hold for most trades, such as the costs of a run that modelled market impact, is kept whole instead.

fixed_rate

fixed_rate(trades: DataFrame) -> float

The fixed rate, in basis points, the run's rebalance trades paid: the middle of their cost over value, among those with no market impact.

pack_trades

pack_trades(
    trades: DataFrame,
    tables: Mapping[str, DataFrame],
    currency: str,
) -> tuple[dict[str, pd.DataFrame], float] | None

trades as the tables that keep them, and the fixed rate; None when they would not come back exactly, so they are kept as they are.

Parameters:

Name Type Description Default
trades DataFrame

The run's trades (beacon.backtest.breakdown.trades).

required
tables Mapping[str, DataFrame]

The run's closes, currencies, rates and actions.

required
currency str

The book's currency.

required

unpack_trades

unpack_trades(
    packed: Mapping[str, DataFrame],
    tables: Mapping[str, DataFrame],
    currency: str,
    rate: float,
    order: list[str],
) -> pd.DataFrame

The trades pack_trades kept, exactly as they were, in the columns of order.

book_prices

book_prices(
    trades: DataFrame,
    tables: Mapping[str, DataFrame],
    currency: str,
) -> np.ndarray

Each trade's name's close on its day, carried over gaps, in the book's currency, as the books priced it; missing where the run kept none.

same_trades

same_trades(ours: DataFrame, theirs: DataFrame) -> bool

Whether two trade tables are the same: index, columns and dtypes, text equal and every number to the last bit.

A run's data

Everything a run's analysis reads, from a finished run or a stored one, read back exactly as it was written.

data

Everything a run's analysis reads, from a finished run or a stored one.

data = RunData.from_result(result)
data.write(folder)
again = RunData.from_record(folder)

Every analysis of a run is built from a RunData, so it works the same on a run just finished and on one stored months ago: the stored one is read back exactly as it was written, whatever has happened to the data store since.

A run stored before its tables were kept loads with what it has, and missing says, for each table it lacks, why.

Written with a data store's shared folder (beacon.analysis.shared_inputs), a run keeps its market inputs there, once for every run on that store, and only the list of pieces it used itself; it reads them back from there, and says so when the store has since been deleted.

RunData dataclass

RunData(
    currency: str,
    returns: Series,
    nav: Series,
    units: Series,
    initial_capital: float,
    periods_per_year: int,
    risk_free_rate: float,
    comparators: Mapping[str, Series] = dict(),
    tables: Mapping[str, DataFrame] = dict(),
    missing: Mapping[str, str] = dict(),
    performance: Series | None = None,
    performance_start: float | None = None,
    investor_flows: Series | None = None,
    warnings: tuple[str, ...] = (),
)

A run, as its analysis reads it.

Attributes:

Name Type Description
currency str

The book's currency, which every amount is in.

returns Series

The fund's daily returns, as its metrics measure them: of a unit when the run had flows, with cash paid out added back.

nav Series

The fund's net assets at the end of each day, net of fees owed.

units Series

Units outstanding at the end of each day.

initial_capital float

What the fund started with.

periods_per_year int

How many daily returns make a year.

risk_free_rate float

The annual rate excess returns are measured over.

comparators Mapping[str, Series]

Each comparator's levels by name: index, optimised and benchmark, for those the run had.

tables Mapping[str, DataFrame]

The run's tables by name (beacon.analysis.panels), each built or read the first time it is asked for.

missing Mapping[str, str]

For each table or series a stored run lacks, why.

performance Series | None

The level the fund's total return and drawdown are measured along: its NAV, or, when it had flows or paid cash out, what it started with grown by its returns. None for a run stored before it was kept, whose NAV is used.

performance_start float | None

What performance starts from: the initial capital, or a unit's launch price.

investor_flows Series | None

The investors' cash flows, dated, from their side (money put in negative, the final NAV positive), for a run with flows; None for any other.

warnings tuple[str, ...]

What a stored run says as it opens: that the data store its market inputs came from was deleted, say.

from_result classmethod

from_result(result: BacktestResult) -> RunData

A finished run's data, with every table it keeps, built the first time any is asked for.

of_returns classmethod

of_returns(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
    comparators: Mapping[str, Series] | None = None,
    currency: str = "",
) -> RunData

A run known only by its daily returns (the first from its start) and its comparators' levels, for the figures that need nothing more: its headline figures, measured from a start of 1, its comparisons and its figures by period. It has no tables.

latest

latest(name: str, rows: int) -> pd.DataFrame

The last rows rows of table name. A stored run's daily weights are worked out for those days alone, unless already read whole, and are those rows of the whole table exactly.

write

write(
    folder: Path,
    shared: SharedInputs | None = None,
    store: Mapping[str, Any] | None = None,
) -> None

Keep this run in folder, replacing whatever it held: its tables and series as beacon.analysis.panels writes them, and the rest in run.json.

Parameters:

Name Type Description Default
folder Path

Where the run is kept.

required
shared SharedInputs | None

The shared folder of the data store the run read, which keeps its market inputs instead of folder; None to keep them in folder.

None
store Mapping[str, Any] | None

The data store the run read (id and name), kept with the run when shared is given.

None

from_record classmethod

from_record(
    folder: Path, record: Mapping[str, Any] | None = None
) -> RunData

A stored run, as it was written.

Parameters:

Name Type Description Default
folder Path

Where the run was written.

required
record Mapping[str, Any] | None

For a run stored before its data was kept this way, its JSON record (a backtest record's portfolio.nav and the comparators' levels), read for what the folder lacks.

None

Raises:

Type Description
ConfigurationError

If neither the folder nor record holds the run, or the folder was written by a newer version.

A run's tables

The large tables a run keeps beside its record, such as the fund's daily weights in every name, as Parquet files.

panels

The large tables a run's analysis reads, kept as Parquet files in a folder.

A run's record is a JSON document: its summary and its small series. The tables that grow with the number of names and days, such as each day's weights in every name held, are too large for JSON and slow to read back from it, so they are kept beside the record as Parquet files, one per table, with a small manifest saying what is there.

Each table is wide, dates by name, with a date index. It reads back exactly: the same values, names and dates. A record kept before tables were written has no folder, and reading one gives no tables rather than an error.

run_panels

run_panels(
    result: BacktestResult,
) -> dict[str, pd.DataFrame]

The tables a finished run keeps beside its record, by name:

  • fund_weights: the fund's weight in each name held on each day it was recorded, unabridged.
  • name_returns: each day's total return, in the book's currency, of every name the fund held or its index or benchmark held, as beacon.data.returns.name_returns measures it. Left out, with a warning, when the run kept no data to read them from or they cannot be measured (a currency with no rate into the book's).
  • market: for an ETF, its quote on the exchange each day: NAV per share, premium, spread, market price, bid, ask, and what creating and redeeming cost.
  • cash_band_events: for a fund with a cash band, each time its cash left the band and was brought back, as the run recorded it.
  • class_days, class_flows and class_hedges: for a run with share classes, each class's days, flows and hedge trades, as beacon.analysis.class_panels.class_tables gives them.
  • futures_trades and futures_days: for a fund holding index futures, each trade of them with its cost, and each day's position, price, fair value and index level.
  • trades: every trade, why it was made and its tags, as beacon.backtest.breakdown.trades gives them.
  • costs: what the run cost each day, by kind, as beacon.backtest.breakdown.daily_costs gives it.
  • cash: its cash, income, fees and flows each day, as beacon.backtest.breakdown.daily_cash gives them.
  • index_weights, optimised_weights and benchmark_weights: each comparator's daily weights in its names, for those the run has and that hold names (a benchmark given as a bare level series has none).
  • reference: the grouping fields (sector, industry, region, country, currency, exchange) of every name held, as they stood at each rebalance; see beacon.analysis.snapshots.
  • characteristics: their market cap, P/E, P/B, dividend yield and momentum at each rebalance, as beacon.analysis.snapshots.characteristics_snapshots gives them; left out, with a warning, when they cannot be measured.
  • closes, currencies, rates and actions: the closes the name returns are worked out from, in each name's own currency, and what converts them and accounts for splits and cash; see beacon.analysis.packing.
  • traded_values: their average daily traded value at each rebalance and month end, as the liquidity screens measure it (beacon.analysis.snapshots.traded_value_snapshots).

Parameters:

Name Type Description Default
result BacktestResult

The finished run.

required

Returns:

Name Type Description
dict dict[str, DataFrame]

Each table by name, as above.

name_returns_from

name_returns_from(
    tables: Mapping[str, DataFrame],
    currency: str,
    days: DatetimeIndex,
) -> pd.DataFrame

The name returns, worked out from a run's kept closes, currencies, rates and corporate actions, on its NAV days from the first kept close: a year before the fund's first holding, enough for a risk model's window.

The same calculation as beacon.data.returns.name_returns on the data store, on the same numbers, so a finished run and a stored one give the same table to the last bit.

on_sessions

on_sessions(
    returns: DataFrame, days: DatetimeIndex
) -> pd.DataFrame

returns, measured on every day a name has a close, as returns from one of days to the next: a day with a close but no session (a holiday's print) is compounded into the session after it, rather than dropped with its move. Days that follow no such day are as they were.

weights_inputs

weights_inputs(
    result: BacktestResult, tables: Mapping[str, DataFrame]
) -> dict[str, pd.DataFrame]

What each book's daily weights are worked out again from: the fund's quantities and daily totals, each index's units, and for every book its days, its names and the cells that differ from what was recorded.

weights_from

weights_from(
    name: str,
    tables: Mapping[str, DataFrame],
    currency: str,
    dtype: str,
    last: int | None = None,
) -> pd.DataFrame

A book's daily weights, worked out again from what was kept, with the recorded cells put back, so they are exactly what was recorded; for its last days alone when given, which are those days of the whole table.

held_names

held_names(result: BacktestResult) -> list[str]

Every name the fund, its index (both books) or its benchmark held at any point in the run, sorted.

write_panels

write_panels(
    folder: Path, panels: Mapping[str, DataFrame]
) -> dict[str, Any]

Write panels to folder, replacing whatever it held.

The tables are written to a new folder beside it, which then takes its place, so a failure part way leaves the previous tables as they were.

Parameters:

Name Type Description Default
folder Path

Where the tables go.

required
panels Mapping[str, DataFrame]

Each table by name: dates by name, with a date index.

required

Returns:

Name Type Description
dict dict[str, Any]

The manifest written beside them.

Raises:

Type Description
ConfigurationError

If a table's name is not lower-case letters, digits and underscores, or its columns are not text.

read_manifest

read_manifest(folder: Path) -> dict[str, Any] | None

What folder holds, or None when there is no such folder.

Raises:

Type Description
ConfigurationError

If the manifest cannot be read, or was written by a newer version than this one reads.

read_panels

read_panels(
    folder: Path, names: Iterable[str] | None = None
) -> dict[str, pd.DataFrame]

Read tables back from folder.

Parameters:

Name Type Description Default
folder Path

Where the tables were written.

required
names Iterable[str] | None

The tables wanted; every one there when None.

None

Returns:

Name Type Description
dict dict[str, DataFrame]

Each table by name, as it was written. Empty when there is no

dict[str, DataFrame]

folder, as for a record kept before tables were.

Raises:

Type Description
DataNotFoundError

If a table asked for by name is not there.

ConfigurationError

If the folder cannot be read.

Shared market inputs

A data store's market inputs and index results, kept once for every run on it, and the runs that use them.

shared_inputs

A data store's market inputs, kept once for every run on it.

A run keeps the market inputs it read (closes, exchange rates, corporate actions, currencies, traded values, characteristics and reference data), and runs on the same data read mostly the same ones. Kept here, each is kept once:

  • Pieces. Each table is cut into pieces of one name's year (a currency's for the exchange rates; a name's whole entry for its currency), each named by a fingerprint of its contents. A run keeps the list of pieces it used. A repeat run on the same data adds nothing; a longer run adds the earlier years; a wider one the new names; a revised price is a new piece, so an older run keeps the numbers it ran on.
  • On disk. One folder per data store: a catalogue (table, name, year, fingerprint, file, first row, rows) and a few files per table. Everything a run adds goes into one new file per table. merge rewrites the files as one per table, keeping only the pieces still in use, beside the old ones, and swaps the catalogue in; runs name fingerprints, not files, so a merge does not touch them.

A table read back from its pieces equals the one written, to the last bit: the run keeps its days and names, or the order of its rows, beside its list.

SharedInputs

SharedInputs(folder: Path)

A data store's shared folder of market inputs.

Parameters:

Name Type Description Default
folder Path

The folder; created when the first pieces are added.

required

add

add(
    tables: Mapping[str, DataFrame],
) -> tuple[pd.DataFrame, dict[str, Any]]

Keep the pieces of tables (those of SHARED) the folder does not hold yet.

Returns:

Name Type Description
tuple DataFrame

The pieces the tables are made of (table and

dict[str, Any]

fingerprint, a row each), and what rebuilds each table from

tuple[DataFrame, dict[str, Any]]

them (its days and names, or the order of its rows), for the run

tuple[DataFrame, dict[str, Any]]

to keep.

read

read(
    table: str,
    fingerprints: Iterable[int],
    layout: Mapping[str, Any],
) -> pd.DataFrame

table put back together from its pieces fingerprints, as the run that kept layout wrote it.

Raises:

Type Description
KeyError

If a piece is not in the folder.

catalogue

catalogue() -> pd.DataFrame

Every piece the folder holds: its table, name, year, fingerprint, file, first row and rows.

files

files() -> list[str]

The files of pieces the folder holds.

due_a_merge

due_a_merge() -> bool

Whether the folder holds more files than MERGE_AFTER.

merge

merge(in_use: Iterable[tuple[str, int]]) -> None

Rewrite the folder as one file per table holding only the pieces in_use (table and fingerprint), and those in files written in the last GRACE_SECONDS: written beside the old files, the catalogue swapped in, then the old files removed.

mark_deleted

mark_deleted(store: Mapping[str, Any]) -> None

Mark the folder as kept for a data store that was deleted.

deleted

deleted() -> dict[str, Any] | None

The deleted data store this folder was kept for, or None.

folder_lock

folder_lock(folder: Path) -> threading.Lock

The lock that runs adding to, merging or reading folder take.

deleted_warning

deleted_warning(store: Mapping[str, Any]) -> str

The warning a run on the deleted data store store opens with.

to_long

to_long(
    table: str, frame: DataFrame
) -> tuple[pd.DataFrame, dict[str, Any]]

frame as rows of a name, a day and its values, and what puts it back: its days and names, or how its rows are ordered, and its dtypes.

from_long

from_long(
    table: str, long: DataFrame, layout: Mapping[str, Any]
) -> pd.DataFrame

The table to_long was given, from its rows and layout.

shared_runs

The stored runs that keep their market inputs in a data store's shared folder (beacon.analysis.shared_inputs): which runs read a store, the pieces they use, what deleting the store leaves, and moving a run that keeps its inputs itself into the shared layout.

Each function takes the stored runs as a mapping of a name (a strategy, say) to the folder the run was written to.

shared_folder

shared_folder(root: Path, store_id: str) -> SharedInputs

The shared folder of the data store store_id under root.

store_of

store_of(folder: Path) -> str | None

The data store whose shared folder the run in folder keeps its inputs in, or None for a run that keeps them itself.

runs_on

runs_on(
    runs: Mapping[str, Path], store_id: str
) -> list[str]

The runs that keep their inputs in store_id's folder.

in_use

in_use(
    runs: Mapping[str, Path], store_id: str
) -> set[tuple[str, int]]

Every piece (table and fingerprint) the runs on store_id use.

indices_in_use

indices_in_use(
    runs: Mapping[str, Path], store_id: str
) -> set[str]

Every index result the runs on store_id use.

merge_if_due

merge_if_due(
    runs: Mapping[str, Path],
    shared: SharedInputs,
    store_id: str,
) -> bool

Merge shared, keeping the pieces runs use, when it holds too many files, and remove the index results no run uses when it holds too many of those; whether it did either.

forget_store

forget_store(
    runs: Mapping[str, Path],
    root: Path,
    store: Mapping[str, Any],
    cascade: bool = False,
) -> dict[str, Any]

What deleting the data store store leaves of its shared folder: with runs on it, kept and marked as for a deleted store, unless cascade, which removes it, as does no run using it.

Returns:

Name Type Description
dict dict[str, Any]

on_it, the runs on the store (the caller deletes them

dict[str, Any]

under cascade), and warning, said when they are kept.

warnings_of

warnings_of(folder: Path) -> list[str]

What the run in folder says as it opens, without opening it: that its data store was deleted, say.

migrate

migrate(runs: Mapping[str, Path], root: Path) -> list[str]

Move each of runs that keeps its market inputs itself to the shared layout, under unattributed (the run did not record its store), checking every table reads back the same first; the runs moved.

shared_indices

An index's results within a run, kept once for every run that tracked it.

Strategies tracking the same index on the same data store over the same days work out the same index: its target book's holdings, days, names and patch (beacon.analysis.holdings), and its optimised book's when it has one. Kept in the store's shared folder (beacon.analysis.shared_inputs) under indices/<fingerprint>, they are kept once. The fingerprint is of the tables themselves, so a changed definition, other data or other days give another entry, and an older run keeps the index it ran against.

index_tables

index_tables(
    tables: Mapping[str, DataFrame],
) -> dict[str, pd.DataFrame]

The tables of tables that are the index's results.

fingerprint

fingerprint(tables: Mapping[str, DataFrame]) -> str

A fingerprint of tables, their names, columns, dtypes, index and every value.

keep

keep(
    shared_folder: Path, tables: Mapping[str, DataFrame]
) -> str

Keep tables (an index's results) in the shared folder, unless an entry with their fingerprint is there already; the fingerprint.

read

read(
    shared_folder: Path, key: str, names: Iterable[str]
) -> dict[str, pd.DataFrame]

The index's results kept under key.

Raises:

Type Description
KeyError

If the folder holds no such entry.

keys

keys(shared_folder: Path) -> list[str]

Every entry the shared folder holds.

prune

prune(
    shared_folder: Path, in_use: Iterable[str]
) -> list[str]

Remove the entries no run uses, sparing those written in the last GRACE_SECONDS; those removed.

holding_replay

The fund's holdings worked out again from its trades.

A holding changes only when the fund trades the name or a split changes its share count, and the books add each trade to the holding before it, in order. So the fund's holdings on the days they changed (as beacon.analysis.holdings.holding_changes gives them) are worked out again by replaying the trades and the splits in the books' own arithmetic: a split multiplies the holding first, then each buy adds to it and each sell takes from it, a holding below 1e-9 ending.

What the replay does not give back to the last bit is kept: a patch of the recorded rows it gets wrong or misses, and the rows it gives that were not recorded (a split on a day nothing was recorded, say). A stored run keeps those and its trades rather than the holdings, and checks on writing that they come back exactly.

replayed

replayed(
    trades: DataFrame, actions: DataFrame
) -> pd.DataFrame

The fund's holdings on the days they changed, from its trades (a run's trades table) and the actions' splits, by day and then name.

pack_holdings

pack_holdings(
    changes: DataFrame,
    trades: DataFrame,
    actions: DataFrame,
) -> dict[str, pd.DataFrame] | None

What keeps changes given the trades and actions: the recorded rows the replay gets wrong or misses (patch) and the rows it gives that were not recorded (drops); None when they would not give changes back exactly.

unpack_holdings

unpack_holdings(
    trades: DataFrame,
    actions: DataFrame,
    kept: Mapping[str, DataFrame],
    dtypes: Mapping[str, object] | None = None,
) -> pd.DataFrame

The fund's holdings on the days they changed, from the replay with kept's rows dropped and patched, by day and then name.

Snapshots at each rebalance

What a run's names looked like at each rebalance, such as their sector and country, kept with the run so a later rerun of its analysis gives the same answer.

snapshots

What a run's names looked like at each rebalance, kept with the run.

A rerun of a run's analysis months later should give the same answer even if the data store has been refreshed since, so what the analysis groups and weighs names by is read as it stood at each rebalance and kept with the run.

Each snapshot is a long table indexed by date, a row per name per rebalance, with an IDENTIFIER column and a column per field. A name with no record in force on a date has no row for it. Between rebalances, the latest snapshot on or before a day is the one in force.

rebalance_dates

rebalance_dates(
    result: BacktestResult,
) -> list[pd.Timestamp]

Every day the fund or its index rebalanced, within the run, sorted.

reference_snapshots

reference_snapshots(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
) -> pd.DataFrame

The grouping fields of names as they stood on each of dates.

Parameters:

Name Type Description Default
source DataFetcher

The data the run used.

required
names list[str]

The names to describe.

required
dates list[Timestamp]

The days to describe them on.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with IDENTIFIER and each of

DataFrame

GROUPING_FIELDS the reference data has, as text; missing where a

DataFrame

name has no value for a field.

characteristics_snapshots

characteristics_snapshots(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
    currency: str,
    market: dict[str, DataFrame] | None = None,
) -> pd.DataFrame

Each name's characteristics as they stood on each of dates.

  • MARKET_CAP: the close times shares outstanding, in currency.
  • PE_RATIO and PB_RATIO: the pe_ratio and pb_ratio features in force on the date, by DataFetcher.fetch_feature's rules.
  • DIVIDEND_YIELD: ordinary dividends over the trailing year over the close, as DataFetcher.fetch_trailing_dividend_yield gives it.
  • MOMENTUM: the total return, in the name's own currency, from 252 sessions before the date to 21 sessions before it.

A value the data does not give is missing. A name only has a row while it trades: one whose last close is more than five sessions old by the date has stopped, and is left out.

Parameters:

Name Type Description Default
source DataFetcher

The data the run used.

required
names list[str]

The names to describe.

required
dates list[Timestamp]

The days to describe them on.

required
currency str

The currency market caps are in.

required
market dict[str, DataFrame] | None

CLOSE and SHARES_OUTSTANDING by date and name, from at least 400 days before the first date, for a caller that has read them already (market_history); read from source when None.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with IDENTIFIER and each of

DataFrame

CHARACTERISTICS.

market_history

market_history(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
    until: Timestamp | None = None,
) -> dict[str, pd.DataFrame]

The market data characteristics_snapshots reads, by column: closes, shares outstanding and volumes from 400 days before the first of dates to the last of them, or to until when later, in one read.

liquidity_dates

liquidity_dates(
    result: BacktestResult,
) -> list[pd.Timestamp]

Each rebalance and the last day of each month of the run, sorted.

traded_value_snapshots

traded_value_snapshots(
    source: DataFetcher,
    names: list[str],
    dates: list[Timestamp],
    currency: str,
    market: dict[str, DataFrame] | None = None,
    lookback_days: int = DEFAULT_LIQUIDITY_DAYS,
) -> pd.DataFrame

Each name's average daily traded value on each of dates, as the liquidity screens measure it.

The close times the volume, in currency converted day by day, averaged over the name's last lookback_days days with a volume in the window the screens read (beacon.backtest.screens.average_traded).

Parameters:

Name Type Description Default
source DataFetcher

The data the run used.

required
names list[str]

The names to describe.

required
dates list[Timestamp]

The days to describe them on.

required
currency str

The currency traded values are in.

required
market dict[str, DataFrame] | None

CLOSE and VOLUME by date and name, from at least 400 days before the first date (market_history); read from source when None.

None
lookback_days int

How many days with a volume to average.

DEFAULT_LIQUIDITY_DAYS

Returns:

Type Description
DataFrame

pd.DataFrame: Indexed by date, with IDENTIFIER and

DataFrame

TRADED_VALUE; a name with no volume in the window has no row.

Performance

A run's performance, its figures against its comparators, in crises and in each kind of market, and the formulas each figure is computed with. See Performance.

analytics

A run's performance figures, from its data and the settings it is measured with.

analytics = PerformanceAnalytics(RunData.from_record(folder))
analytics.summary()

Every figure is one of beacon.analysis.performance.formulas, so a finished run and a stored one give the same figures, and a backtest's summary is these.

PerformanceAnalytics

PerformanceAnalytics(
    run: RunData, settings: AnalysisSettings | None = None
)

A run's performance figures.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
settings AnalysisSettings | None

What the figures are measured over and against; the run's own when None.

None

periods_per_year property

periods_per_year: int

How many daily returns make a year.

risk_free_rate property

risk_free_rate: float

The annual rate the Sharpe ratio is in excess of.

returns property

returns: Series

The fund's daily returns over the days measured.

before property

before: Timestamp | None

The run's close before the days measured, when they start after the run's first day; None when they start with it.

tracked property

tracked: Series | None

The levels of the comparator tracking is measured against, or None when the run has none.

headline

headline() -> dict[str, float]

The five headline figures: total and annualised return, volatility, Sharpe ratio and maximum drawdown.

Over the whole run, the total return and drawdown run from what the fund started with, so the cost of buying in on the first day counts; over a window, from the close before its first day.

hit_rate_monthly

hit_rate_monthly() -> float | None

The share of calendar months with a positive return.

tracking_error

tracking_error() -> float | None

The annualised spread of the daily returns less the tracked comparator's; None without one or with fewer than two days.

tracking_difference

tracking_difference() -> float | None

The compounded return less the tracked comparator's; None without one.

information_ratio

information_ratio() -> float | None

The mean daily return less the tracked comparator's, over its spread, annualised; None without one or without active risk.

money_weighted_return

money_weighted_return() -> float | None

The annual internal rate of return of the investors' money; None for a run without flows, or when no rate exists.

comparison

comparison(comparator: str | Series) -> Comparison | None

The run against a comparator, given by name or by its levels, over the days both have: the comparator's own figures and the run's relative to it. None for a comparator the run does not have, or one sharing fewer than two days with it.

periods

periods(
    frequency: str, comparators: list[str] | None = None
) -> list[Period]

The run's figures in each calendar year, quarter or month, and its comparators' in each, by name: the index and the benchmark, of those the run has, unless comparators names others.

Raises:

Type Description
ValueError

If the frequency is not annual, quarterly or monthly.

trailing_returns

trailing_returns() -> dict[
    str, return_figures.WindowReturn | None
]

The returns over the last 1, 3 and 6 months and 1, 3, 5 and 10 years, for the month, quarter and year to date, and since inception, by name (1M ... 10Y, MTD, QTD, YTD, inception). Each is annualised as well when it is longer than a year. None for a window longer than the run.

Since inception is the headline's total and annualised return, so over the whole run it starts from what the fund started with.

best_and_worst

best_and_worst() -> dict[
    str,
    tuple[
        return_figures.PeriodReturn,
        return_figures.PeriodReturn,
    ]
    | None,
]

The best and worst day, week, month, quarter and year, by frequency (daily ... annual), with their dates and whether the run covered all of each.

positive_shares

positive_shares() -> dict[str, float | None]

The share of days, months and years with a positive return, by frequency.

gains_and_losses

gains_and_losses(
    frequency: str = return_figures.DAILY,
) -> return_figures.GainsAndLosses

The average gain and loss over days (or months, ...), and their ratio.

longest_streaks

longest_streaks() -> tuple[
    return_figures.Streak | None,
    return_figures.Streak | None,
]

The longest run of months that all gained, and of months that all lost.

means

means() -> dict[str, float | None]

The arithmetic and geometric mean daily return, and each a year.

levels

levels() -> pd.Series

The level the figures are measured along, dated, from its start: over the whole run what the fund started with, then its NAV or a unit's value; over a window, 1 grown by its returns. The start is dated the business day before the first return.

drawdowns

drawdowns(worst: int = WORST) -> DrawdownFigures

The run's drawdowns: the deepest worst with their peak, trough and recovery dates, the current one, and the figures over all of them. The deepest is the headline's maximum drawdown.

drawdown_series

drawdown_series() -> pd.Series

How far below its peak the level stood each day.

ratios

ratios(
    sharpe_benchmark: float = 0.0,
    trials: int | None = None,
    trial_variance: float | None = None,
) -> dict[str, float | None]

The risk-adjusted ratios beyond the Sharpe ratio, by name.

sortino, kappa_3 (daily), upside_potential and omega measure return against the shortfall below the risk-free rate; calmar, martin, sterling and burke against drawdowns; gain_to_pain against losing months. probabilistic_sharpe is the probability the true Sharpe ratio is above sharpe_benchmark (a year); deflated_sharpe the same against the best of trials strategies whose annual Sharpe ratios vary by trial_variance, None unless both are given; lo_sharpe the Sharpe ratio corrected for serial correlation.

distribution

distribution(bins: int = shape.BINS) -> dict[str, Any]

The shape of the daily returns, by name: downside_deviation (below the risk-free rate) and semi_variance (below the mean), both annualised; skew and excess_kurtosis; tail_ratio; serial_correlation by lag in days; and a histogram of bins bins with the normal distribution to compare.

bootstrap

bootstrap(
    samples: int = shape.SAMPLES,
    confidence: float = shape.CONFIDENCE,
    block: int = 1,
    seed: int = shape.SEED,
) -> dict[str, shape.Interval] | None

How far the annualised return and the Sharpe ratio could have come out otherwise, from runs made by drawing the run's own days again; see beacon.analysis.performance.distribution.bootstrap.

rolling

rolling(
    window: int = moving.WINDOWS[1],
    comparator: str | Series | None = None,
) -> pd.DataFrame

Each day's return, volatility, Sharpe ratio, Sortino ratio and maximum drawdown over the window days ending that day, blank until the first full window; with a comparator (by name or levels), its relative return, tracking error, information ratio, beta and correlation too, on the days both have.

rolling_outcomes

rolling_outcomes(
    window: int = moving.WINDOWS[1],
    comparator: str | Series | None = None,
) -> dict[str, float | None]

Over every full window: the worst, middle and best return, the share that gained, and the share ahead of the comparator when one is given.

monthly_grid

monthly_grid(of: str = tables.FUND) -> pd.DataFrame

The calendar grid of monthly returns, a row a year, with each year's: of the fund, of a comparator by name, or of the fund less a comparator (difference index).

Raises:

Type Description
KeyError

If the run has no such comparator.

period_table

period_table(
    frequency: str = formulas.ANNUAL,
) -> pd.DataFrame

Each year's (or quarter's, month's) return, volatility, Sharpe ratio and maximum drawdown for the fund, with its share of positive days, for its index and benchmark, and the fund's less each's.

trailing_table

trailing_table() -> pd.DataFrame

The trailing returns (trailing_returns) of the fund, its index and benchmark and the fund's less each's, a row a window.

month_of_year

month_of_year() -> pd.Series

The fund's mean return in each calendar month over the years.

summary

summary() -> dict[str, float | None]

The headline figures, the monthly hit rate, and, when the run has them, the tracking error and difference against the tracked comparator and the money-weighted return.

settings

What a run's analysis is measured over and against.

AnalysisSettings dataclass

AnalysisSettings(
    start: Timestamp | str | None = None,
    end: Timestamp | str | None = None,
    periods_per_year: int | None = None,
    risk_free_rate: float | None = None,
    tracked: str | None = None,
)

How a run's figures are measured. Each setting left as None takes the run's own.

Attributes:

Name Type Description
start Timestamp | str | None

The first day measured; the run's first when None. A later start measures from the previous day's close.

end Timestamp | str | None

The last day measured; the run's last when None.

periods_per_year int | None

How many daily returns make a year.

risk_free_rate float | None

The annual rate the Sharpe ratio is in excess of.

tracked str | None

The comparator tracking is measured against, by name (index, optimised or benchmark): the index the run traded toward when None, which is the optimised index when it solved one.

relative_analytics

A run against one comparator, its index or its benchmark: how much of its return the comparator explains and what is left (alpha, beta, R-squared, and the t-statistics that say whether the rest is more than luck), how it behaved when the comparator rose and fell, how often and by how much it got ahead, and its stretches of falling behind.

relative = RelativeAnalytics(RunData.from_record(folder), "benchmark")
relative.figures()

The two are compared on the days both have, as the run's comparisons are: the comparator's first day returns nothing from its level that day, and so, when the comparator starts after the run, does the run's.

Regression dataclass

Regression(
    alpha: float,
    beta: float,
    r_squared: float,
    alpha_t: float | None,
    alpha_p: float | None,
    residual_risk: float,
)

The fund's daily excess returns regressed on the comparator's.

Attributes:

Name Type Description
alpha float

The intercept, a year: the return the comparator does not explain.

beta float

The slope: the fund's sensitivity to the comparator.

r_squared float

The share of the fund's variance the comparator explains.

alpha_t float | None

Alpha over its standard error.

alpha_p float | None

The two-sided probability of an alpha this far from 0 by chance.

residual_risk float

The spread of what the comparator does not explain, a year.

YearDifference dataclass

YearDifference(
    year: str,
    fund: float,
    comparator: float,
    difference: float,
)

One calendar year's returns and their difference.

Attributes:

Name Type Description
year str

The year.

fund float

The fund's return in it.

comparator float

The comparator's.

difference float

The fund's less the comparator's, as a fraction (0.019 is 1.9 percentage points).

RelativeAnalytics

RelativeAnalytics(
    run: RunData,
    comparator: str | Series = INDEX,
    settings: AnalysisSettings | None = None,
)

A run against one comparator.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
comparator str | Series

The comparator, by name (index, optimised or benchmark) or by its levels.

INDEX
settings AnalysisSettings | None

What the figures are measured over and with; the run's own when None.

None

Raises:

Type Description
KeyError

If the run has no comparator of that name.

periods_per_year property

periods_per_year: int

How many daily returns make a year.

active property

active: Series

The fund's daily return less the comparator's.

regression

regression() -> Regression | None

Jensen's regression of the fund's daily excess returns on the comparator's; None with fewer than three days. Against a comparator that does not vary there is nothing to be sensitive to, so the beta is 0, as beacon.analysis.performance.formulas.beta gives it, and the alpha is the fund's mean excess return.

treynor

treynor() -> float | None

The annual mean excess return per unit of beta; None without a beta.

information_ratio_t

information_ratio_t() -> dict[str, float | None]

The information ratio's t-statistic, IR times the root of the years, and its two-sided probability by chance.

capture

capture() -> dict[str, float | None]

Up capture, down capture and their ratio, on months: the fund's geometric mean monthly return in the months the comparator rose (fell) over the comparator's in them.

batting_average

batting_average() -> float | None

The share of months the fund's return beat the comparator's.

relative_levels

relative_levels() -> pd.Series

The fund's wealth over the comparator's, from 1 the business day before the first day compared.

relative_drawdowns

relative_drawdowns(worst: int = WORST) -> DrawdownFigures

The drawdowns of the fund's wealth relative to the comparator's: each a stretch of falling behind.

downside_tracking_error

downside_tracking_error() -> float

The root mean square of the days the fund fell behind, every day counted, a year.

tracking_difference_by_year

tracking_difference_by_year() -> tuple[
    list[YearDifference], float | None
]

Each calendar year's return less the comparator's, and the standard deviation of those differences (None with fewer than two years).

figures

figures() -> dict[str, Any]

Every relative figure, by name: today's comparison figures (relative return, tracking error, information ratio, correlation, beta, monthly hit rate) and the rest.

crises

How a run held up in crises: each crisis window's returns, outperformance, drawdowns, beta and alpha, what came after the trough and how long the fund took to recover; and the figures across every crisis.

crises = CrisisAnalytics(RunData.from_record(folder), market=sp500_levels)
crises.crises(), crises.summary()

The windows come from a broad market series by default: each fall of the market deeper than a threshold (15%), from its peak to its trough. Or they are the user's own named episodes, each a start and an end date, instead. A window is measured over the days after its start through its end; one the run covers only part of is marked partial.

Episode dataclass

Episode(name: str, start: Timestamp, end: Timestamp)

A crisis window: a name, its start (the market's peak) and its end (its trough).

Crisis dataclass

Crisis(
    name: str,
    start: Timestamp,
    end: Timestamp,
    partial: bool,
    days: int,
    fund: float,
    comparator: float,
    outperformance: float,
    fund_drawdown: float,
    comparator_drawdown: float,
    beta: float,
    alpha: float,
    after: dict[str, float | None],
    recovery_days: int | None,
)

How the fund held up in one crisis.

Attributes:

Name Type Description
name str

The episode's name, or the market's peak and trough dates.

start Timestamp

The window's start (the market's peak).

end Timestamp

Its end (the trough).

partial bool

Whether the run covered only part of it.

days int

The days measured.

fund float

The fund's return over it.

comparator float

The comparator's.

outperformance float

The fund's less the comparator's.

fund_drawdown float

The fund's deepest fall inside it.

comparator_drawdown float

The comparator's.

beta float

The fund's beta to the comparator over it.

alpha float

The fund's return beyond what the whole run's beta predicts.

after dict[str, float | None]

The fund's and the comparator's returns over the months after the end, by fund 3m ... comparator 6m; None where the run ends too soon.

recovery_days int | None

Trading days from the end until the fund was back at its value at the start; None if it had not been by the run's end.

CrisisAnalytics

CrisisAnalytics(
    run: RunData,
    comparator: str | Series = INDEX,
    market: str | Series | None = None,
    threshold: float = THRESHOLD,
    named: Sequence[Episode] | None = None,
    settings: AnalysisSettings | None = None,
)

How a run held up in crises, against one comparator.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
comparator str | Series

What the fund is measured against in each crisis, by name or levels; the run's index by default.

INDEX
market str | Series | None

The broad market series the crises are found on, by name of one of the run's comparators or by its levels; the run's benchmark when it has one, else its index.

None
threshold float

The market's fall that makes a crisis.

THRESHOLD
named Sequence[Episode] | None

The user's own episodes, used instead of the market's falls.

None
settings AnalysisSettings | None

What the figures are measured over and with.

None

Raises:

Type Description
KeyError

If the run has no comparator of a name given.

crises

crises() -> list[Crisis]

Each crisis the run saw any of, in order.

summary

summary() -> dict[str, float | None]

The figures across every crisis.

market_crises

market_crises(
    market: Series, threshold: float = THRESHOLD
) -> list[Episode]

Each fall of the market deeper than threshold, peak to trough.

crisis_of

crisis_of(
    episode: Episode,
    ours: Series,
    theirs: Series,
    run_beta: float,
) -> Crisis | None

How the fund held up over one episode, on the days the run has; None when the run has none of them.

across

across(
    crises: Sequence[Crisis],
    ours: Series,
    theirs: Series,
    run_beta: float,
    periods_per_year: int,
) -> dict[str, float | None]

The figures across every crisis: the average outperformance, the share outperformed, crisis capture, and the pooled crisis alpha (the mean daily return beyond the whole run's beta over every crisis day, a year).

regimes

How a run did in each kind of market: months the market rose and fell, calm and volatile stretches, and bear markets and corrections.

regimes = RegimeAnalytics(RunData.from_record(folder), market=sp500_levels)
regimes.regimes()["volatile"]

Each regime is a set of days, found on a broad market series as the crises are; over its days the fund is measured against its comparator. A regime's return compounds its days, as if invested on those days alone, and is annualised from their mean so regimes of different lengths compare.

Regime dataclass

Regime(
    days: int,
    fund: float,
    comparator: float,
    difference: float,
    fund_annual: float,
    comparator_annual: float,
    beta: float,
    alpha: float,
)

The fund against its comparator over one regime's days.

Attributes:

Name Type Description
days int

The regime's days the run had.

fund float

The fund's return compounded over them.

comparator float

The comparator's.

difference float

The fund's less the comparator's.

fund_annual float

The fund's mean daily return, a year.

comparator_annual float

The comparator's.

beta float

The fund's beta to the comparator over them.

alpha float

The fund's mean daily return beyond its beta, in excess of the risk-free rate, a year (Jensen's alpha over the regime).

RegimeAnalytics

RegimeAnalytics(
    run: RunData,
    comparator: str | Series = INDEX,
    market: str | Series | None = None,
    settings: AnalysisSettings | None = None,
)

How a run did in each kind of market, against one comparator.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
comparator str | Series

What the fund is measured against, by name or levels; the run's index by default.

INDEX
market str | Series | None

The broad market series the regimes are found on, by name of one of the run's comparators or by its levels; the run's benchmark when it has one, else its index.

None
settings AnalysisSettings | None

What the figures are measured over and with.

None

Raises:

Type Description
KeyError

If the run has no comparator of a name given.

days

days() -> dict[str, pd.Series]

Which days fall in each regime, by name.

regimes

regimes() -> dict[str, Regime | None]

The fund against its comparator in each regime, by name; None for a regime the run never saw.

up_and_down_months

up_and_down_months(
    market: Series,
) -> tuple[pd.Series, pd.Series]

Which days fall in a month the market rose, and in one it fell, from its daily returns.

calm_and_volatile

calm_and_volatile(
    market: Series,
    window: int = VOLATILITY_WINDOW,
    fifth: float = FIFTH,
) -> tuple[pd.Series, pd.Series]

Which days the market's rolling volatility was in its lowest fifth, and in its highest; days before the first full window are neither.

falls

falls(
    levels: Series,
    deeper_than: float,
    no_deeper_than: float | None = None,
) -> pd.Series

Which days fall in one of the market's declines, peak to trough, of more than deeper_than (and at most no_deeper_than).

regime_of

regime_of(
    ours: Series,
    theirs: Series,
    days: Series,
    daily_rate: float,
    periods_per_year: int,
) -> Regime | None

The fund against its comparator over days; None without any.

formulas

The formulas behind a run's performance figures: pure functions of returns and levels, one definition each.

Returns are simple daily returns, and a level series is what they move. A comparator's returns over a run start from its level on the first day, so its first return is nothing (returns_from_start), as a run's first return runs from its starting capital. Figures annualise over periods_per_year returns a year.

A figure that cannot be measured (too few returns) is None, unless its definition says otherwise. The ratios of a mean return to its spread, the Sharpe and information ratios, are the mean daily return over its standard deviation, annualised by the square root of periods_per_year: both halves from the same daily returns, so the ratio does not change with leverage, and times the square root of the years it is the t-statistic of the mean.

returns_from_start

returns_from_start(levels: Series) -> pd.Series

Daily returns of a level series, the first day's being nothing: its returns over a run, measured from its level on the first day.

total_return

total_return(start: float, end: float) -> float

The return from start to end.

compounded_return

compounded_return(returns: Series) -> float

The return of a run of returns, compounded.

annualised_return

annualised_return(
    total: float, periods: int, periods_per_year: int
) -> float

total, earned over periods returns, as a rate a year; 0 over no time.

volatility

volatility(returns: Series, periods_per_year: int) -> float

The annualised standard deviation of returns; 0 with fewer than two.

daily_rate

daily_rate(
    annual_rate: float, periods_per_year: int
) -> float

The rate a period that compounds to annual_rate over a year.

sharpe_ratio

sharpe_ratio(
    returns: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> float

The mean daily return in excess of the risk-free rate, over its standard deviation, annualised; 0 with fewer than two returns, or when they do not vary (NEGLIGIBLE_SPREAD).

The annual risk_free_rate is taken as the daily rate that compounds to it (daily_rate).

drawdown

drawdown(levels: Series) -> pd.Series

How far below its running peak a level series stands each day.

max_drawdown

max_drawdown(path: Series) -> float

The largest fall of path from a running peak; 0 for no path.

path_of

path_of(returns: Series) -> pd.Series

The level a run of returns moves, from 1 before its first.

headline_figures

headline_figures(
    returns: Series,
    total: float,
    path: Series,
    periods_per_year: int,
    risk_free_rate: float = 0.0,
) -> dict[str, float]

The five headline figures of a performance.

Parameters:

Name Type Description Default
returns Series

The daily returns.

required
total float

The return over the whole performance.

required
path Series

The level the drawdown is measured along, from its start.

required
periods_per_year int

How many returns make a year.

required
risk_free_rate float

The annual rate the Sharpe ratio is in excess of.

0.0

Returns:

Name Type Description
dict dict[str, float]

total_return, annualised_return, volatility, sharpe_ratio and

dict[str, float]

max_drawdown.

period_returns

period_returns(
    returns: Series, frequency: str
) -> pd.Series

Returns compounded within each calendar year, quarter or month, by its name (2024, 2024 Q1, 2024-01), in order.

grouped

grouped(
    returns: Series, frequency: str
) -> list[tuple[str, pd.Series]]

returns split into calendar periods, each named, in order.

Raises:

Type Description
ValueError

If the frequency is not one of FREQUENCIES.

hit_rate_monthly

hit_rate_monthly(returns: Series) -> float | None

The share of calendar months whose compounded return was positive.

relative_hit_rate_monthly

relative_hit_rate_monthly(
    ours: Series, theirs: Series
) -> float | None

The share of calendar months in which ours compounded to more than theirs, over the same days.

positive_share

positive_share(returns: Series) -> float

The share of returns above zero.

tracking_error

tracking_error(
    active: Series, periods_per_year: int
) -> float | None

The annualised standard deviation of the active returns (ours less theirs, day by day); None with fewer than two.

tracking_difference

tracking_difference(ours: Series, theirs: Series) -> float

Our compounded return less theirs.

annualised_relative_return

annualised_relative_return(
    our_total: float,
    their_total: float,
    periods: int,
    periods_per_year: int,
) -> float

Our annualised growth less theirs, each total earned over periods returns; 0 over no time.

information_ratio

information_ratio(
    active: Series, periods_per_year: int
) -> float | None

The mean active return over its standard deviation, annualised; None with fewer than two, or when they do not vary (NEGLIGIBLE_SPREAD), as there is no active risk to measure it against.

correlation

correlation(ours: Series, theirs: Series) -> float

The correlation of two return series over the same days; 0 with fewer than two, or when either does not vary (NEGLIGIBLE_SPREAD), as nothing relates them.

beta

beta(ours: Series, theirs: Series) -> float

The sensitivity of ours to theirs: their covariance over the variance of theirs; 0 with fewer than two, or when theirs does not vary (NEGLIGIBLE_SPREAD), as there is nothing to be sensitive to.

market_return

market_return(first_ask: float, last_bid: float) -> float

An exchange investor's return: bought at first_ask, sold at last_bid.

money_weighted_return

money_weighted_return(
    flows: list[tuple[Timestamp, float]],
) -> float | None

The annual rate that discounts an investor's cash flows to zero.

Parameters:

Name Type Description Default
flows list[tuple[Timestamp, float]]

(date, amount) from the investor's side: money put in is negative, money taken out (and the final value) positive.

required

Returns:

Type Description
float | None

float or None: The internal rate of return a year, over calendar days

float | None

(ACT/365), or None when the flows do not change sign, so no rate

float | None

exists.

Portfolio

What a run held and traded, and what it cost. See Portfolio.

portfolio

What a run held: how many names, and its weight in each group of a grouping field (sector, region, currency, ...) each day, against its index's and its benchmark's.

portfolio = PortfolioAnalytics(RunData.from_record(folder))
portfolio.group_weights("SECTOR")

A name's group on a day is the one it had at the latest rebalance on or before that day, as the run's reference table recorded it. A name with no group recorded is in Unknown. A book's group weights add up to its invested weight each day: the fund's cash is in none of them.

PortfolioAnalytics

PortfolioAnalytics(run: RunData)

What a run held, and in which groups.

Parameters:

Name Type Description Default
run RunData

The run's data.

required

weights

weights(book: str = FUND) -> pd.DataFrame | None

A book's daily weights, dates by name: fund, index or benchmark; None for a book the run did not keep weights for (a benchmark given as a level series holds no names).

holdings_count

holdings_count(book: str = FUND) -> pd.Series | None

How many names a book held each day.

group_weights

group_weights(
    field: str, book: str = FUND
) -> pd.DataFrame | None

A book's weight in each group of field each day, dates by group, the groups sorted; None for a book without weights.

Raises:

Type Description
KeyError

If the run kept no reference data, or none for field.

active_group_weights

active_group_weights(
    field: str, against: str = INDEX
) -> pd.DataFrame | None

The fund's weight in each group less against's (index or benchmark), on the days the fund has; None when the comparator holds no names.

turnover

turnover(by: str = 'day') -> pd.DataFrame

The fund's buys, sells, number of trades and their mean value, and its one-way turnover (the lesser of buys and sells) and two-way turnover (all of it), over its assets: each trading day (day, with whether it was a rebalance), each rebalance's own trades (rebalance) or each year (year).

Raises:

Type Description
KeyError

If the run kept no trades (one stored before 0.10.0).

ValueError

If by is none of the three.

trading_by_reason

trading_by_reason() -> pd.DataFrame

The fund's trading by why it was made, a row a reason.

costs

costs(by: str = 'run') -> pd.DataFrame

The fund's costs by kind (fixed, impact, futures rolls and futures trading), the creation fees it received and the net: over the run (run), with each in basis points of its mean NAV a year, or each year (year), with the net in basis points of the year's mean NAV.

Raises:

Type Description
ValueError

If by is neither.

cost_per_traded

cost_per_traded() -> dict[str, float | None]

The trading costs in basis points of the value traded.

costs_by_reason

costs_by_reason() -> pd.DataFrame

The trading costs by why the trade was made.

characteristics

characteristics() -> pd.DataFrame

The weighted market cap, P/E, P/B and dividend yield of the fund, its index and its benchmark (when it holds names) at each rebalance: a row a rebalance, columns by book and figure.

income

income() -> pd.DataFrame

Each day's distribution yield and trailing dividend yield, over the year to it.

dividend_growth

dividend_growth() -> pd.DataFrame

Each calendar year's dividends and their growth.

aum

aum() -> pd.DataFrame

The fund's assets under management and units outstanding each day.

net_flows

net_flows(frequency: str = 'M') -> pd.DataFrame

Each month's (M) or year's (Y) net flows and their share of the AUM it began with.

flow_costs

flow_costs() -> pd.DataFrame

The cost of trading each day's flows, less the creation fees received.

etf_premium

etf_premium() -> dict[str, float | None] | None

An ETF's premium to NAV on the exchange: its mean, volatility and widest each way, and the mean spread; None for another vehicle.

cash_band_events

cash_band_events() -> pd.DataFrame

Each time the cash left its band and was raised or spent back to its target: the cash before and after, the target and band, the gap closed, what was traded and its cost. Empty for a run without a band.

cash_band_summary

cash_band_summary() -> pd.DataFrame

The raises, the spend-downs and both: how many, what they traded and cost, and how close they brought the cash to its target.

cash_band_calendar

cash_band_calendar(frequency: str = 'M') -> pd.DataFrame

How many raises and spend-downs each month (M) or year (Y) had, and their cost.

cash_trading

cash_trading() -> pd.DataFrame

Every trade made to raise or spend cash, by tag and reason.

dealing_costs

dealing_costs() -> pd.DataFrame

Each day's subscriptions or redemptions (an ETF's creations or redemptions): the trading they caused, what the dealing investors paid toward it, the net cost to the fund, and for an ETF the cost to the AP.

dealing_summary

dealing_summary(by: str = 'run') -> pd.DataFrame

The flows' costs added up by kind, over the run (run) or each year (year).

class_costs

class_costs(by: str = 'run') -> pd.DataFrame

Each share class's fees, hedging costs less the carry earned, the trading its flows caused less what its dealing investors paid, and its share of the rest of the fund's trading, in the book's currency: over the run (run) or each year (year).

Raises:

Type Description
KeyError

If the run had no share classes.

futures_trading

futures_trading() -> pd.DataFrame

The fund's futures trading by why each trade was made, between rolls and all of it: trades, contracts, notional, cost per contract and in basis points, and turnover.

Raises:

Type Description
KeyError

If the run held no index futures.

futures_basis

futures_basis(daily: bool = False) -> pd.DataFrame

The futures' basis to their index, its carry and its mispricing: each day (daily), or their mean, volatility and extremes in basis points.

futures_basis_risk

futures_basis_risk() -> pd.DataFrame

The tracking error the futures bring, split into the pricing basis (against their own index) and the underlying mismatch (their index against the fund's).

Risk

A run's factors, value at risk, risk over time, concentration and liquidity. See Risk analysis.

analytics

A run's risk analytics in one place: its factors, value at risk, risk over time, concentration and liquidity, each built when first asked for and kept.

RiskAnalytics

RiskAnalytics(
    run: RunData,
    source: DataFetcher,
    factors: Sequence[Factor] | None = None,
    settings: AnalysisSettings | None = None,
    risk_model: RiskModelSettings | None = None,
    participation: float = PARTICIPATION,
    lookback_days: int = LOOKBACK_DAYS,
)

A run's risk.

Parameters:

Name Type Description Default
run RunData

The run's data.

required
source DataFetcher

The data the run read, which the factors and traded values are measured from.

required
factors Sequence[Factor] | None

The factors; beacon.strategy.default_factors when None.

None
settings AnalysisSettings | None

The window and conventions the value at risk is measured with.

None
risk_model RiskModelSettings | None

How expected risk is worked out; the defaults when None.

None
participation float

The share of a name's daily traded value the liquidity figures assume could be sold.

PARTICIPATION
lookback_days int

The days a name's average traded value is taken over.

LOOKBACK_DAYS

factors cached property

factors: FactorAnalytics

The run's factor exposures, factor returns and betas.

loss cached property

loss: RiskOfLoss

The run's value at risk, expected shortfall and stress windows.

over_time cached property

over_time: RiskOverTime

The run's expected and measured risk, and where it came from.

concentration cached property

concentration: ConcentrationAnalytics

The run's concentration by weight, risk and outcome.

liquidity cached property

liquidity: LiquidityAnalytics

The run's liquidity at each rebalance and month end.

model

The risk model an analysis of a run uses: which kind, estimated over how long, and estimated once at each rebalance for every figure that needs it.

Kinds. sample is the sample covariance of the names' daily returns, shrunk toward a structured target (beacon.risk.estimate_risk_model). factor is the factor model fitted by cross-sectional regression on the names' exposures (beacon.risk.fit_factor_model), which splits risk into factor and specific parts; its exposures at each rebalance are given by the caller.

Structure. A factor model is global or styles. A global model adds country, sector and currency factors to the styles and is fitted on weekly returns (beacon.analysis.risk.global_model); a styles model is the market and the styles alone, on daily returns. auto, the default, takes the global model at a rebalance whose names span more than one country and the styles model otherwise.

Each rebalance's model is estimated on the run's daily name returns over the window sessions before that day, never including it, for every name the fund, its index or its benchmark held on it. A name whose returns do not cover the whole window (one that listed during it, say) is left out of that rebalance's model rather than shortening the window for every name, and is reported by uncovered.

RiskModelSettings dataclass

RiskModelSettings(
    kind: str = SAMPLE,
    window: int = DEFAULT_WINDOW,
    target: str = CONSTANT_CORRELATION,
    intensity: float | None = None,
    structure: str = AUTO,
    country_field: str | None = DOMICILE,
    minimum_names: int = MINIMUM_NAMES,
    sectors: bool = True,
    currencies: bool = True,
    weekly: bool = True,
    weeks: int = DEFAULT_WEEKS,
)

How an analysis estimates its risk model.

Attributes:

Name Type Description
kind str

sample or factor.

window int

The sessions of daily returns before each rebalance it is estimated on.

target str

For a sample model, the structured target it is shrunk toward; see beacon.risk.TARGETS.

intensity float | None

For a sample model, the weight on the target, from 0 to 1; None takes the heuristic from the panel's shape.

structure str

For a factor model, global, styles or auto.

country_field str | None

For a global model, the reference field each name's country is read from: its domicile by default, its listing (COUNTRY_LISTING), or another such as REGION; None for no country factors.

minimum_names int

For a global model, the fewest names a country needs for a factor of its own; one with fewer joins its region's.

sectors bool

For a global model, whether sectors are factors.

currencies bool

For a global model, whether currencies are factors.

weekly bool

For a global model, whether it is fitted on weekly returns; on the window's daily returns when not.

weeks int

For a global model on weekly returns, the weeks before each rebalance it is estimated on.

Raises:

Type Description
ValueError

If the kind, target or structure is unknown, the window is shorter than 20 sessions or the weeks than 8, the minimum names below 1, or the intensity outside 0 to 1.

RebalanceRiskModels

RebalanceRiskModels(
    run: RunData,
    settings: RiskModelSettings | None = None,
    exposures: Exposures | None = None,
    caps: Caps | None = None,
)

A run's risk model at each rebalance, each estimated once when first asked for.

Parameters:

Name Type Description Default
run RunData

The run's data, with its name returns.

required
settings RiskModelSettings | None

How the models are estimated.

None
exposures Exposures | None

For a factor model, each rebalance's style exposures of the names asked about.

None
caps Caps | None

For a global model, each rebalance's market caps of the names asked about, which weight its regressions; every name the same when None.

None

Raises:

Type Description
ValueError

If a factor model is asked for without exposures.

dates property

dates: list[Timestamp]

The run's rebalances, in order.

at

at(date: str | Timestamp) -> RiskModel | FactorRiskModel

The model estimated for the rebalance on date.

Raises:

Type Description
KeyError

If date is not a rebalance.

CalculationError

If fewer than the window's sessions come before it, or no name's returns cover them.

uncovered

uncovered(date: str | Timestamp) -> list[str]

The names held on the rebalance on date whose returns did not cover the window, and so are not in its model.

is_global

is_global(date: str | Timestamp) -> bool

Whether the model at the rebalance on date is the global one.

has_history

has_history(date: str | Timestamp) -> bool

Whether enough returns come before the rebalance on date for its model: the weeks of a weekly global model, the window's sessions otherwise.

held

held(date: Timestamp) -> list[str]

Every name the fund, its index or its benchmark held on date.

forecast

How well each rebalance's expected risk forecast what followed.

At each rebalance with a model (RiskOverTime.dates), the expected volatility and tracking error (RiskOverTime.expected) are set beside the ones measured over the days after it, up to and including the next rebalance (or the run's last day): the standard deviation of the daily returns (or of the fund's less the comparator's) over those days, a year. Their ratio is above 1 where the model under-forecast.

The bias statistic pools the days: each day's return is divided by the daily risk forecast in force at the close before (the expected figure over the root of the days in a year), and the bias is the standard deviation of those standardised returns. It is 1 when the forecasts were right on average, above 1 when the risk was under-forecast. With few days it is noisy: its standard error is about one over the root of twice the days.

book_returns

book_returns(run: RunData, book: str) -> pd.Series

A book's daily returns: the fund's, or a comparator's from its levels.

active_returns

active_returns(
    run: RunData, book: str, against: str
) -> pd.Series

A book's daily returns less against's, on the days both have.

forecast_check

forecast_check(
    expected: dict[Timestamp, dict[str, float]],
    returns: Series,
    active: Series | None,
    periods_per_year: int,
) -> pd.DataFrame

Each rebalance's expected volatility (and tracking error, when active is given) beside the ones measured until the next rebalance.

Parameters:

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

Each rebalance's expected figures, as RiskOverTime.expected gives them.

required
returns Series

The book's daily returns.

required
active Series | None

Its daily returns less the comparator's; None for no tracking error.

required
periods_per_year int

The days in a year the figures are annualised by.

required

bias_statistic

bias_statistic(
    forecasts: Series,
    returns: Series,
    periods_per_year: int,
) -> float | None

The standard deviation of the daily returns after the first forecast, each over the daily forecast in force at the close before; None with fewer than two such days or a forecast of 0.

Attribution

A run's attribution: by name and group, Brinson-Fachler, currency, factors and implementation. See Attribution.

analytics

A run's attribution: what each name, or each group, contributed to a book's return, or to the fund's return over a comparator.

Each day a name contributes its weight at the close before times its return that day (the run's own daily total return for it, in the book's currency), and the days are linked over the window by Carino's factors so the contributions add up to the compounded return (see beacon.analysis.attribution.contribution). A group contributes what its names did, each in the group it was in at that close.

Active attribution takes the fund's weights less the comparator's, both as they drift, against the fund's daily return less the comparator's, linked by the active Carino factor so the contributions add up to the fund's compounded return less the comparator's, the same total Brinson-Fachler, currency, factor and implementation attribution explain. What the names do not explain (cash, costs, fees, futures, the timing of dividends) is the residual, reported with every result and never folded into a name.

Attribution

Attribution(run: RunData)

What produced a run's returns, by name or group.

Parameters:

Name Type Description Default
run RunData

The run's data, with its weights, name returns and reference.

required

contribution

contribution(
    book: str = FUND,
    field: str | None = None,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> AttributionResult

Each name's (or, with field, each group's) contribution to a book's return (fund, index, optimised or benchmark) over the window, the whole run by default.

active

active(
    against: str = INDEX,
    field: str | None = None,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> AttributionResult

Each name's (or group's) contribution to the fund's return over against, from its active weight, over the window: the contributions add up to the fund's compounded return less against's.

brinson

brinson(
    field: str,
    against: str = INDEX,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> BrinsonResult

The fund's Brinson-Fachler attribution against against by the grouping field field, over the window, the whole run by default: each group's allocation, selection and interaction, cash a group of its own, linked over the days.

brinson_by_period

brinson_by_period(
    field: str, against: str = INDEX, frequency: str = "Y"
) -> pd.DataFrame

The Brinson-Fachler effects over each year (Y) or month (M).

currency

currency(
    against: str | None = None,
    by_currency: bool = False,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> EffectsResult

The fund's return (or, against a comparator, its active return) split into its names' local returns, their currencies' moves and the cross term; with by_currency, each by the names' currencies too.

factors

factors(
    analytics: FactorAnalytics,
    against: str | None = None,
    method: str = factor_model.HOLDINGS,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> EffectsResult

The fund's return (or its active return against a comparator) explained by the run's factors: from its holdings' exposures (holdings: market, each factor, specific) or from its returns' betas (returns: risk-free, alpha, each factor, residual).

Raises:

Type Description
ValueError

If the method is neither.

implementation

implementation(
    against: str = INDEX,
    start: str | Timestamp | None = None,
    end: str | Timestamp | None = None,
) -> EffectsResult

The fund's tracking difference against against split into the cap and screens, drift, cash drag, equitisation, each cost, fees, withholding tax, creation fees and dividend treatment, from the run's own records, linked over the window; the rest is the residual.

weights

weights(book: str = FUND) -> pd.DataFrame

A book's daily weights, by name: fund, index, optimised or benchmark.

Raises:

Type Description
KeyError

If the book is none of those, or the run kept no weights for it.

Analysis requests

What a run's analysis should give: the reports, the statistics added to or taken from them, and every choice the analysis makes. See Reports.

request

What a run's analysis should give: the reports wanted, each at a level, with statistics added to or taken from them, the time view each is shown in, and every choice the analysis makes.

Backtest(...).run(definition, start, end,
                  analysis=AnalysisRequest(reports={"factsheet": "core"},
                                           add={"factsheet": ["sortino_ratio"]}))

The run then carries the analytics block (analyse): each statistic once, however many reports show it, and the reports as lists of statistic ids. Only what the statistics asked for need is worked out, so a factsheet's core never builds the risk model.

AnalysisRequest dataclass

AnalysisRequest(
    reports: Mapping[str, str] = (
        lambda: {"factsheet": "core"}
    )(),
    add: Mapping[str, Sequence[str]] = dict(),
    remove: Mapping[str, Sequence[str]] = dict(),
    time_views: Mapping[str, Mapping[str, str]] = dict(),
    statistics: Sequence[str] = (),
    options: AnalysisOptions = AnalysisOptions(),
    factors: Sequence[Factor] | None = None,
)

What a run's analysis should give.

Attributes:

Name Type Description
reports Mapping[str, str]

Each report wanted, by id, with its level: core or extended.

add Mapping[str, Sequence[str]]

By report, optional statistics to show.

remove Mapping[str, Sequence[str]]

By report, included statistics not to show.

time_views Mapping[str, Mapping[str, str]]

By report, statistic ids with the time view to show them in: point, rolling or both.

statistics Sequence[str]

Statistics wanted outside any report, over the window.

options AnalysisOptions

Every choice the analysis makes; the defaults when left out.

factors Sequence[Factor] | None

The factors risk and attribution use; the defaults (size, value, momentum) when None.

Raises:

Type Description
ValueError

If a report, level or statistic is unknown, a statistic added is not optional at the report's level or one taken out is not in it, or a time view is unknown or rolls a statistic that does not roll.

settings property

settings: AnalysisSettings

The window the figures are measured over and against.

selection

selection() -> dict[str, list[tuple[str, str]]]

Each report's statistics to show, in order, with their views: its level's included ones, with those added and without those taken out.

wanted

wanted() -> dict[str, set[str]]

Each statistic to work out, with the views it is wanted in.

parts

parts() -> list[str]

The parts the statistics wanted are worked out by, in order.

as_data

as_data() -> dict[str, Any]

The request as plain data, every default filled in.

analyse

analyse(
    result: Any,
    request: AnalysisRequest,
    progress: Callable[[str], None] | None = None,
) -> dict[str, Any]

The analytics block request asks for, of the finished run result.

Each statistic is worked out once, part by part, with a message as each part starts; one that cannot be worked out for the run is named under unavailable with a code and a reason.

Returns:

Name Type Description
dict dict[str, Any]

request, reports (each report's level and statistics,

dict[str, Any]

by id, with their views), statistics (each one's kind, its

dict[str, Any]

point value and, where wanted, its rolling table, and a

dict[str, Any]

table's summary), unavailable, active_against,

dict[str, Any]

groupings (the reference fields the run kept, which attribution

dict[str, Any]

can group by) and timings (seconds, the run's tables first).

keep_analysis_work

keep_analysis_work(result: Any, folder: Path) -> bool

Keep the factor work a finished run's analysis did in folder, where the run is stored, so a later analysis of it does not do it again; False when there was none.

analyse_run

analyse_run(
    context: Any,
    request: AnalysisRequest,
    progress: Callable[[str], None] | None = None,
    skip: Collection[str] = (),
) -> dict[str, Any]

analyse on a run's Context, for a stored run as for a finished one; the statistics in skip are left out, as already worked out.