pyalloq-backtest 0.1.10__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,38 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyalloq-backtest
3
+ Version: 0.1.10
4
+ Summary: Zero-lookahead historical simulation engines and metrics for PyAlloq.
5
+ Author: Siddeshkanth
6
+ Author-email: Siddeshkanth <pyalloq-info@alloq-alpha.com>
7
+ Requires-Dist: numpy>=1.24
8
+ Requires-Dist: pandas>=2.0
9
+ Requires-Dist: pyalloq-core
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pyalloq-backtest
14
+
15
+ `pyalloq-backtest` provides zero-lookahead historical simulation engines, rolling window splitters, transaction cost modeling, and quantitative performance metrics for **PyAlloq**.
16
+
17
+ ## Key Modules
18
+
19
+ - **`BacktestEngine`**: Executes walk-forward strategy evaluation with periodic rebalancing, transaction cost deduction, and turnover tracking.
20
+ - **`splitters`**: Provides time-series train/test splitters and expanding/rolling window generators for cross-validation.
21
+ - **`costs`**: Transaction cost functions (linear bps costs, fixed fee per trade, slippage models).
22
+ - **`metrics`**: Comprehensive performance metrics including Sharpe ratio, Sortino ratio, Max Drawdown, CAGR, Annualized Volatility, and Turnover.
23
+
24
+ ## Quick Example
25
+
26
+ ```python
27
+ from pyalloq_backtest.engine import BacktestEngine
28
+ from pyalloq_backtest.costs import linear_cost
29
+
30
+ # Initialize zero-lookahead backtest engine
31
+ engine = BacktestEngine(
32
+ rebalance_frequency=21, # Monthly rebalancing (21 trading days)
33
+ cost_model=linear_cost(bps=10.0) # 10 bps transaction cost
34
+ )
35
+
36
+ # Run historical simulation on MarketData and StrategyPipeline
37
+ # results = engine.run(market_data, strategy_pipeline)
38
+ ```
@@ -0,0 +1,26 @@
1
+ # pyalloq-backtest
2
+
3
+ `pyalloq-backtest` provides zero-lookahead historical simulation engines, rolling window splitters, transaction cost modeling, and quantitative performance metrics for **PyAlloq**.
4
+
5
+ ## Key Modules
6
+
7
+ - **`BacktestEngine`**: Executes walk-forward strategy evaluation with periodic rebalancing, transaction cost deduction, and turnover tracking.
8
+ - **`splitters`**: Provides time-series train/test splitters and expanding/rolling window generators for cross-validation.
9
+ - **`costs`**: Transaction cost functions (linear bps costs, fixed fee per trade, slippage models).
10
+ - **`metrics`**: Comprehensive performance metrics including Sharpe ratio, Sortino ratio, Max Drawdown, CAGR, Annualized Volatility, and Turnover.
11
+
12
+ ## Quick Example
13
+
14
+ ```python
15
+ from pyalloq_backtest.engine import BacktestEngine
16
+ from pyalloq_backtest.costs import linear_cost
17
+
18
+ # Initialize zero-lookahead backtest engine
19
+ engine = BacktestEngine(
20
+ rebalance_frequency=21, # Monthly rebalancing (21 trading days)
21
+ cost_model=linear_cost(bps=10.0) # 10 bps transaction cost
22
+ )
23
+
24
+ # Run historical simulation on MarketData and StrategyPipeline
25
+ # results = engine.run(market_data, strategy_pipeline)
26
+ ```
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10.9,<0.11.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "pyalloq-backtest"
7
+ version = "0.1.10"
8
+ description = "Zero-lookahead historical simulation engines and metrics for PyAlloq."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "numpy>=1.24",
13
+ "pandas>=2.0",
14
+ "pyalloq-core",
15
+ ]
16
+
17
+ [[project.authors]]
18
+ name = "Siddeshkanth"
19
+ email = "pyalloq-info@alloq-alpha.com"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8",
24
+ "ruff>=0.6",
25
+ "mypy>=1.11",
26
+ ]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10.9,<0.11.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "pyalloq-backtest"
7
+ version = "0.1.10"
8
+ description = "Zero-lookahead historical simulation engines and metrics for PyAlloq."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Siddeshkanth", email = "pyalloq-info@alloq-alpha.com" }]
12
+
13
+ dependencies = [
14
+ "numpy>=1.24",
15
+ "pandas>=2.0",
16
+ "pyalloq-core",
17
+ ]
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
@@ -0,0 +1,68 @@
1
+ import pandas as pd
2
+ import torch
3
+ from typing import Union, cast, Any
4
+ from abc import ABC, abstractmethod
5
+
6
+ CostData = Union[pd.Series, pd.DataFrame, torch.Tensor]
7
+
8
+
9
+ class BaseCostModel(ABC):
10
+ @abstractmethod
11
+ def calculate_costs(
12
+ self,
13
+ weights_delta: CostData,
14
+ features_slice: dict[str, CostData] | None = None,
15
+ ) -> CostData:
16
+ """Returns the cost in percentage terms for each asset."""
17
+ ...
18
+
19
+
20
+ class FlatBpsCostModel(BaseCostModel):
21
+ "Standard flat fee (Retail / Small AUM)"
22
+
23
+ def __init__(self, bps: float = 10.0) -> None:
24
+ self.cost_pct = bps / 10000.0
25
+
26
+ def calculate_costs(
27
+ self,
28
+ weights_delta: CostData,
29
+ features_slice: dict[str, CostData] | None = None,
30
+ ) -> CostData:
31
+ return weights_delta.abs() * self.cost_pct
32
+
33
+
34
+ class AlmgrenChrissCostModel(BaseCostModel):
35
+ """"""
36
+
37
+ def __init__(
38
+ self, portfolio_aum: float, spread_bps: float = 2.0, gamma: float = 0.1
39
+ ) -> None:
40
+ self.aum = portfolio_aum
41
+ self.spread = spread_bps / 10000.0
42
+ self.gamma = gamma
43
+
44
+ def calculate_costs(
45
+ self,
46
+ weights_delta: CostData,
47
+ features_slice: dict[str, CostData] | None = None,
48
+ ) -> CostData:
49
+ if features_slice is None or "volume" not in features_slice:
50
+ raise ValueError(
51
+ "Almgren-Chriss requires 'volume' in data.features['volume']."
52
+ )
53
+
54
+ delta = cast(Any, weights_delta)
55
+ daily_volume = cast(Any, features_slice["volume"])
56
+
57
+ # Non-linear market impact math based on trade size relative to daily volume
58
+ trade_dollar_size = delta.abs() * self.aum
59
+
60
+ # Safe for torch usage
61
+ safe_volume = daily_volume + 1e-8
62
+ safe_trade = trade_dollar_size + 1e-8
63
+
64
+ # Fixed Spread + Impact Cost (Gamma * sqrt(Trade Size / Volume))
65
+ impact_cost = self.gamma * (safe_trade / safe_volume).pow(0.5) # type: ignore[operator]
66
+ total_cost_pct = (self.spread + impact_cost) * weights_delta.abs() # type: ignore[operator]
67
+
68
+ return total_cost_pct
@@ -0,0 +1,71 @@
1
+ import pandas as pd
2
+ from typing import Any, cast
3
+ from pyalloq_core.pipeline import StrategyPipeline
4
+ from pyalloq_backtest.splitters import BaseWindowSplitter, RollingWindowSplitter
5
+ from pyalloq_backtest.costs import BaseCostModel, FlatBpsCostModel
6
+ from pyalloq_backtest.metrics import MetricsTearSheet
7
+ from pyalloq_core.data import MarketData
8
+ from pyalloq_backtest.costs import CostData
9
+
10
+
11
+ class WalkForwardEngine:
12
+ def __init__(
13
+ self,
14
+ pipeline: StrategyPipeline,
15
+ splitter: BaseWindowSplitter | None = None,
16
+ cost_model: BaseCostModel | None = None,
17
+ rebalance_freq: str = "ME",
18
+ ) -> None:
19
+ self.pipeline = pipeline
20
+ self.rebalance_freq = rebalance_freq
21
+
22
+ self.splitter = splitter or RollingWindowSplitter(lookback_window=252)
23
+ self.cost_model = cost_model or FlatBpsCostModel(bps=10.0)
24
+
25
+ def run(
26
+ self,
27
+ data: MarketData,
28
+ ) -> dict[str, Any]:
29
+ asset_returns = data.prices.pct_change().dropna()
30
+
31
+ raw_index = data.prices.resample(self.rebalance_freq).last().index
32
+ rebalance_dates = pd.DatetimeIndex(raw_index)
33
+
34
+ weights_history = []
35
+ for current_date, data_window in self.splitter.split(data, rebalance_dates):
36
+ weights = self.pipeline.generate_weights(data_window)
37
+ weights.name = current_date
38
+ weights_history.append(weights)
39
+
40
+ df_weights = pd.DataFrame(weights_history)
41
+ df_weights_daily = df_weights.reindex(data.prices.index).ffill().shift(1)
42
+
43
+ weight_changes = df_weights.diff().fillna(df_weights)
44
+ turnover_costs_daily = pd.Series(0.0, index=data.prices.index)
45
+
46
+ for raw_date, row in weight_changes.iterrows():
47
+ date = pd.Timestamp(cast(Any, raw_date))
48
+
49
+ if date in data.prices.index:
50
+ daily_feats: dict[str, CostData] | None = None
51
+
52
+ if data.features:
53
+ daily_feats = {}
54
+ for k, v in data.features.items():
55
+ daily_feats[k] = v.loc[date]
56
+
57
+ costs = self.cost_model.calculate_costs(row, features_slice=daily_feats)
58
+ turnover_costs_daily.loc[date] = costs.sum() # type: ignore[call-overload]
59
+
60
+ portfolio_returns = (df_weights_daily * asset_returns).sum(
61
+ axis=1
62
+ ) - turnover_costs_daily
63
+ portfolio_returns = portfolio_returns.dropna()
64
+
65
+ tear_sheet = MetricsTearSheet.generate(portfolio_returns)
66
+
67
+ return {
68
+ "returns": portfolio_returns,
69
+ "weights": df_weights_daily,
70
+ "tear_sheet": tear_sheet,
71
+ }
@@ -0,0 +1,41 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+
4
+
5
+ class MetricsTearSheet:
6
+ @staticmethod
7
+ def generate(
8
+ portfolio_returns: pd.Series, risk_free_rate: float = 0.0
9
+ ) -> pd.DataFrame:
10
+ metrics = {}
11
+
12
+ ann_factor = 252
13
+
14
+ cum_return = (1 + portfolio_returns).cumprod()
15
+ metrics["total_return"] = cum_return.iloc[-1] - 1
16
+
17
+ n_years = len(portfolio_returns) / ann_factor
18
+ metrics["annualized_return"] = (1 + metrics["total_return"]) ** (
19
+ 1 / n_years
20
+ ) - 1
21
+
22
+ metrics["annualized_volatility"] = portfolio_returns.std() * np.sqrt(ann_factor)
23
+
24
+ excess_return = metrics["annualized_return"] - risk_free_rate
25
+ metrics["sharpe_ratio"] = (
26
+ excess_return / metrics["annualized_volatility"]
27
+ if metrics["annualized_volatility"] > 0
28
+ else 0
29
+ )
30
+
31
+ rolling_max = cum_return.cummax()
32
+ drawdown = (cum_return - rolling_max) / rolling_max
33
+ metrics["maximum_drawdown"] = drawdown.min()
34
+
35
+ metrics["calmar_ratio"] = (
36
+ metrics["annualized_return"] / abs(metrics["maximum_drawdown"])
37
+ if metrics["maximum_drawdown"] != 0
38
+ else 0
39
+ )
40
+
41
+ return pd.DataFrame.from_dict(metrics, orient="index", columns=["Value"])
File without changes
@@ -0,0 +1,113 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from abc import ABC, abstractmethod
4
+ from typing import Generator
5
+ from pyalloq_core.data import MarketData
6
+
7
+
8
+ class BaseWindowSplitter(ABC):
9
+ @abstractmethod
10
+ def split(
11
+ self,
12
+ data: MarketData,
13
+ rebalance_dates: pd.DatetimeIndex,
14
+ ) -> Generator[tuple[pd.Timestamp, MarketData], None, None]:
15
+ """Yields (current date, sliced_market_data)"""
16
+ pass
17
+
18
+
19
+ class RollingWindowSplitter(BaseWindowSplitter):
20
+ def __init__(self, lookback_window: int = 252) -> None:
21
+ self.lookback_window = lookback_window
22
+
23
+ def split(
24
+ self,
25
+ data: MarketData,
26
+ rebalance_dates: pd.DatetimeIndex,
27
+ ) -> Generator[tuple[pd.Timestamp, MarketData], None, None]:
28
+ for current_date in rebalance_dates:
29
+ data_window = data.slice_time(
30
+ end_date=current_date, lookback=self.lookback_window
31
+ )
32
+
33
+ if len(data_window.prices) >= self.lookback_window:
34
+ yield current_date, data_window
35
+
36
+
37
+ class ExpandingWindowSplitter(BaseWindowSplitter):
38
+ def __init__(self, min_periods: int = 252) -> None:
39
+ self.min_periods = min_periods
40
+
41
+ def split(
42
+ self,
43
+ data: MarketData,
44
+ rebalance_dates: pd.DatetimeIndex,
45
+ ) -> Generator[tuple[pd.Timestamp, MarketData], None, None]:
46
+ for current_date in rebalance_dates:
47
+ data_window = data.slice_time(end_date=current_date, lookback=None)
48
+
49
+ if len(data_window.prices) >= self.min_periods:
50
+ yield current_date, data_window
51
+
52
+
53
+ class PurgedKFoldSplitter:
54
+ """
55
+ Used strictly for Cross-Validation (Deep Learning / ML training).
56
+ Splits data into K folds, applying a purge and embargo to prevent data leakage.
57
+ """
58
+
59
+ def __init__(self, n_splits: int = 5, embargo_pct: float = 0.01) -> None:
60
+ self.n_splits = n_splits
61
+ self.embargo_pct = embargo_pct
62
+
63
+ def split(
64
+ self, data: MarketData
65
+ ) -> Generator[tuple[MarketData, MarketData], None, None]:
66
+ """Yields (train_data, test_data) for neural network training."""
67
+
68
+ total_length = len(data.prices)
69
+ fold_size = total_length // self.n_splits
70
+ embargo_size = int(total_length * self.embargo_pct)
71
+
72
+ indices = np.arange(total_length)
73
+ dates = data.prices.index
74
+
75
+ for i in range(self.n_splits):
76
+ test_start = i * fold_size
77
+ test_end = test_start + fold_size if i < self.n_splits - 1 else total_length
78
+
79
+ test_indices = indices[test_start:test_end]
80
+ train_indices_left = indices[: max(0, test_start - embargo_size)]
81
+ train_indices_right = indices[min(total_length, test_end + embargo_size) :]
82
+
83
+ train_indices = np.concatenate([train_indices_left, train_indices_right])
84
+
85
+ if len(train_indices) == 0:
86
+ continue
87
+
88
+ train_dates = dates[train_indices]
89
+ test_dates = dates[test_indices]
90
+
91
+ train_data = MarketData(
92
+ prices=data.prices.loc[train_dates],
93
+ features={k: v.loc[train_dates] for k, v in data.features.items()}
94
+ if data.features
95
+ else {},
96
+ cross_sectional=data.cross_sectional,
97
+ risk_free_rate=data.risk_free_rate.loc[train_dates]
98
+ if isinstance(data.risk_free_rate, pd.Series)
99
+ else data.risk_free_rate,
100
+ )
101
+
102
+ test_data = MarketData(
103
+ prices=data.prices.loc[test_dates],
104
+ features={k: v.loc[test_dates] for k, v in data.features.items()}
105
+ if data.features
106
+ else {},
107
+ cross_sectional=data.cross_sectional,
108
+ risk_free_rate=data.risk_free_rate.loc[test_dates]
109
+ if isinstance(data.risk_free_rate, pd.Series)
110
+ else data.risk_free_rate,
111
+ )
112
+
113
+ yield train_data, test_data