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.
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: pathforge
3
+ Version: 0.1.0
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/pathforge.svg)](https://pypi.org/project/pathforge/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/franmanz/pathforge/blob/main/LICENSE)
7
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](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,6 @@
1
+ from pathforge.forge import PathForge
2
+ from pathforge.result import SimulationResult
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["PathForge", "SimulationResult"]
@@ -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,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: pathforge
3
+ Version: 0.1.0
@@ -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
+ pathforge
@@ -0,0 +1,11 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pathforge"
7
+ version = "0.1.0"
8
+
9
+ [tool.setuptools.packages.find]
10
+ where = ["."]
11
+ include = ["pathforge*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+