Metadata-Version: 2.4
Name: neural-sde
Version: 1.0.0
Summary: Probabilistic price forecasting via stochastic differential equations (GBM/OU/neural), with AIC model selection, Monte Carlo quantiles, and fan charts.
Author: Kevin Downie
License: MIT
Project-URL: Repository, https://github.com/kdownie/neural-sde
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Topic :: Scientific/Engineering :: Mathematics
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy<3,>=1.24
Requires-Dist: scipy<2,>=1.10
Requires-Dist: matplotlib<4,>=3.7
Provides-Extra: torch
Requires-Dist: torch<3,>=2.0; extra == "torch"
Provides-Extra: pandas
Requires-Dist: pandas<4,>=2.0; extra == "pandas"
Provides-Extra: demo
Requires-Dist: yfinance<2,>=1.0; extra == "demo"
Provides-Extra: dashboard
Requires-Dist: streamlit<2,>=1.30; extra == "dashboard"
Requires-Dist: yfinance<2,>=1.0; extra == "dashboard"
Requires-Dist: pandas<4,>=2.0; extra == "dashboard"
Requires-Dist: statsmodels<1,>=0.14; extra == "dashboard"
Provides-Extra: pairs
Requires-Dist: statsmodels<1,>=0.14; extra == "pairs"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# 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

```bash
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](requirements.txt) /
[pyproject.toml](pyproject.toml) for pinned dependency ranges.

## Quickstart

```python
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](#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](#neural-path) above) rather than
requiring drift accuracy the library can't currently deliver.

### Signals

```python
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)`):

```python
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

```python
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

```python
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

```python
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

```python
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](#neural-path)); rejects `model="neural"`
with a clear error rather than silently using an unreliable one.

### Regime detection

```python
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](#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

```python
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

```bash
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](#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`](highlevel.py) | Public API: `fit`/`forecast`/`FittedModel`/`Forecast`/`Signal`/`signal` — start here |
| [`pricing.py`](pricing.py) | Risk-neutral option pricing — see [Beyond forecasting](#beyond-forecasting) |
| [`hedge.py`](hedge.py) | Simulated dynamic delta-hedging — see [Beyond forecasting](#beyond-forecasting) |
| [`portfolio_hedge.py`](portfolio_hedge.py) | Multi-asset correlated dynamic hedging — see [Beyond forecasting](#beyond-forecasting) |
| [`risk.py`](risk.py) | Correlated portfolio VaR/CVaR — see [Beyond forecasting](#beyond-forecasting) |
| [`regime.py`](regime.py) | Rolling volatility regime detection — see [Beyond forecasting](#beyond-forecasting) |
| [`pairs.py`](pairs.py) | Statistical arbitrage / pairs trading — see [Beyond forecasting](#beyond-forecasting), requires `statsmodels` |
| [`dashboard.py`](dashboard.py) | Streamlit UI over the modules above — see [Dashboard](#dashboard) |
| [`sde_core.py`](sde_core.py) | Core SDE theory: drift/diffusion, Ito<->Stratonovich conversion |
| [`solvers.py`](solvers.py) / [`torch_solvers.py`](torch_solvers.py) | Euler-Maruyama, Milstein, Heun solvers (numpy and torch backends) |
| [`multi_asset.py`](multi_asset.py) | Correlated multi-asset GBM/OU/CIR diffusion |
| [`neural_networks.py`](neural_networks.py) / [`trainer.py`](trainer.py) | Neural drift/diffusion networks and training loop — see [Neural path](#neural-path), requires torch |
| [`likelihood.py`](likelihood.py) | Score-matching / contrastive-divergence loss functions |
| [`forecasting.py`](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`](api.py) | Lower-level unified solver entry point |

## Demo

```bash
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:

```python
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

```bash
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](#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](archive/README.md)); they aren't part of the suite.

## Changelog

See [CHANGELOG.md](CHANGELOG.md) for the version history.

## License

[MIT](LICENSE).

## Credit

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