pwb-toolbox 0.1.8__tar.gz → 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/PKG-INFO +1 -1
  2. pwb_toolbox-0.1.10/pwb_toolbox/backtest/__init__.py +5 -0
  3. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/backtest/base_strategy.py +1 -1
  4. pwb_toolbox-0.1.10/pwb_toolbox/backtest/engine.py +33 -0
  5. pwb_toolbox-0.1.10/pwb_toolbox/backtest/portfolio.py +30 -0
  6. pwb_toolbox-0.1.10/pwb_toolbox/backtest/universe.py +37 -0
  7. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox.egg-info/PKG-INFO +1 -1
  8. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox.egg-info/SOURCES.txt +3 -5
  9. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/setup.cfg +1 -1
  10. pwb_toolbox-0.1.8/pwb_toolbox/backtest/__init__.py +0 -57
  11. pwb_toolbox-0.1.8/pwb_toolbox/backtest/execution_models/__init__.py +0 -153
  12. pwb_toolbox-0.1.8/pwb_toolbox/backtest/insight.py +0 -21
  13. pwb_toolbox-0.1.8/pwb_toolbox/backtest/portfolio_models/__init__.py +0 -290
  14. pwb_toolbox-0.1.8/pwb_toolbox/backtest/risk_models/__init__.py +0 -175
  15. pwb_toolbox-0.1.8/pwb_toolbox/backtest/universe_models/__init__.py +0 -183
  16. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/LICENSE.txt +0 -0
  17. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/README.md +0 -0
  18. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/__init__.py +0 -0
  19. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/backtest/ib_connector.py +0 -0
  20. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/datasets/__init__.py +0 -0
  21. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/performance/__init__.py +0 -0
  22. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/performance/metrics.py +0 -0
  23. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/performance/plots.py +0 -0
  24. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox/performance/trade_stats.py +0 -0
  25. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox.egg-info/dependency_links.txt +0 -0
  26. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox.egg-info/requires.txt +0 -0
  27. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pwb_toolbox.egg-info/top_level.txt +0 -0
  28. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/pyproject.toml +0 -0
  29. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_backtest.py +0 -0
  30. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_execution_models.py +0 -0
  31. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_hf_token.py +0 -0
  32. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_ib_connector.py +0 -0
  33. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_portfolio_models.py +0 -0
  34. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_risk_models.py +0 -0
  35. {pwb_toolbox-0.1.8 → pwb_toolbox-0.1.10}/tests/test_universe_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pwb-toolbox
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A toolbox library for quant traders
5
5
  Home-page: https://github.com/paperswithbacktest/pwb-toolbox
6
6
  Author: Your Name
@@ -0,0 +1,5 @@
1
+ from .base_strategy import BaseStrategy
2
+ from .engine import run_strategy
3
+ from .ib_connector import IBConnector, run_ib_strategy
4
+ from .portfolio import MonthlyEqualWeightPortfolio
5
+ from .universe import get_most_liquid_symbols, get_least_volatile_symbols
@@ -18,7 +18,7 @@ class BaseStrategy(bt.Strategy):
18
18
  return False
19
19
  return data.close[0] != data.close[-2]
20
20
 
21
- def __next__(self):
21
+ def next(self):
22
22
  """Update progress bar and log current value."""
23
23
  self.pbar.update(1)
24
24
  self.log_data.append(
@@ -0,0 +1,33 @@
1
+ import backtrader as bt
2
+ import pandas as pd
3
+ import pwb_toolbox.datasets as pwb_ds
4
+
5
+
6
+ def run_strategy(signal, signal_kwargs, portfolio, symbols, start_date, leverage, cash):
7
+ """Run a tactical asset allocation strategy with Backtrader."""
8
+ # Load the data from https://paperswithbacktest.com/datasets
9
+ pivot_df = pwb_ds.get_pricing(
10
+ symbol_list=symbols,
11
+ fields=["open", "high", "low", "close"],
12
+ start_date=start_date,
13
+ extend=True, # Extend the dataset with proxy data
14
+ )
15
+ # Create trading-day index (optional but keeps Cerebro happy)
16
+ trading_days = pd.bdate_range(pivot_df.index.min(), pivot_df.index.max())
17
+ pivot_df = pivot_df.reindex(trading_days)
18
+ pivot_df.ffill(inplace=True) # forward-fill holidays
19
+ pivot_df.bfill(inplace=True) # back-fill leading IPO gaps
20
+ cerebro = bt.Cerebro()
21
+ for symbol in pivot_df.columns.levels[0]:
22
+ data = bt.feeds.PandasData(dataname=pivot_df[symbol].copy())
23
+ cerebro.adddata(data, name=symbol)
24
+ cerebro.addstrategy(
25
+ portfolio,
26
+ total_days=len(trading_days),
27
+ leverage=0.9,
28
+ signal_cls=signal,
29
+ signal_kwargs=signal_kwargs,
30
+ )
31
+ cerebro.broker.set_cash(cash)
32
+ strategy = cerebro.run()[0]
33
+ return strategy
@@ -0,0 +1,30 @@
1
+ from .base_strategy import BaseStrategy
2
+
3
+
4
+ class MonthlyEqualWeightPortfolio(BaseStrategy):
5
+ params = (
6
+ ("leverage", 0.9),
7
+ ("signal_cls", None),
8
+ ("signal_kwargs", {}),
9
+ )
10
+
11
+ def __init__(self):
12
+ super().__init__()
13
+ self.sig = {
14
+ d._name: self.p.signal_cls(d, **self.p.signal_kwargs) for d in self.datas
15
+ }
16
+ self.last_month = -1
17
+
18
+ def next(self):
19
+ """Rebalance portfolio at the start of each month."""
20
+ super().next()
21
+ today = self.datas[0].datetime.date(0)
22
+ if today.month == self.last_month:
23
+ return
24
+ self.last_month = today.month
25
+ longs = [
26
+ d for d in self.datas if self.is_tradable(d) and self.sig[d._name][0] == 1
27
+ ]
28
+ wt = (self.p.leverage / len(longs)) if longs else 0.0
29
+ for d in self.datas:
30
+ self.order_target_percent(d, target=wt if d in longs else 0)
@@ -0,0 +1,37 @@
1
+ from typing import List
2
+ import pandas as pd
3
+ import pwb_toolbox.datasets as pwb_ds
4
+
5
+
6
+ def get_most_liquid_symbols(n: int = 1_200) -> List[str]:
7
+ """Return the `n` most liquid stock symbols (volume × price on last bar)."""
8
+ df = pwb_ds.load_dataset("Stocks-Daily-Price", adjust=True, extend=True)
9
+ last_date = df["date"].max()
10
+ today = df[df["date"] == last_date].copy()
11
+ today["liquidity"] = today["volume"] * today["close"]
12
+ liquid = today.sort_values("liquidity", ascending=False)
13
+ return liquid["symbol"].tolist()[:n]
14
+
15
+
16
+ def get_least_volatile_symbols(symbols=["sp500"], start="1990-01-01") -> List[str]:
17
+ pivot = pwb_ds.get_pricing(
18
+ symbol_list=symbols,
19
+ fields=["open", "high", "low", "close"],
20
+ start_date=start,
21
+ extend=True,
22
+ )
23
+ td = pd.bdate_range(pivot.index.min(), pivot.index.max())
24
+ pivot = pivot.reindex(td).ffill().bfill()
25
+ symbols = []
26
+ for sym in pivot.columns.levels[0]:
27
+ df = (
28
+ pivot[sym]
29
+ .copy()
30
+ .reset_index()
31
+ .rename(columns={"index": "date"})
32
+ .set_index("date")
33
+ )
34
+ if df.close.pct_change().abs().max() > 3: # same volatility filter
35
+ continue
36
+ symbols.append(sym)
37
+ return symbols
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pwb-toolbox
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A toolbox library for quant traders
5
5
  Home-page: https://github.com/paperswithbacktest/pwb-toolbox
6
6
  Author: Your Name
@@ -10,12 +10,10 @@ pwb_toolbox.egg-info/requires.txt
10
10
  pwb_toolbox.egg-info/top_level.txt
11
11
  pwb_toolbox/backtest/__init__.py
12
12
  pwb_toolbox/backtest/base_strategy.py
13
+ pwb_toolbox/backtest/engine.py
13
14
  pwb_toolbox/backtest/ib_connector.py
14
- pwb_toolbox/backtest/insight.py
15
- pwb_toolbox/backtest/execution_models/__init__.py
16
- pwb_toolbox/backtest/portfolio_models/__init__.py
17
- pwb_toolbox/backtest/risk_models/__init__.py
18
- pwb_toolbox/backtest/universe_models/__init__.py
15
+ pwb_toolbox/backtest/portfolio.py
16
+ pwb_toolbox/backtest/universe.py
19
17
  pwb_toolbox/datasets/__init__.py
20
18
  pwb_toolbox/performance/__init__.py
21
19
  pwb_toolbox/performance/metrics.py
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = pwb-toolbox
3
- version = 0.1.8
3
+ version = 0.1.10
4
4
  author = Your Name
5
5
  author_email = hello@paperswithbacktest.com
6
6
  description = A toolbox library for quant traders
@@ -1,57 +0,0 @@
1
- from .base_strategy import BaseStrategy
2
-
3
- from .insight import Direction, Insight
4
-
5
- from .portfolio_models import (
6
- PortfolioConstructionModel,
7
- EqualWeightingPortfolioConstructionModel,
8
- InsightWeightingPortfolioConstructionModel,
9
- MeanVarianceOptimizationPortfolioConstructionModel,
10
- BlackLittermanOptimizationPortfolioConstructionModel,
11
- RiskParityPortfolioConstructionModel,
12
- UnconstrainedMeanVariancePortfolioConstructionModel,
13
- TargetPercentagePortfolioConstructionModel,
14
- DollarCostAveragingPortfolioConstructionModel,
15
- InsightRatioPortfolioConstructionModel,
16
- )
17
- from .ib_connector import IBConnector, run_ib_strategy
18
- from .example.engine import run_backtest, run_ib_backtest
19
-
20
- __all__ = [
21
- "Direction",
22
- "Insight",
23
- "PortfolioConstructionModel",
24
- "EqualWeightingPortfolioConstructionModel",
25
- "InsightWeightingPortfolioConstructionModel",
26
- "MeanVarianceOptimizationPortfolioConstructionModel",
27
- "BlackLittermanOptimizationPortfolioConstructionModel",
28
- "RiskParityPortfolioConstructionModel",
29
- "UnconstrainedMeanVariancePortfolioConstructionModel",
30
- "TargetPercentagePortfolioConstructionModel",
31
- "DollarCostAveragingPortfolioConstructionModel",
32
- "InsightRatioPortfolioConstructionModel",
33
- "RiskManagementModel",
34
- "TrailingStopRiskManagementModel",
35
- "MaximumDrawdownPercentPerSecurity",
36
- "MaximumDrawdownPercentPortfolio",
37
- "MaximumUnrealizedProfitPercentPerSecurity",
38
- "MaximumTotalPortfolioExposure",
39
- "SectorExposureRiskManagementModel",
40
- "MaximumOrderQuantityPercentPerSecurity",
41
- "CompositeRiskManagementModel",
42
- "IBConnector",
43
- "run_ib_strategy",
44
- "run_backtest",
45
- "run_ib_backtest",
46
- ]
47
- from .risk_models import (
48
- RiskManagementModel,
49
- TrailingStopRiskManagementModel,
50
- MaximumDrawdownPercentPerSecurity,
51
- MaximumDrawdownPercentPortfolio,
52
- MaximumUnrealizedProfitPercentPerSecurity,
53
- MaximumTotalPortfolioExposure,
54
- SectorExposureRiskManagementModel,
55
- MaximumOrderQuantityPercentPerSecurity,
56
- CompositeRiskManagementModel,
57
- )
@@ -1,153 +0,0 @@
1
- """Execution models for order placement using Backtrader."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Dict
6
- import backtrader as bt
7
-
8
-
9
- class ExecutionModel:
10
- """Base execution model class."""
11
-
12
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
13
- """Place orders on the given strategy."""
14
- raise NotImplementedError
15
-
16
-
17
- class ImmediateExecutionModel(ExecutionModel):
18
- """Immediately send market orders using ``order_target_percent``."""
19
-
20
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
21
- for data in strategy.datas:
22
- target = weights.get(data._name, 0.0)
23
- strategy.order_target_percent(data=data, target=target)
24
-
25
-
26
- class StandardDeviationExecutionModel(ExecutionModel):
27
- """Only trade when recent volatility exceeds a threshold."""
28
-
29
- def __init__(self, lookback: int = 20, threshold: float = 0.01):
30
- self.lookback = lookback
31
- self.threshold = threshold
32
-
33
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
34
- prices = strategy.prices
35
- for data in strategy.datas:
36
- symbol = data._name
37
- series = prices[symbol]["close"] if (symbol, "close") in prices.columns else prices[symbol]
38
- if len(series) < self.lookback:
39
- continue
40
- vol = series.pct_change().rolling(self.lookback).std().iloc[-1]
41
- if vol is not None and vol > self.threshold:
42
- target = weights.get(symbol, 0.0)
43
- strategy.order_target_percent(data=data, target=target)
44
-
45
-
46
- class VolumeWeightedAveragePriceExecutionModel(ExecutionModel):
47
- """Split orders evenly over a number of steps to approximate VWAP."""
48
-
49
- def __init__(self, steps: int = 3):
50
- self.steps = steps
51
- self._progress: Dict[str, int] = {}
52
-
53
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
54
- for data in strategy.datas:
55
- symbol = data._name
56
- step = self._progress.get(symbol, 0)
57
- if step >= self.steps:
58
- continue
59
- target = weights.get(symbol, 0.0) * (step + 1) / self.steps
60
- strategy.order_target_percent(data=data, target=target)
61
- self._progress[symbol] = step + 1
62
-
63
-
64
- class VolumePercentageExecutionModel(ExecutionModel):
65
- """Execute only a percentage of the target each call."""
66
-
67
- def __init__(self, percentage: float = 0.25):
68
- self.percentage = percentage
69
- self._filled: Dict[str, float] = {}
70
-
71
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
72
- for data in strategy.datas:
73
- symbol = data._name
74
- current = self._filled.get(symbol, 0.0)
75
- target = weights.get(symbol, 0.0)
76
- remaining = target - current
77
- if abs(remaining) < 1e-6:
78
- continue
79
- step_target = current + remaining * self.percentage
80
- self._filled[symbol] = step_target
81
- strategy.order_target_percent(data=data, target=step_target)
82
-
83
-
84
- class TimeProfileExecutionModel(ExecutionModel):
85
- """Execute orders based on a predefined time profile (e.g. TWAP)."""
86
-
87
- def __init__(self, profile: Dict[int, float] | None = None):
88
- self.profile = profile or {0: 1.0}
89
- self._called = 0
90
-
91
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
92
- factor = self.profile.get(self._called, 0.0)
93
- for data in strategy.datas:
94
- target = weights.get(data._name, 0.0) * factor
95
- strategy.order_target_percent(data=data, target=target)
96
- self._called += 1
97
-
98
-
99
- class TrailingLimitExecutionModel(ExecutionModel):
100
- """Use trailing limit orders for execution."""
101
-
102
- def __init__(self, trail: float = 0.01):
103
- self.trail = trail
104
-
105
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
106
- prices = strategy.prices
107
- for data in strategy.datas:
108
- symbol = data._name
109
- price = prices[symbol]["close"].iloc[-1] if (symbol, "close") in prices.columns else prices[symbol].iloc[-1]
110
- target = weights.get(symbol, 0.0)
111
- if target > 0:
112
- strategy.buy(data=data, exectype=bt.Order.Limit, price=price * (1 - self.trail))
113
- elif target < 0:
114
- strategy.sell(data=data, exectype=bt.Order.Limit, price=price * (1 + self.trail))
115
-
116
-
117
- class AdaptiveExecutionModel(ExecutionModel):
118
- """Switch between immediate and VWAP execution based on volatility."""
119
-
120
- def __init__(self, threshold: float = 0.02, steps: int = 3):
121
- self.threshold = threshold
122
- self.vwap = VolumeWeightedAveragePriceExecutionModel(steps=steps)
123
- self.immediate = ImmediateExecutionModel()
124
-
125
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
126
- prices = strategy.prices
127
- for data in strategy.datas:
128
- symbol = data._name
129
- series = prices[symbol]["close"] if (symbol, "close") in prices.columns else prices[symbol]
130
- if len(series) < 2:
131
- continue
132
- vol = series.pct_change().iloc[-1]
133
- if abs(vol) > self.threshold:
134
- self.immediate.execute(strategy, {symbol: weights.get(symbol, 0.0)})
135
- else:
136
- self.vwap.execute(strategy, {symbol: weights.get(symbol, 0.0)})
137
-
138
-
139
- class BufferedExecutionModel(ExecutionModel):
140
- """Only execute when target differs sufficiently from last order."""
141
-
142
- def __init__(self, buffer: float = 0.05):
143
- self.buffer = buffer
144
- self._last: Dict[str, float] = {}
145
-
146
- def execute(self, strategy: bt.Strategy, weights: Dict[str, float]):
147
- for data in strategy.datas:
148
- symbol = data._name
149
- target = weights.get(symbol, 0.0)
150
- last = self._last.get(symbol)
151
- if last is None or abs(target - last) > self.buffer:
152
- strategy.order_target_percent(data=data, target=target)
153
- self._last[symbol] = target
@@ -1,21 +0,0 @@
1
- from dataclasses import dataclass
2
- from enum import Enum, auto
3
- from datetime import datetime
4
-
5
-
6
- class Direction(Enum):
7
- """Possible directions for an Insight."""
8
-
9
- UP = auto()
10
- DOWN = auto()
11
- FLAT = auto()
12
-
13
-
14
- @dataclass
15
- class Insight:
16
- """Simple trading signal produced by an Alpha model."""
17
-
18
- symbol: str
19
- direction: Direction
20
- timestamp: datetime
21
- weight: float = 1.0
@@ -1,290 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
- from typing import Dict, Iterable
5
- import numpy as np
6
- import pandas as pd
7
-
8
- from .. import Insight, Direction
9
-
10
-
11
- class PortfolioConstructionModel(ABC):
12
- """Abstract base class for portfolio construction models."""
13
-
14
- @abstractmethod
15
- def weights(
16
- self,
17
- insights: Iterable[Insight],
18
- price_data: pd.DataFrame | None = None,
19
- ) -> Dict[str, float]:
20
- """Return target weights for each symbol."""
21
- pass
22
-
23
-
24
- class EqualWeightingPortfolioConstructionModel(PortfolioConstructionModel):
25
- """Allocate equal weight to all non-flat insights."""
26
-
27
- def weights(
28
- self,
29
- insights: Iterable[Insight],
30
- price_data: pd.DataFrame | None = None,
31
- ) -> Dict[str, float]:
32
- active = [i for i in insights if i.direction != Direction.FLAT]
33
- if not active:
34
- return {}
35
- w = 1.0 / len(active)
36
- return {i.symbol: (w if i.direction == Direction.UP else -w) for i in active}
37
-
38
-
39
- class InsightWeightingPortfolioConstructionModel(PortfolioConstructionModel):
40
- """Weight positions according to insight weight attribute."""
41
-
42
- def weights(
43
- self,
44
- insights: Iterable[Insight],
45
- price_data: pd.DataFrame | None = None,
46
- ) -> Dict[str, float]:
47
- active = [i for i in insights if i.direction != Direction.FLAT]
48
- if not active:
49
- return {}
50
- total = sum(abs(i.weight) for i in active)
51
- if total == 0:
52
- return {}
53
- return {
54
- i.symbol: (i.weight / total) * (1 if i.direction == Direction.UP else -1)
55
- for i in active
56
- }
57
-
58
-
59
- class RiskParityPortfolioConstructionModel(PortfolioConstructionModel):
60
- """Simple risk parity based on inverse volatility."""
61
-
62
- def __init__(self, lookback: int = 20):
63
- self.lookback = lookback
64
-
65
- def weights(
66
- self,
67
- insights: Iterable[Insight],
68
- price_data: pd.DataFrame | None = None,
69
- ) -> Dict[str, float]:
70
- active = [i for i in insights if i.direction != Direction.FLAT]
71
- if not active or price_data is None:
72
- return {}
73
- vols = {}
74
- for ins in active:
75
- prices = (
76
- price_data[ins.symbol]["close"]
77
- if (ins.symbol, "close") in price_data.columns
78
- else price_data[ins.symbol]
79
- )
80
- if len(prices) < self.lookback:
81
- return {}
82
- vols[ins.symbol] = prices.pct_change().rolling(self.lookback).std().iloc[-1]
83
- inv_vol = {s: 1.0 / v for s, v in vols.items() if v > 0}
84
- total = sum(inv_vol.values())
85
- if total == 0:
86
- return {}
87
- return {
88
- s: (inv_vol[s] / total)
89
- * (
90
- 1
91
- if next(i for i in active if i.symbol == s).direction == Direction.UP
92
- else -1
93
- )
94
- for s in inv_vol
95
- }
96
-
97
-
98
- class MeanVarianceOptimizationPortfolioConstructionModel(PortfolioConstructionModel):
99
- """Mean-variance optimization with weight normalization."""
100
-
101
- def __init__(self, lookback: int = 60):
102
- self.lookback = lookback
103
-
104
- def weights(
105
- self,
106
- insights: Iterable[Insight],
107
- price_data: pd.DataFrame | None = None,
108
- ) -> Dict[str, float]:
109
- active = [i for i in insights if i.direction != Direction.FLAT]
110
- if not active or price_data is None:
111
- return {}
112
- symbols = [i.symbol for i in active]
113
- df = price_data[symbols]
114
- if isinstance(df.columns, pd.MultiIndex):
115
- df = df.xs("close", axis=1, level=-1)
116
- if len(df) < self.lookback:
117
- return {}
118
- rets = df.pct_change().dropna()
119
- mu = rets.mean()
120
- cov = rets.cov()
121
- inv_cov = np.linalg.pinv(cov.values)
122
- exp = np.array(
123
- [
124
- mu[s]
125
- * (
126
- 1
127
- if next(i for i in active if i.symbol == s).direction
128
- == Direction.UP
129
- else -1
130
- )
131
- for s in mu.index
132
- ]
133
- )
134
- raw = inv_cov.dot(exp)
135
- total = np.sum(np.abs(raw))
136
- if total == 0:
137
- return {}
138
- w = raw / total
139
- return {s: float(w[i]) for i, s in enumerate(mu.index)}
140
-
141
-
142
- class UnconstrainedMeanVariancePortfolioConstructionModel(PortfolioConstructionModel):
143
- """Mean-variance optimization without normalization of weights."""
144
-
145
- def __init__(self, lookback: int = 60):
146
- self.lookback = lookback
147
-
148
- def weights(
149
- self,
150
- insights: Iterable[Insight],
151
- price_data: pd.DataFrame | None = None,
152
- ) -> Dict[str, float]:
153
- active = [i for i in insights if i.direction != Direction.FLAT]
154
- if not active or price_data is None:
155
- return {}
156
- symbols = [i.symbol for i in active]
157
- df = price_data[symbols]
158
- if isinstance(df.columns, pd.MultiIndex):
159
- df = df.xs("close", axis=1, level=-1)
160
- if len(df) < self.lookback:
161
- return {}
162
- rets = df.pct_change().dropna()
163
- mu = rets.mean()
164
- cov = rets.cov()
165
- inv_cov = np.linalg.pinv(cov.values)
166
- exp = np.array(
167
- [
168
- mu[s]
169
- * (
170
- 1
171
- if next(i for i in active if i.symbol == s).direction
172
- == Direction.UP
173
- else -1
174
- )
175
- for s in mu.index
176
- ]
177
- )
178
- raw = inv_cov.dot(exp)
179
- return {s: float(raw[i]) for i, s in enumerate(mu.index)}
180
-
181
-
182
- class BlackLittermanOptimizationPortfolioConstructionModel(
183
- MeanVarianceOptimizationPortfolioConstructionModel
184
- ):
185
- """Simplified Black-Litterman model using a blend of market and view returns."""
186
-
187
- def __init__(self, lookback: int = 60, view_weight: float = 0.5):
188
- super().__init__(lookback)
189
- self.view_weight = view_weight
190
-
191
- def weights(
192
- self,
193
- insights: Iterable[Insight],
194
- price_data: pd.DataFrame | None = None,
195
- ) -> Dict[str, float]:
196
- active = [i for i in insights if i.direction != Direction.FLAT]
197
- if not active or price_data is None:
198
- return {}
199
- symbols = [i.symbol for i in active]
200
- df = price_data[symbols]
201
- if isinstance(df.columns, pd.MultiIndex):
202
- df = df.xs("close", axis=1, level=-1)
203
- if len(df) < self.lookback:
204
- return {}
205
- rets = df.pct_change().dropna()
206
- market_mu = rets.mean()
207
- view_mu = pd.Series(
208
- {i.symbol: (1 if i.direction == Direction.UP else -1) for i in active}
209
- )
210
- mu = (1 - self.view_weight) * market_mu + self.view_weight * view_mu
211
- cov = rets.cov()
212
- inv_cov = np.linalg.pinv(cov.values)
213
- exp = mu.loc[market_mu.index].values
214
- raw = inv_cov.dot(exp)
215
- total = np.sum(np.abs(raw))
216
- if total == 0:
217
- return {}
218
- w = raw / total
219
- return {s: float(w[i]) for i, s in enumerate(market_mu.index)}
220
-
221
-
222
- class TargetPercentagePortfolioConstructionModel(PortfolioConstructionModel):
223
- """Return predefined target portfolio percentages."""
224
-
225
- def __init__(self, targets: Dict[str, float]):
226
- self.targets = targets
227
-
228
- def weights(
229
- self,
230
- insights: Iterable[Insight],
231
- price_data: pd.DataFrame | None = None,
232
- ) -> Dict[str, float]:
233
- active_symbols = {
234
- i.symbol: i for i in insights if i.direction != Direction.FLAT
235
- }
236
- return {
237
- s: (
238
- self.targets.get(s, 0.0)
239
- * (1 if active_symbols[s].direction == Direction.UP else -1)
240
- )
241
- for s in active_symbols
242
- if s in self.targets
243
- }
244
-
245
-
246
- class DollarCostAveragingPortfolioConstructionModel(PortfolioConstructionModel):
247
- """Allocate a fixed percentage to each new insight."""
248
-
249
- def __init__(self, allocation: float = 0.1):
250
- self.allocation = allocation
251
-
252
- def weights(
253
- self,
254
- insights: Iterable[Insight],
255
- price_data: pd.DataFrame | None = None,
256
- ) -> Dict[str, float]:
257
- active = [i for i in insights if i.direction != Direction.FLAT]
258
- if not active:
259
- return {}
260
- return {
261
- i.symbol: self.allocation * (1 if i.direction == Direction.UP else -1)
262
- for i in active
263
- }
264
-
265
-
266
- class InsightRatioPortfolioConstructionModel(PortfolioConstructionModel):
267
- """Scale long and short exposure by the ratio of insights."""
268
-
269
- def weights(
270
- self,
271
- insights: Iterable[Insight],
272
- price_data: pd.DataFrame | None = None,
273
- ) -> Dict[str, float]:
274
- ups = [i for i in insights if i.direction == Direction.UP]
275
- downs = [i for i in insights if i.direction == Direction.DOWN]
276
- total = len(ups) + len(downs)
277
- if total == 0:
278
- return {}
279
- up_share = len(ups) / total
280
- down_share = len(downs) / total
281
- weights = {}
282
- if ups:
283
- per = up_share / len(ups)
284
- for i in ups:
285
- weights[i.symbol] = per
286
- if downs:
287
- per = down_share / len(downs)
288
- for i in downs:
289
- weights[i.symbol] = -per
290
- return weights
@@ -1,175 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Dict, Iterable
4
-
5
-
6
- class RiskManagementModel:
7
- """Base class for risk management models."""
8
-
9
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float]) -> Dict[str, float]:
10
- """Return adjusted target weights based on risk rules."""
11
- raise NotImplementedError
12
-
13
-
14
- class TrailingStopRiskManagementModel(RiskManagementModel):
15
- """Close positions if price falls a percentage from the peak."""
16
-
17
- def __init__(self, percent: float = 0.1):
18
- self.percent = percent
19
- self._highs: Dict[str, float] = {}
20
-
21
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float]) -> Dict[str, float]:
22
- out = dict(weights)
23
- for symbol, weight in weights.items():
24
- price = prices.get(symbol)
25
- if price is None:
26
- continue
27
- high = self._highs.get(symbol, price)
28
- if price > high:
29
- high = price
30
- self._highs[symbol] = high
31
- if weight != 0 and price <= high * (1 - self.percent):
32
- out[symbol] = 0.0
33
- return out
34
-
35
-
36
- class MaximumDrawdownPercentPerSecurity(TrailingStopRiskManagementModel):
37
- """Alias of trailing stop for per-security drawdown."""
38
-
39
- def __init__(self, max_drawdown: float = 0.1):
40
- super().__init__(percent=max_drawdown)
41
-
42
-
43
- class MaximumDrawdownPercentPortfolio(RiskManagementModel):
44
- """Flatten portfolio if total drawdown exceeds a threshold."""
45
-
46
- def __init__(self, max_drawdown: float = 0.2):
47
- self.max_drawdown = max_drawdown
48
- self._high: float | None = None
49
-
50
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float]) -> Dict[str, float]:
51
- nav = sum(weights.get(s, 0.0) * prices.get(s, 0.0) for s in weights)
52
- if self._high is None:
53
- self._high = nav
54
- if nav > self._high:
55
- self._high = nav
56
- if self._high and nav <= self._high * (1 - self.max_drawdown):
57
- return {s: 0.0 for s in weights}
58
- return weights
59
-
60
-
61
- class MaximumUnrealizedProfitPercentPerSecurity(RiskManagementModel):
62
- """Take profit once unrealized gain exceeds threshold."""
63
-
64
- def __init__(self, max_profit: float = 0.2):
65
- self.max_profit = max_profit
66
- self._entry: Dict[str, float] = {}
67
-
68
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float]) -> Dict[str, float]:
69
- out = dict(weights)
70
- for symbol, weight in weights.items():
71
- price = prices.get(symbol)
72
- if price is None:
73
- continue
74
- if weight == 0:
75
- self._entry.pop(symbol, None)
76
- continue
77
- entry = self._entry.get(symbol)
78
- if entry is None:
79
- self._entry[symbol] = price
80
- continue
81
- if weight > 0:
82
- profit = (price - entry) / entry
83
- else:
84
- profit = (entry - price) / entry
85
- if profit >= self.max_profit:
86
- out[symbol] = 0.0
87
- self._entry.pop(symbol, None)
88
- return out
89
-
90
-
91
- class MaximumTotalPortfolioExposure(RiskManagementModel):
92
- """Scale weights so total gross exposure stays below a limit."""
93
-
94
- def __init__(self, max_exposure: float = 1.0):
95
- self.max_exposure = max_exposure
96
-
97
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float] | None = None) -> Dict[str, float]:
98
- gross = sum(abs(w) for w in weights.values())
99
- if gross <= self.max_exposure or gross == 0:
100
- return weights
101
- scale = self.max_exposure / gross
102
- return {s: w * scale for s, w in weights.items()}
103
-
104
-
105
- class SectorExposureRiskManagementModel(RiskManagementModel):
106
- """Limit exposure by sector."""
107
-
108
- def __init__(self, sector_map: Dict[str, str], limit: float = 0.3):
109
- self.sector_map = sector_map
110
- self.limit = limit
111
-
112
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float] | None = None) -> Dict[str, float]:
113
- out = dict(weights)
114
- exposures: Dict[str, float] = {}
115
- for symbol, weight in weights.items():
116
- sector = self.sector_map.get(symbol)
117
- if sector is None:
118
- continue
119
- exposures[sector] = exposures.get(sector, 0.0) + abs(weight)
120
- for sector, exposure in exposures.items():
121
- if exposure > self.limit and exposure != 0:
122
- factor = self.limit / exposure
123
- for symbol, weight in weights.items():
124
- if self.sector_map.get(symbol) == sector:
125
- out[symbol] = weight * factor
126
- return out
127
-
128
-
129
- class MaximumOrderQuantityPercentPerSecurity(RiskManagementModel):
130
- """Cap the change in weight for each security per evaluation call."""
131
-
132
- def __init__(self, max_percent: float = 0.1):
133
- self.max_percent = max_percent
134
- self._prev: Dict[str, float] = {}
135
-
136
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float] | None = None) -> Dict[str, float]:
137
- out = {}
138
- for symbol, target in weights.items():
139
- prev = self._prev.get(symbol, 0.0)
140
- diff = target - prev
141
- if diff > self.max_percent:
142
- new = prev + self.max_percent
143
- elif diff < -self.max_percent:
144
- new = prev - self.max_percent
145
- else:
146
- new = target
147
- out[symbol] = new
148
- self._prev[symbol] = new
149
- return out
150
-
151
-
152
- class CompositeRiskManagementModel(RiskManagementModel):
153
- """Combine multiple risk models sequentially."""
154
-
155
- def __init__(self, models: Iterable[RiskManagementModel]):
156
- self.models = list(models)
157
-
158
- def evaluate(self, weights: Dict[str, float], prices: Dict[str, float]) -> Dict[str, float]:
159
- out = dict(weights)
160
- for model in self.models:
161
- out = model.evaluate(out, prices)
162
- return out
163
-
164
-
165
- __all__ = [
166
- "RiskManagementModel",
167
- "TrailingStopRiskManagementModel",
168
- "MaximumDrawdownPercentPerSecurity",
169
- "MaximumDrawdownPercentPortfolio",
170
- "MaximumUnrealizedProfitPercentPerSecurity",
171
- "MaximumTotalPortfolioExposure",
172
- "SectorExposureRiskManagementModel",
173
- "MaximumOrderQuantityPercentPerSecurity",
174
- "CompositeRiskManagementModel",
175
- ]
@@ -1,183 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
- from datetime import date
5
- from typing import Callable, Dict, Iterable, List, Sequence
6
-
7
- import pandas as pd
8
-
9
- from ...datasets import load_dataset
10
-
11
-
12
- class UniverseSelectionModel(ABC):
13
- """Base class for universe selection models."""
14
-
15
- @abstractmethod
16
- def symbols(self, as_of: date | str | None = None) -> List[str]:
17
- """Return the active list of symbols."""
18
- raise NotImplementedError
19
-
20
-
21
- class ManualUniverseSelectionModel(UniverseSelectionModel):
22
- """Universe defined by a static list of tickers."""
23
-
24
- def __init__(self, symbols: Sequence[str]):
25
- self._symbols = list(symbols)
26
-
27
- def symbols(self, as_of: date | str | None = None) -> List[str]:
28
- return list(self._symbols)
29
-
30
-
31
- class ScheduledUniverseSelectionModel(UniverseSelectionModel):
32
- """Switch universe based on a schedule of dates."""
33
-
34
- def __init__(self, schedule: Dict[date | str, Sequence[str]]):
35
- self.schedule = {
36
- (pd.Timestamp(k).date() if not isinstance(k, date) else k): list(v)
37
- for k, v in schedule.items()
38
- }
39
-
40
- def symbols(self, as_of: date | str | None = None) -> List[str]:
41
- if not self.schedule:
42
- return []
43
- dt = pd.Timestamp(as_of or date.today()).date()
44
- valid = [d for d in self.schedule if d <= dt]
45
- if not valid:
46
- return []
47
- last = max(valid)
48
- return self.schedule[last]
49
-
50
-
51
- class CoarseFundamentalUniverseSelectionModel(UniverseSelectionModel):
52
- """Universe filtered using coarse fundamental data."""
53
-
54
- def __init__(
55
- self,
56
- selector: Callable[[pd.DataFrame], Iterable[str]],
57
- dataset: str = "Stocks-Quarterly-BalanceSheet",
58
- ):
59
- self.selector = selector
60
- self.dataset = dataset
61
-
62
- def symbols(self, as_of: date | str | None = None) -> List[str]:
63
- df = load_dataset(self.dataset)
64
- return list(self.selector(df))
65
-
66
-
67
- class FineFundamentalUniverseSelectionModel(UniverseSelectionModel):
68
- """Universe filtered using fine fundamental data."""
69
-
70
- def __init__(
71
- self,
72
- selector: Callable[[pd.DataFrame], Iterable[str]],
73
- dataset: str = "Stocks-Quarterly-Earnings",
74
- ):
75
- self.selector = selector
76
- self.dataset = dataset
77
-
78
- def symbols(self, as_of: date | str | None = None) -> List[str]:
79
- df = load_dataset(self.dataset)
80
- return list(self.selector(df))
81
-
82
-
83
- class ETFConstituentsUniverseSelectionModel(UniverseSelectionModel):
84
- """Universe containing constituents of a given ETF."""
85
-
86
- def __init__(self, etf: str):
87
- self.etf = etf
88
-
89
- def symbols(self, as_of: date | str | None = None) -> List[str]:
90
- df = load_dataset("ETF-Constituents")
91
- if "etf" in df.columns:
92
- col = "etf"
93
- else:
94
- col = df.columns[0] if df.columns else "etf"
95
- if df.empty:
96
- return []
97
- return list(df[df[col] == self.etf]["symbol"].unique())
98
-
99
-
100
- class IndexConstituentsUniverseSelectionModel(UniverseSelectionModel):
101
- """Universe of constituents for a specified index."""
102
-
103
- def __init__(self, index: str):
104
- self.index = index
105
-
106
- def symbols(self, as_of: date | str | None = None) -> List[str]:
107
- df = load_dataset("Index-Constituents")
108
- if df.empty:
109
- return []
110
- col = "index" if "index" in df.columns else df.columns[0]
111
- return list(df[df[col] == self.index]["symbol"].unique())
112
-
113
-
114
- class OptionUniverseSelectionModel(UniverseSelectionModel):
115
- """Universe consisting of options for the given underlyings."""
116
-
117
- def __init__(self, underlying_symbols: Sequence[str]):
118
- self.underlyings = list(underlying_symbols)
119
-
120
- def symbols(self, as_of: date | str | None = None) -> List[str]:
121
- return list(self.underlyings)
122
-
123
-
124
- class ADRUniverseSelectionModel(UniverseSelectionModel):
125
- """Universe of American Depositary Receipts."""
126
-
127
- def __init__(self, dataset: str = "ADR-Listings"):
128
- self.dataset = dataset
129
-
130
- def symbols(self, as_of: date | str | None = None) -> List[str]:
131
- df = load_dataset(self.dataset)
132
- if df.empty:
133
- return []
134
- return list(df["symbol"].unique())
135
-
136
-
137
- class CryptoUniverseSelectionModel(UniverseSelectionModel):
138
- """Universe built from cryptocurrency tickers."""
139
-
140
- def __init__(self, top_n: int | None = None):
141
- self.top_n = top_n
142
-
143
- def symbols(self, as_of: date | str | None = None) -> List[str]:
144
- df = load_dataset("Cryptocurrencies-Daily-Price")
145
- syms = list(dict.fromkeys(df["symbol"]))
146
- if self.top_n is not None:
147
- syms = syms[: self.top_n]
148
- return syms
149
-
150
-
151
- class UniverseSelectionModelChain(UniverseSelectionModel):
152
- """Combine multiple universe selection models."""
153
-
154
- def __init__(self, models: Iterable[UniverseSelectionModel]):
155
- self.models = list(models)
156
-
157
- def symbols(self, as_of: date | str | None = None) -> List[str]:
158
- all_syms: List[str] = []
159
- for m in self.models:
160
- all_syms.extend(m.symbols(as_of))
161
- seen = set()
162
- uniq = []
163
- for s in all_syms:
164
- if s not in seen:
165
- seen.add(s)
166
- uniq.append(s)
167
- return uniq
168
-
169
-
170
- __all__ = [
171
- "UniverseSelectionModel",
172
- "ManualUniverseSelectionModel",
173
- "ScheduledUniverseSelectionModel",
174
- "CoarseFundamentalUniverseSelectionModel",
175
- "FineFundamentalUniverseSelectionModel",
176
- "ETFConstituentsUniverseSelectionModel",
177
- "IndexConstituentsUniverseSelectionModel",
178
- "OptionUniverseSelectionModel",
179
- "ADRUniverseSelectionModel",
180
- "CryptoUniverseSelectionModel",
181
- "UniverseSelectionModelChain",
182
- ]
183
-
File without changes
File without changes
File without changes