Source code for skfolio.datasets._synthetic

"""Synthetic characteristics AssetPanel generator."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import numpy as np
import pandas as pd

from skfolio.containers import MISSING_CATEGORY_CODE, AssetPanel, FieldCategorical
from skfolio.typing import FloatArray

__all__ = ["make_synthetic_characteristics"]

_PERIODS_PER_YEAR = 252

# Generic industry labels used in the synthetic panel, with sampling weights used to
# assign assets to industries (make some industries larger than others).
_INDUSTRY_SAMPLING_WEIGHTS = (
    ("Real Estate", 390.0),
    ("Software", 280.0),
    ("Banks", 270.0),
    ("Energy", 240.0),
    ("Capital Goods", 230.0),
    ("Commercial", 210.0),
    ("Financials", 200.0),
    ("Tech Hardware", 170.0),
    ("Pharma & Biotech", 170.0),
    ("Health Care", 160.0),
    ("Materials", 160.0),
    ("Food & Beverage", 150.0),
    ("Utilities", 150.0),
    ("Retail", 140.0),
    ("Insurance", 140.0),
    ("Semiconductors", 130.0),
)

# Market (global) factor annualized volatility and mean.
_MARKET_ANN_VOL = 0.18
_MARKET_ANN_MEAN = 0.13

# Average industry-factor annualized volatility and its cross-industry dispersion.
_INDUSTRY_ANN_VOL = 0.11
_INDUSTRY_ANN_VOL_SPREAD = 0.04

# Per-asset market-beta dispersion (std of the cross-sectional beta distribution).
_BETA_SPREAD = 0.40

# Cross-sectional dispersion of idiosyncratic volatility and the Student-t tail
# thickness (degrees of freedom) shared by market and idiosyncratic shocks.
_IDIO_LOG_SIGMA = 0.5
_TAIL_DOF = 6.0

# Idiosyncratic returns mix unit-variance components whose weights sum to one, so the
# total idiosyncratic variance is preserved. They include persistent momentum,
# mean-reverting price pressure, a small predictable bearish component and a
# transitory fat-tailed shock for the remaining variance.
_IDIO_MOMENTUM_AUTOCORR = 0.98
_IDIO_MOMENTUM_WEIGHT = 0.005
_IDIO_REVERSAL_AUTOCORR = 0.95
_IDIO_REVERSAL_WEIGHT = 0.3
_BEARISH_SIGNAL_AUTOCORR = 0.99
_BEARISH_SIGNAL_IDIO_VARIANCE_WEIGHT = 0.000625
_BEARISH_SIGNAL_DESCRIPTOR_SENSITIVITY = 0.8

# Explicit style factors recovered by the default descriptors, each given as
# (annualized volatility, annualized mean, AR(1) coefficient). Market beta, momentum
# and reversal are intentionally excluded: beta emerges from the market component and
# momentum/reversal emerge from the realized return path.
_STYLE_FACTORS = {
    "size": (0.033, 0.005, -0.05),
    "value": (0.014, -0.008, 0.01),
    "earnings_yield": (0.017, 0.015, 0.06),
    "profitability": (0.012, 0.002, 0.02),
    "growth": (0.011, -0.001, 0.04),
    "investment": (0.010, 0.000, 0.04),
    "leverage": (0.013, -0.006, 0.11),
    "dividend_yield": (0.012, 0.001, 0.04),
    "liquidity": (0.019, 0.007, -0.01),
    "volatility": (0.031, 0.003, 0.02),
}

# Central value of each characteristic (e.g. book-to-price ~0.34 , share price ~$42).
# Each asset starts from these values and is then shifted by its hidden style traits and
# random noise, so the generated data stays realistic while differing across assets. A
# couple of entries (log_market_cap, price) are size/price levels, not ratios.
_RATIO_MEDIANS = {
    "log_market_cap": 8.5,
    "price": 42.0,
    "book_to_price": 0.34,
    "earnings_to_price": 0.05,
    "forward_earnings_to_price": 0.06,
    "sales_to_price": 0.45,
    "cash_flow_to_price": 0.07,
    "gross_margin": 0.36,
    "ebitda_margin": 0.18,
    "asset_turnover": 0.57,
    "market_leverage": 0.19,
    "sales_growth": 0.065,
    "capex_intensity": 0.025,
    "daily_turnover": 0.008,
    "dividend_yield": 0.02,
    "forward_dividend_yield": 0.017,
    "short_interest_ratio": 0.02,
    "eps_dispersion_ratio": 0.06,
    "cash_to_assets": 0.10,
}

# Per-field finite fraction among active observations (mirrors realistic coverage).
_FIELD_COVERAGE = {
    "ebitda_ttm": 0.90,
    "enterprise_value": 0.90,
    "cost_of_revenue_ttm": 0.90,
    "eps_ntm": 0.80,
    "dps_ntm": 0.80,
    "eps_ntm_std": 0.80,
}

# Fields whose values are always populated on active cells.
_PROTECTED_FIELDS = frozenset(
    {"returns", "adj_close", "adj_volume", "adj_shares_outstanding", "market_cap"}
)


[docs] def make_synthetic_characteristics( n_assets: int = 500, n_observations: int = 2520, *, n_industries: int = 10, start_date: str = "2015-01-01", systematic_variance_ratio: float = 0.5, late_listing_proba: float = 0.15, delisting_proba: float = 0.15, missing_ratio: float = 0.01, random_state: int | None = None, ) -> AssetPanel: r"""Generate a synthetic characteristics :class:`~skfolio.containers.AssetPanel`. The panel generated contains the minimal set of fields required by the default `skfolio` descriptors and :class:`~skfolio.prior.CharacteristicsFactorModel`. It is designed so that fitting a characteristics factor model produces realistic diagnostics: a cross-sectional regression :math:`R^2` away from the degenerate values of :math:`0` and :math:`1`, non-trivial information coefficients and idiosyncratic returns with fat tails. Returns are drawn from a factor structure .. math:: r_{i,t} = \beta_i\,f^{\mathrm{mkt}}_t + f^{\mathrm{ind}(i)}_t + \sum_k B_{i,k}\,f^{k}_t + \varepsilon_{i,t}, where the per-asset loadings :math:`B` are persistent traits. Characteristics are then constructed so that the descriptor of each style is a noisy proxy of the corresponding loading, while accounting identities (for example :math:`\text{market\_cap} = \text{adj\_close} \times \text{adj\_shares\_outstanding}`) are preserved. Fields produced: `returns`, `adj_close`, `adj_volume`, `adj_shares_outstanding`, `market_cap`, `ebitda_ttm`, `enterprise_value`, `net_income_ttm`, `sales_ttm`, `dividends_ttm`, `net_buybacks_ttm`, `book_equity`, `operating_cash_flow_ttm`, `total_debt`, `total_assets`, `industry`, `cost_of_revenue_ttm`, `capex_ttm`, `short_interest`, `eps_ntm`, `dps_ntm`, `eps_ntm_std`. Parameters ---------- n_assets : int, default=500 Number of assets (coverage universe). n_observations : int, default=2520 Number of observations. n_industries : int, default=10 Number of industry groups. Must not exceed 16. start_date : str, default="2015-01-01" First observation date. Observations follow a business-day calendar. systematic_variance_ratio : float, default=0.5 Share of cross-sectional return variance explained by the factor structure. The realized cross-sectional regression :math:`R^2` of a fitted model is close to this value. Must lie in the open interval :math:`(0, 1)`. late_listing_proba : float, default=0.15 Probability that an asset lists after the first observation. delisting_proba : float, default=0.15 Probability that an asset delists before the last observation. missing_ratio : float, default=0.01 Fraction of active fundamental observations set to NaN to emulate reporting gaps. Price, volume, shares and market cap are left intact. random_state : int, optional Seed for the random number generator. Returns ------- panel : AssetPanel Synthetic asset panel with the fields listed above. `industry` is a :class:`~skfolio.containers.FieldCategorical`. Notes ----- The generator is driven by a small set of time-invariant latent asset traits (size, value, quality, risk, growth and liquidity). These traits set the factor loadings :math:`B`, the market beta and the idiosyncratic volatility level and anchor the level of every fundamental and market field so that accounting identities (such as :math:`\text{market\_cap} = \text{adj\_close} \times \text{adj\_shares\_outstanding}`) hold. Each characteristic is therefore a noisy proxy of the trait that drives its matching style factor. Factor returns combine a fat-tailed market factor, zero-mean industry factors and mean-reverting style factors. Idiosyncratic returns mix a transitory shock with a slow persistent component and a fast mean-reverting component. These give the momentum and short-term reversal factors a realistic positive Sharpe without changing the idiosyncratic variance. To support alpha-research examples, the idiosyncratic shock also contains a small predictable component driven by a persistent latent bearish signal :math:`z_{i,t}`. Short interest and analyst forecast dispersion are constructed as noisy increasing functions of :math:`z_{i,t}`, while the next-period return contribution is .. math:: \varepsilon^{\mathrm{signal}}_{i,t+1} = -\sigma_i\sqrt{w_{\mathrm{signal}}}\,z_{i,t}. Consequently, high values of either descriptor predict lower future idiosyncratic returns without same-period look-ahead. Forward-looking and lower-coverage fields (`eps_ntm`, `dps_ntm`, `eps_ntm_std`, `enterprise_value`, `ebitda_ttm` and `cost_of_revenue_ttm`) carry partial coverage to mirror real data, while price, volume, shares and market cap are always populated on active assets. Examples -------- >>> from skfolio.datasets import make_synthetic_characteristics >>> panel = make_synthetic_characteristics(n_assets=200, n_observations=1000) >>> panel.n_assets, panel.n_observations (200, 1000) """ if not 1 <= n_industries <= len(_INDUSTRY_SAMPLING_WEIGHTS): raise ValueError( f"n_industries must be between 1 and {len(_INDUSTRY_SAMPLING_WEIGHTS)}, " f"got {n_industries}." ) if not 0.0 < systematic_variance_ratio < 1.0: raise ValueError( "systematic_variance_ratio must be in the open interval (0, 1)." ) rng = np.random.default_rng(random_state) n_observations, n_assets = int(n_observations), int(n_assets) dates = pd.bdate_range(start=start_date, periods=n_observations) asset_names = np.array([f"A{i:05d}" for i in range(n_assets)]) # Industry assignment, weighted by relative membership. labels, weights = zip(*_INDUSTRY_SAMPLING_WEIGHTS[:n_industries], strict=True) industry_labels = np.array(labels) industry_weights = np.array(weights, dtype=float) industry_weights /= industry_weights.sum() industry_index = rng.choice(n_industries, size=n_assets, p=industry_weights) # Latent persistent asset traits (independent standard normals). trait_size = rng.standard_normal(n_assets) trait_value = rng.standard_normal(n_assets) trait_quality = rng.standard_normal(n_assets) trait_risk = rng.standard_normal(n_assets) trait_growth = rng.standard_normal(n_assets) trait_liquidity = rng.standard_normal(n_assets) def noise(scale: float) -> FloatArray: return scale * rng.standard_normal(n_assets) # Per-asset characteristic ratios derived from the latent traits. log_market_cap = _RATIO_MEDIANS["log_market_cap"] + 1.4 * trait_size base_market_cap = np.exp(log_market_cap) base_price = np.exp(np.log(_RATIO_MEDIANS["price"]) + noise(0.85)) base_shares = base_market_cap / base_price book_to_price = np.exp( np.log(_RATIO_MEDIANS["book_to_price"]) + 0.55 * trait_value + noise(0.45) ) earnings_to_price = ( _RATIO_MEDIANS["earnings_to_price"] + 0.045 * (0.6 * trait_value + 0.6 * trait_quality) + noise(0.05) ) forward_earnings_to_price = np.clip( _RATIO_MEDIANS["forward_earnings_to_price"] + 0.04 * trait_value + 0.03 * trait_quality + noise(0.03), 0.003, None, ) sales_to_price = np.exp( np.log(_RATIO_MEDIANS["sales_to_price"]) - 0.3 * trait_size + 0.5 * trait_value + noise(0.5) ) cash_flow_to_price = ( _RATIO_MEDIANS["cash_flow_to_price"] + 0.05 * trait_quality + noise(0.05) ) gross_margin = np.clip( _RATIO_MEDIANS["gross_margin"] + 0.18 * trait_quality + noise(0.08), 0.03, 0.95, ) ebitda_margin = np.clip( _RATIO_MEDIANS["ebitda_margin"] + 0.08 * trait_quality + noise(0.05), -0.05, 0.6, ) asset_turnover = np.exp( np.log(_RATIO_MEDIANS["asset_turnover"]) - 0.3 * trait_quality + noise(0.4) ) market_leverage = np.clip( _RATIO_MEDIANS["market_leverage"] + 0.13 * trait_value - 0.05 * trait_quality + noise(0.08), 0.0, 0.85, ) # A fraction of firms carry no debt. market_leverage[rng.random(n_assets) < 0.18] = 0.0 sales_growth = _RATIO_MEDIANS["sales_growth"] + 0.12 * trait_growth + noise(0.06) capex_intensity = np.clip( _RATIO_MEDIANS["capex_intensity"] + 0.012 * trait_growth + noise(0.01), 0.0, 0.2, ) base_daily_turnover = np.exp( np.log(_RATIO_MEDIANS["daily_turnover"]) + 0.4 * trait_liquidity - 0.2 * trait_size + noise(0.4) ) payer_proba = np.clip(0.55 + 0.18 * trait_value + 0.18 * trait_quality, 0.05, 0.95) is_payer = rng.random(n_assets) < payer_proba dividend_yield = np.where( is_payer, np.clip( _RATIO_MEDIANS["dividend_yield"] + 0.012 * trait_value + noise(0.012), 0.0, 0.1, ), 0.0, ) forward_dividend_yield = np.where( is_payer, np.clip( _RATIO_MEDIANS["forward_dividend_yield"] + 0.01 * trait_value + noise(0.01), 0.0, 0.1, ), 0.0, ) buyback_yield = 0.01 + 0.02 * trait_quality + noise(0.03) # Asset-specific offsets keep the two signal descriptors heterogeneous and noisy. short_interest_log_offset = -0.3 * trait_quality + noise(0.4) analyst_dispersion_log_offset = noise(0.4) cash_to_assets = np.clip(_RATIO_MEDIANS["cash_to_assets"] + noise(0.05), 0.0, 0.6) beta = np.clip(1.0 + _BETA_SPREAD * trait_risk, 0.1, 3.0) # Cross-sectional idiosyncratic-vol shape (unit-scale lognormal). The absolute # level is set later from `systematic_variance_ratio`. idio_shape = np.exp( 0.35 * trait_risk - 0.15 * trait_size + _IDIO_LOG_SIGMA * rng.standard_normal(n_assets) ) # Exposure-aligned loadings (standardized) for the explicit style factors. gross_profitability = gross_margin * asset_turnover loadings = np.column_stack( [ _standardize(log_market_cap), # size _standardize(np.log(book_to_price)), # value _standardize(earnings_to_price), # earnings_yield _standardize(gross_profitability), # profitability _standardize(sales_growth), # growth _standardize(capex_intensity), # investment _standardize(market_leverage), # leverage _standardize(dividend_yield), # dividend_yield _standardize(np.log(base_daily_turnover)), # liquidity _standardize(np.log(idio_shape)), # volatility ] ) # Factor returns: styles and industries are Gaussian AR(1) and the market has fat # tails style_ann_vol, style_ann_mean, style_autocorr = ( np.array(values) for values in zip(*_STYLE_FACTORS.values(), strict=True) ) style_returns = _ar1_paths( rng, n_observations, style_ann_vol, style_ann_mean, style_autocorr ) market_daily_vol = _MARKET_ANN_VOL / np.sqrt(_PERIODS_PER_YEAR) market_daily_mean = _MARKET_ANN_MEAN / _PERIODS_PER_YEAR market_returns = market_daily_mean + market_daily_vol * _fat_tailed_normal( rng, (n_observations,), _TAIL_DOF ) industry_ann_vol = np.abs( _INDUSTRY_ANN_VOL + _INDUSTRY_ANN_VOL_SPREAD * rng.standard_normal(n_industries) ) industry_returns = _ar1_paths( rng, n_observations, industry_ann_vol, np.zeros(n_industries), np.zeros(n_industries), ) systematic = ( beta[None, :] * market_returns[:, None] + industry_returns[:, industry_index] + style_returns @ loadings.T ) # Scale idiosyncratic returns to hit the target systematic-variance share. systematic_var = systematic.var(axis=1).mean() idio_var = ( systematic_var * (1.0 - systematic_variance_ratio) / systematic_variance_ratio ) idio_daily_vol = np.sqrt(idio_var) * idio_shape / np.sqrt(np.mean(idio_shape**2)) # Allocate the idiosyncratic variance budget across transitory, momentum, # reversal and predictable bearish components. The reversal component is the # unit-variance first difference of a persistent AR(1) process. transitory_component = _fat_tailed_normal( rng, (n_observations, n_assets), _TAIL_DOF ) momentum_component = _ar1_filter( rng.standard_normal((n_observations, n_assets)), _IDIO_MOMENTUM_AUTOCORR ) pressure_level = _ar1_filter( rng.standard_normal((n_observations, n_assets)), _IDIO_REVERSAL_AUTOCORR ) reversal_component = np.zeros_like(pressure_level) reversal_component[1:] = (pressure_level[1:] - pressure_level[:-1]) / np.sqrt( 2.0 * (1.0 - _IDIO_REVERSAL_AUTOCORR) ) # A spawned stream keeps the signal reproducible without consuming draws from the # main stream and shifting the calibrated factor paths. signal_seed = np.random.SeedSequence(random_state).spawn(1)[0] signal_rng = np.random.default_rng(signal_seed) latent_bearish_signal = _ar1_filter( signal_rng.standard_normal((n_observations, n_assets)), _BEARISH_SIGNAL_AUTOCORR, ) # Cross-sectional normalization gives the sensitivity and variance weight stable # meanings across dates and universe sizes. latent_bearish_signal -= latent_bearish_signal.mean(axis=1, keepdims=True) latent_bearish_signal_std = latent_bearish_signal.std(axis=1, keepdims=True) latent_bearish_signal /= np.where( latent_bearish_signal_std == 0.0, 1.0, latent_bearish_signal_std ) idio_shock = ( np.sqrt( 1.0 - _IDIO_MOMENTUM_WEIGHT - _IDIO_REVERSAL_WEIGHT - _BEARISH_SIGNAL_IDIO_VARIANCE_WEIGHT ) * transitory_component + np.sqrt(_IDIO_MOMENTUM_WEIGHT) * momentum_component + np.sqrt(_IDIO_REVERSAL_WEIGHT) * reversal_component ) # The one-period lag makes observable descriptors predict future returns. idio_shock[1:] -= ( np.sqrt(_BEARISH_SIGNAL_IDIO_VARIANCE_WEIGHT) * latent_bearish_signal[:-1] ) idio = idio_daily_vol[None, :] * idio_shock returns = np.maximum(systematic + idio, -0.99) # Listing and delisting windows define the active mask. list_start = np.zeros(n_assets, dtype=int) list_end = np.full(n_assets, n_observations, dtype=int) is_late_listing = rng.random(n_assets) < late_listing_proba list_start[is_late_listing] = rng.integers( 1, max(2, int(0.6 * n_observations)), size=int(is_late_listing.sum()) ) is_delisted = rng.random(n_assets) < delisting_proba if is_delisted.any(): earliest_delisting = np.clip( list_start[is_delisted] + _PERIODS_PER_YEAR, 1, n_observations ) list_end[is_delisted] = rng.integers(earliest_delisting, n_observations + 1) obs_index = np.arange(n_observations)[:, None] active = (obs_index >= list_start[None, :]) & (obs_index < list_end[None, :]) # Guarantee every observation has at least one active asset. active[:, 0] = True # Price, shares, volume and market cap (identity-consistent). adj_close = base_price[None, :] * np.exp( np.cumsum(np.log1p(np.where(active, returns, 0.0)), axis=0) ) issuance_drift = (np.exp(sales_growth) ** (1.0 / _PERIODS_PER_YEAR) - 1.0) * 0.3 share_steps = issuance_drift[None, :] + 5e-5 * rng.standard_normal( (n_observations, n_assets) ) shares = base_shares[None, :] * np.exp( np.cumsum(np.where(active, share_steps, 0.0), axis=0) ) market_cap = adj_close * shares # Shares outstanding are in millions. Traded and shorted shares are raw counts. turnover_noise = np.exp(0.4 * rng.standard_normal((n_observations, n_assets))) adj_volume = shares * 1e6 * base_daily_turnover[None, :] * turnover_noise # Fundamentals grow at each asset's expected return so valuation ratios are # stationary in expectation, plus a zero-median tilt for the growth factor. style_daily_mean = style_ann_mean / _PERIODS_PER_YEAR fundamental_drift = ( beta * market_daily_mean + loadings @ style_daily_mean + issuance_drift + (sales_growth - np.median(sales_growth)) / _PERIODS_PER_YEAR ) growth_path = np.exp( np.arange(n_observations)[:, None] * fundamental_drift[None, :] ) sales = (sales_to_price * base_market_cap)[None, :] * growth_path net_income = (earnings_to_price * base_market_cap)[None, :] * growth_path book_equity = (book_to_price * base_market_cap)[None, :] * growth_path operating_cash_flow = (cash_flow_to_price * base_market_cap)[None, :] * growth_path total_debt = (market_leverage * base_market_cap)[None, :] * growth_path total_assets = sales / asset_turnover[None, :] cost_of_revenue = (1.0 - gross_margin)[None, :] * sales ebitda = ebitda_margin[None, :] * sales capex = capex_intensity[None, :] * total_assets dividends = (dividend_yield * base_market_cap)[None, :] * growth_path net_buybacks = (buyback_yield * base_market_cap)[None, :] * growth_path cash = cash_to_assets[None, :] * total_assets enterprise_value = market_cap + total_debt - cash eps_ntm = forward_earnings_to_price[None, :] * adj_close dps_ntm = forward_dividend_yield[None, :] * adj_close # Both descriptors observe the same bearish signal through independent noisy # offsets. Sensitivity controls their signal-to-noise ratio in log space. eps_dispersion_ratio = np.exp( np.log(_RATIO_MEDIANS["eps_dispersion_ratio"]) + analyst_dispersion_log_offset[None, :] + _BEARISH_SIGNAL_DESCRIPTOR_SENSITIVITY * latent_bearish_signal ) short_interest_ratio = np.clip( np.exp( np.log(_RATIO_MEDIANS["short_interest_ratio"]) + short_interest_log_offset[None, :] + _BEARISH_SIGNAL_DESCRIPTOR_SENSITIVITY * latent_bearish_signal ), 0.0, 0.4, ) eps_ntm_std = eps_dispersion_ratio * np.abs(eps_ntm) short_interest = short_interest_ratio * shares * 1e6 fields = { "returns": returns, "adj_close": adj_close, "adj_volume": adj_volume, "adj_shares_outstanding": shares, "market_cap": market_cap, "ebitda_ttm": ebitda, "enterprise_value": enterprise_value, "net_income_ttm": net_income, "sales_ttm": sales, "dividends_ttm": dividends, "net_buybacks_ttm": net_buybacks, "book_equity": book_equity, "operating_cash_flow_ttm": operating_cash_flow, "total_debt": total_debt, "total_assets": total_assets, "cost_of_revenue_ttm": cost_of_revenue, "capex_ttm": capex, "short_interest": short_interest, "eps_ntm": eps_ntm, "dps_ntm": dps_ntm, "eps_ntm_std": eps_ntm_std, } # Apply coverage-driven missingness then enforce the inactive asset NaN rule. inactive = ~active for name, array in fields.items(): if name not in _PROTECTED_FIELDS: keep = _FIELD_COVERAGE.get(name, 1.0 - missing_ratio) if keep < 1.0: array[(rng.random((n_observations, n_assets)) > keep) & active] = np.nan array[inactive] = np.nan fields[name] = array.astype(np.float32) # Industry is constant over time per asset and missing outside the active universe. industry_codes = np.broadcast_to( industry_index.astype(np.int32)[None, :], (n_observations, n_assets) ).copy() industry_codes[inactive] = MISSING_CATEGORY_CODE fields["industry"] = FieldCategorical(industry_codes, levels=industry_labels) # Estimation mask drops a small set of assets. estimation_mask = active.copy() n_excluded = max(1, int(0.05 * n_assets)) excluded_index = rng.choice( np.arange(1, n_assets), size=min(n_excluded, n_assets - 1), replace=False ) estimation_mask[:, excluded_index] = False return AssetPanel( fields=fields, observations=dates.values, asset_names=asset_names, active_mask=active, estimation_mask=estimation_mask, )
def _standardize(x: FloatArray) -> FloatArray: """Standardize to zero mean and unit standard deviation.""" std = x.std() if std == 0: return x - x.mean() return (x - x.mean()) / std def _ar1_paths( rng: np.random.Generator, n_observations: int, ann_vol: FloatArray, ann_mean: FloatArray, autocorr: FloatArray, ) -> FloatArray: """Simulate stationary AR(1) factor-return paths. Parameters are annualized and converted to per-period units. The innovation scale is set so the stationary volatility matches `ann_vol` regardless of `autocorr`. """ daily_vol = ann_vol / np.sqrt(_PERIODS_PER_YEAR) daily_mean = ann_mean / _PERIODS_PER_YEAR n_factors = len(ann_vol) innovation = rng.standard_normal((n_observations, n_factors)) innovation *= daily_vol * np.sqrt(1.0 - autocorr**2) paths = np.empty((n_observations, n_factors)) paths[0] = daily_mean + daily_vol * rng.standard_normal(n_factors) for t in range(1, n_observations): paths[t] = daily_mean + autocorr * (paths[t - 1] - daily_mean) + innovation[t] return paths def _fat_tailed_normal( rng: np.random.Generator, shape: tuple[int, ...], dof: float ) -> FloatArray: """Draw unit-variance shocks from a Student-t scale mixture of normals.""" z = rng.standard_normal(shape) scale = np.sqrt(dof / rng.chisquare(dof, size=shape)) # Rescale to unit variance (a Student-t variable has variance dof / (dof - 2)). return z * scale * np.sqrt((dof - 2.0) / dof) def _ar1_filter(innovations: FloatArray, autocorr: float) -> FloatArray: r"""Apply an AR(1) recursion along axis 0, preserving unit stationary variance. Unit-variance `innovations` are turned into a unit-variance AR(1) process by scaling the innovation term by :math:`\sqrt{1 - \rho^2}`. """ scale = np.sqrt(1.0 - autocorr**2) out = np.empty_like(innovations) out[0] = innovations[0] for t in range(1, len(innovations)): out[t] = autocorr * out[t - 1] + scale * innovations[t] return out