Skip to content

factrix.metrics.tradability

Tradability metrics: Rank Turnover, Breakeven Cost, Net Spread.

Two flavours of turnover co-exist here, measuring different things:

  • rank_turnover() — 1 − mean(rank autocorrelation). Rank-stability diagnostic; responds to mid-rank reshuffling. Not a notional trading-fraction and should not be fed into breakeven_cost / net_spread.
  • notional_turnover() — one-way 0.5 × Σ|Δw| on the equal-weight top-and-bottom quantile legs, i.e. the fraction of each leg's notional traded per rebalance, averaged over the two legs. Members that leave the universe count on the sell side, so a shrinking universe books its liquidations. Matches Novy-Marx-Velikov (2016) τ; this is the quantity that drives bps trading cost for an equal-weight Q1/Qn long-short portfolio.

These are implementation-feasibility indicators, not factor quality or significance tests.

Input for Rank Turnover: DataFrame with date, asset_id, factor. Input for Breakeven/Net Spread: pre-computed spread and turnover values.

Notes

Pipeline. Per-date turnover / cost diagnostics on quantile-group membership (cross-section step), then time-series mean; descriptive (no formal H₀).

factrix.metrics.tradability.notional_turnover

notional_turnover(data: DataFrame, factor_col: str = 'factor', *, n_groups: int = DEFAULT_N_GROUPS, rebalance_lag: int | None = None, overlap_periods: int = DEFAULT_FORWARD_PERIODS, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Portfolio notional turnover via top/bottom quantile membership churn.

For an equal-weight Q1/Q_n long-short portfolio the only trades that incur cost are changes in top-quantile and bottom-quantile membership — reshuffling within the middle deciles triggers no rebalancing and should not be counted. This is the metric whose units are directly compatible with breakeven_cost / net_spread: Novy-Marx-Velikov (2016) τ = fraction of portfolio value replaced per rebalance.

Convention: one-way turnover on the equal-weight leg. Each leg's per-rebalance turnover is 0.5 × Σ_i |w_t(i) − w_{t-1}(i)| over the union of the leg's prior and current holdings, with w the equal weight 1 / |Q(t)| on members and 0 elsewhere. This is the standard one-way (half-sum) convention: a full rotation costs 1, an unchanged book 0. The two legs are averaged, not summed::

top_churn = 1 - |Q_top_t ∩ Q_top_{t-1}| / max(|Q_top_t|, |Q_top_{t-1}|)
bot_churn = 1 - |Q_bot_t ∩ Q_bot_{t-1}| / max(|Q_bot_t|, |Q_bot_{t-1}|)
turnover  = (top_churn + bot_churn) / 2

The closed form is exact, not an approximation: for k names in today's leg, j in the prior leg and m carried over, the survivors' resizing term m·|1/k − 1/j| cancels against the smaller side's entries or exits, leaving 1 − m / max(j, k). A leg that grows or holds its size reproduces the plain (k − m) / k; a leg that shrinks — a name delisted, or the universe itself narrowing — prices the liquidation and the survivors' resizing that (k − m) / k misses entirely. Averaging the two legs (rather than summing) is what makes τ a per-leg replaced fraction, which is the input breakeven_cost / net_spread expect: they multiply it back up by 4 (2 legs × 2 trades per replacement).

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor.

required
factor_col str

Name of the factor column.

'factor'
n_groups int

Number of quantile groups (default :data:~factrix._types.DEFAULT_N_GROUPS = 5 = quintiles, the same constant quantile_spread defaults to). Must be at least :data:~factrix._types.N_GROUPS_FLOOR = 2 — the top-half / bottom-half book a small universe evaluates with quantile_spread(n_groups=2); this metric prices that same book, so it accepts the same split.

DEFAULT_N_GROUPS
rebalance_lag int | None

Rebalance stride, counted in evaluation-grid observations (periods of the panel handed to the metric). Must be a positive int. When > 1, sub-samples at that stride before pairing consecutive dates. None (default) falls back to overlap_periods: on the full grid that stamp equals the return horizon, giving the holding-period-aligned membership churn this metric has always reported; on a coarser evaluation grid it is the derived overlap, which matches the horizon in evaluation observations only when that grid is evenly spaced. Pass rebalance_lag=1 when the evaluation grid is the rebalance schedule — then the value is adjacent-rebalance membership churn.

None
overlap_periods int

The panel's evaluation-grid overlap (default :data:~factrix._types.DEFAULT_FORWARD_PERIODS). Injected by evaluate from the compute_forward_return stamp, not a user knob; used only as the fallback stride when rebalance_lag is not given.

DEFAULT_FORWARD_PERIODS
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()
Warning

n_groups must match the quantile_spread run whose spread is paired with this turnover, and both must describe the same rebalance schedule. The cost algebra in breakeven_cost / net_spread is a statement about one portfolio, so a τ measured on decile membership churn does not price a quintile spread, and a τ per period does not price a multi-period holding. The two used to ship incompatible defaults (10 / 1 here against 5 / 5 there); on a 60-name, 400-period panel at gross_spread = 0.001, the matched pair gave breakeven 15.7 bps and net −9.14 bps while each function at its own default gave 2.8 bps and −98.02 bps — breakeven understated 5.6x, drag overstated 10.7x. They now share one constant, and the consumers cross-check n_groups when handed MetricResults instead of bare floats. The cost amortisation itself is a separate declaration: pass holding_periods — the rebalance interval in underlying return periods — to breakeven_cost / net_spread.

Returns:

Name Type Description
MetricResult

MetricResult with value = mean per-rebalance turnover ∈ [0, 1].

MetricResult

0 = identical tail sets every rebalance; 1 = full rotation.

Metadata MetricResult

n_rebalances, n_groups, median_cross_section

MetricResult

(the median per-period count of finite factor values),

MetricResult

overlap_periods (the panel's stamp, unchanged), rebalance_lag

MetricResult

(the stride actually sampled at), mean_top_turnover /

MetricResult

mean_bottom_turnover (each

MetricResult

leg's mean replaced fraction, each over the rebalances where that leg

MetricResult

exists on both dates), n_top_rebalances /

MetricResult

n_bottom_rebalances (those per-leg sample sizes). value is the

MetricResult

per-date mean of the two legs over the joint n_rebalances sample;

MetricResult

it equals the arithmetic mean of the two published leg means only when

MetricResult

their samples coincide.

MetricResult

mean_tail_size (per-date average of (|Q_top| + |Q_bot|)/2 at

MetricResult

t, the current leg sizes — the turnover denominator is

MetricResult

max(|Q(t)|, |Q(t-1)|), so the two coincide only while the legs do

MetricResult

not shrink; ≠ n_assets / n_groups signals unbalanced buckets from

MetricResult

ties or a short universe) with its per-leg split

MetricResult

mean_top_tail_size / mean_bottom_tail_size, method.

MetricResult

Long-only reading. mean_top_turnover is the membership churn

MetricResult

of the equal-weight top-quantile book on its own — the matched

MetricResult

turnover proxy for a long-only allocation that holds the top bucket

MetricResult

against a benchmark, which pays nothing for bottom-leg churn. It is

MetricResult

a pre-strategy feasibility diagnostic, not a cost model: weights,

MetricResult

benchmark-relative trades, slippage and capacity stay downstream.

MetricResult

Feed value (the two-leg mean) to breakeven_cost /

MetricResult

net_spread — their 4 × τ accounting is the long-short

MetricResult

book's — and do not mix the two readings.

Notes

Per rebalance date t, with k = |Q(t)|, j = |Q(t-1)| and m = |Q(t) ∩ Q(t-1)| for each leg::

churn_t    = 0.5 * Σ_i |w_t(i) - w_{t-1}(i)| = 1 - m / max(j, k)
turnover_t = (top_churn + bot_churn) / 2
value      = mean_t turnover_t

The sum runs over the union of the two holdings, so a name held at t-1 and gone at t — dropped from the leg, or delisted out of the universe altogether — books its liquidation, and the survivors' resizing is booked with it. Denominating by |Q(t)| alone counts only the buys, which is the same number whenever the leg does not shrink and understates a shrinking leg (a four-name universe narrowing to two, survivors unchanged, reads 0 against a true 0.5).

factrix averages the two legs (rather than summing) so that value is a per-leg replaced fraction in [0, 1]. The consumers restore the missing factors: breakeven_cost and net_spread both use 4 × turnover = 2 legs × 2 trades (sell the leaver, buy the joiner) with a one-way cost per trade. Summing the legs here instead would double-count against those coefficients.

A leg's churn is undefined when that leg is empty on either date, but this does not discard the other leg's well-defined churn. The headline long-short value requires both legs and reports its joint sample as n_rebalances; the per-leg means report their own sample sizes as n_top_rebalances and n_bottom_rebalances.

References

Novy-Marx-Velikov (2016), "A Taxonomy of Anomalies and Their Trading Costs."

Examples:

>>> import factrix as fx
>>> from factrix.metrics.tradability import notional_turnover
>>> panel = fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0)
>>> result = notional_turnover(panel, n_groups=10, rebalance_lag=1)
>>> result.name == ""
True

factrix.metrics.tradability.rank_turnover

rank_turnover(data: DataFrame, factor_col: str = 'factor', overlap_periods: int = 1, rebalance_lag: int | None = None, quantile: float | None = None, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Factor rank-stability via non-overlapping rank autocorrelation.

The periods floor is dynamic — the minimum date count is 2*rebalance_lag + 1 — so it is declared as a resolver (a callable sample_threshold) rather than a constant, letting inspect_data pre-flight it.

\(\text{rank_turnover} = 1 - \mathrm{mean}(\bar\rho)\) where \(\bar\rho\) is the mean rank autocorrelation

What this measures. Sensitivity of the full cross-section rank vector to reshuffling between t and t + lag, where lag is the resolved rebalance lag below. Mid-rank churn (names moving between e.g. Q4 ↔ Q5 in a 10-group split) counts even though those names carry zero weight in a Q1/Qn long-short portfolio. So this is a rank-stability diagnostic, not a notional trading-fraction.

At rebalance_lag=None (the default) the metric measures horizon-aligned rank stability — the stride is the panel's overlap, which on the full grid is the return horizon. At rebalance_lag=1 it measures adjacent-rebalance turnover: how much the ranking moves from one evaluation observation to the next.

When to use this vs notional_turnover. Feed a strategy-cost formula (breakeven_cost / net_spread) with notional_turnover, not this function — the bps coefficients there assume turnover is the fraction of Q1/Qn positions replaced per rebalance, which 1 − ρ does not provide. Keep this metric for ranking-stability comparisons across factors.

Rank autocorrelation is measured between dates t and t + lag, sub-sampled at stride lag (phase-0) so each transition is a non-overlapping snapshot.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor.

required
factor_col str

Name of the factor column. Defaults to "factor".

'factor'
overlap_periods int

The panel's evaluation-grid overlap. Injected by evaluate from the compute_forward_return stamp, or read from the stamp on a standalone call — not a user knob. Used only as the fallback stride when rebalance_lag is not given.

1
rebalance_lag int | None

Rebalance stride, counted in evaluation-grid observations (periods of the panel handed to the metric). Must be a positive int. None (default) falls back to overlap_periods, reproducing the historical behaviour: on the full grid that stamp equals the return horizon, i.e. the standard holding-period-aligned turnover convention (Alphalens' factor_rank_autocorrelation(period=holding)). On a coarser evaluation grid the stamp is the derived overlap, which equals the horizon in evaluation observations only when that grid is evenly spaced — the conservative max-count derivation inflates it otherwise. Pass rebalance_lag=1 when the evaluation grid is the rebalance schedule.

None
quantile float | None

Optional tail filter in (0, 0.5). When set, restrict the Spearman ρ at each pair to assets whose rank at either endpoint lies in the top-q or bottom-q of that date's cross- section — i.e. the statistical region where the long-short spread is actually measured. Union (not intersection) so names entering or leaving the tail both register as turnover.

Caveat: ρ on the tail union is NOT comparable to the unfiltered ρ — tail names are more persistent by construction, so the resulting turnover will typically be lower. Compare only against other tail-filtered estimates at the same q.

None
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()

Returns:

Type Description
MetricResult

MetricResult with value = 1 − mean(ρ) and metadata

MetricResult

carrying mean_rank_autocorrelation, std_rank_autocorrelation,

MetricResult

n_periods, overlap_periods (the panel's stamp, unchanged),

MetricResult

rebalance_lag (the stride actually paired at), quantile, and

MetricResult

n_cross_section_mean (mean assets-per-transition post-filter).

MetricResult

The value lies in [0, 2], not [0, 1]: ρ ranges over

MetricResult

[-1, +1], so a perfectly stable ranking gives 0, an independent

MetricResult

re-draw gives ≈1 (a white-noise factor measures just above or below

MetricResult

it by sampling error), and a systematically reversed ranking gives

MetricResult

up to 2. It is a rank-stability index, not a traded fraction — see

MetricResult

notional_turnover for the fraction of positions replaced.

MetricResult

n_obs counts the adjacent-period transitions the ρ's were

MetricResult

measured over (n_obs_axis="periods"), not (date, asset)

MetricResult

pairs.

MetricResult

std_rank_autocorrelation is the cross-transition sample std. Using

MetricResult

std/√n_periods as an SE is a lower bound: consecutive transitions

MetricResult

share one rank-vector endpoint (transition k and k+1 both involve

MetricResult

rank @ t_{k·h}), so the per-transition ρ's have weak positive

MetricResult

dependence and the true SE is marginally larger. For publication

MetricResult

grade inference, use a heteroskedasticity-and-autocorrelation-consistent (HAC) variance estimator.

Notes

For each non-overlap pair \((t, t+h)\), compute \(\rho_t = \mathrm{Spearman}(\mathrm{rank}_t, \mathrm{rank}_{t+h})\) over assets present in both cross-sections; \(\text{rank_turnover} = 1 - \mathrm{mean}_t \rho_t\). With the optional tail filter, \(\rho_t\) is restricted to the union of top- and bottom-q assets at either endpoint.

factrix exposes this metric as a rank-stability diagnostic only — it is not a notional turnover and should not be fed into breakeven_cost / net_spread. Use notional_turnover() for those.

References

Hansen-Hodrick 1980: justifies the 2h + 1 minimum-date floor for non-overlap pair stride h.

Examples:

>>> import factrix as fx
>>> from factrix.metrics.tradability import rank_turnover
>>> panel = fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0)
>>> result = rank_turnover(panel, rebalance_lag=1)
>>> result.name == ""
True

factrix.metrics.tradability.breakeven_cost

breakeven_cost(gross_spread: float | MetricResult, turnover: float | MetricResult, *, holding_periods: int = DEFAULT_FORWARD_PERIODS, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Breakeven single-leg trading cost in bps.

No static panel-shape thresholds are declared (sample_threshold=SampleThreshold()) because this is a scalar diagnostic function rather than a panel-based metric.

Breakeven = Gross_Spread × holding_periods / (4 × Turnover)

If the actual one-way trading cost is below this, the factor's alpha survives.

Expects turnover to be a notional fraction ∈ [0, 1] — the share of the equal-weight Q1/Q_n portfolio replaced per rebalance. Use notional_turnover(); do not feed in rank_turnover() (which is rank-stability, not position-change).

Time-scale alignment: gross_spread from quantile_spread is per underlying return period — compute_forward_return divides the holding return by forward_periods — while turnover is per-rebalance (one rotation every holding_periods underlying periods). Multiplying the spread by holding_periods puts both sides on the per-rebalance scale before solving net = 0; without it, breakeven is understated by that factor.

Parameters:

Name Type Description Default
gross_spread float | MetricResult

Mean long-short spread per underlying return period. This is the metric's data argument, so it is passed positionally and does not appear on the generated __init__ — the call shape is breakeven_cost(gross_spread, turnover=..., holding_periods=...) and a second positional argument raises TypeError.

required
turnover float | MetricResult

Notional turnover from notional_turnover(). Must be a finite fraction in [0, 1] — the one-way per-leg replaced fraction, top/bottom averaged. rank_turnover's value lives in [0, 2] and is rejected as often as it is merely wrong.

required
holding_periods int

Number of underlying return periods between rebalances — the same unit gross_spread is normalised to. Must be ≥ 1. On the full grid this equals the forward_periods the forward return was built at. On a coarser evaluation grid it is the rebalance interval measured in underlying periods, which is not the evaluation-grid overlap_periods and not the turnover metrics' rebalance_lag.

DEFAULT_FORWARD_PERIODS
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()

Raises:

Type Description
UserInputError

gross_spread is not finite, turnover is not a finite fraction in [0, 1], holding_periods is not an integer ≥ 1, or two MetricResult inputs disagree on n_groups. The domain applies to a bare scalar and to an available MetricResult's value alike; only an unavailable result takes the short-circuit path below.

Note

Unavailable inputs propagate, they do not price. A MetricResult whose producer short-circuited — it carries :attr:~factrix.WarningCode.METRIC_UNAVAILABLE, or simply a non-finite value — comes back as this metric's own short circuit with reason no_gross_spread / no_turnover, the producer's reason under upstream_reason and its advisory codes carried along. gross_spread is inspected first, so when both are unavailable the spread's reason is reported. The alternative — running the algebra on a NaN — produces a NaN breakeven that reads exactly like a computed one.

Note

Pass the MetricResults, not their .value. Both data arguments accept either a bare float or the producing MetricResult. Given the results, this function verifies that the spread and the turnover were bucketed the same way (same n_groups) and raises UserInputError when they were not, recording pairing_checked in metadata when they were. Bare floats carry no provenance, so nothing can be checked: the cost algebra silently prices a quintile spread with decile turnover if that is what it is handed. With the pre-unification defaults that was the likely outcome, not a corner case — breakeven came out 5.6x too low and cost drag 10.7x too high. holding_periods is not cross-checked against the producers: it is a statement about the trading schedule, which no upstream metadata records.

Returns:

Type Description
MetricResult

MetricResult with value = breakeven cost in bps. Metadata carries

MetricResult

gross_spread, turnover and holding_periods.

Notes

breakeven_bps = (gross_spread × holding_periods) / (4 × turnover) × 1e4. Multiplying spread by holding_periods lifts the per-underlying-period spread to the per-rebalance scale matching turnover; × 1e4 converts to bps.

Zero turnover — three different questions. A book that trades nothing pays nothing, so the ratio is read off the sign of its numerator, not evaluated:

  • gross_spread > 0 → +inf. The alpha is free to keep; no finite one-way cost can take it to zero.
  • gross_spread < 0 → -inf. The book already loses money before costs, and no cost ≥ 0 makes it break even. Reporting +inf here — as this function did before #1054 — says a losing book can bear an unlimited cost.
  • gross_spread == 0 → short circuit, reason="no_unique_breakeven_cost". net is already zero at every cost, so no single cost is the boundary; a number here would be a choice, not a measurement.

Example — the unit error this parameter name prevents. A signal evaluated on a coarse grid, holding 20 underlying return periods per rebalance, on a panel whose derived evaluation-grid overlap_periods is 2. At gross_spread = 0.001 and turnover = 0.20 the breakeven is 0.001 × 20 / (4 × 0.20) × 1e4 = 250 bps. Substituting the derived overlap of 2 returns 25 bps — a 10x understatement of the cost the alpha can bear.

Where the 4 comes from (the mirror of net_spread's cost drag — the two must use the same coefficient or breakeven does not solve net = 0):

  1. turnover τ from notional_turnover is the mean per-leg one-way 0.5 × Σ|Δw| fraction of the portfolio replaced per rebalance — the two legs are averaged, not summed, and holdings that left the universe count on the sell side.
  2. Replacing a fraction τ of a leg is a sell of the leaver plus a buy of the joiner: 2τ of traded notional per leg.
  3. A $1 long / $1 short spread holds two legs: 4τ of traded notional per rebalance per $1 of gross exposure.
  4. The returned cost is therefore the one-way (per-trade) cost each of those trades may bear.

The alternative convention states costs per round trip (buy + sell as one number), under which the coefficient is 2 and the breakeven figure doubles. factrix reports one-way because that is how the half-spread-plus-impact estimates this function is fed (and net_spread's estimated_cost_bps default) are quoted; halve a round-trip quote before comparing it to this number.

factrix expects turnover to be a notional fraction in [0, 1] (output of notional_turnover); feeding the rank-stability rank_turnover() value will mis-state breakeven by a factor that grows with mid-rank churn.

References

Novy-Marx-Velikov (2016), "A Taxonomy of Anomalies and Their Trading Costs."

Examples:

>>> from factrix.metrics.tradability import breakeven_cost
>>> result = breakeven_cost(
...     gross_spread=0.001, turnover=0.2, holding_periods=5,
... )
>>> result.name == ""
True

factrix.metrics.tradability.net_spread

net_spread(gross_spread: float | MetricResult, turnover: float | MetricResult, estimated_cost_bps: float = 30.0, *, holding_periods: int = DEFAULT_FORWARD_PERIODS, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Net spread after estimated trading costs (per underlying return period).

No static panel-shape thresholds are declared (sample_threshold=SampleThreshold()) because this is a scalar diagnostic function rather than a panel-based metric.

Net = Gross_Spread - 4 × cost_bps × Turnover / holding_periods

The 4 × is 2 legs × 2 trades: turnover is the mean per-leg fraction replaced per rebalance, each replacement is a sell plus a buy, and a \(1/\)1 long-short holds two legs (see Notes). Default estimated_cost_bps=30 is a conservative one-way mid-cap US equity estimate (half-spread + impact) sized to give a useful headline number; override with a venue-specific estimate when available.

Time-scale alignment: gross_spread is per underlying return period — compute_forward_return divides the holding return by forward_periods — but 4 × cost × turnover is the cost paid once per rebalance. Dividing by holding_periods amortises that cost back to the per-underlying-period scale. Without it, net is over-charged by that factor and any multi-period holding is artificially killed.

Expects turnover to be a notional fraction ∈ [0, 1] — the share of the equal-weight Q1/Q_n portfolio replaced per rebalance. Use notional_turnover(); do not feed in rank_turnover() (which is rank-stability, not position-change).

Parameters:

Name Type Description Default
gross_spread float | MetricResult

Mean long-short spread per underlying return period. This is the metric's data argument, so it is passed positionally and does not appear on the generated __init__ — the call shape is net_spread(gross_spread, turnover=..., estimated_cost_bps=..., holding_periods=...) and a second positional argument raises TypeError.

required
turnover float | MetricResult

Notional turnover from notional_turnover(). Must be a finite fraction in [0, 1] — the one-way per-leg replaced fraction, top/bottom averaged.

required
estimated_cost_bps float

Estimated one-way (per-trade) trading cost in bps — what a single buy or a single sell costs, e.g. half-spread + impact. Halve a round-trip quote before passing it here. Must be finite and ≥ 0: a negative cost would make trading a source of return.

30.0
holding_periods int

Number of underlying return periods between rebalances — the same unit gross_spread is normalised to. Must be ≥ 1. On the full grid this equals the forward_periods the forward return was built at. On a coarser evaluation grid it is the rebalance interval measured in underlying periods, which is not the evaluation-grid overlap_periods and not the turnover metrics' rebalance_lag.

DEFAULT_FORWARD_PERIODS
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()

Raises:

Type Description
UserInputError

gross_spread is not finite, turnover is not a finite fraction in [0, 1], estimated_cost_bps is not a finite value ≥ 0, holding_periods is not an integer ≥ 1, or two MetricResult inputs disagree on n_groups. The domain applies to a bare scalar and to an available MetricResult's value alike. Without it a negative turnover turned the 4 τ c drag into a subsidy and the function raised the alpha it was meant to charge.

Note

gross_spread and turnover accept the producing MetricResults as well as bare floats; passing the results lets this function verify that the two were bucketed the same way. holding_periods is not cross-checked against them — it describes the trading schedule, which no upstream metadata records. An unavailable producer result short-circuits with reason="no_gross_spread" / "no_turnover" instead of being priced. See :func:breakeven_cost.

Returns:

Type Description
MetricResult

MetricResult with value = net spread per underlying return period.

MetricResult

Metadata carries gross_spread, cost_drag,

MetricResult

estimated_cost_bps, turnover and holding_periods.

Notes

net = gross_spread - 4 × (cost_bps / 1e4) × turnover / holding_periods. Cost is incurred once per rebalance, i.e. once per holding_periods underlying return periods, so dividing by holding_periods amortises it back to the scale of gross_spread. Without the amortisation any multi-period holding is over-charged by exactly that factor.

Example — the unit error this parameter name prevents. A signal evaluated on a coarse grid, holding 20 underlying return periods per rebalance, on a panel whose derived evaluation-grid overlap_periods is 2. At gross_spread = 0.001, turnover = 0.20 and a one-way cost of 30 bps the drag is 4 × 0.003 × 0.20 / 20 = 0.00012 and the net spread 0.00088. Substituting the derived overlap of 2 gives a drag of 0.00120 and a net of −0.00020 — a 10x over-charge that flips the sign of the answer.

Where the 4 comes from. Each step of the accounting, in order:

  1. Turnover definition. τ from notional_turnover is the mean per-leg one-way 0.5 × Σ|Δw| fraction of the portfolio replaced per rebalance — (top_churn + bot_churn) / 2, an average of the two legs, not their sum, and taken over the union of prior and current holdings so a shrinking leg books its liquidations.
  2. Trades per rebalance. Replacing a fraction τ of a leg means selling the names that left and buying the names that joined: two trades, not one.
  3. Traded notional. So one leg trades 2τ of its notional per rebalance, and the $1 long / $1 short spread — two legs — trades 4τ per $1 of gross spread exposure.
  4. Cost. estimated_cost_bps is the one-way cost of a single trade, so the drag is 4 τ c.

The alternative convention quotes estimated_cost_bps as a round-trip cost (one buy and its later sell priced together), under which the coefficient is 2 τ c. factrix picks the one-way convention because the estimates practitioners have to hand (half-spread, per-order impact, per-share commission, and the default 30 bps here) are one-way quantities; charging them 2 τ under-states the drag by exactly half. breakeven_cost inverts the same coefficient, so the two remain consistent: the breakeven bps it returns is the one-way cost at which this function's net reaches zero.

factrix expects turnover to be a notional fraction (output of notional_turnover); rank-stability rank_turnover() over-states the cost drag.

References

DeMiguel-Martin-Utrera-Nogales-Uppal (2020), "A Transaction-Cost Perspective on the Multitude of Firm Characteristics." Review of Financial Studies 33(5).

Examples:

>>> from factrix.metrics.tradability import net_spread
>>> result = net_spread(
...     gross_spread=0.001, turnover=0.2,
...     estimated_cost_bps=30.0, holding_periods=5,
... )
>>> result.name == ""
True

Two flavours of turnover — do not mix them

notional_turnover is the Novy-Marx & Velikov (2016) \(\tau\): the fraction of each top/bottom quantile leg's notional traded per rebalance, averaged over the two legs. This is the quantity whose units are compatible with breakeven_cost and net_spread. rank_turnover is 1 - mean(rank autocorrelation), a rank-stability diagnostic over the full cross-section — mid-rank churn that triggers no Q1/Qn rebalance still counts. Feeding rank_turnover() into the cost formulas will mis-state the result by a factor that grows with mid-rank churn.

Use cases

  • Portfolio rebalance cost driver


    notional_turnover — per-rebalance fraction of the equal-weight Q1/Qn long-short portfolio that must be traded. Drop-in input for breakeven_cost / net_spread. Matches the Novy-Marx & Velikov (2016) \(\tau\) used in their anomaly-cost taxonomy.

  • Cross-factor rank-stability comparison


    rank_turnover — \(1 - \overline{\rho}\) on per-date rank autocorrelation, optionally restricted to the top/bottom-\(q\) tail union. Use for stability rankings across factors; not for cost arithmetic. Because \(\rho \in [-1, +1]\), the value lies in \([0, 2]\) — not \([0, 1]\): a stable ranking gives 0, an independent re-draw gives \(\approx 1\), a reversed ranking up to 2. n_obs counts transitions at the rebalance lag (n_obs_axis="periods").

  • Breakeven cost in bps


    breakeven_cost = gross_spread \cdot H / (4 \cdot \tau) \cdot 10^4, where \(H\) is holding_periods. If the venue's actual one-way cost is below this, the factor's alpha survives. The \(\cdot H\) lift puts the per-underlying-period spread onto the per-rebalance scale of \(\tau\). The \(4\tau\) is two legs times two trades (sell the leaver, buy the joiner) per unit of per-leg turnover; halve a round-trip quote before comparing it to this number. See the breakeven_cost Notes for the full derivation.

  • Net spread after estimated costs


    net_spread = gross_spread - 4 \cdot (cost_{bps} / 10^4) \cdot \tau / H, with cost_bps quoted one-way. The cost is paid once per rebalance, i.e. once per \(H\) underlying return periods, so dividing by \(H\) amortises it back to the scale of gross_spread — without that, any multi-period holding would be artificially killed. breakeven_cost inverts this same \(4\tau\) coefficient, so the two stay consistent.

The turnover convention

notional_turnover reports one-way turnover on each equal-weight leg,

\[ \tau_{\text{leg}}(t) = \tfrac{1}{2} \sum_{i} \bigl| w_t(i) - w_{t-1}(i) \bigr| , \qquad w_t(i) = \frac{\mathbb{1}\left[i \in Q(t)\right]}{\left|Q(t)\right|} , \]

the sum running over the union of the leg's prior and current holdings. An unchanged book gives 0 and a full rotation gives 1; value is the average of the top and bottom legs, which is why the cost helpers multiply it back up by \(4\tau\) (2 legs \(\times\) 2 trades). Halve a two-way (round-trip) turnover quote before comparing it to this number, exactly as for estimated_cost_bps.

Writing \(k = |Q(t)|\), \(j = |Q(t-1)|\) and \(m = |Q(t) \cap Q(t-1)|\), that sum has the exact closed form

\[ \tau_{\text{leg}}(t) = 1 - \frac{m}{\max(j,\, k)} , \]

because the survivors' resizing term \(m \cdot |1/k - 1/j|\) cancels against the smaller side's entries or exits. The metric evaluates this form, so the membership definition and the weight-change definition are the same statement, not two approximations of each other.

A shrinking leg books its liquidations

Denominating by today's leg size \(k\) alone counts only the buys. That is the same number whenever the leg grows or holds its size, and it understates a leg that shrinks — a holding delisted, a narrowing universe, a thinner bucket. A four-name universe cut to two, with both survivors keeping their leg, forces each equal-weight leg to sell the departed name and double the survivor: \(\tau = 0.5\), where the \(1 - m/k\) reading was \(0\).

A leg's churn is skipped only when that leg is empty on either date. Its valid sample is reported as metadata["n_top_rebalances"] or metadata["n_bottom_rebalances"]; an empty opposite leg does not erase a well-defined long-only diagnostic. The headline long-short value still needs both legs and metadata["n_rebalances"] counts that joint sample. Consequently, value equals the arithmetic mean of the two published per-leg means only when their samples coincide. mean_tail_size / mean_top_tail_size / mean_bottom_tail_size report the current leg sizes at \(t\) — they equal the turnover denominator only while the legs do not shrink.

The cost algebra's domain

breakeven_cost and net_spread solve one portfolio's arithmetic, so they police the economic domain of what they are handed rather than pushing any float through the formula.

Input Domain Why
gross_spread finite real numeric scalar; bool / strings rejected A non-finite spread has no reading as a per-period return.
turnover finite real numeric scalar, \(0 \le \tau \le 1\); bool / strings rejected The one-way per-leg replaced fraction above. rank_turnover lives in \([0, 2]\) and does not belong here.
estimated_cost_bps finite real numeric scalar, \(\ge 0\); None, bool and strings rejected A one-way per-trade cost. Omit the argument to use net_spread's 30 bps default; None is not a default sentinel. A negative cost would make trading a source of return.
holding_periods integer \(\ge 1\) A rebalance interval in underlying return periods.

A violation raises UserInputError, with the same bounds applied to a bare scalar and to an available MetricResult's value. Before this was enforced, breakeven_cost(0.001, turnover=-0.2, holding_periods=1) returned \(+\infty\) and net_spread(..., turnover=-0.2, estimated_cost_bps=30) increased the alpha it was meant to charge.

Unavailable inputs propagate; they are not priced

A MetricResult whose producer short-circuited — it carries METRIC_UNAVAILABLE, or simply a non-finite value — comes back as the consumer's own short circuit: reason is no_gross_spread or no_turnover, the producer's reason travels under upstream_reason, and its advisory codes are carried along. gross_spread is inspected first, so when both are unavailable the spread's reason is the one reported. Running the algebra on a NaN instead yields a NaN breakeven that reads exactly like a computed one.

Zero turnover — three different questions

A book that trades nothing pays nothing, so breakeven_cost reads the limit off the sign of the numerator rather than evaluating the ratio.

gross_spread Result Reading
\(> 0\) \(+\infty\) The alpha is free to keep; no finite one-way cost takes it to zero.
\(< 0\) \(-\infty\) The book already loses before costs; no cost \(\ge 0\) makes it break even.
\(= 0\) short circuit, reason="no_unique_breakeven_cost" net is zero at every cost, so no single cost is the boundary.

Returning \(+\infty\) for all three — as this function did before — says a losing book can bear an unlimited cost.

Four period counts, four questions

The tradability surface touches all four of the period counts factrix keeps apart. They coincide on a full evaluation grid and come apart the moment compute_forward_return(..., dates=) puts the evaluation grid on a coarser spacing than the return horizon.

Quantity Question it answers Unit Who declares it
forward_periods Over how many periods was the return measured? Underlying period grid compute_forward_return; stamped
overlap_periods How many adjacent evaluation observations share future periods? Evaluation-grid observations Derived and stamped; injected into metrics
rebalance_lag How far apart are the rankings / memberships being compared? Evaluation-grid observations The user, on rank_turnover / notional_turnover
holding_periods How many return periods pass between paying trading cost? Underlying period grid The user, on breakeven_cost / net_spread

Do not substitute one for another

rebalance_lag defaults to the injected overlap_periods, which reproduces the horizon-aligned turnover the metrics have always reported. Pass rebalance_lag=1 when the evaluation grid is the rebalance schedule. holding_periods has no such default relationship to the stamp: it must be the rebalance interval measured in underlying return periods, because gross_spread is normalised to that unit (compute_forward_return divides by forward_periods).

Worked numbers — the 10x cost-drag error

A signal holding 20 underlying return periods per rebalance, evaluated on a coarse grid whose derived overlap_periods is 2. At gross_spread = 0.001, turnover = 0.20 and a one-way cost of 30 bps:

holding_periods=20  ->  drag = 4 * 0.003 * 0.20 / 20 = 0.00012, net =  0.00088
overlap_periods=2   ->  drag = 4 * 0.003 * 0.20 /  2 = 0.00120, net = -0.00020

Breakeven is 250 bps at 20 underlying periods and 25 bps at overlap 2 — a 10x error that flips the sign of the net spread.

Migrating to holding_periods

breakeven_cost and net_spread now take holding_periods=; the former forward_periods= and overlap_periods= keywords raise TypeError. Pass the rebalance interval in underlying return periods. Do not substitute the panel's derived overlap_periods on a coarse evaluation grid, where the units differ. This rename does not affect rank_turnover or notional_turnover. They stride at the injected overlap_periods by default; rebalance_lag is an optional override, and the resolved value is reported as metadata["rebalance_lag"].

Choosing a function

Goal Function
Per-rebalance Q1/Qn membership churn — feeds the cost formulas (default \(\tau\)) notional_turnover
Top-leg-only churn — matched proxy for an equal-weight top-quantile long-only book (not a cost model) notional_turnover → metadata["mean_top_turnover"]
Rank-stability diagnostic across the full cross-section (or tail-union) rank_turnover
Breakeven trading cost in bps, given a gross spread and \(\tau\) breakeven_cost
Net per-period spread after a venue-specific cost estimate net_spread

Worked example — notional turnover then breakeven and net spread

quantile_spread → notional_turnover → breakeven_cost / net_spread

import factrix as fx
from factrix.metrics.quantile import quantile_spread
from factrix.metrics.tradability import (
    notional_turnover, breakeven_cost, net_spread,
)
from factrix.preprocess import compute_forward_return

raw   = fx.datasets.make_cs_panel(
    n_assets=500, n_dates=500, ic_target=0.08, rng=2024,
)
# Stamps both horizons; every standalone call below reads the overlap.
panel = compute_forward_return(raw, forward_periods=5)

# quantile_spread returns {factor_name: MetricResult}; notional_turnover
# returns a single MetricResult.
spread = quantile_spread(panel, n_groups=10)["factor"]
tau    = notional_turnover(panel, n_groups=10)
print(spread.value, tau.value)
# 0.00258  0.897   (approximate)

# The scalar helpers take the gross spread positionally and every other
# parameter by keyword. Pass the MetricResults, not their .value: the
# helper then verifies the two describe the same portfolio.
# holding_periods is the rebalance interval in underlying return periods.
# On this full grid that is the forward_periods the return was built at.
be  = breakeven_cost(spread, turnover=tau, holding_periods=5)
net = net_spread(spread, turnover=tau,
                 estimated_cost_bps=30.0, holding_periods=5)
print(be.value, net.value)
# 36.0   0.00043   (approximate; one-way bps and per-period spread)

The spread and the turnover must price the same portfolio

The cost algebra is a statement about one book, so a τ measured on decile membership churn does not price a quintile spread, and a τ per period does not price a multi-period holding. quantile_spread and notional_turnover used to ship incompatible defaults (n_groups 5 vs 10, stride 5 vs 1). On a 60-name, 400-period panel at gross_spread = 0.001, the matched pair gives breakeven 15.7 bps and net −9.14 bps; each function at its own default gave 2.8 bps and −98.02 bps — breakeven understated 5.6×, drag overstated 10.7×.

They now share one constant (DEFAULT_N_GROUPS = 5, DEFAULT_FORWARD_PERIODS = 5), so the defaults pair by construction. And when handed the producing MetricResults rather than bare floats, breakeven_cost / net_spread cross-check n_groups and raise UserInputError on a mismatch, recording pairing_checked in metadata otherwise. Bare floats carry no provenance, so nothing can be verified — prefer passing the results.

holding_periods is not cross-checked against the producers. It describes the trading schedule in underlying return periods, which no upstream metadata records; the stride a producer does record is an evaluation-grid count, so equality between the two would not have been evidence of a correctly paired book.

monotonicity deliberately keeps its own n_groups=10: a decile curve is the shape it is calibrated to read, not a long-short leg.

breakeven_cost and net_spread are scalar post-processing helpers, not panel-evaluation metrics. Both are @metric classes, so the gross spread is their call-time data argument and everything else must be passed by keyword — a second positional argument raises TypeError. inspect_data() marks these helpers as standalone so they are not included in inspect_data().usable.to_metrics_dict().

See also