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.
- pyalloq_backtest-0.1.10/PKG-INFO +38 -0
- pyalloq_backtest-0.1.10/README.md +26 -0
- pyalloq_backtest-0.1.10/pyproject.toml +29 -0
- pyalloq_backtest-0.1.10/pyproject.toml.orig +23 -0
- pyalloq_backtest-0.1.10/src/pyalloq_backtest/__init__.py +0 -0
- pyalloq_backtest-0.1.10/src/pyalloq_backtest/costs.py +68 -0
- pyalloq_backtest-0.1.10/src/pyalloq_backtest/engine.py +71 -0
- pyalloq_backtest-0.1.10/src/pyalloq_backtest/metrics.py +41 -0
- pyalloq_backtest-0.1.10/src/pyalloq_backtest/py.typed +0 -0
- pyalloq_backtest-0.1.10/src/pyalloq_backtest/splitters.py +113 -0
|
@@ -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"]
|
|
File without changes
|
|
@@ -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
|