malatium 0.1.2__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.
- malatium-0.1.2/PKG-INFO +170 -0
- malatium-0.1.2/README.md +156 -0
- malatium-0.1.2/pyproject.toml +15 -0
- malatium-0.1.2/setup.cfg +4 -0
- malatium-0.1.2/src/malatium/__init__.py +1 -0
- malatium-0.1.2/src/malatium/backtester.py +138 -0
- malatium-0.1.2/src/malatium/costs.py +72 -0
- malatium-0.1.2/src/malatium/data.py +45 -0
- malatium-0.1.2/src/malatium/objectives.py +107 -0
- malatium-0.1.2/src/malatium/optimizer_constraints.py +27 -0
- malatium-0.1.2/src/malatium/result.py +158 -0
- malatium-0.1.2/src/malatium/risk_model.py +72 -0
- malatium-0.1.2/src/malatium/strategy.py +110 -0
- malatium-0.1.2/src/malatium/trading_constraints.py +83 -0
- malatium-0.1.2/src/malatium.egg-info/PKG-INFO +170 -0
- malatium-0.1.2/src/malatium.egg-info/SOURCES.txt +17 -0
- malatium-0.1.2/src/malatium.egg-info/dependency_links.txt +1 -0
- malatium-0.1.2/src/malatium.egg-info/requires.txt +7 -0
- malatium-0.1.2/src/malatium.egg-info/top_level.txt +1 -0
malatium-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: malatium
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Quant research backtesting tools.
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: bear-lake>=0.1.5
|
|
8
|
+
Requires-Dist: cvxpy>=1.8.1
|
|
9
|
+
Requires-Dist: numpy>=2.4.2
|
|
10
|
+
Requires-Dist: polars>=1.38.1
|
|
11
|
+
Requires-Dist: tqdm>=4.67.3
|
|
12
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
13
|
+
Requires-Dist: seaborn>=0.13.2
|
|
14
|
+
|
|
15
|
+
# Malatium
|
|
16
|
+
|
|
17
|
+
Quant primitives for building a hedge fund. Malatium provides a modular backtesting framework with convex portfolio optimization, pluggable risk models, transaction cost modeling, and flexible data providers.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install malatium
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from malatium.backtester import Backtester
|
|
29
|
+
from malatium.strategy import OptimizationStrategy
|
|
30
|
+
from malatium.objectives import MaxUtilityWithTargetActiveRisk
|
|
31
|
+
from malatium.optimizer_constraints import LongOnly, FullyInvested
|
|
32
|
+
from malatium.trading_constraints import MinPositionSize
|
|
33
|
+
from malatium.risk_model import FactorRiskModel
|
|
34
|
+
from malatium.costs import LinearCost
|
|
35
|
+
import datetime as dt
|
|
36
|
+
|
|
37
|
+
# 1. Create your data providers (see "Data Providers" below)
|
|
38
|
+
start = dt.date(2026, 1, 1)
|
|
39
|
+
end = dt.date(2026, 12, 31)
|
|
40
|
+
|
|
41
|
+
calendar = MyCalendarProvider(start, end)
|
|
42
|
+
returns = MyReturnsProvider(start, end)
|
|
43
|
+
alphas = MyAlphaProvider(start, end)
|
|
44
|
+
factor_loadings = MyFactorLoadingsProvider(start, end)
|
|
45
|
+
factor_covariances = MyFactorCovariancesProvider(start, end)
|
|
46
|
+
idio_vol = MyIdioVolProvider(start, end)
|
|
47
|
+
benchmark = MyBenchmarkProvider(start, end)
|
|
48
|
+
|
|
49
|
+
# 2. Define a strategy
|
|
50
|
+
strategy = OptimizationStrategy(
|
|
51
|
+
alphas=alphas,
|
|
52
|
+
risk_model=FactorRiskModel(factor_loadings, factor_covariances, idio_vol),
|
|
53
|
+
objective=MaxUtilityWithTargetActiveRisk(target_active_risk=0.05),
|
|
54
|
+
optimizer_constraints=[LongOnly(), FullyInvested()],
|
|
55
|
+
trading_constraints=[MinPositionSize(dollars=1)],
|
|
56
|
+
benchmark=benchmark,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 3. Run the backtest
|
|
60
|
+
bt = Backtester()
|
|
61
|
+
result = bt.run(
|
|
62
|
+
calendar=calendar,
|
|
63
|
+
returns=returns,
|
|
64
|
+
strategy=strategy,
|
|
65
|
+
cost_model=LinearCost(bps=5),
|
|
66
|
+
start=start,
|
|
67
|
+
end=end,
|
|
68
|
+
initial_capital=100_000,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# 4. Analyze results
|
|
72
|
+
print(result.summary())
|
|
73
|
+
result.plot_equity_curve("equity_curve.png")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Data Providers
|
|
77
|
+
|
|
78
|
+
Allomancy uses Protocol-based data providers — one per dataset. Each provider has a single `get()` method. Any class with a matching `get()` signature satisfies the protocol automatically (no subclassing required).
|
|
79
|
+
|
|
80
|
+
| Provider | `get()` signature | Returns |
|
|
81
|
+
|----------|-------------------|---------|
|
|
82
|
+
| `CalendarProvider` | `get(start, end) -> list[dt.date]` | Trading dates in range |
|
|
83
|
+
| `ReturnsProvider` | `get(date) -> DataFrame` | `[date, ticker, return]` |
|
|
84
|
+
| `AlphaProvider` | `get(date) -> DataFrame` | `[date, ticker, alpha]` |
|
|
85
|
+
| `FactorLoadingsProvider` | `get(date) -> DataFrame` | `[date, ticker, factor, loading]` |
|
|
86
|
+
| `FactorCovariancesProvider` | `get(date) -> DataFrame` | `[date, factor_1, factor_2, covariance]` |
|
|
87
|
+
| `IdioVolProvider` | `get(date) -> DataFrame` | `[date, ticker, idio_vol]` |
|
|
88
|
+
| `BenchmarkProvider` | `get(date) -> DataFrame` | `[date, ticker, weight]` |
|
|
89
|
+
|
|
90
|
+
### Writing a Data Provider
|
|
91
|
+
|
|
92
|
+
Each provider is a simple class with a `get()` method. Pre-loading data into memory during `__init__` is recommended for backtest speed.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import datetime as dt
|
|
96
|
+
import polars as pl
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MyReturnsProvider:
|
|
100
|
+
def __init__(self, start: dt.date, end: dt.date) -> None:
|
|
101
|
+
self._returns = load_returns_from_db(start, end)
|
|
102
|
+
|
|
103
|
+
def get(self, date_: dt.date) -> pl.DataFrame:
|
|
104
|
+
return self._returns.filter(pl.col("date").eq(date_))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MyAlphaProvider:
|
|
108
|
+
def __init__(self, start: dt.date, end: dt.date) -> None:
|
|
109
|
+
self._alphas = load_alphas_from_db(start, end)
|
|
110
|
+
|
|
111
|
+
def get(self, date_: dt.date) -> pl.DataFrame:
|
|
112
|
+
return self._alphas.filter(pl.col("date").eq(date_))
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Components
|
|
116
|
+
|
|
117
|
+
### Objectives
|
|
118
|
+
|
|
119
|
+
Control how the optimizer selects portfolio weights.
|
|
120
|
+
|
|
121
|
+
- **`MaxUtility(lambda_)`** — Mean-variance optimization: maximize `w @ alpha - lambda/2 * w' Sigma w`.
|
|
122
|
+
- **`MaxUtilityWithTargetActiveRisk(target_active_risk)`** — Iteratively finds the risk-aversion parameter that produces a portfolio matching the target active risk (annualized tracking error vs. benchmark).
|
|
123
|
+
|
|
124
|
+
### Optimizer Constraints
|
|
125
|
+
|
|
126
|
+
Hard constraints passed to the CVXPY solver.
|
|
127
|
+
|
|
128
|
+
- **`LongOnly()`** — No short positions (`w >= 0`).
|
|
129
|
+
- **`FullyInvested()`** — Weights sum to one (`sum(w) == 1`).
|
|
130
|
+
|
|
131
|
+
Implement `OptimizerConstraint` to add your own.
|
|
132
|
+
|
|
133
|
+
### Trading Constraints
|
|
134
|
+
|
|
135
|
+
Post-optimization filters applied to the resulting weights.
|
|
136
|
+
|
|
137
|
+
- **`MinPositionSize(dollars)`** — Zeroes out any position smaller than the given dollar amount.
|
|
138
|
+
|
|
139
|
+
Implement `TradingConstraint` to add your own.
|
|
140
|
+
|
|
141
|
+
### Risk Models
|
|
142
|
+
|
|
143
|
+
Build the covariance matrix used by the optimizer.
|
|
144
|
+
|
|
145
|
+
- **`FactorRiskModel(factor_loadings, factor_covariances, idio_vol)`** — Computes `Sigma = X F X' + D^2` from factor loadings (`X`), factor covariances (`F`), and idiosyncratic volatilities (`D`).
|
|
146
|
+
|
|
147
|
+
Implement `RiskModel` to add your own.
|
|
148
|
+
|
|
149
|
+
### Cost Models
|
|
150
|
+
|
|
151
|
+
Estimate transaction costs deducted from capital at each rebalance.
|
|
152
|
+
|
|
153
|
+
- **`NoCost()`** — Zero cost.
|
|
154
|
+
- **`LinearCost(bps)`** — Cost proportional to turnover: `turnover * capital * bps / 10,000`.
|
|
155
|
+
|
|
156
|
+
Implement `CostModel` to add your own.
|
|
157
|
+
|
|
158
|
+
### Backtest Results
|
|
159
|
+
|
|
160
|
+
`BacktestResult` provides:
|
|
161
|
+
|
|
162
|
+
| Method | Returns |
|
|
163
|
+
|--------|---------|
|
|
164
|
+
| `summary()` | DataFrame with annualized return, volatility, Sharpe ratio, and max drawdown |
|
|
165
|
+
| `portfolio_returns()` | Daily portfolio returns and values |
|
|
166
|
+
| `sharpe_ratio()` | Annualized Sharpe ratio |
|
|
167
|
+
| `annualized_return()` | Annualized return (%) |
|
|
168
|
+
| `annualized_volatility()` | Annualized volatility (%) |
|
|
169
|
+
| `max_drawdown()` | Maximum peak-to-trough drawdown |
|
|
170
|
+
| `plot_equity_curve(path)` | Save an equity curve chart to disk |
|
malatium-0.1.2/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Malatium
|
|
2
|
+
|
|
3
|
+
Quant primitives for building a hedge fund. Malatium provides a modular backtesting framework with convex portfolio optimization, pluggable risk models, transaction cost modeling, and flexible data providers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install malatium
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from malatium.backtester import Backtester
|
|
15
|
+
from malatium.strategy import OptimizationStrategy
|
|
16
|
+
from malatium.objectives import MaxUtilityWithTargetActiveRisk
|
|
17
|
+
from malatium.optimizer_constraints import LongOnly, FullyInvested
|
|
18
|
+
from malatium.trading_constraints import MinPositionSize
|
|
19
|
+
from malatium.risk_model import FactorRiskModel
|
|
20
|
+
from malatium.costs import LinearCost
|
|
21
|
+
import datetime as dt
|
|
22
|
+
|
|
23
|
+
# 1. Create your data providers (see "Data Providers" below)
|
|
24
|
+
start = dt.date(2026, 1, 1)
|
|
25
|
+
end = dt.date(2026, 12, 31)
|
|
26
|
+
|
|
27
|
+
calendar = MyCalendarProvider(start, end)
|
|
28
|
+
returns = MyReturnsProvider(start, end)
|
|
29
|
+
alphas = MyAlphaProvider(start, end)
|
|
30
|
+
factor_loadings = MyFactorLoadingsProvider(start, end)
|
|
31
|
+
factor_covariances = MyFactorCovariancesProvider(start, end)
|
|
32
|
+
idio_vol = MyIdioVolProvider(start, end)
|
|
33
|
+
benchmark = MyBenchmarkProvider(start, end)
|
|
34
|
+
|
|
35
|
+
# 2. Define a strategy
|
|
36
|
+
strategy = OptimizationStrategy(
|
|
37
|
+
alphas=alphas,
|
|
38
|
+
risk_model=FactorRiskModel(factor_loadings, factor_covariances, idio_vol),
|
|
39
|
+
objective=MaxUtilityWithTargetActiveRisk(target_active_risk=0.05),
|
|
40
|
+
optimizer_constraints=[LongOnly(), FullyInvested()],
|
|
41
|
+
trading_constraints=[MinPositionSize(dollars=1)],
|
|
42
|
+
benchmark=benchmark,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# 3. Run the backtest
|
|
46
|
+
bt = Backtester()
|
|
47
|
+
result = bt.run(
|
|
48
|
+
calendar=calendar,
|
|
49
|
+
returns=returns,
|
|
50
|
+
strategy=strategy,
|
|
51
|
+
cost_model=LinearCost(bps=5),
|
|
52
|
+
start=start,
|
|
53
|
+
end=end,
|
|
54
|
+
initial_capital=100_000,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# 4. Analyze results
|
|
58
|
+
print(result.summary())
|
|
59
|
+
result.plot_equity_curve("equity_curve.png")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Data Providers
|
|
63
|
+
|
|
64
|
+
Allomancy uses Protocol-based data providers — one per dataset. Each provider has a single `get()` method. Any class with a matching `get()` signature satisfies the protocol automatically (no subclassing required).
|
|
65
|
+
|
|
66
|
+
| Provider | `get()` signature | Returns |
|
|
67
|
+
|----------|-------------------|---------|
|
|
68
|
+
| `CalendarProvider` | `get(start, end) -> list[dt.date]` | Trading dates in range |
|
|
69
|
+
| `ReturnsProvider` | `get(date) -> DataFrame` | `[date, ticker, return]` |
|
|
70
|
+
| `AlphaProvider` | `get(date) -> DataFrame` | `[date, ticker, alpha]` |
|
|
71
|
+
| `FactorLoadingsProvider` | `get(date) -> DataFrame` | `[date, ticker, factor, loading]` |
|
|
72
|
+
| `FactorCovariancesProvider` | `get(date) -> DataFrame` | `[date, factor_1, factor_2, covariance]` |
|
|
73
|
+
| `IdioVolProvider` | `get(date) -> DataFrame` | `[date, ticker, idio_vol]` |
|
|
74
|
+
| `BenchmarkProvider` | `get(date) -> DataFrame` | `[date, ticker, weight]` |
|
|
75
|
+
|
|
76
|
+
### Writing a Data Provider
|
|
77
|
+
|
|
78
|
+
Each provider is a simple class with a `get()` method. Pre-loading data into memory during `__init__` is recommended for backtest speed.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import datetime as dt
|
|
82
|
+
import polars as pl
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MyReturnsProvider:
|
|
86
|
+
def __init__(self, start: dt.date, end: dt.date) -> None:
|
|
87
|
+
self._returns = load_returns_from_db(start, end)
|
|
88
|
+
|
|
89
|
+
def get(self, date_: dt.date) -> pl.DataFrame:
|
|
90
|
+
return self._returns.filter(pl.col("date").eq(date_))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MyAlphaProvider:
|
|
94
|
+
def __init__(self, start: dt.date, end: dt.date) -> None:
|
|
95
|
+
self._alphas = load_alphas_from_db(start, end)
|
|
96
|
+
|
|
97
|
+
def get(self, date_: dt.date) -> pl.DataFrame:
|
|
98
|
+
return self._alphas.filter(pl.col("date").eq(date_))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Components
|
|
102
|
+
|
|
103
|
+
### Objectives
|
|
104
|
+
|
|
105
|
+
Control how the optimizer selects portfolio weights.
|
|
106
|
+
|
|
107
|
+
- **`MaxUtility(lambda_)`** — Mean-variance optimization: maximize `w @ alpha - lambda/2 * w' Sigma w`.
|
|
108
|
+
- **`MaxUtilityWithTargetActiveRisk(target_active_risk)`** — Iteratively finds the risk-aversion parameter that produces a portfolio matching the target active risk (annualized tracking error vs. benchmark).
|
|
109
|
+
|
|
110
|
+
### Optimizer Constraints
|
|
111
|
+
|
|
112
|
+
Hard constraints passed to the CVXPY solver.
|
|
113
|
+
|
|
114
|
+
- **`LongOnly()`** — No short positions (`w >= 0`).
|
|
115
|
+
- **`FullyInvested()`** — Weights sum to one (`sum(w) == 1`).
|
|
116
|
+
|
|
117
|
+
Implement `OptimizerConstraint` to add your own.
|
|
118
|
+
|
|
119
|
+
### Trading Constraints
|
|
120
|
+
|
|
121
|
+
Post-optimization filters applied to the resulting weights.
|
|
122
|
+
|
|
123
|
+
- **`MinPositionSize(dollars)`** — Zeroes out any position smaller than the given dollar amount.
|
|
124
|
+
|
|
125
|
+
Implement `TradingConstraint` to add your own.
|
|
126
|
+
|
|
127
|
+
### Risk Models
|
|
128
|
+
|
|
129
|
+
Build the covariance matrix used by the optimizer.
|
|
130
|
+
|
|
131
|
+
- **`FactorRiskModel(factor_loadings, factor_covariances, idio_vol)`** — Computes `Sigma = X F X' + D^2` from factor loadings (`X`), factor covariances (`F`), and idiosyncratic volatilities (`D`).
|
|
132
|
+
|
|
133
|
+
Implement `RiskModel` to add your own.
|
|
134
|
+
|
|
135
|
+
### Cost Models
|
|
136
|
+
|
|
137
|
+
Estimate transaction costs deducted from capital at each rebalance.
|
|
138
|
+
|
|
139
|
+
- **`NoCost()`** — Zero cost.
|
|
140
|
+
- **`LinearCost(bps)`** — Cost proportional to turnover: `turnover * capital * bps / 10,000`.
|
|
141
|
+
|
|
142
|
+
Implement `CostModel` to add your own.
|
|
143
|
+
|
|
144
|
+
### Backtest Results
|
|
145
|
+
|
|
146
|
+
`BacktestResult` provides:
|
|
147
|
+
|
|
148
|
+
| Method | Returns |
|
|
149
|
+
|--------|---------|
|
|
150
|
+
| `summary()` | DataFrame with annualized return, volatility, Sharpe ratio, and max drawdown |
|
|
151
|
+
| `portfolio_returns()` | Daily portfolio returns and values |
|
|
152
|
+
| `sharpe_ratio()` | Annualized Sharpe ratio |
|
|
153
|
+
| `annualized_return()` | Annualized return (%) |
|
|
154
|
+
| `annualized_volatility()` | Annualized volatility (%) |
|
|
155
|
+
| `max_drawdown()` | Maximum peak-to-trough drawdown |
|
|
156
|
+
| `plot_equity_curve(path)` | Save an equity curve chart to disk |
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "malatium"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Quant research backtesting tools."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"bear-lake>=0.1.5",
|
|
9
|
+
"cvxpy>=1.8.1",
|
|
10
|
+
"numpy>=2.4.2",
|
|
11
|
+
"polars>=1.38.1",
|
|
12
|
+
"tqdm>=4.67.3",
|
|
13
|
+
"matplotlib>=3.10.8",
|
|
14
|
+
"seaborn>=0.13.2",
|
|
15
|
+
]
|
malatium-0.1.2/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from malatium.data import CalendarProvider, ReturnsProvider, BenchmarkProvider
|
|
3
|
+
from malatium.strategy import Strategy
|
|
4
|
+
from malatium.costs import CostModel, NoCost
|
|
5
|
+
from malatium.result import BacktestResult
|
|
6
|
+
import polars as pl
|
|
7
|
+
import datetime as dt
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
RebalanceFrequency = Literal['daily', 'weekly', 'monthly']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Backtester:
|
|
14
|
+
"""Engine that runs a strategy over historical data and tracks portfolio performance.
|
|
15
|
+
|
|
16
|
+
On each trading date the backtester generates new weights, deducts
|
|
17
|
+
transaction costs, computes PnL from forward returns, and rolls
|
|
18
|
+
capital forward.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def _is_rebalance_date(
|
|
22
|
+
self,
|
|
23
|
+
date_: dt.date,
|
|
24
|
+
prev_date: dt.date | None,
|
|
25
|
+
frequency: RebalanceFrequency,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Return True if the portfolio should be rebalanced on this date."""
|
|
28
|
+
if prev_date is None:
|
|
29
|
+
return True
|
|
30
|
+
if frequency == 'daily':
|
|
31
|
+
return True
|
|
32
|
+
if frequency == 'weekly':
|
|
33
|
+
y1, w1, _ = prev_date.isocalendar()
|
|
34
|
+
y2, w2, _ = date_.isocalendar()
|
|
35
|
+
return (y1, w1) != (y2, w2)
|
|
36
|
+
if frequency == 'monthly':
|
|
37
|
+
return (prev_date.year, prev_date.month) != (date_.year, date_.month)
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def run(
|
|
41
|
+
self,
|
|
42
|
+
calendar: CalendarProvider,
|
|
43
|
+
returns: ReturnsProvider,
|
|
44
|
+
strategy: Strategy,
|
|
45
|
+
start: dt.date,
|
|
46
|
+
end: dt.date,
|
|
47
|
+
initial_capital: float,
|
|
48
|
+
cost_model: CostModel | None = None,
|
|
49
|
+
rebalance_frequency: RebalanceFrequency = 'daily',
|
|
50
|
+
benchmark: BenchmarkProvider | None = None,
|
|
51
|
+
) -> BacktestResult:
|
|
52
|
+
"""Execute the backtest and return a BacktestResult.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
calendar: Provider for trading dates.
|
|
56
|
+
returns: Provider for next-period forward returns.
|
|
57
|
+
strategy: Strategy that generates portfolio weights each period.
|
|
58
|
+
start: First date of the backtest (inclusive).
|
|
59
|
+
end: Last date of the backtest (inclusive).
|
|
60
|
+
initial_capital: Starting portfolio value in dollars.
|
|
61
|
+
cost_model: Transaction cost model (defaults to NoCost).
|
|
62
|
+
rebalance_frequency: How often to rebalance — 'daily', 'weekly'
|
|
63
|
+
(first trading day of each ISO week), or 'monthly' (first
|
|
64
|
+
trading day of each calendar month).
|
|
65
|
+
benchmark: Optional benchmark provider for benchmark returns.
|
|
66
|
+
When supplied, BacktestResult will include benchmark-relative
|
|
67
|
+
analytics.
|
|
68
|
+
"""
|
|
69
|
+
if cost_model is None:
|
|
70
|
+
cost_model = NoCost()
|
|
71
|
+
|
|
72
|
+
capital = initial_capital
|
|
73
|
+
holdings: pl.DataFrame | None = None
|
|
74
|
+
prev_date: dt.date | None = None
|
|
75
|
+
results_list: list[pl.DataFrame] = []
|
|
76
|
+
benchmark_returns_list: list[dict] = []
|
|
77
|
+
|
|
78
|
+
for date_ in tqdm(calendar.get(start, end), "RUNNING BACKTEST"):
|
|
79
|
+
rebalance = self._is_rebalance_date(date_, prev_date, rebalance_frequency)
|
|
80
|
+
|
|
81
|
+
if rebalance:
|
|
82
|
+
new_weights = strategy.generate_weights(date_, capital)
|
|
83
|
+
costs = cost_model.compute_costs(holdings, new_weights, capital)
|
|
84
|
+
capital -= costs
|
|
85
|
+
else:
|
|
86
|
+
new_weights = holdings.with_columns(pl.lit(date_).alias('date'))
|
|
87
|
+
|
|
88
|
+
forward_returns = returns.get(date_)
|
|
89
|
+
|
|
90
|
+
results = (
|
|
91
|
+
new_weights
|
|
92
|
+
.join(forward_returns, on=['date', 'ticker'], how='left')
|
|
93
|
+
.with_columns(pl.col('return').fill_null(0))
|
|
94
|
+
.with_columns(
|
|
95
|
+
pl.col('weight').mul(pl.lit(capital)).alias('value'),
|
|
96
|
+
)
|
|
97
|
+
.with_columns(
|
|
98
|
+
pl.col('value').mul('return').alias('pnl'),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
invested = results['value'].sum()
|
|
103
|
+
cash = capital - invested
|
|
104
|
+
capital = invested + results['pnl'].sum() + cash
|
|
105
|
+
|
|
106
|
+
# Compute drifted weights for next day's holdings
|
|
107
|
+
holdings = (
|
|
108
|
+
results
|
|
109
|
+
.with_columns(
|
|
110
|
+
((pl.col('value') + pl.col('pnl')) / capital).alias('weight'),
|
|
111
|
+
)
|
|
112
|
+
.select('date', 'ticker', 'weight')
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if benchmark is not None:
|
|
116
|
+
bm_weights = benchmark.get(date_)
|
|
117
|
+
bm_return = (
|
|
118
|
+
bm_weights
|
|
119
|
+
.join(forward_returns, on=['date', 'ticker'], how='left')
|
|
120
|
+
.with_columns(pl.col('return').fill_null(0))
|
|
121
|
+
.select(pl.col('weight').mul(pl.col('return')).sum())
|
|
122
|
+
.item()
|
|
123
|
+
)
|
|
124
|
+
benchmark_returns_list.append({
|
|
125
|
+
'date': date_,
|
|
126
|
+
'benchmark_return': float(bm_return),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
prev_date = date_
|
|
130
|
+
results_list.append(results)
|
|
131
|
+
|
|
132
|
+
benchmark_returns = (
|
|
133
|
+
pl.DataFrame(benchmark_returns_list)
|
|
134
|
+
if benchmark_returns_list
|
|
135
|
+
else None
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return BacktestResult(pl.concat(results_list), benchmark_returns)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import polars as pl
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CostModel(ABC):
|
|
6
|
+
"""Base class for transaction cost estimation."""
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def compute_costs(
|
|
10
|
+
self,
|
|
11
|
+
old_weights: pl.DataFrame | None,
|
|
12
|
+
new_weights: pl.DataFrame,
|
|
13
|
+
capital: float,
|
|
14
|
+
) -> float:
|
|
15
|
+
"""Estimate the dollar cost of rebalancing from old_weights to new_weights.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
old_weights: Previous portfolio weights, or None for the first rebalance.
|
|
19
|
+
new_weights: Target portfolio weights with columns [ticker, weight].
|
|
20
|
+
capital: Current portfolio capital in dollars.
|
|
21
|
+
"""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NoCost(CostModel):
|
|
26
|
+
"""Cost model that charges zero transaction costs."""
|
|
27
|
+
|
|
28
|
+
def compute_costs(
|
|
29
|
+
self,
|
|
30
|
+
old_weights: pl.DataFrame | None,
|
|
31
|
+
new_weights: pl.DataFrame,
|
|
32
|
+
capital: float,
|
|
33
|
+
) -> float:
|
|
34
|
+
"""Return 0.0 (no costs)."""
|
|
35
|
+
return 0.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LinearCost(CostModel):
|
|
39
|
+
"""Transaction cost proportional to portfolio turnover.
|
|
40
|
+
|
|
41
|
+
Cost is computed as: turnover * capital * bps / 10,000.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
bps: Cost in basis points per unit of turnover.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, bps: float):
|
|
48
|
+
self.bps = bps
|
|
49
|
+
|
|
50
|
+
def compute_costs(
|
|
51
|
+
self,
|
|
52
|
+
old_weights: pl.DataFrame | None,
|
|
53
|
+
new_weights: pl.DataFrame,
|
|
54
|
+
capital: float,
|
|
55
|
+
) -> float:
|
|
56
|
+
"""Compute linear transaction costs based on weight turnover."""
|
|
57
|
+
if old_weights is None:
|
|
58
|
+
turnover = new_weights['weight'].abs().sum()
|
|
59
|
+
else:
|
|
60
|
+
combined = (
|
|
61
|
+
new_weights.select('ticker', pl.col('weight').alias('new_weight'))
|
|
62
|
+
.join(
|
|
63
|
+
old_weights.select('ticker', pl.col('weight').alias('old_weight')),
|
|
64
|
+
on='ticker',
|
|
65
|
+
how='full',
|
|
66
|
+
coalesce=True,
|
|
67
|
+
)
|
|
68
|
+
.fill_null(0)
|
|
69
|
+
)
|
|
70
|
+
turnover = (combined['new_weight'] - combined['old_weight']).abs().sum()
|
|
71
|
+
|
|
72
|
+
return turnover * capital * self.bps / 10_000
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import polars as pl
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CalendarProvider(Protocol):
|
|
7
|
+
"""Provides the list of trading dates for a given range."""
|
|
8
|
+
|
|
9
|
+
def get(self, start: dt.date, end: dt.date) -> list[dt.date]: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReturnsProvider(Protocol):
|
|
13
|
+
"""Provides next-period forward returns with columns [date, ticker, return]."""
|
|
14
|
+
|
|
15
|
+
def get(self, date_: dt.date) -> pl.DataFrame: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AlphaProvider(Protocol):
|
|
19
|
+
"""Provides expected returns with columns [date, ticker, alpha]."""
|
|
20
|
+
|
|
21
|
+
def get(self, date_: dt.date) -> pl.DataFrame: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FactorLoadingsProvider(Protocol):
|
|
25
|
+
"""Provides factor exposures with columns [date, ticker, factor, loading]."""
|
|
26
|
+
|
|
27
|
+
def get(self, date_: dt.date) -> pl.DataFrame: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FactorCovariancesProvider(Protocol):
|
|
31
|
+
"""Provides factor covariance matrix with columns [date, factor_1, factor_2, covariance]."""
|
|
32
|
+
|
|
33
|
+
def get(self, date_: dt.date) -> pl.DataFrame: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IdioVolProvider(Protocol):
|
|
37
|
+
"""Provides idiosyncratic volatility with columns [date, ticker, idio_vol]."""
|
|
38
|
+
|
|
39
|
+
def get(self, date_: dt.date) -> pl.DataFrame: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BenchmarkProvider(Protocol):
|
|
43
|
+
"""Provides benchmark portfolio weights with columns [date, ticker, weight]."""
|
|
44
|
+
|
|
45
|
+
def get(self, date_: dt.date) -> pl.DataFrame: ...
|