malatium 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.
- malatium-0.1.0/.github/workflows/publish.yml +33 -0
- malatium-0.1.0/.github/workflows/test.yml +16 -0
- malatium-0.1.0/.gitignore +10 -0
- malatium-0.1.0/PKG-INFO +113 -0
- malatium-0.1.0/README.md +99 -0
- malatium-0.1.0/pyproject.toml +41 -0
- malatium-0.1.0/src/malatium/__init__.py +0 -0
- malatium-0.1.0/src/malatium/backtester.py +165 -0
- malatium-0.1.0/src/malatium/data.py +62 -0
- malatium-0.1.0/src/malatium/optimizer/__init__.py +24 -0
- malatium-0.1.0/src/malatium/optimizer/base.py +34 -0
- malatium-0.1.0/src/malatium/optimizer/constraints.py +67 -0
- malatium-0.1.0/src/malatium/optimizer/mvo.py +91 -0
- malatium-0.1.0/src/malatium/optimizer/objectives.py +36 -0
- malatium-0.1.0/src/malatium/providers.py +42 -0
- malatium-0.1.0/src/malatium/results.py +275 -0
- malatium-0.1.0/src/malatium/risk_model/__init__.py +5 -0
- malatium-0.1.0/src/malatium/risk_model/base.py +39 -0
- malatium-0.1.0/src/malatium/risk_model/constructor.py +31 -0
- malatium-0.1.0/src/malatium/risk_model/factor.py +72 -0
- malatium-0.1.0/src/malatium/schemas.py +103 -0
- malatium-0.1.0/src/malatium/strategy.py +93 -0
- malatium-0.1.0/src/malatium/types.py +25 -0
- malatium-0.1.0/tests/conftest.py +92 -0
- malatium-0.1.0/tests/test_backtester.py +175 -0
- malatium-0.1.0/tests/test_optimizer.py +139 -0
- malatium-0.1.0/tests/test_results.py +71 -0
- malatium-0.1.0/tests/test_risk_model.py +69 -0
- malatium-0.1.0/tests/test_strategy.py +68 -0
- malatium-0.1.0/uv.lock +1041 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Publishes to PyPI on a version tag (v0.1.0) through trusted publishing.
|
|
2
|
+
# One-time setup on pypi.org: add a trusted publisher for this repository
|
|
3
|
+
# with workflow `publish.yml` and environment `pypi`. No token is stored here.
|
|
4
|
+
name: publish
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v5
|
|
16
|
+
- run: uv build
|
|
17
|
+
- uses: actions/upload-artifact@v4
|
|
18
|
+
with:
|
|
19
|
+
name: dist
|
|
20
|
+
path: dist/
|
|
21
|
+
|
|
22
|
+
publish:
|
|
23
|
+
needs: build
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: pypi
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/download-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv sync --all-extras
|
|
15
|
+
- run: uv run ruff check .
|
|
16
|
+
- run: uv run pytest -q
|
malatium-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: malatium
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Volatility portfolio backtesting and optimization in dollar vega, built on Polars and CVXPY.
|
|
5
|
+
Project-URL: Repository, https://github.com/Atium-Research/malatium
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: cvxpy>=1.6
|
|
8
|
+
Requires-Dist: dataframely>=2.7
|
|
9
|
+
Requires-Dist: numpy>=2.0
|
|
10
|
+
Requires-Dist: polars>=1.30
|
|
11
|
+
Provides-Extra: plot
|
|
12
|
+
Requires-Dist: matplotlib>=3.9; extra == 'plot'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Malatium
|
|
16
|
+
|
|
17
|
+
Volatility portfolio backtesting and optimization in dollar vega, built on [Polars](https://pola.rs/) and [CVXPY](https://www.cvxpy.org/). Sibling of [atium](https://github.com/Atium-Research/atium) with vega in place of capital.
|
|
18
|
+
|
|
19
|
+
Malatium is data-agnostic. It takes frames in through providers and never reads a store: the reference-return panel it prices a book off, the risk model tables and the scores or alphas all come from the caller (in the Atium stack, from `ml-data-pipelines` through `ml-data-access`).
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install malatium
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Units
|
|
28
|
+
|
|
29
|
+
Every position is a number of **units** of a reference instrument per name (in the Atium stack, one delta-hedged ATM straddle rolled on a fixed rule). A unit is marked by its **dollar vega**, the dollars of P&L per one-point move in implied vol. The return of a unit is its daily P&L divided by its dollar vega at inception, `pnl_per_vega`. Strategy weights are fractions of a **gross vega budget**; the backtester turns a weight into units by multiplying by the budget and dividing by the unit's dollar vega at today's close.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import datetime as dt
|
|
35
|
+
|
|
36
|
+
from malatium.backtester import Backtester
|
|
37
|
+
from malatium.optimizer import MVO, GrossCap, MaxUtility, NetVegaNeutral, TurnoverPenalty
|
|
38
|
+
from malatium.providers import PanelProvider, TradingCalendar
|
|
39
|
+
from malatium.results import BacktestResults
|
|
40
|
+
from malatium.risk_model import FactorRiskModelConstructor
|
|
41
|
+
from malatium.strategy import OptimizationStrategy, QuantileSpreadStrategy
|
|
42
|
+
|
|
43
|
+
# Frames in the schemas of `malatium.schemas`, loaded however you like.
|
|
44
|
+
calendar = TradingCalendar(sessions)
|
|
45
|
+
reference = PanelProvider(reference_returns_df)
|
|
46
|
+
|
|
47
|
+
# A rank book: long the cheapest decile of scores, short the richest.
|
|
48
|
+
rank_book = QuantileSpreadStrategy(
|
|
49
|
+
PanelProvider(scores_df), universe=PanelProvider(universe_df), quantile=0.1
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# A mean-variance book: alphas against a factor risk model.
|
|
53
|
+
risk_model = FactorRiskModelConstructor(
|
|
54
|
+
PanelProvider(factor_loadings_df),
|
|
55
|
+
PanelProvider(factor_covariances_df),
|
|
56
|
+
PanelProvider(idio_vol_df),
|
|
57
|
+
)
|
|
58
|
+
optimizer = MVO(
|
|
59
|
+
objectives=[MaxUtility(risk_aversion=0.2), TurnoverPenalty(cost=0.1)],
|
|
60
|
+
constraints=[NetVegaNeutral(0.025), GrossCap(1.0)],
|
|
61
|
+
)
|
|
62
|
+
mvo_book = OptimizationStrategy(PanelProvider(alphas_df), risk_model, optimizer)
|
|
63
|
+
|
|
64
|
+
records_df = Backtester().run(
|
|
65
|
+
calendar,
|
|
66
|
+
reference,
|
|
67
|
+
mvo_book,
|
|
68
|
+
start=dt.date(2018, 7, 2),
|
|
69
|
+
end=dt.date(2025, 6, 30),
|
|
70
|
+
gross_vega=20_000.0,
|
|
71
|
+
rebalance_frequency="weekly",
|
|
72
|
+
)
|
|
73
|
+
results = BacktestResults(records_df)
|
|
74
|
+
results.summary()
|
|
75
|
+
results.factor_regression(factor_returns_df)
|
|
76
|
+
BacktestResults.decile_table(scores_df, reference_returns_df, horizon_days=60)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## What is here
|
|
80
|
+
|
|
81
|
+
| module | holds |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `schemas.py`, `types.py` | dataframely schemas for every frame in and out |
|
|
84
|
+
| `data.py` | provider protocols: `get(date_)` for scores, alphas, universe, reference returns, risk model tables |
|
|
85
|
+
| `providers.py` | `TradingCalendar`, `PanelProvider` over frames in memory |
|
|
86
|
+
| `strategy.py` | `Strategy`, `QuantileSpreadStrategy`, `OptimizationStrategy` |
|
|
87
|
+
| `optimizer/` | `MVO`; objectives `MaxUtility`, `TurnoverPenalty`; constraints `NetVegaNeutral`, `FactorNeutral`, `PerNameCap`, `GrossShortCap`, `GrossCap` |
|
|
88
|
+
| `risk_model/` | `RiskModel`, `FactorRiskModel` (Sigma = B F B' + D²), `FactorRiskModelConstructor` |
|
|
89
|
+
| `backtester.py` | the daily loop over the reference-return panel |
|
|
90
|
+
| `results.py` | `BacktestResults`: summary, factor regression, decile table, plots (`pip install 'malatium[plot]'`) |
|
|
91
|
+
|
|
92
|
+
## Reference returns
|
|
93
|
+
|
|
94
|
+
`ReferenceReturnsSchema` is the contract between the data and the engine. Per `(date, symbol)`:
|
|
95
|
+
|
|
96
|
+
| column | meaning |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `pnl_per_vega` | the unit's P&L today per dollar of its inception vega |
|
|
99
|
+
| `cost_per_vega` | rolling and hedging cost today, same scale |
|
|
100
|
+
| `exit_cost_per_vega` | half-spread to close the unit today, same scale |
|
|
101
|
+
| `dollar_vega` | the unit's dollar vega at today's close |
|
|
102
|
+
| `entry_vega` | inception dollar vega of the unit held into today |
|
|
103
|
+
| `event` | `open`, `roll`, `forced_close` or empty |
|
|
104
|
+
|
|
105
|
+
Between rebalances the backtester holds a constant number of units, so its P&L is `units × entry_vega × pnl_per_vega` and its cost `|units| × entry_vega × cost_per_vega`; a rebalance trades `|Δunits| × half-spread`. Costs are charged at `cost_fraction` of those amounts, zero by default.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv sync --all-extras
|
|
111
|
+
uv run pytest
|
|
112
|
+
uv run ruff check . && uv run ruff format .
|
|
113
|
+
```
|
malatium-0.1.0/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Malatium
|
|
2
|
+
|
|
3
|
+
Volatility portfolio backtesting and optimization in dollar vega, built on [Polars](https://pola.rs/) and [CVXPY](https://www.cvxpy.org/). Sibling of [atium](https://github.com/Atium-Research/atium) with vega in place of capital.
|
|
4
|
+
|
|
5
|
+
Malatium is data-agnostic. It takes frames in through providers and never reads a store: the reference-return panel it prices a book off, the risk model tables and the scores or alphas all come from the caller (in the Atium stack, from `ml-data-pipelines` through `ml-data-access`).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install malatium
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Units
|
|
14
|
+
|
|
15
|
+
Every position is a number of **units** of a reference instrument per name (in the Atium stack, one delta-hedged ATM straddle rolled on a fixed rule). A unit is marked by its **dollar vega**, the dollars of P&L per one-point move in implied vol. The return of a unit is its daily P&L divided by its dollar vega at inception, `pnl_per_vega`. Strategy weights are fractions of a **gross vega budget**; the backtester turns a weight into units by multiplying by the budget and dividing by the unit's dollar vega at today's close.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import datetime as dt
|
|
21
|
+
|
|
22
|
+
from malatium.backtester import Backtester
|
|
23
|
+
from malatium.optimizer import MVO, GrossCap, MaxUtility, NetVegaNeutral, TurnoverPenalty
|
|
24
|
+
from malatium.providers import PanelProvider, TradingCalendar
|
|
25
|
+
from malatium.results import BacktestResults
|
|
26
|
+
from malatium.risk_model import FactorRiskModelConstructor
|
|
27
|
+
from malatium.strategy import OptimizationStrategy, QuantileSpreadStrategy
|
|
28
|
+
|
|
29
|
+
# Frames in the schemas of `malatium.schemas`, loaded however you like.
|
|
30
|
+
calendar = TradingCalendar(sessions)
|
|
31
|
+
reference = PanelProvider(reference_returns_df)
|
|
32
|
+
|
|
33
|
+
# A rank book: long the cheapest decile of scores, short the richest.
|
|
34
|
+
rank_book = QuantileSpreadStrategy(
|
|
35
|
+
PanelProvider(scores_df), universe=PanelProvider(universe_df), quantile=0.1
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# A mean-variance book: alphas against a factor risk model.
|
|
39
|
+
risk_model = FactorRiskModelConstructor(
|
|
40
|
+
PanelProvider(factor_loadings_df),
|
|
41
|
+
PanelProvider(factor_covariances_df),
|
|
42
|
+
PanelProvider(idio_vol_df),
|
|
43
|
+
)
|
|
44
|
+
optimizer = MVO(
|
|
45
|
+
objectives=[MaxUtility(risk_aversion=0.2), TurnoverPenalty(cost=0.1)],
|
|
46
|
+
constraints=[NetVegaNeutral(0.025), GrossCap(1.0)],
|
|
47
|
+
)
|
|
48
|
+
mvo_book = OptimizationStrategy(PanelProvider(alphas_df), risk_model, optimizer)
|
|
49
|
+
|
|
50
|
+
records_df = Backtester().run(
|
|
51
|
+
calendar,
|
|
52
|
+
reference,
|
|
53
|
+
mvo_book,
|
|
54
|
+
start=dt.date(2018, 7, 2),
|
|
55
|
+
end=dt.date(2025, 6, 30),
|
|
56
|
+
gross_vega=20_000.0,
|
|
57
|
+
rebalance_frequency="weekly",
|
|
58
|
+
)
|
|
59
|
+
results = BacktestResults(records_df)
|
|
60
|
+
results.summary()
|
|
61
|
+
results.factor_regression(factor_returns_df)
|
|
62
|
+
BacktestResults.decile_table(scores_df, reference_returns_df, horizon_days=60)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## What is here
|
|
66
|
+
|
|
67
|
+
| module | holds |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `schemas.py`, `types.py` | dataframely schemas for every frame in and out |
|
|
70
|
+
| `data.py` | provider protocols: `get(date_)` for scores, alphas, universe, reference returns, risk model tables |
|
|
71
|
+
| `providers.py` | `TradingCalendar`, `PanelProvider` over frames in memory |
|
|
72
|
+
| `strategy.py` | `Strategy`, `QuantileSpreadStrategy`, `OptimizationStrategy` |
|
|
73
|
+
| `optimizer/` | `MVO`; objectives `MaxUtility`, `TurnoverPenalty`; constraints `NetVegaNeutral`, `FactorNeutral`, `PerNameCap`, `GrossShortCap`, `GrossCap` |
|
|
74
|
+
| `risk_model/` | `RiskModel`, `FactorRiskModel` (Sigma = B F B' + D²), `FactorRiskModelConstructor` |
|
|
75
|
+
| `backtester.py` | the daily loop over the reference-return panel |
|
|
76
|
+
| `results.py` | `BacktestResults`: summary, factor regression, decile table, plots (`pip install 'malatium[plot]'`) |
|
|
77
|
+
|
|
78
|
+
## Reference returns
|
|
79
|
+
|
|
80
|
+
`ReferenceReturnsSchema` is the contract between the data and the engine. Per `(date, symbol)`:
|
|
81
|
+
|
|
82
|
+
| column | meaning |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `pnl_per_vega` | the unit's P&L today per dollar of its inception vega |
|
|
85
|
+
| `cost_per_vega` | rolling and hedging cost today, same scale |
|
|
86
|
+
| `exit_cost_per_vega` | half-spread to close the unit today, same scale |
|
|
87
|
+
| `dollar_vega` | the unit's dollar vega at today's close |
|
|
88
|
+
| `entry_vega` | inception dollar vega of the unit held into today |
|
|
89
|
+
| `event` | `open`, `roll`, `forced_close` or empty |
|
|
90
|
+
|
|
91
|
+
Between rebalances the backtester holds a constant number of units, so its P&L is `units × entry_vega × pnl_per_vega` and its cost `|units| × entry_vega × cost_per_vega`; a rebalance trades `|Δunits| × half-spread`. Costs are charged at `cost_fraction` of those amounts, zero by default.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv sync --all-extras
|
|
97
|
+
uv run pytest
|
|
98
|
+
uv run ruff check . && uv run ruff format .
|
|
99
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "malatium"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Volatility portfolio backtesting and optimization in dollar vega, built on Polars and CVXPY."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cvxpy>=1.6",
|
|
9
|
+
"dataframely>=2.7",
|
|
10
|
+
"numpy>=2.0",
|
|
11
|
+
"polars>=1.30",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
plot = ["matplotlib>=3.9"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/Atium-Research/malatium"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8",
|
|
23
|
+
"ruff>=0.6",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/malatium"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 100
|
|
38
|
+
target-version = "py313"
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
File without changes
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Daily loop over a book of reference units, priced off the reference-return panel.
|
|
2
|
+
|
|
3
|
+
The book holds `units` of the reference instrument per name. Each session:
|
|
4
|
+
|
|
5
|
+
1. Mark the units held into today. P&L is `units × entry_vega × pnl_per_vega`,
|
|
6
|
+
the holding cost (rolls, hedging) `|units| × entry_vega × cost_per_vega`.
|
|
7
|
+
A held name with no row today is closed with no P&L and no cost.
|
|
8
|
+
2. On a rebalance session, the strategy's weights times the gross vega
|
|
9
|
+
budget are the target dollar vega per name; units are the target divided
|
|
10
|
+
by the unit's dollar vega at today's close. Names the strategy does not
|
|
11
|
+
weight go to zero. Trading `|Δunits|` pays the half-spread of the unit.
|
|
12
|
+
3. Record one row per name held at any point today.
|
|
13
|
+
|
|
14
|
+
Costs are charged at `cost_fraction` of the panel's half-spread and holding
|
|
15
|
+
cost; `exit_cost` is always recorded and never charged.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import datetime as dt
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
import polars as pl
|
|
22
|
+
|
|
23
|
+
from malatium.data import CalendarProvider, ReferenceReturnsProvider
|
|
24
|
+
from malatium.schemas import PositionResultsSchema, ReferenceReturnsSchema
|
|
25
|
+
from malatium.strategy import Strategy
|
|
26
|
+
from malatium.types import PositionResults, ReferenceReturns, Weights
|
|
27
|
+
|
|
28
|
+
HOLDINGS_SCHEMA = {"symbol": pl.String, "units": pl.Float64}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_rebalance_date(
|
|
32
|
+
date_: dt.date,
|
|
33
|
+
previous: dt.date | None,
|
|
34
|
+
frequency: Literal["daily", "weekly", "monthly"],
|
|
35
|
+
) -> bool:
|
|
36
|
+
if previous is None or frequency == "daily":
|
|
37
|
+
return True
|
|
38
|
+
if frequency == "weekly":
|
|
39
|
+
return date_.isocalendar()[:2] != previous.isocalendar()[:2]
|
|
40
|
+
if frequency == "monthly":
|
|
41
|
+
return (date_.year, date_.month) != (previous.year, previous.month)
|
|
42
|
+
raise ValueError(f"unknown rebalance frequency {frequency!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Backtester:
|
|
46
|
+
def run(
|
|
47
|
+
self,
|
|
48
|
+
calendar_provider: CalendarProvider,
|
|
49
|
+
reference_returns_provider: ReferenceReturnsProvider,
|
|
50
|
+
strategy: Strategy,
|
|
51
|
+
start: dt.date,
|
|
52
|
+
end: dt.date,
|
|
53
|
+
gross_vega: float,
|
|
54
|
+
rebalance_frequency: Literal["daily", "weekly", "monthly"] = "weekly",
|
|
55
|
+
cost_fraction: float = 0.0,
|
|
56
|
+
) -> PositionResults:
|
|
57
|
+
"""Run `strategy` from `start` to `end` and return one row per (date, symbol) held.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
calendar_provider: Trading sessions.
|
|
61
|
+
reference_returns_provider: The reference unit's marks per date.
|
|
62
|
+
strategy: Produces budget-fraction weights on rebalance dates.
|
|
63
|
+
start: First session, inclusive.
|
|
64
|
+
end: Last session, inclusive.
|
|
65
|
+
gross_vega: The budget in dollars per vol point that weights are fractions of.
|
|
66
|
+
rebalance_frequency: When `generate_weights` is called.
|
|
67
|
+
cost_fraction: Share of the panel's half-spread and holding cost to charge.
|
|
68
|
+
"""
|
|
69
|
+
holdings_df = pl.DataFrame(schema=HOLDINGS_SCHEMA)
|
|
70
|
+
previous: dt.date | None = None
|
|
71
|
+
days: list[pl.DataFrame] = []
|
|
72
|
+
for date_ in calendar_provider.get(start, end):
|
|
73
|
+
today_df = ReferenceReturnsSchema.validate(
|
|
74
|
+
reference_returns_provider.get(date_), cast=True
|
|
75
|
+
)
|
|
76
|
+
weights_df = None
|
|
77
|
+
if is_rebalance_date(date_, previous, rebalance_frequency):
|
|
78
|
+
weights_df = strategy.generate_weights(date_)
|
|
79
|
+
day_df = step(date_, holdings_df, today_df, weights_df, gross_vega, cost_fraction)
|
|
80
|
+
holdings_df = day_df.filter(pl.col("units") != 0).select("symbol", "units")
|
|
81
|
+
days.append(day_df)
|
|
82
|
+
previous = date_
|
|
83
|
+
if not days:
|
|
84
|
+
return PositionResultsSchema.create_empty()
|
|
85
|
+
return PositionResultsSchema.validate(pl.concat(days), cast=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def step(
|
|
89
|
+
date_: dt.date,
|
|
90
|
+
holdings_df: pl.DataFrame,
|
|
91
|
+
today_df: ReferenceReturns,
|
|
92
|
+
weights_df: Weights | None,
|
|
93
|
+
gross_vega: float,
|
|
94
|
+
cost_fraction: float,
|
|
95
|
+
) -> pl.DataFrame:
|
|
96
|
+
"""One session: mark, optionally rebalance, record."""
|
|
97
|
+
present = pl.col("dollar_vega").is_not_null()
|
|
98
|
+
inception_vega = (
|
|
99
|
+
pl.when(pl.col("event").is_in(["open", "roll"]))
|
|
100
|
+
.then(pl.col("dollar_vega"))
|
|
101
|
+
.otherwise(pl.col("entry_vega"))
|
|
102
|
+
.fill_null(0.0)
|
|
103
|
+
)
|
|
104
|
+
frame = (
|
|
105
|
+
holdings_df.join(today_df.drop("date"), on="symbol", how="full", coalesce=True)
|
|
106
|
+
.with_columns(pl.col("units").fill_null(0.0))
|
|
107
|
+
.with_columns(
|
|
108
|
+
(pl.col("units") * pl.col("entry_vega") * pl.col("pnl_per_vega"))
|
|
109
|
+
.fill_null(0.0)
|
|
110
|
+
.alias("pnl"),
|
|
111
|
+
(pl.col("units").abs() * pl.col("entry_vega") * pl.col("cost_per_vega") * cost_fraction)
|
|
112
|
+
.fill_null(0.0)
|
|
113
|
+
.alias("cost"),
|
|
114
|
+
(pl.col("exit_cost_per_vega").fill_null(0.0) * inception_vega).alias("half_spread"),
|
|
115
|
+
pl.when(present).then(pl.col("units")).otherwise(0.0).alias("marked_units"),
|
|
116
|
+
pl.when((pl.col("units") != 0) & ~present)
|
|
117
|
+
.then(pl.lit("forced_close"))
|
|
118
|
+
.otherwise(pl.col("event").fill_null(""))
|
|
119
|
+
.alias("event"),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if weights_df is None:
|
|
123
|
+
frame = frame.with_columns(pl.col("marked_units").alias("new_units"))
|
|
124
|
+
else:
|
|
125
|
+
targets_df = weights_df.select(
|
|
126
|
+
"symbol", (pl.col("weight") * gross_vega).alias("target_vega")
|
|
127
|
+
)
|
|
128
|
+
sizable = present & (pl.col("dollar_vega") > 0)
|
|
129
|
+
frame = (
|
|
130
|
+
frame.join(targets_df, on="symbol", how="left")
|
|
131
|
+
.with_columns(
|
|
132
|
+
pl.when(sizable)
|
|
133
|
+
.then(pl.col("target_vega").fill_null(0.0) / pl.col("dollar_vega"))
|
|
134
|
+
.otherwise(pl.col("marked_units"))
|
|
135
|
+
.alias("new_units")
|
|
136
|
+
)
|
|
137
|
+
.with_columns((pl.col("new_units") - pl.col("marked_units")).alias("traded"))
|
|
138
|
+
.with_columns(
|
|
139
|
+
(
|
|
140
|
+
pl.col("cost") + pl.col("traded").abs() * pl.col("half_spread") * cost_fraction
|
|
141
|
+
).alias("cost"),
|
|
142
|
+
pl.when((pl.col("marked_units") == 0) & (pl.col("new_units") != 0))
|
|
143
|
+
.then(pl.lit("open"))
|
|
144
|
+
.when((pl.col("marked_units") != 0) & (pl.col("new_units") == 0))
|
|
145
|
+
.then(pl.lit("close"))
|
|
146
|
+
.when((pl.col("traded") != 0) & (pl.col("event") == ""))
|
|
147
|
+
.then(pl.lit("trade"))
|
|
148
|
+
.otherwise(pl.col("event"))
|
|
149
|
+
.alias("event"),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return (
|
|
153
|
+
frame.filter((pl.col("units") != 0) | (pl.col("new_units") != 0))
|
|
154
|
+
.select(
|
|
155
|
+
pl.lit(date_).alias("date"),
|
|
156
|
+
"symbol",
|
|
157
|
+
pl.col("new_units").alias("units"),
|
|
158
|
+
(pl.col("new_units") * pl.col("dollar_vega").fill_null(0.0)).alias("dollar_vega"),
|
|
159
|
+
"pnl",
|
|
160
|
+
"cost",
|
|
161
|
+
(pl.col("new_units").abs() * pl.col("half_spread")).alias("exit_cost"),
|
|
162
|
+
"event",
|
|
163
|
+
)
|
|
164
|
+
.sort("symbol")
|
|
165
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Provider protocols: anything that answers `get(date_)` with that day's frame."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from malatium.types import (
|
|
7
|
+
Alphas,
|
|
8
|
+
FactorCovariances,
|
|
9
|
+
FactorLoadings,
|
|
10
|
+
IdioVol,
|
|
11
|
+
ReferenceReturns,
|
|
12
|
+
Scores,
|
|
13
|
+
Universe,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CalendarProvider(Protocol):
|
|
18
|
+
"""Trading sessions in an inclusive window."""
|
|
19
|
+
|
|
20
|
+
def get(self, start: dt.date, end: dt.date) -> list[dt.date]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UniverseProvider(Protocol):
|
|
24
|
+
"""Names in the universe on a date, with columns [date, symbol]."""
|
|
25
|
+
|
|
26
|
+
def get(self, date_: dt.date) -> Universe: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScoresProvider(Protocol):
|
|
30
|
+
"""Cross-sectional scores with columns [date, symbol, score]."""
|
|
31
|
+
|
|
32
|
+
def get(self, date_: dt.date) -> Scores: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AlphaProvider(Protocol):
|
|
36
|
+
"""Expected P&L per dollar of vega with columns [date, symbol, alpha]."""
|
|
37
|
+
|
|
38
|
+
def get(self, date_: dt.date) -> Alphas: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReferenceReturnsProvider(Protocol):
|
|
42
|
+
"""The reference unit's marks for a date; see `ReferenceReturnsSchema`."""
|
|
43
|
+
|
|
44
|
+
def get(self, date_: dt.date) -> ReferenceReturns: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FactorLoadingsProvider(Protocol):
|
|
48
|
+
"""Factor exposures with columns [date, symbol, factor, loading]."""
|
|
49
|
+
|
|
50
|
+
def get(self, date_: dt.date) -> FactorLoadings: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FactorCovariancesProvider(Protocol):
|
|
54
|
+
"""Factor covariance with columns [date, factor_1, factor_2, covariance]."""
|
|
55
|
+
|
|
56
|
+
def get(self, date_: dt.date) -> FactorCovariances: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class IdioVolProvider(Protocol):
|
|
60
|
+
"""Idiosyncratic vol per dollar of vega with columns [date, symbol, idio_vol]."""
|
|
61
|
+
|
|
62
|
+
def get(self, date_: dt.date) -> IdioVol: ...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from malatium.optimizer.base import Objective, OptimizationError, OptimizerConstraint
|
|
2
|
+
from malatium.optimizer.constraints import (
|
|
3
|
+
FactorNeutral,
|
|
4
|
+
GrossCap,
|
|
5
|
+
GrossShortCap,
|
|
6
|
+
NetVegaNeutral,
|
|
7
|
+
PerNameCap,
|
|
8
|
+
)
|
|
9
|
+
from malatium.optimizer.mvo import MVO
|
|
10
|
+
from malatium.optimizer.objectives import MaxUtility, TurnoverPenalty
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MVO",
|
|
14
|
+
"FactorNeutral",
|
|
15
|
+
"GrossCap",
|
|
16
|
+
"GrossShortCap",
|
|
17
|
+
"MaxUtility",
|
|
18
|
+
"NetVegaNeutral",
|
|
19
|
+
"Objective",
|
|
20
|
+
"OptimizationError",
|
|
21
|
+
"OptimizerConstraint",
|
|
22
|
+
"PerNameCap",
|
|
23
|
+
"TurnoverPenalty",
|
|
24
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Objective and constraint interfaces.
|
|
2
|
+
|
|
3
|
+
`MVO` builds `cp.Problem(Maximize(sum of objective terms), constraints)` and
|
|
4
|
+
passes every term the same keyword arguments:
|
|
5
|
+
|
|
6
|
+
alphas (n,) expected daily P&L per dollar of vega
|
|
7
|
+
covariance (n, n) daily covariance of P&L per dollar of vega
|
|
8
|
+
loadings (n, k) factor loadings
|
|
9
|
+
factors list[str]
|
|
10
|
+
previous (n,) the weights held before this rebalance
|
|
11
|
+
symbols list[str]
|
|
12
|
+
|
|
13
|
+
`weights` is the (n,) CVXPY variable: each name's fraction of the gross vega
|
|
14
|
+
budget, long vega positive.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
|
|
19
|
+
import cvxpy as cp
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Objective(ABC):
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def build(self, weights: cp.Variable, **kwargs) -> cp.Expression:
|
|
25
|
+
"""An expression to maximise; a penalty returns a negative expression."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OptimizerConstraint(ABC):
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def build(self, weights: cp.Variable, **kwargs) -> cp.Constraint | list[cp.Constraint]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OptimizationError(RuntimeError):
|
|
34
|
+
"""The solver did not return an optimal point."""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Hard constraints on the weight vector. Every cap is a fraction of the gross vega budget."""
|
|
2
|
+
|
|
3
|
+
import cvxpy as cp
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from malatium.optimizer.base import OptimizerConstraint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NetVegaNeutral(OptimizerConstraint):
|
|
10
|
+
"""|sum(w)| <= tolerance."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, tolerance: float = 0.0):
|
|
13
|
+
self.tolerance = tolerance
|
|
14
|
+
|
|
15
|
+
def build(self, weights: cp.Variable, **kwargs) -> list[cp.Constraint]:
|
|
16
|
+
return [cp.sum(weights) <= self.tolerance, cp.sum(weights) >= -self.tolerance]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FactorNeutral(OptimizerConstraint):
|
|
20
|
+
"""|B' w| <= epsilon per factor; every factor unless `factors` names a subset."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, epsilon: float = 0.0, factors: tuple[str, ...] | None = None):
|
|
23
|
+
self.epsilon = epsilon
|
|
24
|
+
self.factors = factors
|
|
25
|
+
|
|
26
|
+
def build(self, weights: cp.Variable, **kwargs) -> list[cp.Constraint]:
|
|
27
|
+
loadings: np.ndarray = kwargs["loadings"]
|
|
28
|
+
names: list[str] = kwargs["factors"]
|
|
29
|
+
columns = [
|
|
30
|
+
index
|
|
31
|
+
for index, name in enumerate(names)
|
|
32
|
+
if self.factors is None or name in self.factors
|
|
33
|
+
]
|
|
34
|
+
if not columns:
|
|
35
|
+
return []
|
|
36
|
+
exposure = loadings[:, columns].T @ weights
|
|
37
|
+
return [exposure <= self.epsilon, exposure >= -self.epsilon]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PerNameCap(OptimizerConstraint):
|
|
41
|
+
"""|w_i| <= cap."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, cap: float):
|
|
44
|
+
self.cap = cap
|
|
45
|
+
|
|
46
|
+
def build(self, weights: cp.Variable, **kwargs) -> cp.Constraint:
|
|
47
|
+
return cp.abs(weights) <= self.cap
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GrossShortCap(OptimizerConstraint):
|
|
51
|
+
"""sum(max(-w_i, 0)) <= cap."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, cap: float):
|
|
54
|
+
self.cap = cap
|
|
55
|
+
|
|
56
|
+
def build(self, weights: cp.Variable, **kwargs) -> cp.Constraint:
|
|
57
|
+
return cp.sum(cp.pos(-weights)) <= self.cap
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GrossCap(OptimizerConstraint):
|
|
61
|
+
"""sum(|w_i|) <= cap; 1.0 is the whole budget."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, cap: float = 1.0):
|
|
64
|
+
self.cap = cap
|
|
65
|
+
|
|
66
|
+
def build(self, weights: cp.Variable, **kwargs) -> cp.Constraint:
|
|
67
|
+
return cp.norm1(weights) <= self.cap
|