pwb-toolbox 0.1.5__py3-none-any.whl → 0.1.7__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.
- pwb_toolbox/backtest/__init__.py +51 -0
- pwb_toolbox/backtest/base_strategy.py +33 -0
- pwb_toolbox/backtest/execution_models/__init__.py +153 -0
- pwb_toolbox/backtest/insight.py +21 -0
- pwb_toolbox/backtest/portfolio_models/__init__.py +290 -0
- pwb_toolbox/backtest/risk_models/__init__.py +175 -0
- pwb_toolbox/backtest/universe_models/__init__.py +183 -0
- pwb_toolbox/datasets/__init__.py +52 -45
- {pwb_toolbox-0.1.5.dist-info → pwb_toolbox-0.1.7.dist-info}/METADATA +18 -3
- pwb_toolbox-0.1.7.dist-info/RECORD +14 -0
- pwb_toolbox-0.1.5.dist-info/RECORD +0 -7
- {pwb_toolbox-0.1.5.dist-info → pwb_toolbox-0.1.7.dist-info}/WHEEL +0 -0
- {pwb_toolbox-0.1.5.dist-info → pwb_toolbox-0.1.7.dist-info}/licenses/LICENSE.txt +0 -0
- {pwb_toolbox-0.1.5.dist-info → pwb_toolbox-0.1.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,51 @@
|
|
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
|
+
|
18
|
+
__all__ = [
|
19
|
+
"Direction",
|
20
|
+
"Insight",
|
21
|
+
"PortfolioConstructionModel",
|
22
|
+
"EqualWeightingPortfolioConstructionModel",
|
23
|
+
"InsightWeightingPortfolioConstructionModel",
|
24
|
+
"MeanVarianceOptimizationPortfolioConstructionModel",
|
25
|
+
"BlackLittermanOptimizationPortfolioConstructionModel",
|
26
|
+
"RiskParityPortfolioConstructionModel",
|
27
|
+
"UnconstrainedMeanVariancePortfolioConstructionModel",
|
28
|
+
"TargetPercentagePortfolioConstructionModel",
|
29
|
+
"DollarCostAveragingPortfolioConstructionModel",
|
30
|
+
"InsightRatioPortfolioConstructionModel",
|
31
|
+
"RiskManagementModel",
|
32
|
+
"TrailingStopRiskManagementModel",
|
33
|
+
"MaximumDrawdownPercentPerSecurity",
|
34
|
+
"MaximumDrawdownPercentPortfolio",
|
35
|
+
"MaximumUnrealizedProfitPercentPerSecurity",
|
36
|
+
"MaximumTotalPortfolioExposure",
|
37
|
+
"SectorExposureRiskManagementModel",
|
38
|
+
"MaximumOrderQuantityPercentPerSecurity",
|
39
|
+
"CompositeRiskManagementModel",
|
40
|
+
]
|
41
|
+
from .risk_models import (
|
42
|
+
RiskManagementModel,
|
43
|
+
TrailingStopRiskManagementModel,
|
44
|
+
MaximumDrawdownPercentPerSecurity,
|
45
|
+
MaximumDrawdownPercentPortfolio,
|
46
|
+
MaximumUnrealizedProfitPercentPerSecurity,
|
47
|
+
MaximumTotalPortfolioExposure,
|
48
|
+
SectorExposureRiskManagementModel,
|
49
|
+
MaximumOrderQuantityPercentPerSecurity,
|
50
|
+
CompositeRiskManagementModel,
|
51
|
+
)
|
@@ -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,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
|
@@ -0,0 +1,175 @@
|
|
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
|
+
]
|
@@ -0,0 +1,183 @@
|
|
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
|
+
|
pwb_toolbox/datasets/__init__.py
CHANGED
@@ -813,65 +813,71 @@ def __extend_etfs(df_etfs):
|
|
813
813
|
symbols = df_etfs.symbol.unique()
|
814
814
|
mapping = {k: v for k, v in mapping.items() if k in symbols}
|
815
815
|
|
816
|
-
|
817
|
-
|
818
|
-
|
819
|
-
|
820
|
-
|
821
|
-
|
822
|
-
|
823
|
-
|
824
|
-
|
825
|
-
|
816
|
+
# Nothing to extend → just return the input
|
817
|
+
if not mapping:
|
818
|
+
return df_etfs.copy()
|
819
|
+
|
820
|
+
# ------------------------------------------------------------------ step 2
|
821
|
+
grouped = defaultdict(list) # {path: [proxy1, proxy2, ...]}
|
822
|
+
for _, (path, proxy) in mapping.items():
|
823
|
+
grouped[path].append(proxy)
|
824
|
+
|
825
|
+
# Load each dataset only if there's at least one proxy symbol
|
826
|
+
other_frames = []
|
827
|
+
for path, proxies in grouped.items():
|
828
|
+
if proxies: # skip empty lists
|
829
|
+
other_frames.append(load_dataset(path, proxies, to_usd=True))
|
826
830
|
|
831
|
+
# If no proxy data could be loaded, fall back to raw ETF data
|
832
|
+
if not other_frames:
|
833
|
+
return df_etfs.copy()
|
834
|
+
|
835
|
+
df_others = pd.concat(other_frames, ignore_index=True)
|
836
|
+
|
837
|
+
# ------------------------------------------------------------------ step 3
|
827
838
|
frames = []
|
828
|
-
for etf,
|
829
|
-
other_symbol = other[1]
|
830
|
-
# Get the ETF & Index data
|
839
|
+
for etf, (__, proxy) in mapping.items():
|
831
840
|
etf_data = df_etfs[df_etfs["symbol"] == etf]
|
832
|
-
|
833
|
-
continue
|
834
|
-
other_data = df_others[df_others["symbol"] == other_symbol]
|
835
|
-
if other_data.empty:
|
836
|
-
continue
|
837
|
-
|
838
|
-
# Find the first overlapping date
|
839
|
-
common_dates = etf_data["date"].isin(other_data["date"])
|
840
|
-
first_common_date = etf_data.loc[common_dates, "date"].min()
|
841
|
+
proxy_data = df_others[df_others["symbol"] == proxy]
|
841
842
|
|
842
|
-
if
|
843
|
-
|
843
|
+
if etf_data.empty or proxy_data.empty:
|
844
|
+
frames.append(etf_data) # keep raw ETF if proxy missing
|
844
845
|
continue
|
845
846
|
|
846
|
-
|
847
|
-
|
847
|
+
# Find first overlapping date
|
848
|
+
first_common = etf_data.loc[
|
849
|
+
etf_data["date"].isin(proxy_data["date"]), "date"
|
850
|
+
].min()
|
851
|
+
if pd.isna(first_common):
|
852
|
+
frames.append(etf_data) # no overlap → keep raw ETF
|
853
|
+
continue
|
848
854
|
|
849
|
-
# Compute
|
850
|
-
|
851
|
-
|
855
|
+
# Compute adjustment factor on that date
|
856
|
+
k = (
|
857
|
+
etf_data.loc[etf_data["date"] == first_common, "close"].iloc[0]
|
858
|
+
/ proxy_data.loc[proxy_data["date"] == first_common, "close"].iloc[0]
|
852
859
|
)
|
853
860
|
|
854
|
-
#
|
855
|
-
|
856
|
-
|
857
|
-
]
|
858
|
-
for column in ["open", "high", "low", "close"]:
|
859
|
-
index_data_before_common.loc[:, column] *= adjustment_factor
|
860
|
-
index_data_before_common.loc[:, "symbol"] = etf
|
861
|
+
# Scale proxy history before the overlap
|
862
|
+
hist = proxy_data[proxy_data["date"] < first_common].copy()
|
863
|
+
hist[["open", "high", "low", "close"]] *= k
|
864
|
+
hist["symbol"] = etf
|
861
865
|
|
862
|
-
# Combine
|
863
|
-
|
864
|
-
frames.append(combined_data)
|
866
|
+
# Combine proxy history + actual ETF data
|
867
|
+
frames.append(pd.concat([hist, etf_data]))
|
865
868
|
|
866
|
-
|
867
|
-
|
869
|
+
# Add ETFs that were never in the mapping
|
870
|
+
untouched = set(symbols) - set(mapping)
|
871
|
+
frames.append(df_etfs[df_etfs["symbol"].isin(untouched)])
|
868
872
|
|
869
|
-
|
870
|
-
|
871
|
-
|
873
|
+
return (
|
874
|
+
pd.concat(frames, ignore_index=True)
|
875
|
+
.sort_values(["date", "symbol"])
|
876
|
+
.reset_index(drop=True)
|
877
|
+
)
|
872
878
|
|
873
879
|
|
874
|
-
ALLOWED_FIELDS = {"open", "high", "low", "close"}
|
880
|
+
ALLOWED_FIELDS = {"open", "high", "low", "close", "volume"}
|
875
881
|
|
876
882
|
|
877
883
|
def get_pricing(
|
@@ -925,6 +931,7 @@ def get_pricing(
|
|
925
931
|
("Cryptocurrencies-Daily-Price", extend),
|
926
932
|
("Bonds-Daily-Price", extend),
|
927
933
|
("Commodities-Daily-Price", extend),
|
934
|
+
("Forex-Daily-Price", extend),
|
928
935
|
("Indices-Daily-Price", False), # indices generally have no proxy data
|
929
936
|
]
|
930
937
|
remaining = set(symbol_list) # symbols still to fetch
|
@@ -1,15 +1,17 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pwb-toolbox
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.7
|
4
4
|
Summary: A toolbox library for quant traders
|
5
5
|
Home-page: https://github.com/paperswithbacktest/pwb-toolbox
|
6
6
|
Author: Your Name
|
7
7
|
Author-email: hello@paperswithbacktest.com
|
8
8
|
License: MIT
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
10
12
|
Classifier: License :: OSI Approved :: MIT License
|
11
13
|
Classifier: Operating System :: OS Independent
|
12
|
-
Requires-Python: >=3.
|
14
|
+
Requires-Python: >=3.10
|
13
15
|
Description-Content-Type: text/markdown
|
14
16
|
License-File: LICENSE.txt
|
15
17
|
Requires-Dist: datasets
|
@@ -31,6 +33,7 @@ To install the pwb-toolbox package:
|
|
31
33
|
```bash
|
32
34
|
pip install pwb-toolbox
|
33
35
|
```
|
36
|
+
This package requires Python 3.10 or higher.
|
34
37
|
|
35
38
|
To login to Huggingface Hub with Access Token
|
36
39
|
|
@@ -116,6 +119,19 @@ df = pwb_ds.load_dataset(
|
|
116
119
|
)
|
117
120
|
```
|
118
121
|
|
122
|
+
## Backtest engine
|
123
|
+
|
124
|
+
The `pwb_toolbox.backtest` module offers simple building blocks for running
|
125
|
+
Backtrader simulations. Alpha models generate `Insight` objects which are turned
|
126
|
+
into portfolio weights and executed via Backtrader orders.
|
127
|
+
|
128
|
+
```python
|
129
|
+
from pwb_toolbox.backtest.examples import GoldenCrossAlpha, EqualWeightPortfolio
|
130
|
+
from pwb_toolbox.backtest import run_backtest
|
131
|
+
|
132
|
+
run_backtest(["SPY", "QQQ"], GoldenCrossAlpha(), EqualWeightPortfolio(), start="2015-01-01")
|
133
|
+
```
|
134
|
+
|
119
135
|
## Contributing
|
120
136
|
|
121
137
|
Contributions to the `pwb-toolbox` package are welcome! If you have any improvements, new datasets, or strategy ideas to share, please follow these guidelines:
|
@@ -150,5 +166,4 @@ The `pwb-toolbox` package is released under the MIT license. See the LICENSE fil
|
|
150
166
|
## Contact
|
151
167
|
|
152
168
|
For any questions, issues, or suggestions regarding the `pwb-toolbox` package, please contact the maintainers or create an issue on the repository. We appreciate your feedback and involvement in improving the package.
|
153
|
-
|
154
169
|
Happy trading!
|
@@ -0,0 +1,14 @@
|
|
1
|
+
pwb_toolbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
pwb_toolbox/backtest/__init__.py,sha256=PnyGN0ZF2Apc1yPxQPHEAE7OtEvMza63zZqJPJTyIAg,1868
|
3
|
+
pwb_toolbox/backtest/base_strategy.py,sha256=PQTO9vytnxeDplmaDUC8ORYwo9dTUbwhNrrmHlpDAAU,994
|
4
|
+
pwb_toolbox/backtest/insight.py,sha256=NPrNr7ToNUpqHvgOjgtsP1g8p1Pn8yXuD6YSO-zYePg,394
|
5
|
+
pwb_toolbox/backtest/execution_models/__init__.py,sha256=kMa-C7DPeCwB81pyOp3gjIUSYpI3EuCn1uO9vLTJK4Q,5996
|
6
|
+
pwb_toolbox/backtest/portfolio_models/__init__.py,sha256=VDDDOUhu4kPxYJsOb9dH-qHTfM-Hj8O7hmzLXGuSxs8,9353
|
7
|
+
pwb_toolbox/backtest/risk_models/__init__.py,sha256=Sbd4CeGGhxRFQfdsiMoL7ws-1NJq6IkhxQhXAnGacpY,6319
|
8
|
+
pwb_toolbox/backtest/universe_models/__init__.py,sha256=-NXd_dhPKHgfBpynWjKJ4YxHLvagNhNPfU_JUreK7fc,5715
|
9
|
+
pwb_toolbox/datasets/__init__.py,sha256=3TnI0mcjJywvkKbUdQ-dahD0Py7fjna7lG9cv07vGMg,22259
|
10
|
+
pwb_toolbox-0.1.7.dist-info/licenses/LICENSE.txt,sha256=_Wjz7o7St3iVSPBRzE0keS8XSqSJ03A3NZ6cMlTaSK8,1079
|
11
|
+
pwb_toolbox-0.1.7.dist-info/METADATA,sha256=wgpnREqh2IhIP7TOmAfkzznrXj-TNs-tzyPV-i1Xwoo,5237
|
12
|
+
pwb_toolbox-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
13
|
+
pwb_toolbox-0.1.7.dist-info/top_level.txt,sha256=TZcXcF2AMkKkibZOuq6AYsHjajPgddHAGjQUT64OYGY,12
|
14
|
+
pwb_toolbox-0.1.7.dist-info/RECORD,,
|
@@ -1,7 +0,0 @@
|
|
1
|
-
pwb_toolbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
-
pwb_toolbox/datasets/__init__.py,sha256=quP-eG9-ExUUwYFxvSwoFWmhzXH_2bfZLwTfdTHdds8,22155
|
3
|
-
pwb_toolbox-0.1.5.dist-info/licenses/LICENSE.txt,sha256=_Wjz7o7St3iVSPBRzE0keS8XSqSJ03A3NZ6cMlTaSK8,1079
|
4
|
-
pwb_toolbox-0.1.5.dist-info/METADATA,sha256=aoumC8jamm2AG8LDdqrCN13xuvS-UoDbzIMPLl0Uv9Q,4617
|
5
|
-
pwb_toolbox-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
6
|
-
pwb_toolbox-0.1.5.dist-info/top_level.txt,sha256=TZcXcF2AMkKkibZOuq6AYsHjajPgddHAGjQUT64OYGY,12
|
7
|
-
pwb_toolbox-0.1.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|