pathforge 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
pathforge/forge.py ADDED
@@ -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
+
pathforge/result.py ADDED
@@ -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,13 @@
1
+ pathforge/__init__.py,sha256=M9tLY7ChgR_bZDHHTd1JzrLNLlJYVjLAsoibu7gtRK8,156
2
+ pathforge/forge.py,sha256=_0TRjto1ywCosN0H1WRF8rEdpT4lNRWl4E35bg92vMM,3404
3
+ pathforge/result.py,sha256=g09cOA4OGj7onQx5nrr-EdUaDxVU7hEcQ7Tv50hwRaU,3382
4
+ pathforge/models/__init__.py,sha256=M5b7wJ82kLctw6oO66C-531nHFjDBIS3nS9BvsyNCks,299
5
+ pathforge/models/base.py,sha256=r9bFFHv21nGEIDifJ2Pk_XOaaZ8D3iX18YyXtV3EKqE,380
6
+ pathforge/models/bootstrap.py,sha256=-I8pvvnj9qFCS-1BG_c6cObQEldaqYfrWQkWDHb3u20,1945
7
+ pathforge/models/garch.py,sha256=ZREtSz1AF8qi6YvqP9SOYdnBQUvM-ID1-oQYuuZ2eyA,1561
8
+ pathforge/models/gbm.py,sha256=57p5LiLqe2454Df8poOX2uVkqkdbstZfUPJcc9J7Y9g,728
9
+ pathforge/models/jump_diffusion.py,sha256=38jZTRF4Ofh6fGtzduAqmUQ52nCF3G7jgiEhgyBaI7w,2111
10
+ pathforge-0.1.0.dist-info/METADATA,sha256=DMIIS0_zz-wgIDeucyk0Gakqqc1P_MJfPFVYCzZNE8Y,56
11
+ pathforge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ pathforge-0.1.0.dist-info/top_level.txt,sha256=WBWzGZygpfnpcOQxftawh_h2RpNF3NarOG6tYv62nHw,10
13
+ pathforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pathforge