pwb-toolbox 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

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,57 @@
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
+ )
@@ -0,0 +1,33 @@
1
+ import backtrader as bt
2
+ from tqdm import tqdm
3
+
4
+
5
+ class BaseStrategy(bt.Strategy):
6
+ """Base strategy providing progress logging utilities."""
7
+
8
+ params = (("total_days", 0),)
9
+
10
+ def __init__(self):
11
+ super().__init__()
12
+ self.pbar = tqdm(total=self.params.total_days)
13
+ self.log_data = []
14
+
15
+ def is_tradable(self, data):
16
+ """Return True if the instrument's price is not constant."""
17
+ if len(data.close) < 3:
18
+ return False
19
+ return data.close[0] != data.close[-2]
20
+
21
+ def __next__(self):
22
+ """Update progress bar and log current value."""
23
+ self.pbar.update(1)
24
+ self.log_data.append(
25
+ {
26
+ "date": self.datas[0].datetime.date(0).isoformat(),
27
+ "value": self.broker.getvalue(),
28
+ }
29
+ )
30
+
31
+ def get_latest_positions(self):
32
+ """Get a dictionary of the latest positions."""
33
+ return {data._name: self.broker.getposition(data).size for data in self.datas}
@@ -0,0 +1,153 @@
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
@@ -0,0 +1,69 @@
1
+ """Lightweight helpers for running Interactive Brokers backtests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, Mapping, Type
6
+
7
+ import backtrader as bt
8
+
9
+
10
+ class IBConnector:
11
+ """Utility for creating Backtrader IB stores and data feeds."""
12
+
13
+ def __init__(
14
+ self,
15
+ host: str = "127.0.0.1",
16
+ port: int = 7497,
17
+ client_id: int = 1,
18
+ store_class: Type[bt.stores.IBStore] | None = None,
19
+ feed_class: Type[bt.feeds.IBData] | None = None,
20
+ ) -> None:
21
+ self.host = host
22
+ self.port = port
23
+ self.client_id = client_id
24
+ self.store_class = store_class or bt.stores.IBStore
25
+ self.feed_class = feed_class or bt.feeds.IBData
26
+
27
+ def get_store(self) -> bt.stores.IBStore:
28
+ """Instantiate and return an ``IBStore``."""
29
+ return self.store_class(host=self.host, port=self.port, clientId=self.client_id)
30
+
31
+ def create_feed(self, **kwargs) -> bt.feeds.IBData:
32
+ """Create an ``IBData`` feed bound to the connector's store."""
33
+ store = kwargs.pop("store", None) or self.get_store()
34
+ return self.feed_class(store=store, **kwargs)
35
+
36
+
37
+ def run_ib_strategy(
38
+ strategy: type[bt.Strategy],
39
+ data_config: Iterable[Mapping[str, object]],
40
+ **ib_kwargs,
41
+ ):
42
+ """Run ``strategy`` with Interactive Brokers data feeds.
43
+
44
+ Parameters
45
+ ----------
46
+ strategy:
47
+ The ``bt.Strategy`` subclass to execute.
48
+ data_config:
49
+ Iterable of dictionaries passed to ``IBData`` for each feed.
50
+ ib_kwargs:
51
+ Arguments forwarded to :class:`IBConnector`.
52
+ Examples
53
+ --------
54
+ >>> data_cfg = [{"dataname": "AAPL", "name": "AAPL", "what": "MIDPOINT"}]
55
+ >>> run_ib_strategy(MyStrategy, data_cfg, host="127.0.0.1")
56
+
57
+ """
58
+ connector = IBConnector(**ib_kwargs)
59
+ cerebro = bt.Cerebro()
60
+ store = connector.get_store()
61
+ cerebro.broker = store.getbroker()
62
+
63
+ for cfg in data_config:
64
+ data = connector.create_feed(store=store, **cfg)
65
+ name = cfg.get("name")
66
+ cerebro.adddata(data, name=name)
67
+
68
+ cerebro.addstrategy(strategy)
69
+ return cerebro.run()
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,290 @@
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