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.
@@ -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 |
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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: ...