Architecture
Current-state reference for internal contracts, dispatch, and module layout. Use it as a lookup page rather than a start-to-finish guide. User-facing scope lives in Where factrix fits; callable details live in the API reference.
Positioning¶
factrix is a factor-inference and screening library, not a backtest engine. Each metric keeps its estimate, tested tail, p-value, metadata, and warnings explicit; downstream portfolio and execution state stays outside the package.
Period grid, not calendar¶
factrix never reads the calendar. This is a first-order design principle, not an implementation detail, and every other page that mentions frequency links here.
dateis an ordering and alignment key. Its granularity — minutes, days, weeks, months — is never inspected; aDateorDatetimedtype is required only so ordering is unambiguous.- Every horizon, window, lag, stride and sample floor is a count of periods
on the panel's own grid — the distinct sorted
datevalues present in the panel — never calendar time and never row position within an asset.forward_periods=5is five periods of whatever one row represents. - There is no annualisation factor, no trading-day constant, no business-day calendar and no date arithmetic anywhere in the library.
- The evaluation grid may be spaced unevenly in periods (a caller-chosen rebalance grid on a finer price grid). Nothing assumes a constant stride; quantities that depend on spacing (overlap, non-overlapping stride) are derived from period indices, never from dates.
- Which real-world cadence a period represents, and aligning the factor and price sources onto one grid, is the caller's responsibility (Preparing data §5).
Ragged panels and the row/period distinction. A rolling window or a shift
in polars counts rows within a frame; on a panel whose assets all carry every
period the two coincide, and on a ragged one — an asset missing periods the
other names have — they do not. The event family therefore reindexes onto the
panel grid before any window is taken: each asset is laid onto the full set of
distinct dates, an absent period becoming a null row rather than no row, so an
estimation_window of 30 spans exactly 30 grid periods for every asset and the
missing periods inside it count as missing observations instead of pulling
older periods in to fill the window. The same rule governs the event offsets in
compute_event_returns: k periods after the event is k steps on the grid,
and an offset landing on a period the asset does not have has no return, rather
than silently reaching further out. A dense panel is unaffected — the reindex
is a no-op there — and an asset short of periods raises
WarningCode.RAGGED_PERIOD_GRID, because the window still spans the requested
periods but rests on a smaller sample than the other names'.
Wording rule. Documentation, docstrings, warning messages and code comments say periods. They do not say days, trading days, weeks, month-ends or any other calendar unit, and examples that need a concrete cadence say so explicitly ("on a daily panel, five periods is five days") rather than baking it into the contract. A reviewer who finds calendar vocabulary in a contract-level statement should treat it as a defect.
Global architecture¶
flowchart TD
User["User<br/>(data, metrics=[...])"]
EVAL["evaluate()<br/>Dispatch API"]
REG["Registry<br/>list[MetricSpec]"]
DAG["DagExecutor<br/>computes dependency graph"]
ER["EvaluationResult<br/>groups / metrics / warnings"]
SCREEN["multi_factor<br/>FDR screening"]
User -->|"evaluate(panel, metrics=[...])"| EVAL
EVAL --> REG
REG --> DAG
DAG -->|"results"| ER
ER -->|"batch"| SCREEN
The dispatch runs through a Directed Acyclic Graph (DAG) executor on a closed list[MetricSpec] rather than a registry-keyed procedure table.
Public API surface¶
The public surface has four layers:
evaluateandevaluate_horizonsbuild factor results.by_slice, slice tests, andcompareinspect or contrast those results.multi_factorprocedures apply declared-family FDR and partial-conjunction rules.- Result dataclasses, enums, errors, and discovery helpers expose the stable contracts used by all three layers.
The API reference is the source for current signatures and
the complete entry-point list. __version__ is sourced from
pyproject.toml and managed by Commitizen.
DataStructure — the derived fourth axis¶
The user-facing axes FactorScope and FactorDensity, together with the metric
selection itself, are the SSOT;
see Concepts § Three orthogonal design axes
for their values and orthogonality.
DataStructure is the fourth axis but is not user-facing — it is derived at
evaluate-time from panel["asset_id"].n_unique() (factrix._detect_structure):
PANEL for n_assets >= 2, TIMESERIES for n_assets == 1. Each MetricSpec declares the
(scope, density, structure) cell it applies to (None on an axis = *
wildcard); the DAG executor derives the runtime structure and normally
dispatches each requested metric by cell match. A metric inapplicable to the data's
factor cell (scope / density / data structure axes) raises under strict=True or
short-circuits to a NaN MetricResult under strict=False. A
not_applicable* type-routing verdict — the metric's signal type does not
fit the factor (e.g. a continuous-magnitude metric on a discrete ±k signal), a
separate axis from the cell — is soft even under strict=True (see § Error UX
contract → strict and applicability). There is no separate routing token and
no scope-collapse step (see § PANEL / TIMESERIES equivalence).
MetricSpec SSOT dispatch¶
Each factrix/metrics/*.py module decorates its public callables with
@metric — the source of truth, resolved through factrix._metric_index:
MetricSpec(name, cell, aggregation, ...)— the typed per-callable spec;cellis a(scope, density, structure)CellwithNone=*wildcard on any axis.spec_by_name() -> dict[str, MetricSpec]— name → spec lookup across every registered metric.public_specs()— visibility-filtered(family, MetricSpec)pairs (dropsPIPELINE-role stage-1 helpers pulled only viarequires). The family is the declaring module stem; callers that only need specs should iteratefor _, spec in public_specs().list_metrics()— the public runtime discovery API, grouped by metric family.
@metric-class registration feeds the index via
factrix.metrics._registry.register. Every introspection / validation path
reads this index — no parallel rule table.
Adding a metric decorates one callable with @metric; the DAG executor picks
it up by cell match.
Explicit sparse-event override. One narrow exception preserves the event
contract for frequent {0, R} signals: when a factor is detected as DENSE
only because its zero ratio is below the automatic sparse threshold
(0 < sparse_ratio < 0.5), an explicitly requested SPARSE metric may run if
the same (scope, structure) cell would otherwise match. The result's
EvaluationResult.cell still records the detected data cell (DENSE), and the
metric/result warnings include WarningCode.FREQUENT_EVENT_SIGNAL. This is an
explicit-call escape hatch for frequent event studies, not a discovery default:
inspect_data().usable excludes the warned metric and places it in
degraded, so bulk discovery flows do not silently cross the density axis.
EvaluationResult dataclass contract¶
factrix/_results.py:
@dataclass(frozen=True, slots=True)
class EvaluationResult:
factor: str
cell: tuple[FactorScope, FactorDensity, DataStructure]
forward_periods: int
overlap_periods: int
n_periods: int
n_pairs: int
n_assets: int
metrics: Mapping[str, MetricResult]
plan: str
params: Mapping[str, Hashable] = field(default_factory=dict)
metadata: Mapping[str, Any] = field(default_factory=dict)
warnings: list[Warning] = field(default_factory=list)
One unified EvaluationResult is returned by evaluate — no per-cell subclass.
Dispatch runs through the DAG executor
(factrix._dag.DagExecutor) on a closed list[MetricSpec].
cellis the detected factor cell(scope, density, structure)for this result group. Under the explicit sparse-event override, this remains the detectedDENSEcell even though the requested sparse metric is allowed to run with aFREQUENT_EVENT_SIGNALwarning.- Per-metric outputs live in
metrics, a read-onlyMapping[str, MetricResult](MappingProxyType) mapping each label to itsMetricResult. Screening verbs read each result'sMetricResult.p_value— by convention the mainstream metric's. - Advisory diagnostics are a flat
list[Warning]onwarnings— per-metric records carrysource=<metric name>, bundle / pre-dispatch records carrysource=None. planis the DAG executor's numbered topological execution plan.to_frame()/to_dict()are the serialisation exit points.
overlap_periods: injection order and signature defaults¶
overlap_periods is an injected parameter (MetricBase._INJECTED_PARAMS),
not a user knob: it describes the data, not the test. MetricBase.__call__
resolves it in a fixed order:
- the caller's explicit
overlap_periods=(re-validated at the boundary, so a standalone call rejects the same bad valueevaluatewould); - otherwise, for a frame input, the panel's reserved overlap stamp written by
compute_forward_return(MetricBase._stamped_overlap_periods); - otherwise, the body's signature default.
So the signature default is unreachable under evaluate — that path always
injects the stamped horizon. It binds only on a standalone call against an
unstamped frame, against a non-frame input (series consumers), or against a
producer's derived frame that carries no stamp (fm_beta's beta_df). Reading
a signature default as "the horizon this metric assumes" is therefore wrong; it
is only the fallback for an undeclared, unstamped input.
Three defaults are in use, and the difference is deliberate:
| Default | Metrics | Meaning |
|---|---|---|
5 (DEFAULT_FORWARD_PERIODS) |
most metrics — ic, quantile_spread, k_spread, caar, event_quality.*, predictive_beta, … |
"Assume the library's default forward horizon." It matches compute_forward_return's own default, so a hand-built unstamped panel is treated the way the default pipeline would have stamped it. |
1 |
spanning_alpha, rank_turnover |
The input is non-overlapping by construction — spanning_alpha consumes compute_spread_series' already-strided output, and on rank_turnover the value is a stride fallback rather than a HAC floor. 1 means "one period, no overlap". |
None |
fm_beta, common_quantile_spread, common_asymmetry |
"No horizon declared." The value flows straight into the optional-typed HAC resolvers (_resolve_nw_lags, _resolve_har_lags, _har_dof), which read None as "apply no overlap floor and no effective-dof cap". |
None and 1 are numerically identical in all three resolvers — the floor
is max(h - 1, 0) = 0 and the dof cap is guarded by h > 1 — so the int /
int | None split is a semantic one (undeclared vs declared-as-one), not a
behavioural one. None and 5 are not: the 5 default raises the bandwidth at
small T and always tightens the dof cap, so unifying the None group onto
DEFAULT_FORWARD_PERIODS would silently move p-values on every unstamped
standalone call. That is why the three groups are left as they are.
PANEL / TIMESERIES equivalence¶
Both structures produce real MetricResult.p_value values — neither is degraded.
(INDIVIDUAL, DENSE, *) × n_assets == 1 is mathematically undefined (no
cross-sectional dispersion → IC and per-period ordinary least squares (OLS) undefined). The IC / FM
specs declare cell.structure = PANEL, so under strict=True evaluate raises
IncompatibleAxisError; under strict=False the metric short-circuits to a NaN
MetricResult with a reason. Explicit and user-correctable, never a silent rewrite.
(*, SPARSE, *) × n_assets == 1 is well-defined and runs with no scope-collapse step.
Sparse metrics whose MetricSpec.cell.structure is wildcarded (None) apply
at n_assets == 1, so the DAG executor runs them directly on the single-asset series —
there is no scope-collapse step, and no sentinel routing both sparse scopes to a
shared TIMESERIES procedure. At n_assets == 1 the INDIVIDUAL / COMMON distinction is
moot (one asset — no scope axis), but that falls out of the derived structure
rather than an explicit routing token. Sparse metrics that still need an asset
cross-section, such as clustering_hhi (cell.structure=PANEL), remain
unavailable at n_assets == 1.
Sample guards¶
User-facing tier semantics (hard block / soft warning / clean) live in Guides § Panel vs timeseries — Sample guards. This section catalogues the internal constants that back those tiers.
Naming grammar¶
Every sample-size identifier — runtime count, declarative floor, calibrated constant, warning code, drop-stat key — is named off a single axis token so a reader resolves which axis a name guards from the name alone. New identifiers must use an axis token, with two deliberate registers/exceptions:
- Two registers for the time axis. The data layer speaks the panel
column token
date(n_dates= distinct dates, grounded inadapt(date="date")); the stats layer speaks the axis tokenperiods(n_periods,MIN_PERIODS_*,min_periods). Same dimension, two registers — a count of the raw column vs the abstract series length. - The cross-metric neutral
n_obs.MetricResult.n_obs(a first-class serialized field) and the genericfx.inferenceestimators carry an axis-agnosticn_obson purpose: at those layers the caller's axis is unknown and any single token would mislabel a pooled or estimator-specific count. Per-metric metadata keys still use an axis token. groupsis not a sample axis. The quantile-bucket countn_groups(quantile spread, monotonicity) is a partition parameter the caller chooses, not an observed sample dimension: below its target it is silently downscaled to fit the cross-section (_downscale_n_groupsinfactrix/_stats/slice_policy.py), never blocked or warned like a thinn_periods. So it carries noSampleThresholdtier and stays out of_AXES(which is the four genuine sample axes above). Its per-bucket floor ismin_assets_per_group— anassets-axis quantity scoped per bucket, already within the grammar, not a fifth axis.
| Axis token | Dimension |
|---|---|
periods |
time-series length (T; number of dates / draws) |
assets |
cross-sectional asset count (n_assets per period) |
pairs |
complete (factor, return) pairs (FM cross-section) |
events |
event-period count |
Four layers, one grammar:
- Runtime counts —
n_<axis>:n_periods,n_assets,n_events,n_pairs. Drop accounting adds the directional/derived formsn_<axis>_in,n_<axis>_out,dropped_<axis>. - Per-metric declarative floors (
SampleThreshold,factrix/_metric_index.py) —min_<axis>/warn_<axis>fields (min_periods/warn_periods, …). A metric is unusable belowmin, degraded in[min, warn), clean at≥ warn;__post_init__enforces themin <= warninvariant. Axes a metric does not use are leftNone. - Calibrated module constants (SSOT for the literals) —
MIN_[<DOMAIN>_]<AXIS>[_<TIER>]. TheAXIStoken is mandatory (PERIODS/ASSETS/EVENTS/PAIRS);DOMAINis an optional prefix qualifier (PORTFOLIO,FM,IC) that disambiguates when the same axis is gated by a different statistic.TIERis_HARD(raise / short-circuit floor) or_WARN(degrade floor)._HARDis dropped on any axis that never raises —MIN_ASSETS_WARNcarries no_HARDbecause the cross-asset t-test is defined forn_assets ≥ 2(only weak, never undefined), so a single warn floor flags the whole thin regime and severity is read from then_assetsmetadata. A constant for one axis must never gate another — introduce a separate_PERIODSconstant even when the calibrated value coincides with an_ASSETSone. - Warning codes (
factrix/_codes.py) carry the same axis token so the degraded axis is legible from the code alone:UNRELIABLE_SE_SHORT_PERIODS,FEW_EVENTS,FEW_ASSETS,BORDERLINE_PORTFOLIO_PERIODS, and the axis-specific drop pairEXCESSIVE_PERIOD_DROPS/EXCESSIVE_ASSET_DROPS.
Silent-drop diagnostics emit a fixed per-axis metadata schema
(factrix/metrics/_helpers.py): n_<axis>_in, n_<axis>_out,
dropped_<axis>, drop_rate, drop_reason — the count keys carry the
axis token, the rate keys are axis-neutral.
Where the constants live, and what enforces the grammar. The calibrated
literals are declared once and imported everywhere: the generic TIMESERIES
floors and the HAC bandwidth rules in factrix/_stats/constants.py, the
per-metric domain floors (MIN_COMMON_BETA_PERIODS_HARD, MIN_FM_PERIODS_*,
MIN_PORTFOLIO_PERIODS_*, …) alongside their neighbours in factrix/_types.py.
No module re-declares one locally. tests/test_sample_naming_lint.py holds the
lints: FX003 on the MIN_* constant names, FX005 on metric metadata keys (they
must name their axis rather than use the neutral obs / sample vocabulary
reserved for MetricResult.n_obs and fx.inference), and FX006 on the two
single-sourced signature defaults overlap_periods / n_groups, which must
reference DEFAULT_FORWARD_PERIODS / DEFAULT_N_GROUPS instead of repeating
the value.
Effective-sample single source. The count a metric gates on
(min_<axis>), reports (n_obs / n_<axis>), and records in drop-stats
must be the one the statistic is actually estimated on — the complete
observations after pairwise finite-value filtering, not the raw row count.
Missing factor values are normal in real research, so a cross-sectional
reduction counts the finite (factor, return) cross-section per period:
compute_fm_betas
(MIN_FM_ASSETS_HARD) and compute_ic (MIN_IC_ASSETS_HARD) both gate on that
finite-pair count, dropping a date with many names but a factor defined
for few rather than leaking a high-variance estimate. Counting null-padded rows
would let the gate, the report, and the estimate silently disagree.
Backing constants¶
User-facing @metric decorators must spell out sample_threshold=....
Use a non-empty SampleThreshold when inspect_data() can pre-flight a
runtime sample gate; use SampleThreshold() when the lack of a static
panel-shape floor is deliberate. Primitive producers under
factrix/metrics/_primitives/ may omit the decorator field because consumers
surface the user-facing threshold or drop statistics.
factrix/_stats/constants.py:
MIN_PERIODS_HARD = 20,MIN_PERIODS_WARN = 30— the two-tiern_periodsthresholds.MIN_ASSETS_WARN = 30— the singlen_assetswarn floor (no_HARDtier). Then_assetsaxis never raises (cross-asset t-test on E[β] is mathematically defined forn_assets ≥ 2), so constant naming deliberately drops the_HARDsuffix to avoid implying a raise.auto_bartlett(T) = max(1, int(4 * (T/100)**(2/9)))— Newey-West (1994) auto lag rule.- Hansen-Hodrick (1980) overlap floor:
max(auto_bartlett(T), forward_periods - 1)— ensures NW lag covers MA(h-1) structure from overlapping forward returns.
factrix/_types.py and the metric primitives keep the per-metric thresholds used
internally by the primitives that procedures wrap:
MIN_IC_ASSETS_HARD = 2,MIN_IC_ASSETS_WARN = 10—compute_icdrops only dates with fewer than 2 complete(factor, return)pairs, the true computability floor for a per-period Spearman IC. Dates with 2..9 complete pairs are retained, and IC consumers /inspect_datasurfaceWarningCode.FEW_ASSETSbecause the cross-section is statistically thin.MIN_SERIES_PERIODS_HARD = 10— shared periods-axis floor for non-overlapping series diagnostics (icpost-stride mean test,positive_rate, and the series-mean non-overlap pre-flight). It is intentionally not IC-named because the same 10-draw floor applies outside IC.MIN_EVENTS_HARD = 4,MIN_EVENTS_WARN = 30— two-tier sparse-cell event-count floor.n < HARDshort-circuits the CAAR / event-quality primitives;HARD ≤ n < WARNemitsWarningCode.FEW_EVENTS.MIN_FM_ASSETS_HARD = 3(factrix/metrics/_primitives/_fm_betas.py) —compute_fm_betasemits a date only with ≥ 3 complete(factor, return)pairs and non-zero cross-sectional variance; the closed-form slopeCov_t(x, y) / Var_t(x)is computed batched across factors (onegroup_by("date").agg), so degenerate (zero-variance) dates are dropped rather than assigned an arbitrary least-norm slope.MIN_FM_ASSETS_WARN = 10preserves those computable 3..9-asset dates while surfacingWarningCode.FEW_ASSETSfrom FM consumers andinspect_data.
Inflation cost at low n_assets¶
For interpreting borderline p-values when n_assets falls in the warning bands:
df = n_assets − 1 → t_crit at n_assets = 3 ≈ 4.30 (+119% vs asymptotic 1.96),
at 5 ≈ 2.78 (+42%), at 10 ≈ 2.26 (+15%), at 20 ≈ 2.09 (+7%). The test still
runs; the warning surfaces the inflation so callers can read p ≈ 0.04 as
"borderline at this n_assets" rather than "rejected".
Naming: data (DataFrame) vs df_* (degrees of freedom)¶
df is ambiguous — degrees of freedom in a statistics context, DataFrame
in the polars/pandas idiom. The collision is killed by position, so every name
resolves to one meaning on sight:
df_…prefix → degrees of freedom.df_num(numerator / restriction rankK-1),df_denom(denominator),df_resid(residual). A DoF value never goes unqualified.…_dfsuffix → DataFrame. A named frame keeps the informative idiom (ic_df,caar_df,beta_df,common_betas_df) — the prefix is the content, the_dfsays "frame". A standalone frame usesdata, or a semantic noun where one reads better (panel,per_period,factor_panel,subset,residuals).- bare
df/_df→ banned. The unqualified token is exactly the ambiguous case (could be either register), so factrix never declares a parameter, local, dataclass field, or dict / column key nameddfor_df.
The one tolerated bare df is the scipy distribution kwarg
(sp_stats.chi2.sf(q, df=h), t.sf(t, df=...)): it is scipy's own parameter name
at the call site, not a name factrix declares, and carries no DataFrame ambiguity
inside a dist.sf(...) call. The positional split keeps DoF self-describing rather
than loosening to a bare df — the same read-it-once principle as the
sample-axis naming grammar.
Enforcement. tests/test_naming_df.py walks every factrix/ module with ast
and fails if any function parameter, assignment target, or dataclass field is named
exactly df or _df — closing the abbreviation back-flow at CI rather than relying
on review. (ruff has no built-in for an identifier-name ban; the AST guard mirrors
tests/test_docs_matrix.py.)
Error UX contract¶
User-facing raises follow a single canonical message format so callers learn to read factrix errors once and recover programmatically across all functions.
Hierarchy¶
FactrixError # base — all factrix-raised errors
├── IncompatibleAxisError
├── IncompatibleInferenceError # inference= outside the metric's allowlist
├── InsufficientSampleError # carries .axis / .actual / .required / .shortfalls
└── UserInputError # named-set typo / type mismatch
UserInputError is the marker for "user typed the wrong thing"
(unknown metric / expand_over key, column not in panel,
wrong type). Catch it separately from IncompatibleAxisError (axis miswire) and
InsufficientSampleError (data limitation) when those branches need
different recovery. The split at the strict=True boundary is mechanical:
_enforce_strict reads the short-circuit reason vocabulary — an
insufficient_* reason is a sample shortfall (InsufficientSampleError), a
no_* reason is a missing input column or config (UserInputError). A battery
that fails on both raises UserInputError, the actionable one.
strict and applicability¶
evaluate(strict=True) (the default) is loud about a metric that fits the
data but could not produce a value, and silent-by-design about a metric whose
type does not fit. The split is deliberate — applicability is a first-class
output of type-routed evaluation, not a user error — so it should not be
re-collapsed into "raise on anything inapplicable":
metadata["reason"] class |
Meaning | strict=True |
|---|---|---|
not_applicable* |
The metric's signal type does not fit this factor (e.g. a continuous-magnitude metric on a discrete ±k signal). The type-routing verdict. | soft — NaN + is_applicable=False; the applicable metrics in the same call still return |
insufficient_* |
The metric fits but the sample is too thin | raise (UserInputError) |
no_* |
Missing input column / config | raise |
| cell mismatch | Requested metric cell (scope / density / data structure) does not match the factor's detected cell | raise (IncompatibleAxisError), except for the explicit sparse-event override described in MetricSpec SSOT dispatch |
Rationale: throwing a mixed battery at a panel and seeing which metrics apply is
the core type-routed-evaluation workflow; aborting it because one metric's type
does not fit would discard the applicable results too. The deficiency cases
(insufficient_* / no_* / structure) stay loud because there the metric was
the right choice and the data or call is at fault — a NaN slipping silently into
a research result is the failure mode to avoid. strict=False makes every
case soft. Implemented in _enforce_strict / _is_type_routing_reason.
Three required fields¶
Every user-facing raise that takes a named input must carry:
- Trigger: the kwarg / column name and the value received
- Diagnostic: either fuzzy candidates (named-set error) or an expected-shape string (type error)
- Docs link: deployed-docs anchor for the function
Constructor¶
UserInputError is keyword-only and renders its own message:
UserInputError(
*,
func_name: str,
field: str,
value: object,
candidates: Iterable[object] | None = None, # named-set typo
expected: str | None = None, # type / shape mismatch
docs_path: str, # "api/<func_name>#<anchor>"
)
- Exactly one of
candidates/expectedcarries the diagnostic. - Fuzzy match:
difflib.get_close_matches(str(value), candidates, n=3, cutoff=0.6). - Non-string candidates are coerced via
str(...)soEnummembers or type objects work without pre-conversion at the call site. docs_pathis appended tohttps://awwesomeman.github.io/factrix/so the deployed base URL lives in one place (factrix._errors._DOCS_BASE).- Long candidate lists truncate to the first 15 with a
Available (15 of N, see Docs):header; longvaluereprs cap at 120 chars to keep messages readable when callers pass DataFrames or polars expressions. - Language: English (consistent with docstrings; errors land in stack traces / CI output).
Structured attributes¶
Sub-issues and downstream consumers (LLM agents, screening loops) recover via attributes, not message substrings:
.func_name,.field,.value,.expected,.docs_url.candidates: tuple[str, ...]— sorted,()in the type-mismatch branch.suggestions: tuple[str, ...]— difflib top-3,()when none above cutoff
UserInputError multi-inherits from ValueError so generic ecosystem
code (pytest.raises(ValueError), broad except ValueError) still
catches it.
Adoption¶
The contract is opt-in for new user-facing raises. Each v1 entry point declares conformance in its own acceptance criteria; retrofit of pre-contract raise sites is tracked separately so the helper itself can land without forcing a sweep.
Procedure pipelines¶
The mainstream-metric pipelines differ in aggregation order — which axis is
collapsed first determines small-sample failure modes and the n_assets == 1 behaviour. The
cell a factor dispatches to determines which pipeline runs.
The n_periods floors below are per procedure, not one global rule, and
each is checked against the effective sample the estimator uses (post-stride
count for a sub-sampling method), never the raw date count. The HAC path floors
at MIN_PERIODS_HARD; a non-overlapping stride path floors at
MIN_SERIES_PERIODS_HARD on the post-stride sample plus
MIN_SERIES_PERIODS_HARD x forward_periods on the raw dates. Breaching a hard
floor raises InsufficientSampleError under strict=True;
n_periods < MIN_PERIODS_WARN emits UNRELIABLE_SE_SHORT_PERIODS. The binding
axis may be assets rather than periods for a bucketed metric. The per-procedure "Failure modes" lists below
record only the procedure-specific failures; for the user-facing tier
matrix see Guides § Panel vs timeseries. For
the trigger / meaning of every code emitted below see the
WarningCode table.
Terminology — aggregation regime¶
Two regimes, each with concrete sub-forms. Pipeline pseudocode tags each
step with (cross-section step) or (time-series step) inline:
- cross-section step — aggregate over assets at a fixed date
per-period— applied to every date (continuous panel)per-event-period— restricted to dates wherefactor != 0(sparse cells)- time-series step — aggregate over the time axis
per-asset— fix one asset, aggregate its full date sequence (filter(asset_id == X))- on a previously-built time-indexed series — e.g. NW HAC t-test on
IC[t]orβ[i]after the upstream step has produced the series
Unqualified per-event is not used — always written as per-event-period
to keep the regime unambiguous.
Inference selection (inference=)¶
Only the series-mean family (ic, quantile_spread, quantile_spread_vw,
k_spread) takes a selectable inference=; every other metric carries a
fixed estimator by its statistical shape, so the absence of the knob is by
design. This section is the SSOT for that rule; the factrix.inference
module docstring carries the navigation summary and links here.
The knob is offered only where the headline test is "average an overlapping
per-date series and test mean != 0", because there the choice between
non-overlap sub-sampling and a HAC SE genuinely changes the standard error and
is the caller's to make. Elsewhere the estimator follows the metric's shape:
event-study metrics use Brown-Warner / standardized-AR on the event axis; the
fm_beta family builds its Fama-MacBeth / Driscoll-Kraay SE into the
estimator; common_beta carries its own per-asset time-series SE;
directional_hit_rate (Pesaran-Timmermann) and positive_rate (binomial) have
a fixed distribution and no SE to choose; oos_decay and concentration are
descriptive and run no headline test; trend and common_asymmetry pair a
magnitude with an estimator-specific p-value.
inference= is typed as a closed union of named members, never an open
Inference Protocol a caller can implement. An unvetted user-supplied SE
estimator — wrong-axis HAC, mis-calibrated bandwidth — would silently emit
invalid p-values, so each curated member instead ships a calibrated
min_input_periods and a vetted compute. The Inference Protocol in
inference/_base.py constrains member identity rather than inviting external
implementations, and the union grows only when a member is validated for that
metric family.
The per-metric applicable_inference frozenset is a vetting record, not a
dispatch table. Every inference=-bearing metric dispatches polymorphically
and could in principle run any series-mean member; the set records which
members have been measured on that metric's series. The IC series and the
long-short spread series have different distributions, so each carries its own
size table in inference calibration
and each admits a member only on the strength of it.
HansenHodrick is a complete series-mean member that no metric's union
admits: its rectangular kernel has no PSD guarantee and can clamp a negative
variance (WarningCode.RECT_KERNEL_NEGATIVE_VARIANCE), so the vetted HAC is
NeweyWest's PSD-guaranteed Bartlett kernel. It stays importable from
factrix.inference.series_mean for standalone comparison studies and is not
re-exported from factrix.inference, because a name there reads as "pass this
to a metric" and every such call raises IncompatibleInferenceError.
individual_continuous(IC) — cross-section first¶
per-period Spearman across n_assets (cross-section step)
→ n_periods-length IC time series
→ NW HAC t-test on mean(IC) (time-series step)
Failure modes:
- per-period pairwise-complete
n_assets< 2 →MIN_IC_ASSETS_HARDdrops that date; if every date drops, output is NaN withinsufficient_ic_assets. - per-period pairwise-complete
2 ≤ n_assets < 10→ IC is returned withWarningCode.FEW_ASSETSkeyed toMIN_IC_ASSETS_WARN.
individual_continuous(FM) — cross-section first¶
per-period OLS R = α + β·F across n_assets (cross-section step)
→ n_periods-length λ time series
→ NW HAC t-test on mean(λ) (time-series step)
Failure modes:
- per-period
n_assets< 3 →MIN_FM_ASSETS_HARDdrops that date. - per-period
3 <= n_assets < 10→ FM beta is returned withWarningCode.FEW_ASSETSkeyed toMIN_FM_ASSETS_WARNbecause df =n_assets- 2 is minimal. n_periods < MIN_FM_PERIODS_HARD = 4→ short-circuit to insufficient (math floor — NW HACtundefined below).MIN_FM_PERIODS_HARD ≤ n_periods < MIN_FM_PERIODS_WARN = 30→ returns the FMt/pbut emitsWarningCode.UNRELIABLE_SE_SHORT_PERIODSand the borderline propagates intoEvaluationResult.warnings.
individual_sparse (CAAR PANEL) — cross-section first (events)¶
per-event-period mean of signed_car = return × factor (cross-section step)
→ event-period-indexed CAAR
grid-aware non-overlap subsample by date_ordinal → independent event-period sample
→ OLS t-test on mean(CAAR) (event-time step)
The CAAR series is event-period-indexed: compute_caar filters to
factor != 0, collapses same-period events to one cross-asset mean, and
retains each event period's date_ordinal on the full panel grid. The
caar procedure then takes a greedy non-overlap subsample where consecutive
kept event periods are at least forward_periods grid periods apart. This
keeps the event-only mean estimator intact while avoiding overlap-induced
dependence from forward-return windows; dense zero-fill is deliberately not
used because non-event zeros would dominate the sparse event mean.
Magnitude is preserved as a weight in signed_car (no .sign() coercion
at this layer — compute_caar's docstring carries the input-form
behaviour table). User-facing MEAN reports the per-event-period mean (the
average effect on event periods); n_obs reflects the non-overlap event-period
sample the t-stat is computed on.
Failure modes:
n_events < MIN_EVENTS_HARD = 4→ event series too short → short-circuits to a NaNMetricResult(p_valueconservatively1.0).MIN_EVENTS_HARD ≤ n_events < MIN_EVENTS_WARN = 30→ CAARtis returned butWarningCode.FEW_EVENTSfires; thecaarmetric attaches it toMetricResult.warning_codesand the DAG executor lifts it intoEvaluationResult.warnings.
common_continuous — time-series first¶
per-asset OLS R_i = α_i + β_i·F over all n_periods dates (time-series step)
→ n_assets-length β vector
→ cross-asset t-test on E[β] (cross-section step)
Failure modes:
- per-asset
n_periods < MIN_COMMON_BETA_PERIODS_HARD = 20, no finite(factor, return)pair, or factor time-variance<= EPSILON→ asset dropped. - surviving
n_assets < MIN_ASSETS_WARN = 30→WarningCode.FEW_ASSETS(still runs; severity scales with the producer output, not the raw universe). n_assets = 1→ no asset cross-section to aggregate the per-asset βs over. The cell declarescell.structure = PANEL, soevaluateraisesIncompatibleAxisErrorunderstrict=True(NaN +structure_mismatchunderstrict=False); there is no single-series β fallback. See §PANEL/TIMESERIES equivalence.
common_sparse (PANEL) — event-time metrics¶
broadcast sparse event column `{0, R}` across assets
→ same scope-agnostic sparse metrics as individual_sparse
→ CAAR / BMP / event diagnostics on the event-time sample
Common × Sparse is not the common_continuous time-series-first
OLS-β flow. The factor is broadcast across assets, but its sparse {0, R}
shape matches the event-time contract used by individual_sparse; the DAG
therefore dispatches the same sparse metrics (caar, bmp_z,
event_hit_rate, clustering_hhi, etc.) through their registered
scope-wildcard sparse cells.
Failure modes:
- The same sparse event-count guards as
individual_sparseapply:MIN_EVENTS_HARDhard floor andMIN_EVENTS_WARNwarning for CAAR. - Same-period event clustering is more likely because every asset shares the
event period; use
clustering_hhiand preferbmp_z(its Kolari-Pynnönen adjustment is on by default) when the HHI is high. - Metrics that require a panel asset cross-section, such as
clustering_hhi, remain unavailable onn_assets == 1even though most sparse event-axis metrics havestructure=None.
common_continuous at n_assets == 1 — not supported¶
common_continuous metrics (common_beta, common_quantile, common_asymmetry)
test the cross-asset distribution of per-asset βs, so they require
n_assets >= 2. At n_assets == 1 the cell (COMMON, DENSE, PANEL) does not match the
derived TIMESERIES structure, so evaluate raises
IncompatibleAxisError (or NaN + structure_mismatch under
strict=False). There is no single-series beta collapse inside
common_beta; use predictive_beta for the explicit single-asset dense
predictive regression
forward_return_t = alpha + beta * factor_t + epsilon_t with Newey-West HAC
inference. For single-asset dense directional diagnostics, use
directional_hit_rate on the long-panel
(date, asset_id, factor, forward_return) shape. Two-column diagnostics
(positive_rate, oos_decay, ic_trend) remain standalone (date, value) tools;
their evaluate() path layers on panel IC series, not raw single-asset dense
panels. Sparse metrics whose structure is wildcarded remain available at n_assets == 1.
(*, SPARSE, *) × n_assets == 1 (TS dummy) — time-series only¶
single-asset OLS y_t = α + β·D_t + ε on period-dense series (time-series step)
→ NW HAC t-test on β
+ Ljung-Box on residual
+ event_temporal_hhi
+ event-window-overlap check
Reached whenever a sparse factor evaluates at n_assets == 1 and the requested sparse
metric's structure is wildcarded — the DAG executor runs it directly on the
single-asset series (no scope-collapse step; at n_assets == 1 the two scopes are
statistically equivalent). Sparse metrics that require a cross-asset panel, such
as clustering_hhi, still raise / short-circuit on the cell mismatch. The
series is the full period grid with
zero-padding on non-event periods (distinct from the CAAR computation, which
works on the event-period-only series). Factor magnitudes are
preserved (no .sign() coercion at this layer).
Failure modes:
- Ljung-Box p < 0.05 on residuals →
WarningCode.SERIAL_CORRELATION_DETECTED. - Consecutive event gap on one asset <
forward_periods→ the event is dropped by the event-axis non-overlap sampling pass andWarningCode.EVENT_WINDOW_OVERLAPfires once per metric. The gap that matters is one horizon, not two: windows(t, t+h]and(t', t'+h]share periods exactly whent' - t < h.
Family functions and the resolution layer¶
The low-level public adjusted-p primitives under the explicitly exported
factrix.stats namespace are distinct
from the family verbs in this section. holm_adjusted_p accepts a p-value
vector; romano_wolf_adjusted_p accepts observed statistics plus a caller-
supplied joint, null-centred, consistently studentized (B, m) bootstrap
matrix. stationary_bootstrap_resamples can jointly resample an aligned
(T, m) matrix with common block indices, but it does not choose a null,
estimand, or studentization. These primitives do not consume
EvaluationResult and do not run through _resolve_family.
A future panel-aware Romano-Wolf family verb would need a metric-specific
per-period statistic contract and explicit alignment across factors and
horizons. It must not reconstruct a different estimand silently from scalar
EvaluationResult fields; that higher-level workflow remains separate from
the adjusted-p primitive.
Closed-form Holm/BHY procedures adjust p-values as supplied; they neither
validate nor repair producer calibration. They do not require every hypothesis
in a family to share the same alternative. The producer records alternative,
and the family layer neither converts tails nor infers them from statistic
signs. There is intentionally no generic tail-conversion helper.
EvaluationResult-based multiple-testing functions share partitioning and
p-value resolution in factrix/_family.py. Single-metric procedures use
_resolve_family; cross-metric procedures flatten the result × metric grid in
_multi_factor.py::_normalize_metric_hypotheses, reusing _partition and
_attach_p_values. Each procedure runs only after these family-resolution
invariants pass.
Two signature classes¶
The shared layer admits two function shapes — important to keep distinct so a resampling-based function cannot retroactively force a kwarg onto the closed-form ones:
| Class | Functions | Signature shape |
|---|---|---|
| Closed-form (p-value only) | bhy / bhy_across_metrics / bhy_hierarchical / partial_conjunction / partial_conjunction_across_metrics |
(results, *, metrics, ...); expand_over is available only where the procedure supports separate context families |
| Resampling-based | Not exposed at the multi_factor layer |
Requires a future metric-specific panel and bootstrap contract; the low-level stats.romano_wolf_adjusted_p primitive is not this workflow |
Family-resolution invariants¶
For input results: Sequence[EvaluationResult], expand_over: Sequence[str] | None,
and metric: str (one resolved spec):
expand_overnames must be present in every result'sparams, except the built-inforward_periods;factoris rejected because it is an identity dimension, not a family partition.- The hypothesis identity per result is
(factor, forward_periods, *sorted(params.items()))and must be unique across the input. Every declared parameter therefore joins identity even when it does not partition the family.EvaluationResult.__hash__ = None, so dedup walks the tuple, not a result hash. - The specified
metricmust have a computedp_valuethat is non-NaN, and must be populated on every result. - Resolved
p_valueper entry: the p-value read from the specifiedmetric.
Cross-metric BHY adds the metric label to this identity. Cross-metric partial
conjunction instead treats the predeclared metric list as a fixed condition
axis: insufficient endpoints count as p=1 and do not reduce m; an identity
with fewer than min_pass active endpoints remains auditable but does not enter
the outer BHY family.
All screening input errors route through factrix._errors.UserInputError so
fuzzy suggestions and docs links render uniformly.
expand_over semantics¶
expand_over declares per-bucket independent families (Benjamini & Bogomolov
2014, Selective Inference on Multiple Families of Hypotheses, JRSS-B). Each
unique tuple of params[k] for k in expand_over is its own step-up batch —
e.g. expand_over=["regime_id"] runs one BHY step-up per regime.
Caller responsibilities¶
bhy does not auto-partition; the caller declares the family explicitly:
- Mixing cells without a distinct
factorraisesUserInputError(duplicate identity). Setfactorper candidate, or useexpand_overif results legitimately share identity. - Mixing
forward_periodswithout a horizon partition emits an informationalRuntimeWarning. Pooling is correct when selection may choose across horizons;expand_over=("forward_periods",)is only for predeclared, separately reported horizon screens and does not control later horizon shopping.
Mainstream metric vs supplementary metric¶
A documentation convention — not a code-enforced tier — for organising the
metrics in factrix/metrics/*.py. Both kinds register a MetricSpec via
@metric with the same role=METRIC; the distinction is editorial intent, and
evaluate() runs exactly the metrics the caller passes either way. Choosing
which kind to add:
This is the developer-facing counterpart to the user guide's
First-pass metrics vs diagnostics
section. It maps to primary specifications vs robustness / diagnostic reads in
quant research, but it deliberately does not introduce a MetricSpec field or a
list_metrics() filter.
| Kind | Intent | Definition | How callers reach it |
|---|---|---|---|
| Mainstream metric | the headline mean-significance test for a cell | The conventional PASS/FAIL test for a (scope, density, structure) cell (IC / FM / CAAR / TS-β) |
passed into evaluate(metrics=...); its MetricResult.p_value is what the screening verbs read |
| Supplementary metric | second-look / diagnostic | Diagnostic / second-look / multi-statistic decomposition, surfaced alongside the mainstream metric and importable directly | the metric's MetricResult in EvaluationResult.metrics, and from factrix.metrics import X |
When to add a mainstream metric¶
Add a mainstream metric when introducing the headline mean-significance test for
a legal cell on the axis (FactorScope × FactorDensity × metric × DataStructure)
that does not have one yet. Nothing enforces one-per-cell; keeping each cell to a
single agreed default test is a convention that gives callers an obvious first
choice, not an invariant the code checks.
When to add a supplementary metric¶
Everything else. Specifically:
- Same cell already has a mainstream metric but you want to surface a different angle
(non-linearity, asymmetry, decomposition, regime split). Example precedent:
event_quality.py(event_hit_rate / profit_factor / event_skewness / signal_density) all supplement the mainstream CAAR metric for(*, SPARSE, PANEL). - Descriptive diagnostic without a formal H₀ (concentration Herfindahl-Hirschman index (HHI), tradability, out-of-sample (OOS) decay).
- Multi-factor relationship outside the single-factor inference frame (
spanning.py).
Supplementary metric contract¶
- Take
pl.DataFramewith the cell's standard schema (date, asset_id, factor, forward_return) plus any optional columns - Return
MetricResult(factrix/_results.py) —name,value, optionalp_value,n_obs,stat,warning_codes, and ametadatadict for cell-specific scalars - Use
_short_circuit_output(...)for sample-floor failures rather than raising - Reuse
_stats/primitives (_p_value_from_t,_calc_t_stat, NW HAC helpers) so the statistical treatment matches the mainstream metrics — most notably NW HAC SE for any inference on overlapping forward returns, never iid Welch / OLS SE
A supplementary metric's p-value carries no special status over the mainstream
metric's; when run standalone (from factrix.metrics import X) outside
evaluate, the user is responsible for collecting comparable p-values into a
family themselves if FDR control is needed across a batch.
Module layout¶
factrix/
├── __init__.py # public surface + evaluate()
├── _axis.py # FactorScope / FactorDensity / DataStructure / Tier + spec-metadata
│ # enums (Aggregation / SpecRole / InputShape / OutputShape)
├── _codes.py # WarningCode StrEnum
├── _errors.py # flat hierarchy: FactrixError → {IncompatibleAxisError, IncompatibleInferenceError, InsufficientSampleError, UserInputError}
├── _metric_index.py # MetricSpec + @metric-registry SSOT (spec_by_name / list_metrics / public_specs / metric_spec)
├── _dag.py # DagExecutor — MetricSpec.requires / batchable dispatch (+ CycleError)
├── _results.py # EvaluationResult / MetricResult / Warning dataclasses
├── _inspect.py # inspect_data — typed data introspection with per-metric verdict
├── _compare.py # compare — multi-metric leaderboard over EvaluationResult lists
├── _family.py # _partition / _attach_p_values / _resolve_family — shared FDR family resolution
├── _multi_factor.py # per-metric, cross-metric, partial-conjunction, and hierarchical FDR impls
├── multi_factor.py # public namespace (re-exports the FDR verbs)
├── _data_input.py # input-type gateway for public entry points
├── adapt.py # column-name adapter → factrix canonical names
├── _logging.py # shared loggers
├── _ols.py # shared OLS helpers (spanning metrics + orthogonalize preprocess)
├── _types.py # shared constants: EPSILON, DDOF, MIN_IC_ASSETS_HARD/WARN,
│ # MIN_SERIES_PERIODS_HARD, MIN_EVENTS_HARD/WARN,
│ # MIN_OOS_PERIODS_HARD, MIN_PORTFOLIO_PERIODS_HARD/WARN, ...
├── _stats/ # numerics: core, hac, bootstrap, unit_root, wald, ols, diagnostics, slice_policy, constants
├── inference/ # curated metric-internal inference members (series-mean dataclasses)
├── stats/ # public statistical helper surface (block_bootstrap, driscoll_kraay, ...)
├── metrics/ # @metric callables (ic, fm_beta, common_beta, caar, ...) + _registry
│ # per-cell thresholds (MIN_FM_PERIODS_HARD/WARN, MIN_COMMON_BETA_PERIODS_HARD) live
│ # alongside the metrics that enforce them
├── slicing/ # by_slice + slice_pairwise_test / slice_joint_test
├── preprocess/ # compute_forward_return / normalize / orthogonalize
└── datasets.py # synthetic CS / event panels
Invariants¶
Hard constraints — violating these breaks the API contract:
MetricSpecisfrozen=True, slots=True; every construction path runs__post_init__, which enforces the field invariants (e.g.role=METRIC → output_shape=SCALAR).- All result dataclasses —
EvaluationResult,MetricResult,Warning— arefrozen=True, slots=True;EvaluationResult.metricsis aMappingProxyTypefor read-only per-metric outputs. One unifiedEvaluationResult— no per-cell subclass. - The metric-spec SSOT is the
@metricregistration in eachfactrix/metrics/*.py, resolved throughfactrix._metric_index(spec_by_name/public_specs/list_metrics); no parallel rule table.@metric-class registration feeds the index viafactrix.metrics._registry.register. Slice-boundary warnings readMetricSpec.slice_boundary_sensitive; aggregation categories are not a proxy rule table. - The DAG executor is the single dispatch path.
DagExecutortopologically orders specs byMetricSpec.requires(raisingCycleErroron cycles), runsbatchable=Trueproducers once per factor batch andbatchable=Falseconsumers once per factor, and short-circuits a downstream consumer with a NaNMetricResult+WarningCode.UPSTREAM_UNAVAILABLErather than invoking it on missing upstream data. MetricResult.p_valueis the single canonical p-value read path —EvaluationResult.to_frame()/to_dict(),compare, and the BHY family resolver all read it; the p-value lives only on the field and is not duplicated intometadata. A formal p-value andalternative(two-sided/greater/less) must be present together; p-values are finite and in[0, 1].warningsflag interpretation risk but never rebind it. A standard error at or belowEPSILONis degenerate, not null evidence: t and Wald consumers withhold the test as NaN and surfacedegenerate_variancewhile retaining a valid point estimate. The canonical test for that isfactrix._stats.core._degenerate_t_input, not a barex < EPSILON: NaN is unordered, so a negative-polarity threshold is false for it and lets a non-finite dispersion through. FX007 intests/stats/test_degenerate_guard_polarity.pyrejects new bare comparisons and carries the pre-existing sites as a baseline — that baseline is a migration ledger, not a bug list. A short circuit runs no kernel, so itsmetadatamay state anything true about the input, the configuration or the attempt, but a key that would describe the withheld result is neutralised (NaN/None/ empty /False/ an explicit failure state) unless the metric documents, at that branch, that the key describes the attempt instead. FX008 intests/stats/test_short_circuit_flag_polarity.pylints the decidable half — a*_applied/*_adjusted/*_flippedkey reporting a stage as having run — on the same baseline shape as FX007. The contract itself is Which auxiliary keys a short circuit may carry.- Family declaration is explicit: by default a screening verb's input list is one family, optionally split per bucket via
expand_overwhere the API supports it.family_sizecan declare a larger plan-level size per bucket / identity / group; a scalar applies to each sub-family and a strict mapping handles heterogeneous sizes. Declared-unsubmitted candidates remain inert non-rejections under either inactive policy and are reported separately from submitted inactive candidates. Shared resolution enforces (a) the result identity(factor, forward_periods, *sorted(params.items()))is unique across the input — everyparamsentry joins it automatically, whilemetadatanever does, (b)expand_overnames come only fromEvaluationResult.params(or the built-inforward_periods), never the factor and never ametadatakey, (c) formal p-values are populated before procedures read them. Cross-metric BHY adds the metric label to the hypothesis identity; cross-metric partial conjunction keeps the predeclared metric count fixed under insufficient endpoints. Mixed horizons warn so the caller confirms whether selection is pooled or predeclared per horizon. - A metric whose effective sample is below its own hard floor raises
InsufficientSampleErrorunderstrict=True(per metric, per axis, on the effective post-stride/post-drop sample — not a globalTrule); metrics never silently produce a result on under-sampled data. NW HAC bandwidths readoverlap_periodsand never fall belowh - 1; scalar series means and rank-one contrasts use the wider calibrated3(h - 1)floor, while multi-restriction Wald paths retain the Hansen-Hodrick floor.
For the user-facing field walk of EvaluationResult (and its
metrics mapping), see
Reading results. The MetricResult.p_value
contract above is what that page links back to.
Testing¶
tests/ covers the current public surface only. Fixtures are fully synthetic
(tests/conftest.py + factrix.datasets); no test reads real market data
from disk.
Run: uv run pytest
Documentation source-of-truth and generation rules live in Documentation conventions, so this page can remain focused on runtime architecture.