Skip to content

factrix.metrics.tradability

Tradability metrics: Turnover, Breakeven Cost, Net Spread.

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

  • 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() — fraction of top-and-bottom quantile members replaced per rebalance. 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 measures — they belong in Profile, not in Gates.

Input for 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(df: DataFrame, factor_col: str = 'factor', *, n_groups: int = 10, forward_periods: int = 1) -> MetricOutput

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.

Per-rebalance turnover is the mean of two one-sided overlap losses::

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

(k − m) / k for k names in today's tail and m carry-overs equals the fraction of that leg that must be traded under equal weighting. Averaging the two legs keeps the × 2 factor in breakeven_cost = spread / (2 × turnover) × 1e4 consistent.

Parameters:

Name Type Description Default
df 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 10 = deciles). Must be ≥ 3 so top and bottom are distinct buckets.

10
forward_periods int

Rebalance stride. When > 1, sub-samples at stride h before pairing consecutive dates — matches a holding-period-aligned rebalance schedule.

1

Returns:

Name Type Description
MetricOutput

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

MetricOutput

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

Metadata MetricOutput

n_rebalances, n_groups, forward_periods,

MetricOutput

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

MetricOutput

n_assets / n_groups signals unbalanced buckets from ties or

MetricOutput

a short universe), method.

Notes

Per rebalance date t::

top_churn = 1 - |Q_top(t) ∩ Q_top(t-1)| / |Q_top(t)|
bot_churn = 1 - |Q_bot(t) ∩ Q_bot(t-1)| / |Q_bot(t)|
turnover_t = (top_churn + bot_churn) / 2
value = mean_t turnover_t

factrix averages the two legs (rather than summing) to keep the × 2 factor in breakeven_cost = spread / (2 × turnover) consistent with single-leg rather than round-trip cost accounting. Names dropped from Q_top(t-1) / Q_bot(t-1) by delisting before t are silently missed on the sell side — a real portfolio would still book that liquidation cost.

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, seed=0)
>>> result = notional_turnover(panel, n_groups=10, forward_periods=5)
>>> result.name
'notional_turnover'

factrix.metrics.tradability.turnover

turnover(df: DataFrame, factor_col: str = 'factor', forward_periods: int = 1, quantile: float | None = None) -> MetricOutput

Factor rank-stability via non-overlapping rank autocorrelation.

\(\text{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 + forward_periods. 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.

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 + forward_periods, sub-sampled at stride forward_periods (phase-0) so each pair is a non-overlapping snapshot. This aligns the stability window with the forward-return horizon used elsewhere in the profile.

Parameters:

Name Type Description Default
df DataFrame

Panel with date, asset_id, factor.

required
factor_col str

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

'factor'
forward_periods int

Sampling stride in periods — should match the forward-return horizon the factor is being evaluated against. 1 reproduces the lag-1 behaviour.

1
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

Returns:

Type Description
MetricOutput

MetricOutput with value = turnover estimate (0–1) and metadata

MetricOutput

carrying mean_rank_autocorrelation, std_rank_autocorrelation,

MetricOutput

n_pairs, forward_periods, quantile, and

MetricOutput

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

MetricOutput

std_rank_autocorrelation is the cross-pair sample std. Using

MetricOutput

std/√n_pairs as an SE is a lower bound: consecutive pairs

MetricOutput

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

MetricOutput

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

MetricOutput

dependence and the true SE is marginally larger. For publication

MetricOutput

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{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 turnover
>>> panel = fx.datasets.make_cs_panel(n_assets=80, n_dates=180, seed=0)
>>> result = turnover(panel, forward_periods=5)
>>> result.name
'turnover'

factrix.metrics.tradability.breakeven_cost

breakeven_cost(gross_spread: float, turnover: float, *, forward_periods: int) -> MetricOutput

Breakeven single-leg trading cost in bps.

Breakeven = Gross_Spread × forward_periods / (2 × Turnover)

If the actual 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 turnover() (which is rank-stability, not position-change).

Time-scale alignment: gross_spread from quantile_spread is per-period (forward_return is divided by N upstream), but turnover is per-rebalance (one rotation every N periods). Multiplying spread by forward_periods puts both sides on the per-rebalance scale before solving net=0 — without it, breakeven is understated by N×.

Parameters:

Name Type Description Default
gross_spread float

Per-period mean long-short spread.

required
turnover float

Notional turnover ∈ [0, 1] from notional_turnover().

required
forward_periods int

Holding period N matching the upstream compute_forward_return and notional_turnover stride.

required

Returns:

Type Description
MetricOutput

MetricOutput with value = breakeven cost in bps.

Notes

breakeven_bps = (gross_spread × forward_periods) / (2 × turnover) × 1e4. Multiplying spread by forward_periods lifts the per-period spread to the per-rebalance scale matching turnover; × 2 is the long+short single-leg pair; × 1e4 converts to bps.

factrix expects turnover to be a notional fraction in [0, 1] (output of notional_turnover); feeding the rank-stability 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, forward_periods=5,
... )
>>> result.name
'breakeven_cost'

factrix.metrics.tradability.net_spread

net_spread(gross_spread: float, turnover: float, estimated_cost_bps: float = 30.0, *, forward_periods: int) -> MetricOutput

Net spread after estimated trading costs (per-period).

Net = Gross_Spread - 2 × cost_bps × Turnover / forward_periods

The 2 × accounts for both legs of the long-short portfolio needing to be traded (long side + short side) at each rebalance. Default estimated_cost_bps=30 is a conservative single-leg 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-period (forward_return is divided by N upstream) but 2 × cost × turnover is the cost paid once per N-period rebalance. Dividing by forward_periods amortises that cost back to per-period. Without it, net is over-charged by N× and any factor with h ≥ 2 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 turnover() (which is rank-stability, not position-change).

Parameters:

Name Type Description Default
gross_spread float

Per-period mean long-short spread.

required
turnover float

Notional turnover ∈ [0, 1] from notional_turnover().

required
estimated_cost_bps float

Estimated single-leg trading cost in bps.

30.0
forward_periods int

Holding period N matching the upstream compute_forward_return and notional_turnover stride.

required

Returns:

Type Description
MetricOutput

MetricOutput with value = net spread (per-period).

Notes

net = gross_spread - 2 × (cost_bps / 1e4) × turnover / forward_periods. Cost is incurred once per forward_periods- period rebalance, so dividing by forward_periods amortises it back to the per-period scale of gross_spread. Without the amortisation any factor with h >= 2 is over-charged by h x.

factrix expects turnover to be a notional fraction (output of notional_turnover); rank-stability 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, forward_periods=5,
... )
>>> result.name
'net_spread'

Two flavours of turnover — do not mix them

notional_turnover is the Novy-Marx & Velikov (2016) \(\tau\): fraction of top-and-bottom quantile members replaced per rebalance. This is the quantity whose units are compatible with breakeven_cost and net_spread. 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 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


    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.

  • Breakeven cost in bps


    breakeven_cost = gross_spread \cdot h / (2 \cdot \tau) \cdot 10^4. If the venue's actual round-trip cost is below this, the factor's alpha survives. The \cdot h lift puts per-period spread onto the per-rebalance scale of \(\tau\).

  • Net spread after estimated costs


    net_spread = gross_spread - 2 \cdot (cost_{bps} / 10^4) \cdot \tau / h. The cost is paid once per \(h\)-period rebalance, so dividing by \(h\) amortises it back to the per-period scale of gross_spread — without that, any factor with \(h \geq 2\) would be artificially killed.

Choosing a function

Goal Function
Per-rebalance Q1/Qn membership churn — feeds the cost formulas (default \(\tau\)) notional_turnover
Rank-stability diagnostic across the full cross-section (or tail-union) 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 — 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, seed=2024,
)
panel = compute_forward_return(raw, forward_periods=5)

spread = quantile_spread(panel, forward_periods=5, n_groups=10)
tau    = notional_turnover(panel, n_groups=10, forward_periods=5)
print(spread.value, tau.value)
# 0.0021  0.18   (approximate)

be  = breakeven_cost(spread.value, tau.value, forward_periods=5)
net = net_spread(spread.value, tau.value,
                 estimated_cost_bps=30.0, forward_periods=5)
print(be.value, net.value)
# 291.7   0.00189   (approximate; bps and per-period spread)

See also