Skip to content

Build point-in-time research datasets

The persistra.research package keeps information availability, feature construction, signal evaluation, and future outcomes separate. It accepts normalized vintage histories and ordinary pandas frames. It does not choose favorable results or claim that a supplied equity universe is survivorship-free.

Select versions known on one date

Use select_vintage to select the active source version of every observation on an explicit calendar date:

from persistra.data import synthetic
from persistra.research import select_vintage

history = synthetic.vintage_series("GDP", periods=12)
known = select_vintage(history, known_on="2023-06-01")

print(known.frame[["period_label", "available_from", "value"]])

Availability boundaries are inclusive. The knowledge date and vintage availability fields are timezone-naive calendar dates because VintageSeriesSet has daily source availability. Retrieval time remains provenance and never determines which version is selected.

Pass a whole-day publication lag only when it is part of the research policy:

import pandas as pd

lagged = select_vintage(
    history,
    known_on="2023-06-01",
    publication_lag=pd.Timedelta(days=2),
)

The lag moves the effective knowledge cutoff backward by two days. The result retains the original source intervals and records the chosen lag separately.

Compare explicit vintage-content policies

Use project_vintage_history when a study needs to isolate revision policy. The real-time projection preserves every provider interval. The first-release projection ignores later revisions. The final-vintage projection deliberately exposes each last retained value from its observation's first recorded release date:

from persistra.research import project_vintage_history

real_time = project_vintage_history(history, "real_time")
first_release = project_vintage_history(history, "first_release")
final_vintage = project_vintage_history(history, "final_vintage")

The first-release and final-vintage projections make their selected versions open-ended. They preserve present, missing, and deleted states and never replace them with an older present value. Record the chosen policy in the research manifest. Final vintage is intentionally lookahead-biased and is useful as a comparison, not as a causal feature history.

Assemble a feature panel

Each FeatureSpec requires a maximum staleness limit. It also names the normalized calendar field that defines observation age. Use period_start or period_end; do not substitute availability or retrieval time for the observation date.

import pandas as pd

from persistra.research import FeatureSpec, build_feature_panel

decisions = pd.date_range("2023-03-01", periods=10, freq="MS")
spec = FeatureSpec(
    name="gdp",
    source=history,
    maximum_staleness=pd.Timedelta(days=120),
    publication_lag=pd.Timedelta(days=1),
    observation_date_column="period_start",
)

features = build_feature_panel([spec], decision_dates=decisions)

print(features.frame)
print(features.provenance)

For every decision date, the builder first selects source versions available under the lag policy. It then chooses the most recent eligible observation date and rejects a match older than maximum_staleness. An explicitly missing or deleted latest observation remains missing. The builder does not fall back to an older nonmissing value.

FeaturePanel.provenance has one row for every feature and decision date. Matched rows record the source series identity, provider key, period label, observation date, source availability interval, retrieval time, age, lag, staleness limit, deletion state, and selected value. Unmatched rows retain the source and policy fields. FeaturePanel.policies records the same source identity and policy once per feature.

Construct future labels separately

forward_returns calculates simple returns after feature construction. Its horizon is an explicit count of index observations:

from persistra.data import pivot_bars
from persistra.research import forward_returns

bars = synthetic.bars("ASSET", periods=30)
prices = pivot_bars([bars], field="close")
labels = forward_returns(prices, horizon=5)

print(labels.frame)
print(labels.label_ends)

label_ends records the actual index label at the end of each horizon. The final rows remain missing when the requested future horizon does not exist. Feature panels and labels are different result types so future values cannot enter feature construction by convenience.

Fit caller-defined factor regressions

Factor models accept supplied return and exposure panels. They do not define, download, or interpret factors. Time-series regressions require date-by-asset returns and date-by-factor returns with the same index:

import pandas as pd

from persistra.research import fit_time_series_factor_model

factor_dates = pd.date_range("2025-01-01", periods=8)
factor_returns = pd.DataFrame(
    {
        "factor_a": [-0.02, 0.01, 0.03, -0.01, 0.02, 0.00, 0.01, -0.01],
        "factor_b": [0.01, -0.01, 0.00, 0.02, -0.02, 0.01, 0.02, -0.01],
    },
    index=factor_dates,
)
asset_returns = pd.DataFrame(
    {
        "AAA": 0.001 + 1.2 * factor_returns["factor_a"],
        "BBB": -0.001 + 0.8 * factor_returns["factor_b"],
    },
    index=factor_dates,
)
model = fit_time_series_factor_model(
    asset_returns,
    factor_returns,
    covariance="newey_west",
    hac_lags=2,
)

print(model.coefficients)
print(model.standard_errors)
print(model.diagnostics)

Each asset uses its pairwise-complete observations. OLS is the default. Supply a positive date-by-asset weight panel for WLS. Rank-deficient designs retain their least-norm coefficients but mark inference unavailable. The result keeps fitted values and residuals on the original axes. Use rolling_time_series_factor_model with a positive observation window, or with window=None for expanding estimates. An estimate dated t never uses a later observation.

Cross-sectional regressions use a sorted (date, asset) MultiIndex exposure frame. Its columns are caller-defined factors:

import pandas as pd

from persistra.research import fama_macbeth_regression, forward_returns

factor_dates = pd.date_range("2025-01-01", periods=6)
assets = ["AAA", "BBB", "CCC", "DDD"]
levels = pd.DataFrame(
    {
        "AAA": [100, 101, 103, 102, 104, 105],
        "BBB": [100, 99, 100, 101, 100, 102],
        "CCC": [100, 102, 101, 103, 105, 104],
        "DDD": [100, 100, 101, 100, 102, 103],
    },
    index=factor_dates,
)
equity_labels = forward_returns(levels, horizon=1)

exposures = pd.DataFrame(
    {
        "date": [factor_dates[0]] * 4,
        "asset": assets,
        "exposure_a": [-1.0, -0.5, 0.5, 1.0],
        "exposure_b": [0.2, -0.2, -0.2, 0.2],
    }
).set_index(["date", "asset"])
exposures = exposures.reindex(
    pd.MultiIndex.from_product(
        [equity_labels.frame.index, assets],
        names=["date", "asset"],
    )
).groupby(level="asset").ffill()

fama_macbeth = fama_macbeth_regression(
    equity_labels,
    exposures,
    hac_lags=1,
)
print(fama_macbeth.cross_sectional.factor_returns)
print(fama_macbeth.premia.statistics)

Passing ForwardReturnLabels preserves the prediction horizon and excludes rows without a complete label end. estimate_cross_sectional_factor_returns exposes each period regression. summarize_factor_premia applies classical or Newey-West inference to any supplied factor-return history. build_factor_risk_model combines current exposures, factor-return covariance, and residual variance into a reconciled asset covariance matrix. Factor and residual histories must use the same ordered datetime index. The optional observation window selects one trailing sample from that shared index before covariance estimation. Missing return values remain missing within that temporal sample and follow each estimator's documented complete-observation requirements.

Choose sample, diagonal_shrinkage, constant_correlation, ledoit_wolf, or ewma as the factor covariance estimator. Diagonal and constant-correlation policies take the explicit shrinkage value. EWMA takes ewma_decay. Ledoit-Wolf estimates and records its shrinkage intensity. A complete, symmetric, positive-semidefinite factor covariance DataFrame may be supplied instead. Every FactorRiskModel retains the estimator identity, effective parameters, factor and residual observation counts, and portable manifest_parameters.

The risk model defaults as_of to the final timestamp in the effective sample. An explicit boundary must be nonmissing, use the same timezone awareness as the histories, and be no earlier than that timestamp. A later compatible boundary is allowed. The builder rejects histories that extend beyond the boundary; it never silently truncates them. Optional diagonal shrinkage remains an explicit model parameter. build_factor_portfolio_forecast then combines that risk model with caller-supplied factor premia and optional asset alpha. It records each asset's expected-return decomposition without assuming a factor definition, return frequency, or annualization. Use attribute_factor_portfolio with absolute weights, or with benchmark weights for active attribution, to reconcile expected return and variance to factor and idiosyncratic components.

Build a dated research-to-portfolio path with only the histories available at each date:

import numpy as np

from persistra.portfolio import (
    MeanVarianceObjective,
    NetExposureConstraint,
    PortfolioProblem,
    WeightBounds,
    optimize_portfolio,
)
from persistra.research import rolling_factor_portfolio_forecasts

risk_dates = pd.date_range("2025-02-01", periods=6)
risk_factors = pd.DataFrame(
    {
        "market": [-0.02, 0.01, 0.03, -0.01, 0.02, 0.01],
        "quality": [0.01, -0.01, 0.00, 0.02, -0.02, 0.01],
    },
    index=risk_dates,
)
risk_residuals = pd.DataFrame(
    {
        "AAA": [0.01, -0.01, 0.005, 0.0, -0.005, 0.01],
        "BBB": [-0.005, 0.0, 0.01, -0.01, 0.005, 0.0],
    },
    index=risk_dates,
)
risk_assets = pd.Index(["AAA", "BBB"], name="asset")
risk_exposures = pd.DataFrame(
    np.tile([[1.0, 0.2], [0.5, 1.0]], (len(risk_dates), 1)),
    index=pd.MultiIndex.from_product([risk_dates, risk_assets], names=["date", "asset"]),
    columns=risk_factors.columns,
)
risk_premia = pd.DataFrame(
    {"market": [0.01] * len(risk_dates), "quality": [0.005] * len(risk_dates)},
    index=risk_dates,
)
forecast_path = rolling_factor_portfolio_forecasts(
    risk_exposures,
    risk_factors,
    risk_residuals,
    risk_premia,
    window=4,
    minimum_observations=3,
    covariance="ledoit_wolf",
)
available_forecasts = [step.forecast for step in forecast_path.steps if step.status == "ok"]
latest_forecast = available_forecasts[-1]
portfolio = optimize_portfolio(
    PortfolioProblem(
        covariance=latest_forecast.asset_covariance,
        expected_returns=latest_forecast.expected_returns,
        objective=MeanVarianceObjective(risk_aversion=10.0),
        constraints=(WeightBounds(0.0, 1.0), NetExposureConstraint(1.0, 1.0)),
        as_of=latest_forecast.as_of,
    )
)
print(forecast_path.diagnostics)
print(portfolio.weights)

window=None selects expanding history. Early dates and dates with unavailable exposures, premia, alpha, or insufficient return history remain in the ordered result as typed unavailable steps with reasons. Successful steps carry the covariance policy into their forecast. Changing a later input cannot change an earlier step.

Transform cross-sectional equity signals

Cross-sectional functions accept a wide date-by-asset frame. The ordered columns are the explicit fixed universe for that call. Keep the same columns even when an asset has no observation; use a missing value instead of silently changing the universe.

import numpy as np
import pandas as pd

from persistra.research import (
    clip_cross_section,
    neutralize_cross_section,
    rank_cross_section,
    standardize_cross_section,
)

dates = pd.date_range("2025-01-02", periods=3)
signals = pd.DataFrame(
    [[-2.0, 0.1, 0.4, 8.0], [-1.0, 0.2, 0.5, 4.0], [0.0, np.nan, 0.8, 2.0]],
    index=dates,
    columns=["AAA", "BBB", "CCC", "DDD"],
)

ranks = rank_cross_section(signals)
clipped = clip_cross_section(signals, lower_quantile=0.05, upper_quantile=0.95)
standardized = standardize_cross_section(clipped)

Ranking exposes its tie method, direction, and percentile choice. Clipping uses per-date quantiles. Standardization uses the available cross-section on each date and leaves a constant cross-section missing because it has no scale.

Neutralization performs a separate least-squares regression on each date. Supply a date-by-asset group panel for time-varying sector or other trustworthy classifications. Named numeric exposure panels must use the same axes:

groups = pd.DataFrame(
    [["technology", "technology", "health", "health"]] * len(dates),
    index=dates,
    columns=signals.columns,
)
size = pd.DataFrame(
    [[10.0, 12.0, 8.0, 9.0]] * len(dates),
    index=dates,
    columns=signals.columns,
)

residual = neutralize_cross_section(
    signals,
    groups=groups,
    exposures={"log_market_value": size},
)

The regression includes an intercept and group fixed effects. It uses only complete rows. A date without enough observations to estimate the requested controls remains missing.

Evaluate signal ordering and quantile portfolios

Information coefficients require ForwardReturnLabels, so every result retains the explicit label horizon. Pearson and rank ICs use the same pairwise-complete sample and report its count:

from persistra.research import forward_returns, information_coefficients

levels = pd.DataFrame(
    [[100.0, 100.0, 100.0, 100.0], [99.0, 100.5, 101.0, 102.0], [98.5, 101.0, 102.0, 103.0]],
    index=dates,
    columns=signals.columns,
)
equity_labels = forward_returns(levels, horizon=1)
ic = information_coefficients(ranks, equity_labels, minimum_count=3)
print(ic.statistics[["count", "pearson", "rank"]])

The signal and label frames must have identical dates and asset columns. Pass the group panel to calculate separate ICs for each observed date and classification. Use summarize_groups to report group-level signal means, forward returns, dispersion, ICs, and counts. A valid panel may have an empty date axis. forward_returns and the signal evaluators preserve that input as typed, schema-correct empty output instead of treating the absence of evaluation dates as an error.

quantile_portfolios uses explicit equal weighting by default. Supply nonnegative date-by-asset weights for market-value, liquidity, inverse-volatility, or other caller-defined weighting. The following example also applies a five-basis-point linear cost per unit of absolute asset weight traded:

from persistra.research import quantile_portfolios

volume = pd.DataFrame(
    [[1_000_000.0, 800_000.0, 700_000.0, 500_000.0]] * len(dates),
    index=dates,
    columns=signals.columns,
)
portfolio_weights = volume.copy()
quantiles = quantile_portfolios(
    ranks,
    equity_labels,
    quantiles=2,
    groups=groups,
    volumes=volume,
    weights=portfolio_weights,
    costs=0.0005,
)

print(quantiles.returns)
print(quantiles.costs)
print(quantiles.net_returns)
print(quantiles.spread)
print(quantiles.net_spread)
print(quantiles.turnover)
print(quantiles.weight_diagnostics)
print(quantiles.capacity)

Assignments are made independently on each date and within each supplied group. Ties stay together. A group with fewer assets than the requested quantile count remains unassigned. Raw weights are normalized within each date, group, and quantile; active group sleeves receive equal portfolio weight. Missing and zero weights remain visible in weight_diagnostics alongside raw coverage and effective membership after missing labels. Turnover includes the initial move from cash and subsequent one-way formation changes.

Costs may be one nonnegative scalar, an asset-indexed Series, or a date-by-asset DataFrame. Each value is a decimal return charge per unit of absolute asset weight bought or sold on that formation date. These linear research costs are not fills, spread, impact, or order-level execution. The result reports gross returns, costs, net returns, gross and net top-minus-bottom spreads, and reconciled spread costs. Its summary includes gross and net compounded returns only for a one-observation horizon, where labels do not overlap. Capacity fields report observed volume count, total, median, and minimum separately from modeled costs. For a zero-date panel, the aggregate summary retains each portfolio row with a period count of zero.

Compare benchmarks and repeated searches

Compare one or more candidate return series with an aligned benchmark. The summary reports pairwise counts, means, differences, tracking error, win rate, and correlation:

from persistra.research import (
    adjust_pvalues,
    compare_benchmark,
    deflated_sharpe_ratio,
    probabilistic_sharpe_ratio,
)

candidates = pd.DataFrame({"momentum_spread": quantiles.spread})
equal_weight = equity_labels.frame.mean(axis="columns")
comparison = compare_benchmark(
    candidates,
    equal_weight,
    benchmark_name="fixed_universe_equal_weight",
)

pvalues = pd.Series({"momentum": 0.01, "volume_trend": 0.04, "reversal": 0.20})
corrected = adjust_pvalues(
    pvalues,
    method="benjamini-hochberg",
    alpha=0.05,
)

selected_returns = quantiles.net_spread.dropna()
psr = probabilistic_sharpe_ratio(
    selected_returns,
    periods_per_year=252,
    benchmark_sharpe=0.5,
    skewness=0.1,
    kurtosis=3.4,
)
dsr = deflated_sharpe_ratio(
    selected_returns,
    periods_per_year=252,
    trial_count=40,
    trial_sharpe_standard_deviation=0.35,
    skewness=0.1,
    kurtosis=3.4,
)

adjust_pvalues supports Bonferroni family-wise error control and Benjamini-Hochberg false discovery rate control. It adjusts supplied p-values; it does not infer a test or hide the number of hypotheses searched.

probabilistic_sharpe_ratio implements the nonnormality-aware sampling approximation from Bailey and López de Prado's Sharpe ratio efficient frontier paper. Supply the annualization frequency, annualized benchmark Sharpe, skewness, and Pearson kurtosis; the function does not infer these research choices. The result reports the observed sample count, mean, standard deviation, annualized observed and benchmark Sharpes, sampling standard error, test statistic, and probability. Insufficient or constant returns produce a typed unavailable result with a reason.

deflated_sharpe_ratio uses the expected maximum independent-trial benchmark from the original deflated Sharpe ratio paper. Supply the count of every trial searched and the standard deviation of their annualized Sharpe ratios, including unsuccessful or unreported candidates. It returns the expected-maximum benchmark and the same intermediate estimates. The approximation assumes independent and identically distributed observations at the declared frequency and does not correct serial dependence or correlated trials.

Neither probability proves out-of-sample validity. Use these search-aware diagnostics alongside the nested temporal splits below, inspect unavailable results, and reserve the outer evaluation windows for performance estimates that did not influence selection.

Generate leakage-safe temporal splits

Expanding and rolling generators keep index order and never shuffle observations:

from persistra.research import expanding_window_splits, rolling_window_splits

expanding = expanding_window_splits(
    labels,
    initial_train_size=15,
    evaluation_size=5,
    embargo=1,
)
rolling = rolling_window_splits(
    labels,
    train_size=15,
    evaluation_size=5,
    embargo=1,
)

The requested training size defines the candidate window before purging. A training row is purged when its label end is on or after the first evaluation date. The optional embargo then removes the requested number of safe observations nearest the evaluation boundary. Observation counts, rather than calendar durations, keep this rule consistent with label horizons and irregular trading calendars. Each returned TemporalSplit exposes train_index, evaluation_index, purged_index, and embargoed_index. validate_temporal_split rejects observation overlap, incomplete horizons, and training labels that reach the evaluation period. It also requires embargoed rows to remain separate. A custom step must be at least the evaluation size so evaluation blocks do not overlap.

For model or hyperparameter selection, nest inner validation splits entirely inside each outer training window:

from persistra.research import nested_expanding_window_splits

nested = nested_expanding_window_splits(
    labels,
    outer_initial_train_size=15,
    outer_evaluation_size=5,
    inner_initial_train_size=7,
    inner_evaluation_size=2,
    outer_embargo=1,
    inner_embargo=1,
)

Use each NestedTemporalSplit.inner sequence to select a model, refit that choice on the corresponding outer.train_index, and evaluate it once on outer.evaluation_index. The outer evaluation observations are unavailable to every inner train, validation, purge, and embargo index by construction. Both levels use observation counts, so expanding and rolling variants also work with irregular calendars. Every retained, purged, and embargoed index remains explicit and typed for leakage assertions and audit records.

Align a time-varying universe

Represent membership as dated intervals instead of inferring it from the surviving columns of a wide panel. Every interval declares a stable asset identity, an included, excluded, or delisted state, and source provenance:

from datetime import UTC, datetime

import pandas as pd

from persistra.research import (
    MissingMembershipPolicy,
    UniverseMembership,
    apply_universe,
)

membership_frame = pd.DataFrame(
    [
        ("A", "2024-01-01", None, "included", "committee", "2023-12-31", datetime(2024, 1, 2, tzinfo=UTC)),
        ("B", "2024-01-02", None, "included", "committee", "2024-01-01", datetime(2024, 1, 2, tzinfo=UTC)),
    ],
    columns=[
        "asset_id", "valid_from", "valid_through", "state", "source", "source_as_of", "retrieved_at"
    ],
)
universe = UniverseMembership("committee-history", membership_frame)
candidate_signals = pd.DataFrame(
    {"A": [0.2, 0.1], "B": [0.9, 0.3]},
    index=pd.date_range("2024-01-01", periods=2, freq="D"),
)
eligible_signals = apply_universe(
    candidate_signals,
    universe,
    missing=MissingMembershipPolicy.EXCLUDE,
)

The first-date value for B becomes missing even though B survives into the current dataset. This controlled distinction prevents future membership from leaking backward. Alignment never forward fills: MissingMembershipPolicy.ERROR requires complete history, while EXCLUDE makes uncovered cells ineligible. DelistingPolicy.ERROR rejects a delisted interval; EXCLUDE masks it. Apply the same universe to level inputs before forward_returns, signal or classification panels before evaluation, and signals before construct_portfolio. Existing missing-value contracts then retain the complete asset axes without treating nonmembers as unavailable observations.

Add universe.dataset_scope() to the datasets passed to create_research_manifest. Its stable content identity hashes normalized intervals and provenance, binding the research run to the exact membership history rather than only a current constituent list.

Record a portable research manifest

Use a versioned JSON manifest to connect dataset identity, parameters, environment versions, and external output checksums without an experiment database:

from persistra.research import (
    DatasetScope,
    create_research_manifest,
    write_research_manifest,
)

dataset = DatasetScope(
    name="daily_equities",
    scope={
        "symbols": list(levels.columns),
        "start": str(levels.index.min().date()),
        "end": str(levels.index.max().date()),
        "survivorship_free": False,
    },
    schema_version="bars-v1",
    snapshot_identity="duckdb:snapshot-42",
)
manifest = create_research_manifest(
    [dataset],
    feature_parameters={"momentum": {"lookback": 20, "lag": 1}},
    label_parameters={"horizon": equity_labels.horizon},
    split_parameters={"initial_train_size": 252, "evaluation_size": 21, "embargo": 1},
    benchmark_parameters={"name": "fixed_universe_equal_weight"},
    model_parameters={"factor_risk": latest_forecast.manifest_parameters},
    random_seeds={},
    execution_status="not-run",
)
write_research_manifest(manifest, "research-manifest.json")

Manifest publication is exclusive by default. Persistra writes and fsyncs a private file in the destination directory before exposing the complete manifest. Pass overwrite=True only when replacing an existing manifest is intentional; replacement is atomic.

DatasetScope requires a normalized schema version plus a content identity or stored snapshot identity. create_research_manifest records Persistra and every declared base runtime dependency by default. It also records the Python implementation, Python version, and stable system-machine platform descriptor. Pass include_runtime=False to omit those facts in a privacy-sensitive context, or use runtime_overrides to replace selected values explicitly. Call environment_versions(extras=("viz",)) or environment_versions(extras=("inspect",)) and pass the result as environment when optional visualization or inspector dependencies participate in a run. The installed Persistra metadata is the authoritative dependency inventory, so packaging tests detect declaration drift. Feature, label, split, benchmark, and model parameters and scopes may contain strings, integers, finite floats, booleans, nulls, string-keyed mappings, and sequences. Constructors recursively copy these portable JSON values, expose mappings as read-only mappings, and expose sequences as tuples. A validated dataset scope or manifest therefore keeps the same serialized representation for its lifetime. For completed external research, call identify_artifact on each output and record the identities with execution status succeeded or failed. Each identity includes the artifact name, SHA-256 checksum, and byte size. manifest_from_json and read_research_manifest reject unknown or incomplete schema fields.

Load a supported Draft 2020-12 schema with research_manifest_schema(version). The packaged v1 schema, Python parser, serializer, and maintained example are checked together so their contracts cannot drift. The v1 schema is strict; unknown or removed fields are rejected.

Verify completed outputs under an explicit trusted artifact directory:

from pathlib import Path

from persistra.research import verify_manifest_artifacts

artifact_root = Path("research-artifacts")
artifact_root.mkdir(exist_ok=True)
verification = verify_manifest_artifacts(manifest, artifact_root)
verification.raise_for_errors()

Verification streams each regular file while recomputing its SHA-256 and byte size. It does not follow symlinks or allow absolute and parent-traversal names. Structured findings distinguish missing, unexpected, unsafe, resized, and content-modified artifacts. Set report_unexpected=False only when the trusted root intentionally contains unrelated files.

Keep notebooks, live data, caches, figures, credentials, and generated manifests outside the repository. The library does not need a CLI because the Python API writes and verifies one file directly.

Validate across periods and fixed universes

Treat aggregate statistics as a starting point. Check economically motivated signals such as lagged price momentum, reversal, or volume trends across multiple periods and multiple explicit fixed-universe slices. Compare them with simple baselines and retain missing coverage and sample counts. A positive aggregate IC does not establish stability.

The automated suite exercises momentum and volume-trend examples on deterministic controlled panels across two periods and two universe slices. This verifies calculation and slicing behavior; it is not empirical evidence. Live or notebook validation belongs in an external research workspace. Never construct historical fundamental factors from present-day company snapshots. Point-in-time fundamental research needs a separate design for filings, amendments, taxonomies, availability, security identity, and a survivorship-aware universe.

Summarize explicit regimes

Supply a regime label for every return index row:

from persistra.research import summarize_regimes

regimes = pd.Series(
    ["expansion", "contraction"] * 15,
    index=labels.frame.index,
)
summary = summarize_regimes(
    labels.frame,
    regimes,
    periods_per_year=252,
)

print(summary.coverage)
print(summary.regime_statistics)

The statistics report observed count, within-regime coverage, arithmetic mean return, sample volatility, and maximum drawdown for each regime and return column. Volatility is annualized only when periods_per_year is present. Drawdown resets when the regime changes or a return is missing, so separate regime episodes are never compounded into a fictitious continuous path.

The summary describes supplied regimes. It does not infer them, estimate a hidden classifier, or interpret association as causal evidence.

Carry research into a strategy

Keep the factor definitions, estimation window, inference choice, feature provenance, label horizon, split policy, and model as_of with the resulting forecast. Use build_factor_portfolio_forecast to connect a FactorRiskModel and caller-supplied premia to the portfolio optimizer without discarding contribution detail.

For replay, freeze the selected model and retain its declared inputs before producing Trading Engine scenario intents. The factor-model examples show complete regression-to-attribution patterns.