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.
@@ -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
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .DS_Store
@@ -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
+ ```
@@ -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