Skip to main content

Probabilistic price forecasting via stochastic differential equations (GBM/OU/neural), with AIC model selection, Monte Carlo quantiles, and fan charts.

Project description

neural-sde

Probabilistic price forecasting via stochastic differential equations. Fit a GBM or OU (mean-reverting) model to a price series, generate Monte Carlo forecasts with quantified uncertainty, and answer questions like "what's the probability this is up 10% in 30 days?" — without pretending to know the future.

This library produces probabilistic forecasts, not point predictions. forecast() returns a distribution over future paths, not a single "the price will be X" answer. Treat every number it gives you — quantiles, prob_above(), the fan chart — as a statement about uncertainty, not a promise. Financial markets are not stationary; a model fit on last year's regime can be a poor guide to next month's.

Install

pip install -e .                 # core: numpy, scipy, matplotlib
pip install -e ".[torch,pandas]" # + neural SDE path and pandas Series input
pip install -e ".[demo]"         # + yfinance, to run scripts/demo_forecast.py on real data

Requires Python 3.9+. See requirements.txt / pyproject.toml for pinned dependency ranges.

Quickstart

from neural_sde.highlevel import fit, forecast

model = fit(prices)                        # auto: AIC picks GBM vs OU
fc = forecast(model, horizon=30)           # antithetic Monte Carlo
print(fc.prob_above(prices[-1] * 1.10))    # P(+10% by day 30)
print(fc.quantiles([0.05, 0.5, 0.95]))     # 5th/50th/95th percentile price
fc.plot("forecast.png")                    # fan chart: history -> forecast cone

prices is any 1-D array-like or pandas Series of price levels (not returns). fit(model="neural") routes to a torch-based NeuralSDETrainer when torch is installed — see Neural path below before using it for anything besides diffusion/volatility. The GBM/OU parametric paths use exact transition densities (the maximum-likelihood fit given the data, no discretization approximation) and are the production-quality default. "Exact" describes the transition density, not a guarantee that every fitted parameter is unbiased in finite samples: OU's theta (mean- reversion speed) MLE has a well-documented finite-sample upward bias -- same family as Hurwicz/Cochrane's bias for AR(1) coefficients in econometrics, not an approximation error specific to this implementation. Empirically ~15-25% high at daily sampling with a few years of data, worse for slower-reverting series (tests/test_highlevel.py); mu and sigma don't share this bias.

On regimes

fit() estimates parameters from whatever window of history you give it. A GBM fit on a 6-month bull run will forecast that drift forward; an OU fit on a period of active mean-reversion will forecast reversion. Neither model knows the regime changed the day after your training window ends. Re-fit periodically, and sanity-check model.summary() (drift/vol/theta) against what you'd expect for the asset before trusting the forecast.

Neural path

fit(model="neural") learns state-dependent drift f(x,t) and diffusion g(x,t) functions instead of GBM/OU's parametric forms.

Diffusion recovery is solid — verified within 0.4-14% of the true value on synthetic GBM/OU calibration tests, and it's a genuine capability the parametric paths don't have: state-dependent volatility, not a single constant sigma. Useful for anything that wants to react to changing vol/regime (option pricing, vol-aware risk sizing).

Drift recovery is not usable at daily sampling frequency, and this isn't a bug that more engineering fixes. Recovering a state-dependent drift function from a single daily-sampled price path hits a hard signal-to-noise limit: SNR ≈ mu * sqrt(dt) / sigma, a few percent for typical parameters. A 5-seed-averaged sweep from 2,000 to 20,000 daily observations (up to 79 simulated years) improved median drift error only from ~267% to ~135% — consistent with the expected slow 1/sqrt(n) convergence, and nowhere near a usable tolerance. Extrapolating that rate, closing the gap would need on the order of 1,000+ years of daily data. The parametric GBM/OU drift estimate doesn't have this problem: it fits one global scalar averaged over the entire series rather than attempting state resolution, which sidesteps the issue instead of solving it.

Practical guidance: use the neural path for diffusion/volatility modeling. For anything that needs a drift estimate — hedging deltas, regime signals, mean-reversion speed — use the parametric GBM/OU drift, not the neural path's drift(x,t).

Beyond forecasting

Thin layers on top of fit/forecast, each designed around what's actually reliable (see Neural path above) rather than requiring drift accuracy the library can't currently deliver.

Signals

from neural_sde.highlevel import fit, forecast, signal

fc = forecast(fit(prices), horizon=30)
sig = signal(fc, prob_threshold=0.6)   # P(favorable direction) >= 60% to act
print(sig.summary())                    # direction, size, stop/target levels
sig.exit_triggered(current_price)       # True once price crosses stop or target

A thin wrapper, not a new estimation step — augments a strategy you already have rather than replacing it. Direction and confidence come straight from prob_above/prob_below; size is proportional to the forecast's implied Sharpe ratio; stop/target come from the forecast's own quantile bounds.

Optionally pass a regime (from detect_regime, below) to scale that size by volatility targeting — target_vol / current_vol, clipped to vol_scale_bounds (default (0.5, 1.5)):

from neural_sde.regime import detect_regime

reg = detect_regime(prices, window=20)
sig = signal(fc, regime=reg)   # shrinks size in high-vol regimes, sizes up in low-vol

target_vol defaults to the regime's own low/high threshold midpoint, so it adapts per-asset with no hand-picked reference vol. Omitting regime (the default) leaves sizing exactly as before — regime_scale is 1.0 and size is unchanged.

Options pricing

from neural_sde.pricing import price_european_call

result = price_european_call(fit(prices), K=110, T=30/252, r=0.04)
print(result.summary())   # price, std error, delta, gamma

Risk-neutral Monte Carlo — by the fundamental theorem of asset pricing, an option's value only depends on the underlying's diffusion under the risk-neutral measure; the drift is always the risk-free rate, never the asset's real-world drift estimate. That's what makes this usable with model="neural" for genuinely state-dependent volatility even though the neural path's real-world drift isn't reliable — pricing never touches that estimate in the first place. With model="gbm", this reduces to exactly Black-Scholes (verified in test_pricing.py against the closed-form formula); OU and neural extend beyond it.

Dynamic hedging

from neural_sde.hedge import simulate_delta_hedge_call

result = simulate_delta_hedge_call(fit(prices), K=110, T=30/252, r=0.04,
                                   rebalance_freq=5)   # rebalance every 5 trading days
print(result.summary())   # P&L mean/std, vs. not hedging at all, effectiveness

If you sold this option, how well would rebalancing a delta hedge every rebalance_freq trading days have replicated its payoff? Simulates many "true" outcomes for the underlying under the same risk-neutral measure Options pricing already trusts, reprices/re-deltas from pricing.py's own bump-and-reprice engine at each rebalance date (one implementation to trust across gbm/ou/neural, not a per-model-kind closed-form Greek), and reports the resulting P&L distribution against a no-hedge baseline. Diffusion-only like Options pricing, so it's safe with model="neural" for the same reason. Verified two ways in test_hedge.py: more frequent rebalancing measurably reduces tracking error (the textbook prediction), and for GBM the MC-based delta hedge's variance reduction lands in the same ballpark as an independent closed-form Black-Scholes-delta hedge on freshly simulated paths.

Portfolio hedging

from neural_sde.portfolio_hedge import OptionPosition, simulate_portfolio_hedge

models = {"AAPL": fit(aapl_prices), "MSFT": fit(msft_prices)}
positions = [
    OptionPosition(ticker="AAPL", option_type="call", K=200, T=30/252, quantity=-1),
    OptionPosition(ticker="MSFT", option_type="put", K=400, T=30/252, quantity=-1),
]
result = simulate_portfolio_hedge(models, positions, r=0.04,
                                  price_histories={"AAPL": aapl_prices, "MSFT": msft_prices})
print(result.summary())

The multi-asset generalization of dynamic hedging: a book of options, possibly across multiple underlyings, hedged together rather than position-by-position. Two things a position-by-position hedge would miss: positions on the SAME underlying net into one combined delta hedge (traded once, not independently); and the underlyings' "true" outcome paths are drawn jointly from a Cholesky-correlated risk-neutral simulation using their historical correlation (the same estimator risk.py's portfolio VaR already uses), so the aggregate P&L reflects diversification. Every other piece -- the risk-neutral diffusion, the bump-and-reprice engine -- is the same code hedge.py/pricing.py already use per-asset. Known simplification: all positions must share one expiry (staggered expiries aren't supported).

Verified in test_portfolio_hedge.py by reduction: a single-position portfolio matches hedge.py's own result exactly (not just approximately -- same option price, mean/std P&L, and effectiveness to float precision), and two exactly offsetting positions (long + short the same contract) net to zero premium and zero hedged P&L, with effectiveness correctly reporting NaN rather than a division blow-up when there's no unhedged risk left to reduce.

Portfolio risk

from neural_sde.risk import portfolio_var

models = {"AAPL": fit(aapl_prices), "MSFT": fit(msft_prices)}
port = portfolio_var(models, shares={"AAPL": 100, "MSFT": -50},
                     price_histories={"AAPL": aapl_prices, "MSFT": msft_prices})
print(port.summary())   # VaR/CVaR at 95%/99%, per-position contribution

Correlated Monte Carlo VaR/CVaR across positions — samples each asset's terminal price jointly (via the historical correlation between their returns), not independently, so diversification and hedges actually show up in the portfolio-level risk number. Deliberately restricted to model="gbm" or "ou" — VaR is a real-world-measure risk metric that depends on each asset's actual drift, which is exactly the estimate the neural path can't produce reliably (see Neural path); rejects model="neural" with a clear error rather than silently using an unreliable one.

Regime detection

from neural_sde.regime import detect_regime

reg = detect_regime(prices, window=20)
print(reg.summary())
reg.current_regime      # "low" | "normal" | "high"
reg.regime_changes()    # indices where the label transitions

Rolling realized-volatility classification, relative to the series' own history — percentile thresholds on a trailing-window annualized vol estimate, not a fitted regime-switching model. Uses only diffusion-side estimation (the reliable half of this library per Neural path), so it works the same regardless of which fit() model kind produced the price series. Verified against a real, well-known event, not just synthetic data: correctly flags SPY's March 2020 COVID crash as a "high" vol regime (test_regime.py). Feeds into signal()'s optional vol-targeted sizing (above) via vol_scale_factor().

Pairs trading

from neural_sde.pairs import analyze_pair

pair = analyze_pair(prices_a, prices_b, ticker_a="KO", ticker_b="PEP")
print(pair.summary())
print(pair.signal().summary())   # long_spread / short_spread / flat, sized by z-score

Frames statistical arbitrage as spread mean-reversion, not a new estimation problem: compute a hedge ratio via OLS, form the spread, and fit this library's own fit(spread, model="auto") to it -- OU winning AIC selection over GBM by the same margin used everywhere else in this library IS the mean-reversion test. Requires statsmodels (optional -- pip install -e ".[pairs]") for a proper Engle-Granger cointegration test, since Dickey-Fuller critical values are a well-trodden solved problem, not something worth hand-rolling the way this library's own SDE fitting is.

Discovered while building this and worth knowing before trusting a fit: OU winning AIC selection and Engle-Granger confirming cointegration can disagree. A theta that looks fast annualized can still imply a daily AR(1) coefficient very close to 1 (unit-root tests have low power that close to 1 even with hundreds of observations), so signal() requires both by default (require_cointegration=True) rather than trading on the OU fit alone — verified in test_pairs.py against two independent GBMs where AIC alone spuriously preferred OU but Engle-Granger correctly blocked the trade.

Dashboard

pip install -e ".[dashboard]"
streamlit run dashboard.py

A seven-tab Streamlit UI over the modules above (Forecast & Signal, Option Pricing, Dynamic Hedging, Portfolio Hedging, Pairs Trading, Portfolio Risk, Regime), calling neural_sde directly rather than through either of the project's MCP servers — a standing verification harness as much as a demo: every panel drives real ticker data through the actual library code, the same check that caught real bugs in both MCP servers before this dashboard existed. Model choice (auto/gbm/ou/neural) is a visible sidebar control rather than hidden, since which one you're looking at changes what the numbers mean (see Neural path). The Regime tab's classification is shared with the Forecast & Signal tab, so toggling "Scale position size by volatility regime" there sizes the signal using the same regime shown in the Regime tab, not a separate computation.

What's inside

Module Purpose
highlevel.py Public API: fit/forecast/FittedModel/Forecast/Signal/signal — start here
pricing.py Risk-neutral option pricing — see Beyond forecasting
hedge.py Simulated dynamic delta-hedging — see Beyond forecasting
portfolio_hedge.py Multi-asset correlated dynamic hedging — see Beyond forecasting
risk.py Correlated portfolio VaR/CVaR — see Beyond forecasting
regime.py Rolling volatility regime detection — see Beyond forecasting
pairs.py Statistical arbitrage / pairs trading — see Beyond forecasting, requires statsmodels
dashboard.py Streamlit UI over the modules above — see Dashboard
sde_core.py Core SDE theory: drift/diffusion, Ito<->Stratonovich conversion
solvers.py / torch_solvers.py Euler-Maruyama, Milstein, Heun solvers (numpy and torch backends)
multi_asset.py Correlated multi-asset GBM/OU/CIR diffusion
neural_networks.py / trainer.py Neural drift/diffusion networks and training loop — see Neural path, requires torch
likelihood.py Score-matching / contrastive-divergence loss functions
forecasting.py NeuralSDEForecaster (used internally by fit(model="neural")); its OptionPricer predates the normalization fixes in trainer.py and isn't wired up to them — use pricing.py instead
api.py Lower-level unified solver entry point

Demo

pip install -e ".[demo]"
python scripts/demo_forecast.py

With yfinance installed and network access, edit the top of the script to pull real data:

import yfinance as yf
from neural_sde.highlevel import fit, forecast

prices = yf.download("AAPL", period="2y")["Close"]
model = fit(prices)
fc = forecast(model, horizon=30)
print(fc.summary())
fc.plot("forecast.png")

Offline, the script instead demos against synthetic data with known dynamics, which doubles as a calibration test — it simulates GBM and OU paths with known parameters, fits them back, and checks:

  1. fit() recovers the true GBM parameters and AIC selects GBM.
  2. fit() recovers the true OU parameters and AIC selects OU.
  3. Monte Carlo quantiles agree with the exact lognormal distribution to <1%.
  4. The exact and euler (core-library) engines agree on the median to <3%.

forecast.png and forecast_example.png in this directory are pre-generated sample outputs from that script — regenerate them yourself with python scripts/demo_forecast.py.

Testing

pip install -e ".[dev]"
pytest

tests/test_highlevel.py (the core fit/forecast API -- GBM/OU parameter recovery against known synthetic data, Forecast's distribution cross-checked against the exact closed-form lognormal for GBM), tests/test_signal.py, tests/test_pricing.py, tests/test_hedge.py, tests/test_portfolio_hedge.py, tests/test_risk.py, tests/test_regime.py, and tests/test_pairs.py (self-verifying checks for the Beyond forecasting modules) are script-style rather than pytest-discoverable — tests/conftest.py explicitly excludes them from collection, since each calls sys.exit() at true module level by design (so python tests/test_pricing.py exits non-zero on failure for shell/CI use) — run each directly: python tests/test_pricing.py, etc.

archive/ holds superseded tests kept for historical reference (see archive/README.md); they aren't part of the suite.

Changelog

See CHANGELOG.md for the version history.

License

MIT.

Credit

Built by Kevin Downie in collaboration with Claude (Anthropic).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

neural_sde-1.0.0.tar.gz (304.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

neural_sde-1.0.0-py3-none-any.whl (268.7 kB view details)

Uploaded Python 3

File details

Details for the file neural_sde-1.0.0.tar.gz.

File metadata

  • Download URL: neural_sde-1.0.0.tar.gz
  • Upload date:
  • Size: 304.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for neural_sde-1.0.0.tar.gz
Algorithm Hash digest
SHA256 aca04714269ba22143f347958adc01a7cbbfed02537bc6027de00144cd9e4529
MD5 9140b09b4abe9d7f882c9f6cd39d403a
BLAKE2b-256 503bf1b188a209f31bdaf573c53b3d318f6d2d29087d81c9bf8c4995d7483b64

See more details on using hashes here.

File details

Details for the file neural_sde-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: neural_sde-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 268.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for neural_sde-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 31d3ffdafe62c5a26098a8593c4317d0786c79c4857382f5280bac2be53576f8
MD5 5513889f6a5dc4a4638f1e2149efedd2
BLAKE2b-256 bfba41c2deee81ac041f6dd621c726fce536132800580e752196e037c2167be5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page