pathforge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pathforge-0.1.0/PKG-INFO +3 -0
- pathforge-0.1.0/README.md +101 -0
- pathforge-0.1.0/pathforge/__init__.py +6 -0
- pathforge-0.1.0/pathforge/forge.py +97 -0
- pathforge-0.1.0/pathforge/models/__init__.py +6 -0
- pathforge-0.1.0/pathforge/models/base.py +18 -0
- pathforge-0.1.0/pathforge/models/bootstrap.py +58 -0
- pathforge-0.1.0/pathforge/models/garch.py +49 -0
- pathforge-0.1.0/pathforge/models/gbm.py +26 -0
- pathforge-0.1.0/pathforge/models/jump_diffusion.py +62 -0
- pathforge-0.1.0/pathforge/result.py +96 -0
- pathforge-0.1.0/pathforge.egg-info/PKG-INFO +3 -0
- pathforge-0.1.0/pathforge.egg-info/SOURCES.txt +16 -0
- pathforge-0.1.0/pathforge.egg-info/dependency_links.txt +1 -0
- pathforge-0.1.0/pathforge.egg-info/top_level.txt +1 -0
- pathforge-0.1.0/pyproject.toml +11 -0
- pathforge-0.1.0/setup.cfg +4 -0
- pathforge-0.1.0/tests/test_pathforge.py +77 -0
pathforge-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# 🔥 pathforge
|
|
2
|
+
|
|
3
|
+
> Simulate realistic financial markets from historical price data — for strategy testing, research, and risk analysis.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/pathforge/)
|
|
6
|
+
[](https://github.com/franmanz/pathforge/blob/main/LICENSE)
|
|
7
|
+
[](https://www.python.org/)
|
|
8
|
+
|
|
9
|
+
## Why pathforge?
|
|
10
|
+
|
|
11
|
+
Testing a trading strategy on a single historical price series tells you how it performed on **one specific path** the market happened to take. That's not enough. A robust strategy should work across the full range of outcomes the market could have produced.
|
|
12
|
+
|
|
13
|
+
`pathforge` learns the statistical behaviour of any asset from its historical prices and generates hundreds of realistic alternative price paths. Test your strategy across all of them and you'll know how robust it really is.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
```bash
|
|
17
|
+
pip install pathforge
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
To use the built-in plot functionality:
|
|
21
|
+
```bash
|
|
22
|
+
pip install pathforge[examples]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
```python
|
|
27
|
+
import pathforge as pf
|
|
28
|
+
import yfinance as yf
|
|
29
|
+
|
|
30
|
+
# Download historical price data
|
|
31
|
+
ticker = yf.Ticker("AAPL")
|
|
32
|
+
prices = ticker.history(period="5y")["Close"]
|
|
33
|
+
|
|
34
|
+
# Create a forge and fit a model
|
|
35
|
+
forge = pf.PathForge(prices)
|
|
36
|
+
forge.fit(model="garch")
|
|
37
|
+
|
|
38
|
+
# Simulate one year of trading days across 100 paths
|
|
39
|
+
sim = forge.simulate(days=252, n_paths=100, seed=42)
|
|
40
|
+
|
|
41
|
+
# Explore the results
|
|
42
|
+
sim.summary()
|
|
43
|
+
sim.plot()
|
|
44
|
+
|
|
45
|
+
# Get the paths as a DataFrame for your own analysis
|
|
46
|
+
df = sim.to_dataframe() # shape: (253, 100)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Models
|
|
50
|
+
|
|
51
|
+
| Model | `model=` | Best for |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| Geometric Brownian Motion | `"gbm"` | Fast baseline, simple assumptions |
|
|
54
|
+
| GARCH(1,1) | `"garch"` | Realistic volatility clustering |
|
|
55
|
+
| Block Bootstrap | `"bootstrap"` | Non-parametric, no distributional assumptions |
|
|
56
|
+
| Merton Jump Diffusion | `"jump_diffusion"` | Capturing sudden crashes and spikes |
|
|
57
|
+
|
|
58
|
+
### Which model should I use?
|
|
59
|
+
|
|
60
|
+
- **GBM** — good sanity check, fast, but underestimates tail risk
|
|
61
|
+
- **GARCH** — best for most use cases, captures the volatility clustering seen in real markets
|
|
62
|
+
- **Bootstrap** — most honest for strategy testing, resamples real historical behaviour directly
|
|
63
|
+
- **Jump Diffusion** — best when your data contains sudden large moves you want to preserve
|
|
64
|
+
|
|
65
|
+
## API Reference
|
|
66
|
+
|
|
67
|
+
### `PathForge(data)`
|
|
68
|
+
|
|
69
|
+
The main class. Pass a `pd.Series` or `pd.DataFrame` of daily closing prices.
|
|
70
|
+
|
|
71
|
+
| Method | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `.fit(model="garch")` | Fit a simulation model to the historical data |
|
|
74
|
+
| `.simulate(days=252, n_paths=100, start_price=None, seed=None)` | Generate simulated price paths |
|
|
75
|
+
|
|
76
|
+
### `SimulationResult`
|
|
77
|
+
|
|
78
|
+
Returned by `.simulate()`.
|
|
79
|
+
|
|
80
|
+
| Attribute / Method | Description |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `.paths` | `np.ndarray` of shape `(days+1, n_paths)` |
|
|
83
|
+
| `.to_dataframe()` | Paths as a `pd.DataFrame`, one column per path |
|
|
84
|
+
| `.summary()` | Print statistical summary of the simulation |
|
|
85
|
+
| `.plot(max_paths=50)` | Plot simulated paths with historical context |
|
|
86
|
+
|
|
87
|
+
## Roadmap
|
|
88
|
+
|
|
89
|
+
- [ ] Poisson jump diffusion ✅
|
|
90
|
+
- [ ] Intraday timeframes (1m, 5m, 15m, 1h)
|
|
91
|
+
- [ ] Multi-asset correlated simulation
|
|
92
|
+
- [ ] Regime switching model
|
|
93
|
+
- [ ] CLI: `pathforge simulate AAPL --days 252 --paths 500`
|
|
94
|
+
|
|
95
|
+
## Contributing
|
|
96
|
+
|
|
97
|
+
PRs and issues welcome at [github.com/franmanz/pathforge](https://github.com/franmanz/pathforge).
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT © 2026 franmanz
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from pathforge.result import SimulationResult
|
|
4
|
+
|
|
5
|
+
## MAIN CLASS
|
|
6
|
+
class PathForge:
|
|
7
|
+
"""
|
|
8
|
+
Fits a statistical model to historical price data and generates
|
|
9
|
+
simulated future price paths.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, data):
|
|
12
|
+
self._prices = self._extract_prices(data)
|
|
13
|
+
self._returns = self._prices.pct_change().dropna()
|
|
14
|
+
self._model = None
|
|
15
|
+
self._fitted = False
|
|
16
|
+
|
|
17
|
+
def _extract_prices(self, data):
|
|
18
|
+
if isinstance(data, pd.Series):
|
|
19
|
+
return data.dropna()
|
|
20
|
+
if isinstance(data, pd.DataFrame):
|
|
21
|
+
return data.iloc[:, 0].dropna()
|
|
22
|
+
raise TypeError("data must be a pandas Series or DataFrame")
|
|
23
|
+
|
|
24
|
+
def fit(self, model="garch"):
|
|
25
|
+
"""
|
|
26
|
+
Fit a simulation model to the historical price data.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
model : str
|
|
31
|
+
"gbm", "garch", or "bootstrap"
|
|
32
|
+
"""
|
|
33
|
+
if model == "gbm":
|
|
34
|
+
from pathforge.models.gbm import GBMModel
|
|
35
|
+
self._model = GBMModel(self._returns)
|
|
36
|
+
elif model == "garch":
|
|
37
|
+
from pathforge.models.garch import GARCHModel
|
|
38
|
+
self._model = GARCHModel(self._returns)
|
|
39
|
+
elif model == "bootstrap":
|
|
40
|
+
from pathforge.models.bootstrap import BlockBootstrapModel
|
|
41
|
+
self._model = BlockBootstrapModel(self._returns)
|
|
42
|
+
elif model == "jump_diffusion":
|
|
43
|
+
from pathforge.models.jump_diffusion import JumpDiffusionModel
|
|
44
|
+
self._model = JumpDiffusionModel(self._returns)
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError(f"Unknown model '{model}'. Choose from: gbm, garch, bootstrap")
|
|
47
|
+
|
|
48
|
+
self._model.fit()
|
|
49
|
+
self._fitted = True
|
|
50
|
+
return self #allows forge.fit("garch").simulate(...) in one line etc.
|
|
51
|
+
|
|
52
|
+
#Simulation method
|
|
53
|
+
def simulate(self, days=252, n_paths=100, start_price=None, seed=None):
|
|
54
|
+
"""
|
|
55
|
+
Generate simulated price paths.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
days : int
|
|
60
|
+
Number of trading days to simulate. 252 is one trading year.
|
|
61
|
+
n_paths : int
|
|
62
|
+
Number of independent paths to generate.
|
|
63
|
+
start_price : float, optional
|
|
64
|
+
Starting price. Defaults to the last observed price.
|
|
65
|
+
seed : int, optional
|
|
66
|
+
Random seed for reproducibility.
|
|
67
|
+
"""
|
|
68
|
+
if not self._fitted:
|
|
69
|
+
raise RuntimeError("Call .fit() before .simulate()")
|
|
70
|
+
|
|
71
|
+
if seed is not None:
|
|
72
|
+
np.random.seed(seed)
|
|
73
|
+
|
|
74
|
+
if start_price is None:
|
|
75
|
+
start_price = float(self._prices.iloc[-1])
|
|
76
|
+
|
|
77
|
+
returns_matrix = self._model.sample(days=days, n_paths=n_paths)
|
|
78
|
+
price_paths = self._build_price_paths(returns_matrix, start_price)
|
|
79
|
+
|
|
80
|
+
return SimulationResult(price_paths, historical_prices=self._prices, model_name=self._model.__class__.__name__)
|
|
81
|
+
|
|
82
|
+
#Build price paths is reverse of pct_change()
|
|
83
|
+
def _build_price_paths(self, returns_matrix, start_price):
|
|
84
|
+
"""Convert a matrix of returns into price paths."""
|
|
85
|
+
n_days, n_paths = returns_matrix.shape
|
|
86
|
+
price_paths = np.empty((n_days + 1, n_paths))
|
|
87
|
+
price_paths[0] = start_price
|
|
88
|
+
for t in range(n_days):
|
|
89
|
+
price_paths[t + 1] = price_paths[t] * (1 + returns_matrix[t])
|
|
90
|
+
return price_paths
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from pathforge.models.gbm import GBMModel
|
|
2
|
+
from pathforge.models.garch import GARCHModel
|
|
3
|
+
from pathforge.models.bootstrap import BlockBootstrapModel
|
|
4
|
+
from pathforge.models.jump_diffusion import JumpDiffusionModel
|
|
5
|
+
|
|
6
|
+
__all__ = ["GBMModel", "GARCHModel", "BlockBootstrapModel", "JumpDiffusionModel"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseModel:
|
|
6
|
+
"""Base class for all PathForge simulation models."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, returns):
|
|
9
|
+
self.returns = returns.values.astype(float)
|
|
10
|
+
self.params_ = {}
|
|
11
|
+
|
|
12
|
+
def fit(self):
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
def sample(self, days, n_paths):
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pathforge.models.base import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BlockBootstrapModel(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Block Bootstrap model.
|
|
8
|
+
|
|
9
|
+
Resamples contiguous blocks of historical returns to generate
|
|
10
|
+
simulated paths. Makes no distributional assumptions — the
|
|
11
|
+
simulated paths are built entirely from real historical data.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, returns, block_size=None):
|
|
15
|
+
super().__init__(returns)
|
|
16
|
+
self._block_size_override = block_size
|
|
17
|
+
|
|
18
|
+
def fit(self):
|
|
19
|
+
if self._block_size_override is not None:
|
|
20
|
+
block_size = self._block_size_override
|
|
21
|
+
else:
|
|
22
|
+
block_size = self._estimate_block_size()
|
|
23
|
+
|
|
24
|
+
self.params_["block_size"] = block_size
|
|
25
|
+
|
|
26
|
+
def sample(self, days, n_paths):
|
|
27
|
+
block_size = self.params_["block_size"]
|
|
28
|
+
n = len(self.returns)
|
|
29
|
+
simulated = np.empty((days, n_paths))
|
|
30
|
+
|
|
31
|
+
for path in range(n_paths):
|
|
32
|
+
path_returns = []
|
|
33
|
+
while len(path_returns) < days:
|
|
34
|
+
start = np.random.randint(0, n - block_size + 1)
|
|
35
|
+
block = self.returns[start: start + block_size]
|
|
36
|
+
remaining = days - len(path_returns)
|
|
37
|
+
path_returns.extend(block[:remaining].tolist())
|
|
38
|
+
simulated[:, path] = path_returns
|
|
39
|
+
|
|
40
|
+
return simulated
|
|
41
|
+
|
|
42
|
+
def _estimate_block_size(self):
|
|
43
|
+
"""Estimate block size from the autocorrelation of squared returns."""
|
|
44
|
+
sq_returns = self.returns ** 2
|
|
45
|
+
n = len(sq_returns)
|
|
46
|
+
max_lag = min(50, n // 5)
|
|
47
|
+
threshold = 1.96 / np.sqrt(n)
|
|
48
|
+
|
|
49
|
+
acf = np.array([
|
|
50
|
+
np.corrcoef(sq_returns[:-lag], sq_returns[lag:])[0, 1]
|
|
51
|
+
for lag in range(1, max_lag + 1)
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
significant_lags = np.where(np.abs(acf) > threshold)[0]
|
|
55
|
+
|
|
56
|
+
if len(significant_lags) == 0:
|
|
57
|
+
return 10
|
|
58
|
+
return max(10, int(significant_lags[-1]) + 1)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import warnings
|
|
3
|
+
from pathforge.models.base import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GARCHModel(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
GARCH(1,1) model.
|
|
9
|
+
|
|
10
|
+
Captures volatility clustering — the tendency for large price
|
|
11
|
+
moves to be followed by more large moves.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def fit(self):
|
|
15
|
+
try:
|
|
16
|
+
from arch import arch_model
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise ImportError("Install arch to use GARCH: pip install arch")
|
|
19
|
+
|
|
20
|
+
pct_returns = self.returns * 100
|
|
21
|
+
|
|
22
|
+
am = arch_model(pct_returns, mean="Constant", vol="GARCH", p=1, q=1)
|
|
23
|
+
with warnings.catch_warnings():
|
|
24
|
+
warnings.simplefilter("ignore")
|
|
25
|
+
result = am.fit(disp="off")
|
|
26
|
+
|
|
27
|
+
params = result.params
|
|
28
|
+
self.params_["mu"] = float(params["mu"]) / 100
|
|
29
|
+
self.params_["omega"] = float(params["omega"]) / 10000
|
|
30
|
+
self.params_["alpha"] = float(params["alpha[1]"])
|
|
31
|
+
self.params_["beta"] = float(params["beta[1]"])
|
|
32
|
+
|
|
33
|
+
def sample(self, days, n_paths):
|
|
34
|
+
mu = self.params_["mu"]
|
|
35
|
+
omega = self.params_["omega"]
|
|
36
|
+
alpha = self.params_["alpha"]
|
|
37
|
+
beta = self.params_["beta"]
|
|
38
|
+
|
|
39
|
+
hist_var = np.var(self.returns)
|
|
40
|
+
simulated = np.empty((days, n_paths))
|
|
41
|
+
|
|
42
|
+
for path in range(n_paths):
|
|
43
|
+
sigma2 = hist_var
|
|
44
|
+
for t in range(days):
|
|
45
|
+
eps = np.random.normal(0, np.sqrt(sigma2))
|
|
46
|
+
simulated[t, path] = mu + eps
|
|
47
|
+
sigma2 = omega + alpha * eps**2 + beta * sigma2
|
|
48
|
+
|
|
49
|
+
return simulated
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pathforge.models.base import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GBMModel(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Geometric Brownian Motion model.
|
|
8
|
+
|
|
9
|
+
Fits a normal distribution to historical log-returns,
|
|
10
|
+
then simulates new returns using that distribution.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def fit(self):
|
|
14
|
+
log_returns = np.log1p(self.returns)
|
|
15
|
+
self.params_["mu"] = float(log_returns.mean())
|
|
16
|
+
self.params_["sigma"] = float(log_returns.std())
|
|
17
|
+
|
|
18
|
+
def sample(self, days, n_paths):
|
|
19
|
+
mu = self.params_["mu"]
|
|
20
|
+
sigma = self.params_["sigma"]
|
|
21
|
+
|
|
22
|
+
Z = np.random.standard_normal((days, n_paths))
|
|
23
|
+
log_returns = (mu - 0.5 * sigma**2) + sigma * Z
|
|
24
|
+
return np.expm1(log_returns)
|
|
25
|
+
|
|
26
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pathforge.models.base import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class JumpDiffusionModel(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Merton Jump Diffusion model.
|
|
8
|
+
|
|
9
|
+
Extends GBM by adding a Poisson jump component to capture
|
|
10
|
+
sudden large price moves such as crashes or earnings surprises.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
returns : pd.Series
|
|
15
|
+
Daily simple returns.
|
|
16
|
+
jump_threshold : float
|
|
17
|
+
Number of standard deviations beyond which a historical
|
|
18
|
+
return is classified as a jump. Default is 3.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, returns, jump_threshold=3.0):
|
|
22
|
+
super().__init__(returns)
|
|
23
|
+
self.jump_threshold = jump_threshold
|
|
24
|
+
|
|
25
|
+
def fit(self):
|
|
26
|
+
# Step 1 — fit the GBM component on normal days
|
|
27
|
+
std = self.returns.std()
|
|
28
|
+
jump_mask = np.abs(self.returns) > self.jump_threshold * std
|
|
29
|
+
|
|
30
|
+
normal_returns = self.returns[~jump_mask]
|
|
31
|
+
jump_returns = self.returns[jump_mask]
|
|
32
|
+
|
|
33
|
+
log_normal = np.log1p(normal_returns)
|
|
34
|
+
self.params_["mu"] = float(log_normal.mean())
|
|
35
|
+
self.params_["sigma"] = float(log_normal.std())
|
|
36
|
+
|
|
37
|
+
# Step 2 — fit the jump component
|
|
38
|
+
n_days = len(self.returns)
|
|
39
|
+
self.params_["lambda"] = float(len(jump_returns) / n_days)
|
|
40
|
+
self.params_["mu_j"] = float(jump_returns.mean()) if len(jump_returns) > 0 else 0.0
|
|
41
|
+
self.params_["sigma_j"] = float(jump_returns.std()) if len(jump_returns) > 1 else 0.01
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sample(self, days, n_paths):
|
|
45
|
+
mu = self.params_["mu"]
|
|
46
|
+
sigma = self.params_["sigma"]
|
|
47
|
+
lam = self.params_["lambda"]
|
|
48
|
+
mu_j = self.params_["mu_j"]
|
|
49
|
+
sigma_j = self.params_["sigma_j"]
|
|
50
|
+
|
|
51
|
+
# GBM component
|
|
52
|
+
Z = np.random.standard_normal((days, n_paths))
|
|
53
|
+
log_returns = (mu - 0.5 * sigma**2) + sigma * Z
|
|
54
|
+
|
|
55
|
+
# Jump component
|
|
56
|
+
jumps_occur = np.random.poisson(lam, (days, n_paths))
|
|
57
|
+
jump_sizes = np.random.normal(mu_j, sigma_j, (days, n_paths))
|
|
58
|
+
jump_component = jumps_occur * jump_sizes
|
|
59
|
+
|
|
60
|
+
return np.expm1(log_returns + jump_component)
|
|
61
|
+
|
|
62
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SimulationResult:
|
|
6
|
+
"""
|
|
7
|
+
The output of a PathForge simulation.
|
|
8
|
+
|
|
9
|
+
Attributes
|
|
10
|
+
----------
|
|
11
|
+
paths : np.ndarray, shape (days+1, n_paths)
|
|
12
|
+
Simulated price paths. Row 0 is the starting price.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, paths, historical_prices=None, model_name=""):
|
|
16
|
+
self.paths = paths
|
|
17
|
+
self.historical_prices = historical_prices
|
|
18
|
+
self.model_name = model_name
|
|
19
|
+
|
|
20
|
+
def to_dataframe(self):
|
|
21
|
+
"""Return simulated paths as a pandas DataFrame."""
|
|
22
|
+
n_paths = self.paths.shape[1]
|
|
23
|
+
columns = [f"path_{i}" for i in range(n_paths)]
|
|
24
|
+
return pd.DataFrame(self.paths, columns=columns)
|
|
25
|
+
|
|
26
|
+
def summary(self):
|
|
27
|
+
"""Print a statistical summary of the simulated paths."""
|
|
28
|
+
start = self.paths[0, 0]
|
|
29
|
+
final = self.paths[-1, :]
|
|
30
|
+
returns = (final - start) / start
|
|
31
|
+
|
|
32
|
+
print(f"Paths : {self.paths.shape[1]}")
|
|
33
|
+
print(f"Days : {self.paths.shape[0] - 1}")
|
|
34
|
+
print(f"Start price : {start:.4f}")
|
|
35
|
+
print(f"")
|
|
36
|
+
print(f"Final price distribution:")
|
|
37
|
+
print(f" Mean : {final.mean():.4f}")
|
|
38
|
+
print(f" Median : {np.median(final):.4f}")
|
|
39
|
+
print(f" Std : {final.std():.4f}")
|
|
40
|
+
print(f" 5th pct : {np.percentile(final, 5):.4f}")
|
|
41
|
+
print(f" 95th pct : {np.percentile(final, 95):.4f}")
|
|
42
|
+
print(f"")
|
|
43
|
+
print(f"Return distribution:")
|
|
44
|
+
print(f" Mean : {returns.mean():.2%}")
|
|
45
|
+
print(f" Median : {np.median(returns):.2%}")
|
|
46
|
+
print(f" 5th pct : {np.percentile(returns, 5):.2%}")
|
|
47
|
+
print(f" 95th pct : {np.percentile(returns, 95):.2%}")
|
|
48
|
+
|
|
49
|
+
def plot(self, max_paths=50):
|
|
50
|
+
"""
|
|
51
|
+
Plot simulated price paths.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
max_paths : int
|
|
56
|
+
Maximum number of paths to draw. Defaults to 50.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
import matplotlib.pyplot as plt
|
|
60
|
+
except ImportError:
|
|
61
|
+
raise ImportError("Install matplotlib to use .plot(): pip install matplotlib")
|
|
62
|
+
|
|
63
|
+
n = min(max_paths, self.paths.shape[1])
|
|
64
|
+
fig, ax = plt.subplots(figsize=(12, 6))
|
|
65
|
+
|
|
66
|
+
for i in range(n):
|
|
67
|
+
ax.plot(self.paths[:, i], alpha=0.3, lw=0.8, color="steelblue")
|
|
68
|
+
|
|
69
|
+
median_path = np.median(self.paths, axis=1)
|
|
70
|
+
ax.plot(median_path, color="darkblue", lw=2, label="Median path")
|
|
71
|
+
|
|
72
|
+
p5 = np.percentile(self.paths, 5, axis=1)
|
|
73
|
+
p95 = np.percentile(self.paths, 95, axis=1)
|
|
74
|
+
ax.fill_between(range(len(p5)), p5, p95, alpha=0.15, color="steelblue", label="5–95th percentile")
|
|
75
|
+
|
|
76
|
+
if self.historical_prices is not None:
|
|
77
|
+
hist = self.historical_prices[-126:]
|
|
78
|
+
hist_normalised = hist / hist.iloc[-1] * self.paths[0, 0]
|
|
79
|
+
ax.plot(
|
|
80
|
+
range(-len(hist), 0),
|
|
81
|
+
hist_normalised.values,
|
|
82
|
+
color="red",
|
|
83
|
+
lw=1.5,
|
|
84
|
+
label="Historical",
|
|
85
|
+
zorder=5
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ax.set_title(f"PathForge Simulation — {self.model_name} ({self.paths.shape[1]} paths)")
|
|
89
|
+
ax.set_xlabel("Days")
|
|
90
|
+
ax.set_ylabel("Price")
|
|
91
|
+
ax.grid()
|
|
92
|
+
ax.legend()
|
|
93
|
+
plt.tight_layout()
|
|
94
|
+
plt.show()
|
|
95
|
+
|
|
96
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pathforge/__init__.py
|
|
4
|
+
pathforge/forge.py
|
|
5
|
+
pathforge/result.py
|
|
6
|
+
pathforge.egg-info/PKG-INFO
|
|
7
|
+
pathforge.egg-info/SOURCES.txt
|
|
8
|
+
pathforge.egg-info/dependency_links.txt
|
|
9
|
+
pathforge.egg-info/top_level.txt
|
|
10
|
+
pathforge/models/__init__.py
|
|
11
|
+
pathforge/models/base.py
|
|
12
|
+
pathforge/models/bootstrap.py
|
|
13
|
+
pathforge/models/garch.py
|
|
14
|
+
pathforge/models/gbm.py
|
|
15
|
+
pathforge/models/jump_diffusion.py
|
|
16
|
+
tests/test_pathforge.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pathforge
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
import pathforge as pf
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def price_series():
|
|
9
|
+
np.random.seed(0)
|
|
10
|
+
returns = np.random.normal(0.0005, 0.015, 500)
|
|
11
|
+
prices = 100 * np.cumprod(1 + returns)
|
|
12
|
+
dates = pd.date_range("2020-01-01", periods=500, freq="B")
|
|
13
|
+
return pd.Series(prices, index=dates)
|
|
14
|
+
|
|
15
|
+
def test_accepts_series(price_series):
|
|
16
|
+
forge = pf.PathForge(price_series)
|
|
17
|
+
assert len(forge._prices) == 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_accepts_dataframe(price_series):
|
|
21
|
+
df = pd.DataFrame({"close": price_series, "other": price_series * 1.1})
|
|
22
|
+
forge = pf.PathForge(df)
|
|
23
|
+
assert len(forge._prices) == 500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_simulate_before_fit_raises(price_series):
|
|
27
|
+
forge = pf.PathForge(price_series)
|
|
28
|
+
with pytest.raises(RuntimeError):
|
|
29
|
+
forge.simulate()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_invalid_model_raises(price_series):
|
|
33
|
+
forge = pf.PathForge(price_series)
|
|
34
|
+
with pytest.raises(ValueError):
|
|
35
|
+
forge.fit(model="nonexistent")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("model", ["gbm", "garch", "bootstrap", "jump_diffusion"])
|
|
39
|
+
def test_output_shape(price_series, model):
|
|
40
|
+
forge = pf.PathForge(price_series)
|
|
41
|
+
forge.fit(model=model)
|
|
42
|
+
sim = forge.simulate(days=252, n_paths=10, seed=42)
|
|
43
|
+
assert sim.paths.shape == (253, 10)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("model", ["gbm", "garch", "bootstrap", "jump_diffusion"])
|
|
47
|
+
def test_prices_always_positive(price_series, model):
|
|
48
|
+
forge = pf.PathForge(price_series)
|
|
49
|
+
forge.fit(model=model)
|
|
50
|
+
sim = forge.simulate(days=252, n_paths=10, seed=42)
|
|
51
|
+
assert np.all(sim.paths > 0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_reproducibility(price_series):
|
|
55
|
+
forge = pf.PathForge(price_series)
|
|
56
|
+
forge.fit(model="gbm")
|
|
57
|
+
sim1 = forge.simulate(days=100, n_paths=10, seed=1)
|
|
58
|
+
sim2 = forge.simulate(days=100, n_paths=10, seed=1)
|
|
59
|
+
np.testing.assert_array_equal(sim1.paths, sim2.paths)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_start_price(price_series):
|
|
63
|
+
forge = pf.PathForge(price_series)
|
|
64
|
+
forge.fit(model="gbm")
|
|
65
|
+
sim = forge.simulate(days=10, n_paths=5, start_price=500.0, seed=0)
|
|
66
|
+
assert np.all(sim.paths[0] == 500.0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_to_dataframe(price_series):
|
|
70
|
+
forge = pf.PathForge(price_series)
|
|
71
|
+
forge.fit(model="gbm")
|
|
72
|
+
sim = forge.simulate(days=50, n_paths=5, seed=0)
|
|
73
|
+
df = sim.to_dataframe()
|
|
74
|
+
assert df.shape == (51, 5)
|
|
75
|
+
assert list(df.columns) == [f"path_{i}" for i in range(5)]
|
|
76
|
+
|
|
77
|
+
|