Skip to content

Factor-model examples

Persistra fits regression models from factors you supply. It does not define reference factors, download factor libraries, infer economic meaning, or annualize results. The examples below use arbitrary value and momentum columns to show the contracts.

Build aligned return panels

Time-series regressions require one sorted date index shared by the date-by-asset and date-by-factor panels:

import pandas as pd

dates = pd.date_range("2025-01-01", periods=80, freq="D")
factor_returns = pd.DataFrame(
    {
        "value": [((position % 11) - 5) / 160 for position in range(80)],
        "momentum": [((position % 7) - 3) / 130 for position in range(80)],
    },
    index=dates,
)
noise = pd.Series([((position % 5) - 2) / 1200 for position in range(80)], index=dates)
asset_returns = pd.DataFrame(
    {
        "asset-a": 0.0005 + 1.0 * factor_returns["value"] + 0.2 * factor_returns["momentum"] + noise,
        "asset-b": -0.0003 - 0.4 * factor_returns["value"] + 0.9 * factor_returns["momentum"] - noise,
        "asset-c": 0.0001 + 0.3 * factor_returns["value"] - 0.6 * factor_returns["momentum"] + noise / 2,
    },
    index=dates,
)

assert asset_returns.index.equals(factor_returns.index)

Missing values are removed independently for each asset. Do not pre-fill unknown returns merely to make a rectangle complete.

Fit time-series regressions

from persistra.research import fit_time_series_factor_model

static_model = fit_time_series_factor_model(
    asset_returns,
    factor_returns,
    covariance="newey_west",
    hac_lags=4,
)

print(static_model.coefficients)
print(static_model.standard_errors)
print(static_model.t_statistics)
print(static_model.diagnostics)

Choose covariance="classical", "hc3", or "newey_west". Newey-West inference accepts an explicit lag count or a data-dependent default. The coefficient estimates do not change when only the covariance estimator changes.

Weighted least squares uses a positive date-by-asset panel on the same axes:

regression_weights = pd.DataFrame(1.0, index=dates, columns=asset_returns.columns)
regression_weights.loc[dates[:10], "asset-c"] = 0.5

weighted_model = fit_time_series_factor_model(
    asset_returns,
    factor_returns,
    weights=regression_weights,
    covariance="hc3",
)

assert weighted_model.coefficients.index.equals(asset_returns.columns)

Inspect diagnostics["status"] before consuming inference. A rank-deficient design retains the least-norm coefficient estimate but marks unavailable inference explicitly.

Fit rolling or expanding regressions

from persistra.research import rolling_time_series_factor_model

rolling_model = rolling_time_series_factor_model(
    asset_returns,
    factor_returns,
    window=40,
    minimum_observations=20,
    covariance="hc3",
)
expanding_model = rolling_time_series_factor_model(
    asset_returns,
    factor_returns,
    window=None,
    minimum_observations=20,
)

latest_date = dates[-1]
latest_betas = rolling_model.coefficients.xs(latest_date, level="date")
print(latest_betas)

An estimate dated t uses no observation later than t. Early rows remain on the result axis with insufficient_observations status instead of disappearing.

Estimate cross-sectional factor returns

Create forward-return labels separately from exposures. The label object records the horizon and the actual end date of every label:

from persistra.research import estimate_cross_sectional_factor_returns, forward_returns

prices = 100.0 * (1.0 + asset_returns).cumprod()
labels = forward_returns(prices, horizon=1)
exposure_index = pd.MultiIndex.from_product(
    [dates, asset_returns.columns],
    names=["date", "asset"],
)
cross_sectional_exposures = pd.DataFrame(
    {
        "value": [1.0, -0.5, 0.2] * len(dates),
        "momentum": [0.1, 0.8, -0.6] * len(dates),
    },
    index=exposure_index,
)

cross_sectional = estimate_cross_sectional_factor_returns(
    labels,
    cross_sectional_exposures,
    covariance="hc3",
)

assert cross_sectional.label_horizon == 1
print(cross_sectional.factor_returns.tail())

With only three assets and an intercept, this toy example has no residual degrees of freedom. It is useful for demonstrating alignment, not for inference. A real cross-section needs enough complete assets for the requested terms.

Summarize premia and run Fama-MacBeth

from persistra.research import fama_macbeth_regression, summarize_factor_premia

premia_summary = summarize_factor_premia(
    cross_sectional.factor_returns,
    covariance="newey_west",
    hac_lags=3,
)
fama_macbeth = fama_macbeth_regression(
    labels,
    cross_sectional_exposures,
    hac_lags=3,
)

print(premia_summary.statistics)
print(fama_macbeth.premia.statistics)

summarize_factor_premia also accepts any caller-supplied factor-return history. Fama-MacBeth preserves the label horizon, per-date cross-sectional results, and time-series premium inference.

Build a factor risk model

Use current exposures with a history of factor and residual returns:

from persistra.research import build_factor_risk_model

current_exposures = static_model.coefficients[["value", "momentum"]]
risk_model = build_factor_risk_model(
    current_exposures,
    factor_returns,
    static_model.residuals,
    shrinkage=0.20,
    window=60,
    as_of=dates[-1],
)

assert risk_model.asset_covariance.index.equals(asset_returns.columns)
print(risk_model.factor_covariance)
print(risk_model.idiosyncratic_variance)

Diagonal shrinkage applies to factor covariance. The asset covariance reconciles factor and idiosyncratic variance for the supplied exposure snapshot.

Build a point-in-time portfolio forecast

from persistra.research import build_factor_portfolio_forecast

estimated_premia = factor_returns.tail(30).mean()
asset_alpha = pd.Series(
    {"asset-a": 0.0002, "asset-b": 0.0, "asset-c": -0.0001}
)
forecast = build_factor_portfolio_forecast(
    risk_model,
    estimated_premia,
    alpha=asset_alpha,
)

print(forecast.expected_returns)
print(forecast.expected_return_contributions)

The function applies no hidden scaling. Factor contribution equals exposure times supplied premium, and expected asset return equals alpha plus those contributions.

Attribute absolute or active weights

from persistra.research import attribute_factor_portfolio

portfolio_weights = pd.Series(
    {"asset-a": 0.45, "asset-b": 0.35, "asset-c": 0.20}
)
benchmark_weights = pd.Series(
    {"asset-a": 1 / 3, "asset-b": 1 / 3, "asset-c": 1 / 3}
)

absolute_attribution = attribute_factor_portfolio(forecast, portfolio_weights)
active_attribution = attribute_factor_portfolio(
    forecast,
    portfolio_weights,
    benchmark_weights=benchmark_weights,
)

print(absolute_attribution.factor_exposures)
print(active_attribution.expected_return_contributions)
print(active_attribution.variance_contributions)

Active attribution uses portfolio minus benchmark weights. Expected-return contributions include alpha and every factor. Variance contributions include every factor and an idiosyncratic term.

Carry model output into a strategy

Keep the model's as_of, factor definitions, estimation window, and input identities with the target artifact. In an external strategy, update the model only from completed history visible in StrategyView. In a precomputed-target workflow, place the dated target panel directly in a scenario. Either route should use the same portfolio constraints and attribution checks that were reviewed in research.