Source code for fynance.research.synthetic

#!/usr/bin/env python
# -*- coding: utf-8 -*-

""" Synthetic price generators.

Seeded synthetic price paths so the whole research harness is testable with
**zero real data**. They also serve as a **null test**: a strategy should show
~no skill on data with no real edge, which is a quick sanity check on the
guardrails. Real-data adapters live downstream (a private research repo), never
here.

"""

# Built-in
from __future__ import annotations

# Third-party
import numpy as np
from numpy.typing import NDArray

# Local
from fynance.core import PriceSeries

__all__ = ['gbm', 'regime_switching']


[docs] def gbm( n: int, *, mu: float = 0.0, sigma: float = 0.01, s0: float = 100.0, seed: int | None = None, ) -> PriceSeries: """ Geometric Brownian motion price path. Log-returns are drawn i.i.d. ``Normal(mu, sigma)``, so the path's mean log-return is ``mu`` and its volatility ``sigma``. Parameters ---------- n : int Number of observations (path length). mu : float Mean per-step log-return. sigma : float Per-step log-return volatility. s0 : float Initial price. seed : int, optional Seed for reproducibility. ``None`` is nondeterministic. Returns ------- fynance.core.PriceSeries Price path of length ``n``. Examples -------- >>> import numpy as np >>> from fynance.research import gbm >>> a, b = gbm(5, seed=7), gbm(5, seed=7) >>> bool(np.allclose(a.to_numpy(), b.to_numpy())) True >>> int(a.to_numpy().size) 5 """ rng = np.random.default_rng(seed) log_ret = mu + sigma * rng.standard_normal(max(n - 1, 0)) path = np.concatenate([[0.0], np.cumsum(log_ret)]) return PriceSeries(s0 * np.exp(path), name="synthetic-gbm")
[docs] def regime_switching( n: int, *, regimes: tuple[tuple[float, float], ...] = ((0.0, 0.01), (0.0, 0.03)), p_switch: float = 0.02, s0: float = 100.0, seed: int | None = None, ) -> PriceSeries: """ Markov regime-switching price path. At each step the active regime switches (to a uniformly-drawn regime) with probability ``p_switch``; log-returns are then drawn from the active regime's ``(mu, sigma)``. The varying volatility makes it a natural input for :func:`fynance.detect_regimes`. Parameters ---------- n : int Number of observations (path length). regimes : tuple of (float, float) ``(mu, sigma)`` per regime. p_switch : float Per-step probability of switching regime. s0 : float Initial price. seed : int, optional Seed for reproducibility. ``None`` is nondeterministic. Returns ------- fynance.core.PriceSeries Price path of length ``n``. Examples -------- >>> import numpy as np >>> from fynance.research import regime_switching >>> a, b = regime_switching(5, seed=3), regime_switching(5, seed=3) >>> bool(np.allclose(a.to_numpy(), b.to_numpy())) True """ rng = np.random.default_rng(seed) mus = np.array([r[0] for r in regimes], dtype=np.float64) sigmas = np.array([r[1] for r in regimes], dtype=np.float64) k = mus.size steps = max(n - 1, 0) states: NDArray[np.int_] = np.empty(steps, dtype=np.int_) state = 0 for t in range(steps): if rng.random() < p_switch: state = int(rng.integers(0, k)) states[t] = state log_ret = mus[states] + sigmas[states] * rng.standard_normal(steps) path = np.concatenate([[0.0], np.cumsum(log_ret)]) return PriceSeries(s0 * np.exp(path), name="synthetic-regime")