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 intobreakeven_cost/net_spread.notional_turnover()— one-way0.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 |
required |
factor_col
|
str
|
Name of the factor column. |
'factor'
|
n_groups
|
int
|
Number of quantile groups (default
:data: |
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 |
None
|
overlap_periods
|
int
|
The panel's evaluation-grid overlap (default
:data: |
DEFAULT_FORWARD_PERIODS
|
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
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 |
|
MetricResult
|
|
|
Metadata |
MetricResult
|
|
MetricResult
|
(the median per-period count of finite factor values), |
|
MetricResult
|
|
|
MetricResult
|
(the stride actually sampled at), |
|
MetricResult
|
|
|
MetricResult
|
leg's mean replaced fraction, each over the rebalances where that leg |
|
MetricResult
|
exists on both dates), |
|
MetricResult
|
|
|
MetricResult
|
per-date mean of the two legs over the joint |
|
MetricResult
|
it equals the arithmetic mean of the two published leg means only when |
|
MetricResult
|
their samples coincide. |
|
MetricResult
|
|
|
MetricResult
|
|
|
MetricResult
|
|
|
MetricResult
|
not shrink; ≠ |
|
MetricResult
|
ties or a short universe) with its per-leg split |
|
MetricResult
|
|
|
MetricResult
|
Long-only reading. |
|
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 |
|
MetricResult
|
|
|
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:
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 |
required |
factor_col
|
str
|
Name of the factor column. Defaults to |
'factor'
|
overlap_periods
|
int
|
The panel's evaluation-grid overlap. Injected by
|
1
|
rebalance_lag
|
int | None
|
Rebalance stride, counted in evaluation-grid
observations (periods of the panel handed to the metric). Must
be a positive |
None
|
quantile
|
float | None
|
Optional tail filter in 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 |
()
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with |
MetricResult
|
carrying |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
|
MetricResult
|
The value lies in |
MetricResult
|
|
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
|
|
MetricResult
|
|
MetricResult
|
measured over ( |
MetricResult
|
pairs. |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
share one rank-vector endpoint (transition k and k+1 both involve |
MetricResult
|
|
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:
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 |
required |
turnover
|
float | MetricResult
|
Notional turnover from |
required |
holding_periods
|
int
|
Number of underlying return periods between
rebalances — the same unit |
DEFAULT_FORWARD_PERIODS
|
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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
|
|
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≥ 0makes it break even. Reporting+infhere — 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".netis 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):
turnoverτ fromnotional_turnoveris the mean per-leg one-way0.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.- Replacing a fraction τ of a leg is a sell of the leaver plus a buy of the joiner: 2τ of traded notional per leg.
- A $1 long / $1 short spread holds two legs: 4τ of traded notional per rebalance per $1 of gross exposure.
- 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:
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 |
required |
turnover
|
float | MetricResult
|
Notional turnover from |
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 |
30.0
|
holding_periods
|
int
|
Number of underlying return periods between
rebalances — the same unit |
DEFAULT_FORWARD_PERIODS
|
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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 |
MetricResult
|
|
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:
- Turnover definition. τ from
notional_turnoveris the mean per-leg one-way0.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. - 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.
- Traded notional. So one leg trades
2τof its notional per rebalance, and the $1 long / $1 short spread — two legs — trades4τper $1 of gross spread exposure. - Cost.
estimated_cost_bpsis the one-way cost of a single trade, so the drag is4 τ 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:
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 forbreakeven_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_obscounts 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\) isholding_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 thebreakeven_costNotes for the full derivation. -
Net spread after estimated costs
net_spread = gross_spread - 4 \cdot (cost_{bps} / 10^4) \cdot \tau / H, withcost_bpsquoted 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 ofgross_spread— without that, any multi-period holding would be artificially killed.breakeven_costinverts this same \(4\tau\) coefficient, so the two stay consistent.
The turnover convention¶
notional_turnover reports one-way turnover on each equal-weight leg,
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
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¶
-
quantile_spread/quantile_spread_vw
Source of
gross_spreadfor the cost formulas; pairs naturally withnotional_turnoveron the same Q1/Qn buckets. -
top_concentration
Long-leg concentration on the same top bucket — combine with rank turnover for a feasibility picture.
-
by_slice
Axis-agnostic slice dispatcher for per-slice rank turnover / breakeven summaries.
-
Metric applicability reference
Implementation-feasibility framing, not a factor-quality significance test.
-
Individual × Continuous landing
Adjacent metrics in the same cell.