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 intobreakeven_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 |
required |
factor_col
|
str
|
Name of the factor column. |
'factor'
|
n_groups
|
int
|
Number of quantile groups (default |
10
|
forward_periods
|
int
|
Rebalance stride. When |
1
|
Returns:
| Name | Type | Description |
|---|---|---|
MetricOutput
|
MetricOutput with |
|
MetricOutput
|
|
|
Metadata |
MetricOutput
|
|
MetricOutput
|
|
|
MetricOutput
|
≠ |
|
MetricOutput
|
a short universe), |
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:
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 |
required |
factor_col
|
str
|
Name of the factor column. Defaults to |
'factor'
|
forward_periods
|
int
|
Sampling stride in periods — should match the
forward-return horizon the factor is being evaluated against.
|
1
|
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
|
Returns:
| Type | Description |
|---|---|
MetricOutput
|
MetricOutput with |
MetricOutput
|
carrying |
MetricOutput
|
|
MetricOutput
|
|
MetricOutput
|
|
MetricOutput
|
|
MetricOutput
|
share one rank-vector endpoint (pair k and pair k+1 both involve |
MetricOutput
|
|
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:
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 |
required |
forward_periods
|
int
|
Holding period N matching the upstream
|
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:
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 |
required |
estimated_cost_bps
|
float
|
Estimated single-leg trading cost in bps. |
30.0
|
forward_periods
|
int
|
Holding period N matching the upstream
|
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:
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 forbreakeven_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 hlift 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 ofgross_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¶
-
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 turnover for a feasibility picture.
-
by_slice
Axis-agnostic slice dispatcher for per-slice turnover / breakeven summaries.
-
Metric applicability reference
Profile-level (not Gates) framing of implementation feasibility.
-
Individual × Continuous landing
Adjacent metrics in the same cell.