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:
fit()recovers the true GBM parameters and AIC selects GBM.fit()recovers the true OU parameters and AIC selects OU.- Monte Carlo quantiles agree with the exact lognormal distribution to <1%.
- The
exactandeuler(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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aca04714269ba22143f347958adc01a7cbbfed02537bc6027de00144cd9e4529
|
|
| MD5 |
9140b09b4abe9d7f882c9f6cd39d403a
|
|
| BLAKE2b-256 |
503bf1b188a209f31bdaf573c53b3d318f6d2d29087d81c9bf8c4995d7483b64
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31d3ffdafe62c5a26098a8593c4317d0786c79c4857382f5280bac2be53576f8
|
|
| MD5 |
5513889f6a5dc4a4638f1e2149efedd2
|
|
| BLAKE2b-256 |
bfba41c2deee81ac041f6dd621c726fce536132800580e752196e037c2167be5
|