pwb-toolbox 0.1.7__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.
- pwb_toolbox/backtest/__init__.py +6 -0
- pwb_toolbox/backtest/ib_connector.py +69 -0
- pwb_toolbox/datasets/__init__.py +7 -4
- pwb_toolbox/performance/__init__.py +123 -0
- pwb_toolbox/performance/metrics.py +465 -0
- pwb_toolbox/performance/plots.py +415 -0
- pwb_toolbox/performance/trade_stats.py +138 -0
- {pwb_toolbox-0.1.7.dist-info → pwb_toolbox-0.1.8.dist-info}/METADATA +62 -2
- pwb_toolbox-0.1.8.dist-info/RECORD +19 -0
- pwb_toolbox-0.1.7.dist-info/RECORD +0 -14
- {pwb_toolbox-0.1.7.dist-info → pwb_toolbox-0.1.8.dist-info}/WHEEL +0 -0
- {pwb_toolbox-0.1.7.dist-info → pwb_toolbox-0.1.8.dist-info}/licenses/LICENSE.txt +0 -0
- {pwb_toolbox-0.1.7.dist-info → pwb_toolbox-0.1.8.dist-info}/top_level.txt +0 -0
pwb_toolbox/backtest/__init__.py
CHANGED
@@ -14,6 +14,8 @@ from .portfolio_models import (
|
|
14
14
|
DollarCostAveragingPortfolioConstructionModel,
|
15
15
|
InsightRatioPortfolioConstructionModel,
|
16
16
|
)
|
17
|
+
from .ib_connector import IBConnector, run_ib_strategy
|
18
|
+
from .example.engine import run_backtest, run_ib_backtest
|
17
19
|
|
18
20
|
__all__ = [
|
19
21
|
"Direction",
|
@@ -37,6 +39,10 @@ __all__ = [
|
|
37
39
|
"SectorExposureRiskManagementModel",
|
38
40
|
"MaximumOrderQuantityPercentPerSecurity",
|
39
41
|
"CompositeRiskManagementModel",
|
42
|
+
"IBConnector",
|
43
|
+
"run_ib_strategy",
|
44
|
+
"run_backtest",
|
45
|
+
"run_ib_backtest",
|
40
46
|
]
|
41
47
|
from .risk_models import (
|
42
48
|
RiskManagementModel,
|
@@ -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()
|
pwb_toolbox/datasets/__init__.py
CHANGED
@@ -6,9 +6,12 @@ import re
|
|
6
6
|
import datasets as ds
|
7
7
|
import pandas as pd
|
8
8
|
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
|
10
|
+
def _get_hf_token() -> str:
|
11
|
+
token = os.getenv("HF_ACCESS_TOKEN")
|
12
|
+
if not token:
|
13
|
+
raise ValueError("HF_ACCESS_TOKEN not set")
|
14
|
+
return token
|
12
15
|
|
13
16
|
|
14
17
|
DAILY_PRICE_DATASETS = [
|
@@ -552,7 +555,7 @@ def load_dataset(
|
|
552
555
|
to_usd=True,
|
553
556
|
rate_to_price=True,
|
554
557
|
):
|
555
|
-
dataset = ds.load_dataset(f"paperswithbacktest/{path}", token=
|
558
|
+
dataset = ds.load_dataset(f"paperswithbacktest/{path}", token=_get_hf_token())
|
556
559
|
df = dataset["train"].to_pandas()
|
557
560
|
|
558
561
|
if path in DAILY_PRICE_DATASETS or path in DAILY_FINANCIAL_DATASETS:
|
@@ -0,0 +1,123 @@
|
|
1
|
+
from .metrics import (
|
2
|
+
total_return,
|
3
|
+
cagr,
|
4
|
+
returns_table,
|
5
|
+
rolling_cumulative_return,
|
6
|
+
annualized_volatility,
|
7
|
+
max_drawdown,
|
8
|
+
ulcer_index,
|
9
|
+
ulcer_performance_index,
|
10
|
+
parametric_var,
|
11
|
+
parametric_expected_shortfall,
|
12
|
+
tail_ratio,
|
13
|
+
sharpe_ratio,
|
14
|
+
sortino_ratio,
|
15
|
+
calmar_ratio,
|
16
|
+
omega_ratio,
|
17
|
+
information_ratio,
|
18
|
+
capm_alpha_beta,
|
19
|
+
skewness,
|
20
|
+
kurtosis,
|
21
|
+
variance_ratio,
|
22
|
+
acf,
|
23
|
+
pacf,
|
24
|
+
fama_french_3factor,
|
25
|
+
fama_french_5factor,
|
26
|
+
cumulative_excess_return,
|
27
|
+
)
|
28
|
+
|
29
|
+
from .trade_stats import (
|
30
|
+
hit_rate,
|
31
|
+
average_win_loss,
|
32
|
+
expectancy,
|
33
|
+
profit_factor,
|
34
|
+
trade_duration_distribution,
|
35
|
+
turnover,
|
36
|
+
trade_implementation_shortfall,
|
37
|
+
cumulative_implementation_shortfall,
|
38
|
+
slippage_stats,
|
39
|
+
latency_stats,
|
40
|
+
)
|
41
|
+
|
42
|
+
__all__ = [
|
43
|
+
"total_return",
|
44
|
+
"cagr",
|
45
|
+
"returns_table",
|
46
|
+
"rolling_cumulative_return",
|
47
|
+
"annualized_volatility",
|
48
|
+
"max_drawdown",
|
49
|
+
"ulcer_index",
|
50
|
+
"ulcer_performance_index",
|
51
|
+
"parametric_var",
|
52
|
+
"parametric_expected_shortfall",
|
53
|
+
"tail_ratio",
|
54
|
+
"sharpe_ratio",
|
55
|
+
"sortino_ratio",
|
56
|
+
"calmar_ratio",
|
57
|
+
"omega_ratio",
|
58
|
+
"information_ratio",
|
59
|
+
"capm_alpha_beta",
|
60
|
+
"skewness",
|
61
|
+
"kurtosis",
|
62
|
+
"variance_ratio",
|
63
|
+
"acf",
|
64
|
+
"pacf",
|
65
|
+
"fama_french_3factor",
|
66
|
+
"fama_french_5factor",
|
67
|
+
"cumulative_excess_return",
|
68
|
+
"hit_rate",
|
69
|
+
"average_win_loss",
|
70
|
+
"expectancy",
|
71
|
+
"profit_factor",
|
72
|
+
"trade_duration_distribution",
|
73
|
+
"turnover",
|
74
|
+
"trade_implementation_shortfall",
|
75
|
+
"cumulative_implementation_shortfall",
|
76
|
+
"slippage_stats",
|
77
|
+
"latency_stats",
|
78
|
+
]
|
79
|
+
|
80
|
+
try: # pragma: no cover - optional plotting deps
|
81
|
+
from .plots import (
|
82
|
+
plot_equity_curve,
|
83
|
+
plot_return_heatmap,
|
84
|
+
plot_underwater,
|
85
|
+
plot_rolling_volatility,
|
86
|
+
plot_rolling_var,
|
87
|
+
plot_rolling_sharpe,
|
88
|
+
plot_rolling_sortino,
|
89
|
+
plot_return_scatter,
|
90
|
+
plot_cumulative_excess_return,
|
91
|
+
plot_factor_exposures,
|
92
|
+
plot_trade_return_hist,
|
93
|
+
plot_return_by_holding_period,
|
94
|
+
plot_exposure_ts,
|
95
|
+
plot_cumulative_shortfall,
|
96
|
+
plot_alpha_vs_return,
|
97
|
+
plot_qq_returns,
|
98
|
+
plot_rolling_skewness,
|
99
|
+
plot_rolling_kurtosis,
|
100
|
+
)
|
101
|
+
|
102
|
+
__all__ += [
|
103
|
+
"plot_equity_curve",
|
104
|
+
"plot_return_heatmap",
|
105
|
+
"plot_underwater",
|
106
|
+
"plot_rolling_volatility",
|
107
|
+
"plot_rolling_var",
|
108
|
+
"plot_rolling_sharpe",
|
109
|
+
"plot_rolling_sortino",
|
110
|
+
"plot_return_scatter",
|
111
|
+
"plot_cumulative_excess_return",
|
112
|
+
"plot_factor_exposures",
|
113
|
+
"plot_trade_return_hist",
|
114
|
+
"plot_return_by_holding_period",
|
115
|
+
"plot_exposure_ts",
|
116
|
+
"plot_cumulative_shortfall",
|
117
|
+
"plot_alpha_vs_return",
|
118
|
+
"plot_qq_returns",
|
119
|
+
"plot_rolling_skewness",
|
120
|
+
"plot_rolling_kurtosis",
|
121
|
+
]
|
122
|
+
except Exception: # pragma: no cover - matplotlib may be missing
|
123
|
+
pass
|
@@ -0,0 +1,465 @@
|
|
1
|
+
from calendar import month_abbr
|
2
|
+
from typing import Sequence, Tuple
|
3
|
+
from math import sqrt
|
4
|
+
from statistics import NormalDist
|
5
|
+
|
6
|
+
try:
|
7
|
+
import pandas as pd # type: ignore
|
8
|
+
except ModuleNotFoundError: # pragma: no cover - optional dependency
|
9
|
+
pd = None # type: ignore
|
10
|
+
|
11
|
+
|
12
|
+
def _to_list(data: Sequence[float]) -> list:
|
13
|
+
"""Convert Series-like data to list."""
|
14
|
+
if hasattr(data, "values"):
|
15
|
+
return list(data.values)
|
16
|
+
return list(data)
|
17
|
+
|
18
|
+
|
19
|
+
def total_return(prices: Sequence[float]) -> float:
|
20
|
+
"""Return total return of a price series."""
|
21
|
+
p = _to_list(prices)
|
22
|
+
if not p:
|
23
|
+
return 0.0
|
24
|
+
return p[-1] / p[0] - 1
|
25
|
+
|
26
|
+
|
27
|
+
def cagr(prices: Sequence[float], periods_per_year: int = 252) -> float:
|
28
|
+
"""Compound annual growth rate from a price series."""
|
29
|
+
p = _to_list(prices)
|
30
|
+
if len(p) < 2:
|
31
|
+
return 0.0
|
32
|
+
years = (len(p) - 1) / periods_per_year
|
33
|
+
if years == 0:
|
34
|
+
return 0.0
|
35
|
+
return (p[-1] / p[0]) ** (1 / years) - 1
|
36
|
+
|
37
|
+
|
38
|
+
def returns_table(prices: 'pd.Series') -> 'pd.DataFrame': # type: ignore
|
39
|
+
"""Return monthly and yearly percentage returns from a daily price series."""
|
40
|
+
if pd is None:
|
41
|
+
raise ImportError("pandas is required for returns_table")
|
42
|
+
|
43
|
+
price_list = _to_list(prices)
|
44
|
+
index = list(getattr(prices, 'index', range(len(price_list))))
|
45
|
+
|
46
|
+
years = sorted({dt.year for dt in index})
|
47
|
+
months = list(range(1, 13))
|
48
|
+
data = {month_abbr[m]: [] for m in months}
|
49
|
+
data["Year"] = []
|
50
|
+
|
51
|
+
for year in years:
|
52
|
+
year_start = None
|
53
|
+
year_end = None
|
54
|
+
for m in months:
|
55
|
+
# indices belonging to year & month
|
56
|
+
idx = [i for i, dt in enumerate(index) if dt.year == year and dt.month == m]
|
57
|
+
if idx:
|
58
|
+
start = idx[0]
|
59
|
+
end = idx[-1]
|
60
|
+
ret = price_list[end] / price_list[start] - 1
|
61
|
+
if year_start is None:
|
62
|
+
year_start = price_list[start]
|
63
|
+
year_end = price_list[end]
|
64
|
+
else:
|
65
|
+
ret = None
|
66
|
+
data[month_abbr[m]].append(ret)
|
67
|
+
if year_start is None:
|
68
|
+
data["Year"].append(None)
|
69
|
+
else:
|
70
|
+
data["Year"].append(year_end / year_start - 1)
|
71
|
+
|
72
|
+
return pd.DataFrame(data, index=years)
|
73
|
+
|
74
|
+
|
75
|
+
def rolling_cumulative_return(prices: 'pd.Series', window: int) -> 'pd.Series': # type: ignore
|
76
|
+
"""Rolling cumulative return over a specified window."""
|
77
|
+
if pd is None:
|
78
|
+
raise ImportError("pandas is required for rolling_cumulative_return")
|
79
|
+
|
80
|
+
p = _to_list(prices)
|
81
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
82
|
+
out = []
|
83
|
+
for i in range(len(p)):
|
84
|
+
if i < window:
|
85
|
+
out.append(None)
|
86
|
+
else:
|
87
|
+
out.append(p[i] / p[i - window] - 1)
|
88
|
+
s = pd.Series(out)
|
89
|
+
s.index = index
|
90
|
+
return s
|
91
|
+
|
92
|
+
|
93
|
+
def annualized_volatility(prices: Sequence[float], periods_per_year: int = 252) -> float:
|
94
|
+
"""Annualized volatility from a price series."""
|
95
|
+
p = _to_list(prices)
|
96
|
+
if len(p) < 2:
|
97
|
+
return 0.0
|
98
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
99
|
+
mean = sum(rets) / len(rets)
|
100
|
+
var = sum((r - mean) ** 2 for r in rets) / len(rets)
|
101
|
+
return sqrt(var) * sqrt(periods_per_year)
|
102
|
+
|
103
|
+
|
104
|
+
def max_drawdown(prices: Sequence[float]) -> Tuple[float, int]:
|
105
|
+
"""Maximum drawdown depth and duration."""
|
106
|
+
p = _to_list(prices)
|
107
|
+
if not p:
|
108
|
+
return 0.0, 0
|
109
|
+
peak = p[0]
|
110
|
+
max_depth = 0.0
|
111
|
+
duration = 0
|
112
|
+
cur_duration = 0
|
113
|
+
for price in p:
|
114
|
+
if price > peak:
|
115
|
+
peak = price
|
116
|
+
cur_duration = 0
|
117
|
+
else:
|
118
|
+
cur_duration += 1
|
119
|
+
dd = price / peak - 1
|
120
|
+
if dd < max_depth:
|
121
|
+
max_depth = dd
|
122
|
+
if cur_duration > duration:
|
123
|
+
duration = cur_duration
|
124
|
+
return max_depth, duration
|
125
|
+
|
126
|
+
|
127
|
+
def ulcer_index(prices: Sequence[float]) -> float:
|
128
|
+
"""Ulcer index of a price series."""
|
129
|
+
p = _to_list(prices)
|
130
|
+
if not p:
|
131
|
+
return 0.0
|
132
|
+
peak = p[0]
|
133
|
+
sum_sq = 0.0
|
134
|
+
for price in p:
|
135
|
+
if price > peak:
|
136
|
+
peak = price
|
137
|
+
dd = max(0.0, (peak - price) / peak)
|
138
|
+
sum_sq += dd ** 2
|
139
|
+
return sqrt(sum_sq / len(p))
|
140
|
+
|
141
|
+
|
142
|
+
def ulcer_performance_index(prices: Sequence[float], risk_free_rate: float = 0.0, periods_per_year: int = 252) -> float:
|
143
|
+
"""Ulcer Performance Index."""
|
144
|
+
ui = ulcer_index(prices)
|
145
|
+
if ui == 0:
|
146
|
+
return 0.0
|
147
|
+
return (cagr(prices, periods_per_year) - risk_free_rate) / ui
|
148
|
+
|
149
|
+
|
150
|
+
def _parametric_stats(prices: Sequence[float]) -> Tuple[float, float]:
|
151
|
+
p = _to_list(prices)
|
152
|
+
if len(p) < 2:
|
153
|
+
return 0.0, 0.0
|
154
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
155
|
+
mu = sum(rets) / len(rets)
|
156
|
+
var = sum((r - mu) ** 2 for r in rets) / len(rets)
|
157
|
+
return mu, sqrt(var)
|
158
|
+
|
159
|
+
|
160
|
+
def parametric_var(prices: Sequence[float], level: float = 0.05) -> float:
|
161
|
+
"""Parametric (normal) Value at Risk."""
|
162
|
+
mu, sigma = _parametric_stats(prices)
|
163
|
+
z = NormalDist().inv_cdf(level)
|
164
|
+
return -(mu + sigma * z)
|
165
|
+
|
166
|
+
|
167
|
+
def parametric_expected_shortfall(prices: Sequence[float], level: float = 0.05) -> float:
|
168
|
+
"""Parametric (normal) Expected Shortfall."""
|
169
|
+
mu, sigma = _parametric_stats(prices)
|
170
|
+
z = NormalDist().inv_cdf(level)
|
171
|
+
return -(mu - sigma * NormalDist().pdf(z) / level)
|
172
|
+
|
173
|
+
|
174
|
+
def tail_ratio(prices: Sequence[float]) -> float:
|
175
|
+
"""Tail ratio of returns (95th percentile over 5th percentile)."""
|
176
|
+
p = _to_list(prices)
|
177
|
+
if len(p) < 3:
|
178
|
+
return 0.0
|
179
|
+
rets = sorted(p[i] / p[i - 1] - 1 for i in range(1, len(p)))
|
180
|
+
n = len(rets)
|
181
|
+
q95 = rets[int(0.95 * (n - 1))]
|
182
|
+
q05 = rets[int(0.05 * (n - 1))]
|
183
|
+
if q05 == 0:
|
184
|
+
return 0.0
|
185
|
+
return abs(q95) / abs(q05)
|
186
|
+
|
187
|
+
|
188
|
+
def sharpe_ratio(
|
189
|
+
prices: Sequence[float],
|
190
|
+
risk_free_rate: float = 0.0,
|
191
|
+
periods_per_year: int = 252,
|
192
|
+
) -> float:
|
193
|
+
"""Annualized Sharpe ratio of a price series."""
|
194
|
+
p = _to_list(prices)
|
195
|
+
if len(p) < 2:
|
196
|
+
return 0.0
|
197
|
+
rf_per = risk_free_rate / periods_per_year
|
198
|
+
rets = [p[i] / p[i - 1] - 1 - rf_per for i in range(1, len(p))]
|
199
|
+
mean = sum(rets) / len(rets)
|
200
|
+
var = sum((r - mean) ** 2 for r in rets) / len(rets)
|
201
|
+
if var == 0:
|
202
|
+
return 0.0
|
203
|
+
return mean / sqrt(var) * sqrt(periods_per_year)
|
204
|
+
|
205
|
+
|
206
|
+
def sortino_ratio(
|
207
|
+
prices: Sequence[float],
|
208
|
+
risk_free_rate: float = 0.0,
|
209
|
+
periods_per_year: int = 252,
|
210
|
+
) -> float:
|
211
|
+
"""Annualized Sortino ratio of a price series."""
|
212
|
+
p = _to_list(prices)
|
213
|
+
if len(p) < 2:
|
214
|
+
return 0.0
|
215
|
+
rf_per = risk_free_rate / periods_per_year
|
216
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
217
|
+
mean_excess = sum(r - rf_per for r in rets) / len(rets)
|
218
|
+
downside = [min(0.0, r - rf_per) for r in rets]
|
219
|
+
var = sum(d ** 2 for d in downside) / len(rets)
|
220
|
+
if var == 0:
|
221
|
+
return 0.0
|
222
|
+
return mean_excess / sqrt(var) * sqrt(periods_per_year)
|
223
|
+
|
224
|
+
|
225
|
+
def calmar_ratio(prices: Sequence[float], periods_per_year: int = 252) -> float:
|
226
|
+
"""Calmar ratio of a price series."""
|
227
|
+
mdd, _duration = max_drawdown(prices)
|
228
|
+
if mdd == 0:
|
229
|
+
return 0.0
|
230
|
+
return cagr(prices, periods_per_year) / abs(mdd)
|
231
|
+
|
232
|
+
|
233
|
+
def omega_ratio(
|
234
|
+
prices: Sequence[float],
|
235
|
+
threshold: float = 0.0,
|
236
|
+
periods_per_year: int = 252,
|
237
|
+
) -> float:
|
238
|
+
"""Omega ratio of returns relative to a threshold."""
|
239
|
+
p = _to_list(prices)
|
240
|
+
if len(p) < 2:
|
241
|
+
return 0.0
|
242
|
+
thr = threshold / periods_per_year
|
243
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
244
|
+
gains = sum(max(r - thr, 0.0) for r in rets)
|
245
|
+
losses = sum(max(thr - r, 0.0) for r in rets)
|
246
|
+
if losses == 0:
|
247
|
+
return 0.0
|
248
|
+
return gains / losses
|
249
|
+
|
250
|
+
|
251
|
+
def information_ratio(
|
252
|
+
prices: Sequence[float],
|
253
|
+
benchmark: Sequence[float],
|
254
|
+
periods_per_year: int = 252,
|
255
|
+
) -> float:
|
256
|
+
"""Information ratio of strategy vs. benchmark prices."""
|
257
|
+
p = _to_list(prices)
|
258
|
+
b = _to_list(benchmark)
|
259
|
+
n = min(len(p), len(b))
|
260
|
+
if n < 2:
|
261
|
+
return 0.0
|
262
|
+
strat_rets = [p[i] / p[i - 1] - 1 for i in range(1, n)]
|
263
|
+
bench_rets = [b[i] / b[i - 1] - 1 for i in range(1, n)]
|
264
|
+
active = [r - br for r, br in zip(strat_rets, bench_rets)]
|
265
|
+
mean = sum(active) / len(active)
|
266
|
+
var = sum((a - mean) ** 2 for a in active) / len(active)
|
267
|
+
if var == 0:
|
268
|
+
return 0.0
|
269
|
+
return mean / sqrt(var) * sqrt(periods_per_year)
|
270
|
+
|
271
|
+
|
272
|
+
def capm_alpha_beta(prices: Sequence[float], benchmark: Sequence[float]) -> Tuple[float, float]:
|
273
|
+
"""CAPM alpha and beta relative to a benchmark."""
|
274
|
+
p = _to_list(prices)
|
275
|
+
b = _to_list(benchmark)
|
276
|
+
n = min(len(p), len(b))
|
277
|
+
if n < 2:
|
278
|
+
return 0.0, 0.0
|
279
|
+
strat = [p[i] / p[i - 1] - 1 for i in range(1, n)]
|
280
|
+
bench = [b[i] / b[i - 1] - 1 for i in range(1, n)]
|
281
|
+
mean_x = sum(bench) / len(bench)
|
282
|
+
mean_y = sum(strat) / len(strat)
|
283
|
+
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(bench, strat)) / len(bench)
|
284
|
+
var_x = sum((x - mean_x) ** 2 for x in bench) / len(bench)
|
285
|
+
beta = cov / var_x if var_x else 0.0
|
286
|
+
alpha = mean_y - beta * mean_x
|
287
|
+
return alpha, beta
|
288
|
+
|
289
|
+
|
290
|
+
def _invert_matrix(matrix: Sequence[Sequence[float]]) -> Sequence[Sequence[float]] | None:
|
291
|
+
size = len(matrix)
|
292
|
+
aug = [list(row) + [1 if i == j else 0 for j in range(size)] for i, row in enumerate(matrix)]
|
293
|
+
for i in range(size):
|
294
|
+
pivot = aug[i][i]
|
295
|
+
if abs(pivot) < 1e-12:
|
296
|
+
swap = next((j for j in range(i + 1, size) if abs(aug[j][i]) > 1e-12), None)
|
297
|
+
if swap is None:
|
298
|
+
return None
|
299
|
+
aug[i], aug[swap] = aug[swap], aug[i]
|
300
|
+
pivot = aug[i][i]
|
301
|
+
inv_p = 1 / pivot
|
302
|
+
for j in range(2 * size):
|
303
|
+
aug[i][j] *= inv_p
|
304
|
+
for k in range(size):
|
305
|
+
if k != i:
|
306
|
+
factor = aug[k][i]
|
307
|
+
for j in range(2 * size):
|
308
|
+
aug[k][j] -= factor * aug[i][j]
|
309
|
+
return [row[size:] for row in aug]
|
310
|
+
|
311
|
+
|
312
|
+
def _ols(y: Sequence[float], X: Sequence[Sequence[float]]) -> Sequence[float]:
|
313
|
+
n = len(y)
|
314
|
+
k = len(X[0]) if X else 0
|
315
|
+
xtx = [[0.0 for _ in range(k)] for _ in range(k)]
|
316
|
+
xty = [0.0 for _ in range(k)]
|
317
|
+
for i in range(n):
|
318
|
+
for p in range(k):
|
319
|
+
xty[p] += X[i][p] * y[i]
|
320
|
+
for q in range(k):
|
321
|
+
xtx[p][q] += X[i][p] * X[i][q]
|
322
|
+
inv = _invert_matrix(xtx)
|
323
|
+
if inv is None:
|
324
|
+
return [0.0 for _ in range(k)]
|
325
|
+
beta = [sum(inv[i][j] * xty[j] for j in range(k)) for i in range(k)]
|
326
|
+
return beta
|
327
|
+
|
328
|
+
|
329
|
+
def fama_french_regression(prices: Sequence[float], factors: 'pd.DataFrame', factor_cols: Sequence[str]) -> 'pd.Series': # type: ignore
|
330
|
+
"""Run regression of excess returns on Fama-French factors."""
|
331
|
+
if pd is None:
|
332
|
+
raise ImportError("pandas is required for fama_french_regression")
|
333
|
+
|
334
|
+
p = _to_list(prices)
|
335
|
+
n = min(len(p), len(factors))
|
336
|
+
if n < 2:
|
337
|
+
data = [0.0] * (len(factor_cols) + 1)
|
338
|
+
s = pd.Series(data)
|
339
|
+
s.index = ["alpha"] + list(factor_cols)
|
340
|
+
return s
|
341
|
+
|
342
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, n)]
|
343
|
+
rf = _to_list(factors["RF"]) if "RF" in factors.columns else [0.0] * n
|
344
|
+
y = [rets[i - 1] - rf[i] for i in range(1, n)]
|
345
|
+
x = [[1.0] + [_to_list(factors[c])[i] for c in factor_cols] for i in range(1, n)]
|
346
|
+
beta = _ols(y, x)
|
347
|
+
s = pd.Series(beta)
|
348
|
+
s.index = ["alpha"] + list(factor_cols)
|
349
|
+
return s
|
350
|
+
|
351
|
+
|
352
|
+
def fama_french_3factor(prices: Sequence[float], factors: 'pd.DataFrame') -> 'pd.Series': # type: ignore
|
353
|
+
cols = [c for c in ["Mkt-RF", "SMB", "HML"] if c in getattr(factors, "columns", [])]
|
354
|
+
return fama_french_regression(prices, factors, cols)
|
355
|
+
|
356
|
+
|
357
|
+
def fama_french_5factor(prices: Sequence[float], factors: 'pd.DataFrame') -> 'pd.Series': # type: ignore
|
358
|
+
cols = [c for c in ["Mkt-RF", "SMB", "HML", "RMW", "CMA"] if c in getattr(factors, "columns", [])]
|
359
|
+
return fama_french_regression(prices, factors, cols)
|
360
|
+
|
361
|
+
|
362
|
+
def cumulative_excess_return(prices: Sequence[float], benchmark: Sequence[float]) -> 'pd.Series': # type: ignore
|
363
|
+
"""Cumulative excess return of strategy versus a benchmark."""
|
364
|
+
if pd is None:
|
365
|
+
raise ImportError("pandas is required for cumulative_excess_return")
|
366
|
+
|
367
|
+
p = _to_list(prices)
|
368
|
+
b = _to_list(benchmark)
|
369
|
+
n = min(len(p), len(b))
|
370
|
+
index = list(getattr(prices, 'index', range(len(p))))[:n]
|
371
|
+
cum = []
|
372
|
+
total = 1.0
|
373
|
+
for i in range(n):
|
374
|
+
if i == 0:
|
375
|
+
cum.append(0.0)
|
376
|
+
else:
|
377
|
+
strat_ret = p[i] / p[i - 1] - 1
|
378
|
+
bench_ret = b[i] / b[i - 1] - 1
|
379
|
+
total *= 1 + (strat_ret - bench_ret)
|
380
|
+
cum.append(total - 1)
|
381
|
+
s = pd.Series(cum)
|
382
|
+
s.index = index
|
383
|
+
return s
|
384
|
+
|
385
|
+
|
386
|
+
def skewness(prices: Sequence[float]) -> float:
|
387
|
+
"""Skewness of returns of a price series."""
|
388
|
+
p = _to_list(prices)
|
389
|
+
if len(p) < 3:
|
390
|
+
return 0.0
|
391
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
392
|
+
mean = sum(rets) / len(rets)
|
393
|
+
var = sum((r - mean) ** 2 for r in rets) / len(rets)
|
394
|
+
if var == 0:
|
395
|
+
return 0.0
|
396
|
+
std = sqrt(var)
|
397
|
+
m3 = sum((r - mean) ** 3 for r in rets) / len(rets)
|
398
|
+
return m3 / (std ** 3)
|
399
|
+
|
400
|
+
|
401
|
+
def kurtosis(prices: Sequence[float]) -> float:
|
402
|
+
"""Kurtosis of returns of a price series."""
|
403
|
+
p = _to_list(prices)
|
404
|
+
if len(p) < 3:
|
405
|
+
return 0.0
|
406
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
407
|
+
mean = sum(rets) / len(rets)
|
408
|
+
var = sum((r - mean) ** 2 for r in rets) / len(rets)
|
409
|
+
if var == 0:
|
410
|
+
return 0.0
|
411
|
+
m4 = sum((r - mean) ** 4 for r in rets) / len(rets)
|
412
|
+
return m4 / (var ** 2)
|
413
|
+
|
414
|
+
|
415
|
+
def variance_ratio(prices: Sequence[float], lag: int = 2) -> float:
|
416
|
+
"""Lo-MacKinlay variance ratio test statistic."""
|
417
|
+
p = _to_list(prices)
|
418
|
+
if len(p) <= lag:
|
419
|
+
return 0.0
|
420
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
421
|
+
mean = sum(rets) / len(rets)
|
422
|
+
var = sum((r - mean) ** 2 for r in rets) / len(rets)
|
423
|
+
if var == 0:
|
424
|
+
return 0.0
|
425
|
+
agg = [sum(rets[i - j] for j in range(1, lag + 1)) for i in range(lag, len(rets))]
|
426
|
+
var_lag = sum((a - lag * mean) ** 2 for a in agg) / len(agg)
|
427
|
+
return var_lag / (var * lag)
|
428
|
+
|
429
|
+
|
430
|
+
def acf(prices: Sequence[float], lags: Sequence[int]) -> list[float]:
|
431
|
+
"""Autocorrelation of returns for specified lags."""
|
432
|
+
p = _to_list(prices)
|
433
|
+
if len(p) < 2:
|
434
|
+
return [0.0 for _ in lags]
|
435
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
436
|
+
mean = sum(rets) / len(rets)
|
437
|
+
var = sum((r - mean) ** 2 for r in rets) / len(rets)
|
438
|
+
if var == 0:
|
439
|
+
return [0.0 for _ in lags]
|
440
|
+
out = []
|
441
|
+
for lag in lags:
|
442
|
+
if lag <= 0 or lag >= len(rets):
|
443
|
+
out.append(0.0)
|
444
|
+
else:
|
445
|
+
cov = sum((rets[i] - mean) * (rets[i - lag] - mean) for i in range(lag, len(rets))) / (len(rets) - lag)
|
446
|
+
out.append(cov / var)
|
447
|
+
return out
|
448
|
+
|
449
|
+
|
450
|
+
def pacf(prices: Sequence[float], lags: Sequence[int]) -> list[float]:
|
451
|
+
"""Partial autocorrelation of returns for specified lags."""
|
452
|
+
p = _to_list(prices)
|
453
|
+
if len(p) < 2:
|
454
|
+
return [0.0 for _ in lags]
|
455
|
+
rets = [p[i] / p[i - 1] - 1 for i in range(1, len(p))]
|
456
|
+
out = []
|
457
|
+
for k in lags:
|
458
|
+
if k <= 0 or k >= len(rets):
|
459
|
+
out.append(0.0)
|
460
|
+
continue
|
461
|
+
y = [rets[i] for i in range(k, len(rets))]
|
462
|
+
X = [[1.0] + [rets[i - j - 1] for j in range(k)] for i in range(k, len(rets))]
|
463
|
+
beta = _ols(y, X)
|
464
|
+
out.append(beta[-1] if beta else 0.0)
|
465
|
+
return out
|
@@ -0,0 +1,415 @@
|
|
1
|
+
import matplotlib.pyplot as plt
|
2
|
+
from statistics import NormalDist
|
3
|
+
|
4
|
+
try:
|
5
|
+
import pandas as pd # type: ignore
|
6
|
+
except ModuleNotFoundError: # pragma: no cover - optional dependency
|
7
|
+
pd = None # type: ignore
|
8
|
+
|
9
|
+
from .metrics import (
|
10
|
+
_to_list,
|
11
|
+
returns_table,
|
12
|
+
annualized_volatility,
|
13
|
+
parametric_var,
|
14
|
+
sharpe_ratio,
|
15
|
+
sortino_ratio,
|
16
|
+
skewness,
|
17
|
+
kurtosis,
|
18
|
+
cumulative_excess_return,
|
19
|
+
fama_french_3factor,
|
20
|
+
)
|
21
|
+
|
22
|
+
|
23
|
+
def plot_equity_curve(prices, logy: bool = True, ax=None):
|
24
|
+
"""Plot cumulative return equity curve."""
|
25
|
+
if ax is None:
|
26
|
+
fig, ax = plt.subplots()
|
27
|
+
p = _to_list(prices)
|
28
|
+
cum = [v / p[0] for v in p]
|
29
|
+
ax.plot(getattr(prices, 'index', range(len(p))), cum)
|
30
|
+
if logy:
|
31
|
+
ax.set_yscale('log')
|
32
|
+
ax.set_xlabel('Date')
|
33
|
+
ax.set_ylabel('Cumulative Return')
|
34
|
+
return ax
|
35
|
+
|
36
|
+
|
37
|
+
def plot_return_heatmap(prices, ax=None):
|
38
|
+
"""Plot calendar heatmap of returns from price series."""
|
39
|
+
if pd is None:
|
40
|
+
raise ImportError("pandas is required for plot_return_heatmap")
|
41
|
+
tbl = returns_table(prices)
|
42
|
+
if ax is None:
|
43
|
+
fig, ax = plt.subplots()
|
44
|
+
data = [tbl[m].values for m in tbl.columns if m != 'Year']
|
45
|
+
im = ax.imshow(data, aspect='auto', interpolation='none',
|
46
|
+
cmap='RdYlGn',
|
47
|
+
vmin=min((min(filter(None, row)) for row in data if any(row))),
|
48
|
+
vmax=max((max(filter(None, row)) for row in data if any(row))))
|
49
|
+
ax.set_yticks(range(len(tbl.index)))
|
50
|
+
ax.set_yticklabels(tbl.index)
|
51
|
+
ax.set_xticks(range(len(tbl.columns)-1))
|
52
|
+
ax.set_xticklabels([c for c in tbl.columns if c != 'Year'])
|
53
|
+
plt.colorbar(im, ax=ax)
|
54
|
+
return ax
|
55
|
+
|
56
|
+
|
57
|
+
def plot_underwater(prices, ax=None):
|
58
|
+
"""Plot drawdown (underwater) chart."""
|
59
|
+
if ax is None:
|
60
|
+
fig, ax = plt.subplots()
|
61
|
+
p = _to_list(prices)
|
62
|
+
peak = p[0] if p else 0
|
63
|
+
dd = []
|
64
|
+
for price in p:
|
65
|
+
if price > peak:
|
66
|
+
peak = price
|
67
|
+
dd.append(price / peak - 1)
|
68
|
+
ax.plot(getattr(prices, 'index', range(len(p))), dd)
|
69
|
+
ax.set_ylabel('Drawdown')
|
70
|
+
ax.set_xlabel('Date')
|
71
|
+
return ax
|
72
|
+
|
73
|
+
|
74
|
+
def plot_rolling_volatility(prices, window: int = 63, periods_per_year: int = 252, ax=None):
|
75
|
+
"""Plot rolling annualized volatility."""
|
76
|
+
if pd is None:
|
77
|
+
raise ImportError("pandas is required for plot_rolling_volatility")
|
78
|
+
p = _to_list(prices)
|
79
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
80
|
+
vols = []
|
81
|
+
for i in range(len(p)):
|
82
|
+
if i < window:
|
83
|
+
vols.append(None)
|
84
|
+
else:
|
85
|
+
vols.append(annualized_volatility(p[i - window:i + 1], periods_per_year))
|
86
|
+
s = pd.Series(vols)
|
87
|
+
s.index = index
|
88
|
+
if ax is None:
|
89
|
+
fig, ax = plt.subplots()
|
90
|
+
ax.plot(s.index, s)
|
91
|
+
ax.set_ylabel('Volatility')
|
92
|
+
ax.set_xlabel('Date')
|
93
|
+
return ax
|
94
|
+
|
95
|
+
|
96
|
+
def plot_rolling_var(prices, window: int = 63, level: float = 0.05, ax=None):
|
97
|
+
"""Plot rolling parametric VaR."""
|
98
|
+
if pd is None:
|
99
|
+
raise ImportError("pandas is required for plot_rolling_var")
|
100
|
+
p = _to_list(prices)
|
101
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
102
|
+
vars_ = []
|
103
|
+
for i in range(len(p)):
|
104
|
+
if i < window:
|
105
|
+
vars_.append(None)
|
106
|
+
else:
|
107
|
+
vars_.append(parametric_var(p[i - window:i + 1], level))
|
108
|
+
s = pd.Series(vars_)
|
109
|
+
s.index = index
|
110
|
+
if ax is None:
|
111
|
+
fig, ax = plt.subplots()
|
112
|
+
ax.plot(s.index, s)
|
113
|
+
ax.set_ylabel('VaR')
|
114
|
+
ax.set_xlabel('Date')
|
115
|
+
return ax
|
116
|
+
|
117
|
+
|
118
|
+
def plot_rolling_sharpe(
|
119
|
+
prices,
|
120
|
+
window: int = 63,
|
121
|
+
risk_free_rate: float = 0.0,
|
122
|
+
periods_per_year: int = 252,
|
123
|
+
ax=None,
|
124
|
+
):
|
125
|
+
"""Plot rolling Sharpe ratio."""
|
126
|
+
if pd is None:
|
127
|
+
raise ImportError("pandas is required for plot_rolling_sharpe")
|
128
|
+
p = _to_list(prices)
|
129
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
130
|
+
vals = []
|
131
|
+
for i in range(len(p)):
|
132
|
+
if i < window:
|
133
|
+
vals.append(None)
|
134
|
+
else:
|
135
|
+
vals.append(
|
136
|
+
sharpe_ratio(p[i - window : i + 1], risk_free_rate, periods_per_year)
|
137
|
+
)
|
138
|
+
s = pd.Series(vals)
|
139
|
+
s.index = index
|
140
|
+
if ax is None:
|
141
|
+
fig, ax = plt.subplots()
|
142
|
+
ax.plot(s.index, s)
|
143
|
+
ax.set_ylabel('Sharpe')
|
144
|
+
ax.set_xlabel('Date')
|
145
|
+
return ax
|
146
|
+
|
147
|
+
|
148
|
+
def plot_rolling_sortino(
|
149
|
+
prices,
|
150
|
+
window: int = 63,
|
151
|
+
risk_free_rate: float = 0.0,
|
152
|
+
periods_per_year: int = 252,
|
153
|
+
ax=None,
|
154
|
+
):
|
155
|
+
"""Plot rolling Sortino ratio."""
|
156
|
+
if pd is None:
|
157
|
+
raise ImportError("pandas is required for plot_rolling_sortino")
|
158
|
+
p = _to_list(prices)
|
159
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
160
|
+
vals = []
|
161
|
+
for i in range(len(p)):
|
162
|
+
if i < window:
|
163
|
+
vals.append(None)
|
164
|
+
else:
|
165
|
+
vals.append(
|
166
|
+
sortino_ratio(p[i - window : i + 1], risk_free_rate, periods_per_year)
|
167
|
+
)
|
168
|
+
s = pd.Series(vals)
|
169
|
+
s.index = index
|
170
|
+
if ax is None:
|
171
|
+
fig, ax = plt.subplots()
|
172
|
+
ax.plot(s.index, s)
|
173
|
+
ax.set_ylabel('Sortino')
|
174
|
+
ax.set_xlabel('Date')
|
175
|
+
return ax
|
176
|
+
|
177
|
+
|
178
|
+
def plot_return_scatter(prices, benchmark_prices, ax=None):
|
179
|
+
"""Scatter of strategy vs benchmark returns with regression line."""
|
180
|
+
if pd is None:
|
181
|
+
raise ImportError("pandas is required for plot_return_scatter")
|
182
|
+
p = _to_list(prices)
|
183
|
+
b = _to_list(benchmark_prices)
|
184
|
+
n = min(len(p), len(b))
|
185
|
+
if n < 2:
|
186
|
+
raise ValueError("insufficient data")
|
187
|
+
strat = [p[i] / p[i - 1] - 1 for i in range(1, n)]
|
188
|
+
bench = [b[i] / b[i - 1] - 1 for i in range(1, n)]
|
189
|
+
mean_x = sum(bench) / len(bench)
|
190
|
+
mean_y = sum(strat) / len(strat)
|
191
|
+
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(bench, strat)) / len(bench)
|
192
|
+
var_x = sum((x - mean_x) ** 2 for x in bench) / len(bench)
|
193
|
+
beta = cov / var_x if var_x else 0.0
|
194
|
+
alpha = mean_y - beta * mean_x
|
195
|
+
if ax is None:
|
196
|
+
fig, ax = plt.subplots()
|
197
|
+
ax.scatter(bench, strat, s=10)
|
198
|
+
xs = [min(bench), max(bench)]
|
199
|
+
ys = [alpha + beta * x for x in xs]
|
200
|
+
ax.plot(xs, ys, color='red', label=f"alpha={alpha:.2f}, beta={beta:.2f}")
|
201
|
+
ax.set_xlabel('Benchmark Return')
|
202
|
+
ax.set_ylabel('Strategy Return')
|
203
|
+
ax.legend()
|
204
|
+
return ax
|
205
|
+
|
206
|
+
|
207
|
+
def plot_cumulative_excess_return(prices, benchmark_prices, ax=None):
|
208
|
+
"""Plot cumulative excess return versus benchmark."""
|
209
|
+
if pd is None:
|
210
|
+
raise ImportError("pandas is required for plot_cumulative_excess_return")
|
211
|
+
ser = cumulative_excess_return(prices, benchmark_prices)
|
212
|
+
if ax is None:
|
213
|
+
fig, ax = plt.subplots()
|
214
|
+
ax.plot(ser.index, ser)
|
215
|
+
ax.set_ylabel("Cumulative Excess Return")
|
216
|
+
ax.set_xlabel("Date")
|
217
|
+
return ax
|
218
|
+
|
219
|
+
|
220
|
+
def plot_factor_exposures(prices, factors, ax=None):
|
221
|
+
"""Bar chart of Fama-French 3 factor exposures."""
|
222
|
+
if pd is None:
|
223
|
+
raise ImportError("pandas is required for plot_factor_exposures")
|
224
|
+
exp = fama_french_3factor(prices, factors)
|
225
|
+
names = [n for n in exp.index if n != "alpha"]
|
226
|
+
vals = [exp[n] for n in names]
|
227
|
+
if ax is None:
|
228
|
+
fig, ax = plt.subplots()
|
229
|
+
ax.bar(range(len(vals)), vals)
|
230
|
+
ax.set_xticks(range(len(names)))
|
231
|
+
ax.set_xticklabels(names, rotation=45)
|
232
|
+
ax.set_ylabel("Exposure")
|
233
|
+
return ax
|
234
|
+
|
235
|
+
|
236
|
+
def plot_trade_return_hist(trades, ax=None, bins=20):
|
237
|
+
"""Histogram of trade returns for long and short trades."""
|
238
|
+
if ax is None:
|
239
|
+
fig, ax = plt.subplots()
|
240
|
+
longs = [t.get("return", 0) for t in trades if t.get("direction") == "long"]
|
241
|
+
shorts = [t.get("return", 0) for t in trades if t.get("direction") == "short"]
|
242
|
+
if longs:
|
243
|
+
ax.hist(longs, bins=bins, alpha=0.5, label="Long")
|
244
|
+
if shorts:
|
245
|
+
ax.hist(shorts, bins=bins, alpha=0.5, label="Short")
|
246
|
+
ax.set_xlabel("Trade Return")
|
247
|
+
ax.set_ylabel("Frequency")
|
248
|
+
if longs or shorts:
|
249
|
+
ax.legend()
|
250
|
+
return ax
|
251
|
+
|
252
|
+
|
253
|
+
def plot_return_by_holding_period(trades, ax=None):
|
254
|
+
"""Box plot of trade return grouped by holding period."""
|
255
|
+
if ax is None:
|
256
|
+
fig, ax = plt.subplots()
|
257
|
+
groups = {}
|
258
|
+
for t in trades:
|
259
|
+
entry = t.get("entry")
|
260
|
+
exit_ = t.get("exit")
|
261
|
+
if entry is None or exit_ is None:
|
262
|
+
continue
|
263
|
+
dur = (exit_ - entry).days if hasattr(exit_ - entry, "days") else int(exit_ - entry)
|
264
|
+
groups.setdefault(dur, []).append(t.get("return", 0))
|
265
|
+
if not groups:
|
266
|
+
return ax
|
267
|
+
durations = sorted(groups)
|
268
|
+
data = [groups[d] for d in durations]
|
269
|
+
ax.boxplot(data, positions=range(len(data)))
|
270
|
+
ax.set_xticks(range(len(data)))
|
271
|
+
ax.set_xticklabels([str(d) for d in durations])
|
272
|
+
ax.set_xlabel("Holding Period (days)")
|
273
|
+
ax.set_ylabel("Return")
|
274
|
+
return ax
|
275
|
+
|
276
|
+
|
277
|
+
def plot_exposure_ts(trades, ax=None):
|
278
|
+
"""Time series of gross and net exposure based on open trades."""
|
279
|
+
if pd is None:
|
280
|
+
raise ImportError("pandas is required for plot_exposure_ts")
|
281
|
+
entries = [t.get("entry") for t in trades if t.get("entry") is not None]
|
282
|
+
exits = [t.get("exit") for t in trades if t.get("exit") is not None]
|
283
|
+
if not entries or not exits:
|
284
|
+
if ax is None:
|
285
|
+
fig, ax = plt.subplots()
|
286
|
+
return ax
|
287
|
+
start = min(entries)
|
288
|
+
end = max(exits)
|
289
|
+
idx = pd.date_range(start, end)
|
290
|
+
gross = [0.0 for _ in idx]
|
291
|
+
net = [0.0 for _ in idx]
|
292
|
+
for t in trades:
|
293
|
+
entry = t.get("entry")
|
294
|
+
exit_ = t.get("exit")
|
295
|
+
size = t.get("size", 0.0)
|
296
|
+
if entry is None or exit_ is None:
|
297
|
+
continue
|
298
|
+
for i, date in enumerate(idx):
|
299
|
+
if entry <= date <= exit_:
|
300
|
+
gross[i] += abs(size)
|
301
|
+
net[i] += size
|
302
|
+
if ax is None:
|
303
|
+
fig, ax = plt.subplots()
|
304
|
+
ax.plot(idx, gross, label="Gross")
|
305
|
+
ax.plot(idx, net, label="Net")
|
306
|
+
ax.set_ylabel("Exposure")
|
307
|
+
ax.set_xlabel("Date")
|
308
|
+
ax.legend()
|
309
|
+
return ax
|
310
|
+
|
311
|
+
|
312
|
+
def plot_cumulative_shortfall(trades, ax=None):
|
313
|
+
"""Plot cumulative implementation shortfall over time."""
|
314
|
+
if pd is None:
|
315
|
+
raise ImportError("pandas is required for plot_cumulative_shortfall")
|
316
|
+
|
317
|
+
from .trade_stats import trade_implementation_shortfall
|
318
|
+
|
319
|
+
dates = []
|
320
|
+
cum = []
|
321
|
+
total = 0.0
|
322
|
+
for t in trades:
|
323
|
+
date = t.get("exit") or t.get("entry")
|
324
|
+
total += trade_implementation_shortfall(t)
|
325
|
+
dates.append(date)
|
326
|
+
cum.append(total)
|
327
|
+
|
328
|
+
ser = pd.Series(cum, index=dates)
|
329
|
+
if ax is None:
|
330
|
+
fig, ax = plt.subplots()
|
331
|
+
ax.plot(ser.index, ser)
|
332
|
+
ax.set_ylabel("Cumulative Shortfall")
|
333
|
+
ax.set_xlabel("Date")
|
334
|
+
return ax
|
335
|
+
|
336
|
+
|
337
|
+
def plot_alpha_vs_return(trades, ax=None):
|
338
|
+
"""Scatter plot of forecasted alpha versus realised trade return."""
|
339
|
+
if pd is None:
|
340
|
+
raise ImportError("pandas is required for plot_alpha_vs_return")
|
341
|
+
|
342
|
+
alphas = [t.get("forecast_alpha") for t in trades if t.get("forecast_alpha") is not None]
|
343
|
+
rets = [t.get("return") for t in trades if t.get("forecast_alpha") is not None]
|
344
|
+
|
345
|
+
if ax is None:
|
346
|
+
fig, ax = plt.subplots()
|
347
|
+
ax.scatter(alphas, rets, s=10)
|
348
|
+
ax.set_xlabel("Forecast Alpha")
|
349
|
+
ax.set_ylabel("Realized Return")
|
350
|
+
return ax
|
351
|
+
|
352
|
+
|
353
|
+
def plot_qq_returns(prices, ax=None):
|
354
|
+
"""QQ-plot of returns versus normal distribution."""
|
355
|
+
if ax is None:
|
356
|
+
fig, ax = plt.subplots()
|
357
|
+
p = _to_list(prices)
|
358
|
+
if len(p) < 2:
|
359
|
+
return ax
|
360
|
+
rets = sorted(p[i] / p[i - 1] - 1 for i in range(1, len(p)))
|
361
|
+
n = len(rets)
|
362
|
+
mean = sum(rets) / n
|
363
|
+
var = sum((r - mean) ** 2 for r in rets) / n
|
364
|
+
std = var ** 0.5
|
365
|
+
dist = NormalDist(mean, std)
|
366
|
+
qs = [(i + 0.5) / n for i in range(n)]
|
367
|
+
theo = [dist.inv_cdf(q) for q in qs]
|
368
|
+
ax.scatter(theo, rets, s=10)
|
369
|
+
ax.set_xlabel("Theoretical Quantiles")
|
370
|
+
ax.set_ylabel("Empirical Quantiles")
|
371
|
+
return ax
|
372
|
+
|
373
|
+
|
374
|
+
def plot_rolling_skewness(prices, window: int = 63, ax=None):
|
375
|
+
"""Plot rolling skewness of returns."""
|
376
|
+
if pd is None:
|
377
|
+
raise ImportError("pandas is required for plot_rolling_skewness")
|
378
|
+
p = _to_list(prices)
|
379
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
380
|
+
vals = []
|
381
|
+
for i in range(len(p)):
|
382
|
+
if i < window:
|
383
|
+
vals.append(None)
|
384
|
+
else:
|
385
|
+
vals.append(skewness(p[i - window : i + 1]))
|
386
|
+
s = pd.Series(vals)
|
387
|
+
s.index = index
|
388
|
+
if ax is None:
|
389
|
+
fig, ax = plt.subplots()
|
390
|
+
ax.plot(s.index, s)
|
391
|
+
ax.set_ylabel("Skewness")
|
392
|
+
ax.set_xlabel("Date")
|
393
|
+
return ax
|
394
|
+
|
395
|
+
|
396
|
+
def plot_rolling_kurtosis(prices, window: int = 63, ax=None):
|
397
|
+
"""Plot rolling kurtosis of returns."""
|
398
|
+
if pd is None:
|
399
|
+
raise ImportError("pandas is required for plot_rolling_kurtosis")
|
400
|
+
p = _to_list(prices)
|
401
|
+
index = list(getattr(prices, 'index', range(len(p))))
|
402
|
+
vals = []
|
403
|
+
for i in range(len(p)):
|
404
|
+
if i < window:
|
405
|
+
vals.append(None)
|
406
|
+
else:
|
407
|
+
vals.append(kurtosis(p[i - window : i + 1]))
|
408
|
+
s = pd.Series(vals)
|
409
|
+
s.index = index
|
410
|
+
if ax is None:
|
411
|
+
fig, ax = plt.subplots()
|
412
|
+
ax.plot(s.index, s)
|
413
|
+
ax.set_ylabel("Kurtosis")
|
414
|
+
ax.set_xlabel("Date")
|
415
|
+
return ax
|
@@ -0,0 +1,138 @@
|
|
1
|
+
from collections import Counter
|
2
|
+
from datetime import datetime
|
3
|
+
from typing import Mapping, Sequence, Tuple, Any, Dict, List
|
4
|
+
|
5
|
+
|
6
|
+
def hit_rate(trades: Sequence[Mapping[str, Any]]) -> float:
|
7
|
+
"""Proportion of trades with positive return."""
|
8
|
+
if not trades:
|
9
|
+
return 0.0
|
10
|
+
wins = sum(1 for t in trades if t.get("return", 0) > 0)
|
11
|
+
return wins / len(trades)
|
12
|
+
|
13
|
+
|
14
|
+
def average_win_loss(trades: Sequence[Mapping[str, Any]]) -> Tuple[float, float]:
|
15
|
+
"""Average winning and losing trade returns."""
|
16
|
+
wins = [t.get("return", 0) for t in trades if t.get("return", 0) > 0]
|
17
|
+
losses = [t.get("return", 0) for t in trades if t.get("return", 0) < 0]
|
18
|
+
avg_win = sum(wins) / len(wins) if wins else 0.0
|
19
|
+
avg_loss = sum(losses) / len(losses) if losses else 0.0
|
20
|
+
return avg_win, avg_loss
|
21
|
+
|
22
|
+
|
23
|
+
def expectancy(trades: Sequence[Mapping[str, Any]]) -> float:
|
24
|
+
"""Expected return per trade."""
|
25
|
+
hr = hit_rate(trades)
|
26
|
+
avg_win, avg_loss = average_win_loss(trades)
|
27
|
+
return hr * avg_win + (1 - hr) * avg_loss
|
28
|
+
|
29
|
+
|
30
|
+
def profit_factor(trades: Sequence[Mapping[str, Any]]) -> float:
|
31
|
+
"""Ratio of gross profits to gross losses."""
|
32
|
+
gains = sum(t.get("return", 0) for t in trades if t.get("return", 0) > 0)
|
33
|
+
losses = -sum(t.get("return", 0) for t in trades if t.get("return", 0) < 0)
|
34
|
+
if losses == 0:
|
35
|
+
return float("inf") if gains > 0 else 0.0
|
36
|
+
return gains / losses
|
37
|
+
|
38
|
+
|
39
|
+
def trade_duration_distribution(trades: Sequence[Mapping[str, Any]]) -> Dict[int, int]:
|
40
|
+
"""Distribution of trade holding periods in days."""
|
41
|
+
durations = []
|
42
|
+
for t in trades:
|
43
|
+
entry = t.get("entry")
|
44
|
+
exit_ = t.get("exit")
|
45
|
+
if entry is None or exit_ is None:
|
46
|
+
continue
|
47
|
+
delta = exit_ - entry
|
48
|
+
days = delta.days if hasattr(delta, "days") else int(delta)
|
49
|
+
durations.append(days)
|
50
|
+
return dict(Counter(durations))
|
51
|
+
|
52
|
+
|
53
|
+
def turnover(trades: Sequence[Mapping[str, Any]]) -> float:
|
54
|
+
"""Average number of trades per day."""
|
55
|
+
if not trades:
|
56
|
+
return 0.0
|
57
|
+
entries = [t.get("entry") for t in trades if t.get("entry") is not None]
|
58
|
+
exits = [t.get("exit") for t in trades if t.get("exit") is not None]
|
59
|
+
if not entries or not exits:
|
60
|
+
return 0.0
|
61
|
+
start = min(entries)
|
62
|
+
end = max(exits)
|
63
|
+
period = (end - start).days
|
64
|
+
if period <= 0:
|
65
|
+
return float(len(trades))
|
66
|
+
return len(trades) / period
|
67
|
+
|
68
|
+
|
69
|
+
def trade_implementation_shortfall(trade: Mapping[str, Any]) -> float:
|
70
|
+
"""Implementation shortfall for a single trade.
|
71
|
+
|
72
|
+
Calculated as the difference between the modelled return and the
|
73
|
+
realised return of the trade. If either value is missing the result
|
74
|
+
is ``0.0``.
|
75
|
+
"""
|
76
|
+
|
77
|
+
model_ret = trade.get("model_return")
|
78
|
+
actual_ret = trade.get("return")
|
79
|
+
if model_ret is None or actual_ret is None:
|
80
|
+
return 0.0
|
81
|
+
return model_ret - actual_ret
|
82
|
+
|
83
|
+
|
84
|
+
def cumulative_implementation_shortfall(trades: Sequence[Mapping[str, Any]]) -> float:
|
85
|
+
"""Total implementation shortfall over a collection of trades."""
|
86
|
+
|
87
|
+
return sum(trade_implementation_shortfall(t) for t in trades)
|
88
|
+
|
89
|
+
|
90
|
+
def slippage_stats(trades: Sequence[Mapping[str, Any]]) -> Dict[str, float]:
|
91
|
+
"""Average entry and exit slippage for a set of trades.
|
92
|
+
|
93
|
+
Slippage is measured relative to the model prices. Positive values
|
94
|
+
indicate worse execution than the modelled price.
|
95
|
+
"""
|
96
|
+
|
97
|
+
entry_slip: List[float] = []
|
98
|
+
exit_slip: List[float] = []
|
99
|
+
|
100
|
+
for t in trades:
|
101
|
+
direction = 1 if t.get("direction") == "long" else -1
|
102
|
+
|
103
|
+
if "entry_price" in t and "model_entry_price" in t and t["model_entry_price"]:
|
104
|
+
entry_slip.append(
|
105
|
+
direction
|
106
|
+
* (t["entry_price"] - t["model_entry_price"]) / t["model_entry_price"]
|
107
|
+
)
|
108
|
+
|
109
|
+
if "exit_price" in t and "model_exit_price" in t and t["model_exit_price"]:
|
110
|
+
exit_slip.append(
|
111
|
+
direction
|
112
|
+
* (t["model_exit_price"] - t["exit_price"]) / t["model_exit_price"]
|
113
|
+
)
|
114
|
+
|
115
|
+
avg_entry = sum(entry_slip) / len(entry_slip) if entry_slip else 0.0
|
116
|
+
avg_exit = sum(exit_slip) / len(exit_slip) if exit_slip else 0.0
|
117
|
+
return {"avg_entry_slippage": avg_entry, "avg_exit_slippage": avg_exit}
|
118
|
+
|
119
|
+
|
120
|
+
def latency_stats(trades: Sequence[Mapping[str, Any]]) -> Dict[str, float]:
|
121
|
+
"""Basic latency metrics in seconds between signal and execution."""
|
122
|
+
|
123
|
+
latencies = []
|
124
|
+
for t in trades:
|
125
|
+
signal_time = t.get("signal_time")
|
126
|
+
entry_time = t.get("entry")
|
127
|
+
if signal_time is None or entry_time is None:
|
128
|
+
continue
|
129
|
+
delta = entry_time - signal_time
|
130
|
+
secs = delta.total_seconds() if hasattr(delta, "total_seconds") else float(delta)
|
131
|
+
latencies.append(secs)
|
132
|
+
|
133
|
+
if not latencies:
|
134
|
+
return {"avg_latency_sec": 0.0, "max_latency_sec": 0.0}
|
135
|
+
|
136
|
+
avg_lat = sum(latencies) / len(latencies)
|
137
|
+
max_lat = max(latencies)
|
138
|
+
return {"avg_latency_sec": avg_lat, "max_latency_sec": max_lat}
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pwb-toolbox
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.8
|
4
4
|
Summary: A toolbox library for quant traders
|
5
5
|
Home-page: https://github.com/paperswithbacktest/pwb-toolbox
|
6
6
|
Author: Your Name
|
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE.txt
|
17
17
|
Requires-Dist: datasets
|
18
18
|
Requires-Dist: pandas
|
19
|
+
Requires-Dist: ibapi
|
20
|
+
Requires-Dist: ib_insync
|
19
21
|
Dynamic: license-file
|
20
22
|
|
21
23
|
<div align="center">
|
@@ -128,10 +130,68 @@ into portfolio weights and executed via Backtrader orders.
|
|
128
130
|
```python
|
129
131
|
from pwb_toolbox.backtest.examples import GoldenCrossAlpha, EqualWeightPortfolio
|
130
132
|
from pwb_toolbox.backtest import run_backtest
|
133
|
+
from pwb_toolbox.backtest.execution_models import ImmediateExecutionModel
|
134
|
+
from pwb_toolbox.backtest.risk_models import MaximumTotalPortfolioExposure
|
135
|
+
from pwb_toolbox.backtest.universe_models import ManualUniverseSelectionModel
|
136
|
+
|
137
|
+
run_backtest(
|
138
|
+
ManualUniverseSelectionModel(["SPY", "QQQ"]),
|
139
|
+
GoldenCrossAlpha(),
|
140
|
+
EqualWeightPortfolio(),
|
141
|
+
execution=ImmediateExecutionModel(),
|
142
|
+
risk=MaximumTotalPortfolioExposure(max_exposure=1.0),
|
143
|
+
start="2015-01-01",
|
144
|
+
)
|
145
|
+
```
|
146
|
+
|
147
|
+
## Performance Analysis
|
148
|
+
|
149
|
+
After running a backtest you can analyze the returned equity series using the
|
150
|
+
`pwb_toolbox.performance` module.
|
151
|
+
|
152
|
+
```python
|
153
|
+
from pwb_toolbox.backtest.examples import GoldenCrossAlpha, EqualWeightPortfolio
|
154
|
+
from pwb_toolbox.backtest import run_backtest
|
155
|
+
from pwb_toolbox.backtest.execution_models import ImmediateExecutionModel
|
156
|
+
from pwb_toolbox.performance import total_return, cagr
|
157
|
+
from pwb_toolbox.performance.plots import plot_equity_curve
|
158
|
+
|
159
|
+
result, equity = run_backtest(
|
160
|
+
ManualUniverseSelectionModel(["SPY", "QQQ"]),
|
161
|
+
GoldenCrossAlpha(),
|
162
|
+
EqualWeightPortfolio(),
|
163
|
+
execution=ImmediateExecutionModel(),
|
164
|
+
start="2015-01-01",
|
165
|
+
)
|
131
166
|
|
132
|
-
|
167
|
+
print("Total return:", total_return(equity))
|
168
|
+
print("CAGR:", cagr(equity))
|
169
|
+
|
170
|
+
plot_equity_curve(equity)
|
133
171
|
```
|
134
172
|
|
173
|
+
Plotting utilities require `matplotlib`; some metrics also need `pandas`.
|
174
|
+
|
175
|
+
## Live trading with Interactive Brokers
|
176
|
+
|
177
|
+
`run_ib_strategy` streams Interactive Brokers data and orders. Install `ibapi` and either `atreyu-backtrader-api` or `ib_insync`.
|
178
|
+
|
179
|
+
```python
|
180
|
+
from pwb_toolbox.backtest import IBConnector, run_ib_strategy
|
181
|
+
from pwb_toolbox.backtest.example.engine import SimpleIBStrategy
|
182
|
+
|
183
|
+
data_cfg = [{"dataname": "AAPL", "name": "AAPL"}]
|
184
|
+
run_ib_strategy(
|
185
|
+
SimpleIBStrategy,
|
186
|
+
data_cfg,
|
187
|
+
host="127.0.0.1",
|
188
|
+
port=7497,
|
189
|
+
client_id=1,
|
190
|
+
)
|
191
|
+
```
|
192
|
+
|
193
|
+
Configure `host`, `port`, and `client_id` to match your TWS or Gateway settings. Test with an Interactive Brokers paper account before trading live.
|
194
|
+
|
135
195
|
## Contributing
|
136
196
|
|
137
197
|
Contributions to the `pwb-toolbox` package are welcome! If you have any improvements, new datasets, or strategy ideas to share, please follow these guidelines:
|
@@ -0,0 +1,19 @@
|
|
1
|
+
pwb_toolbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
pwb_toolbox/backtest/__init__.py,sha256=uXP0toenQFhIOn8uwyoKNRjH1bEyVfO0-ryFZAMb1xE,2066
|
3
|
+
pwb_toolbox/backtest/base_strategy.py,sha256=PQTO9vytnxeDplmaDUC8ORYwo9dTUbwhNrrmHlpDAAU,994
|
4
|
+
pwb_toolbox/backtest/ib_connector.py,sha256=5T-pgT_MrDOxqdvXgT_hceIeewPs-rN3j4n-Wr-6JGU,2120
|
5
|
+
pwb_toolbox/backtest/insight.py,sha256=NPrNr7ToNUpqHvgOjgtsP1g8p1Pn8yXuD6YSO-zYePg,394
|
6
|
+
pwb_toolbox/backtest/execution_models/__init__.py,sha256=kMa-C7DPeCwB81pyOp3gjIUSYpI3EuCn1uO9vLTJK4Q,5996
|
7
|
+
pwb_toolbox/backtest/portfolio_models/__init__.py,sha256=VDDDOUhu4kPxYJsOb9dH-qHTfM-Hj8O7hmzLXGuSxs8,9353
|
8
|
+
pwb_toolbox/backtest/risk_models/__init__.py,sha256=Sbd4CeGGhxRFQfdsiMoL7ws-1NJq6IkhxQhXAnGacpY,6319
|
9
|
+
pwb_toolbox/backtest/universe_models/__init__.py,sha256=-NXd_dhPKHgfBpynWjKJ4YxHLvagNhNPfU_JUreK7fc,5715
|
10
|
+
pwb_toolbox/datasets/__init__.py,sha256=o2Q6nw8HmV_gTFfovhPJkoGdFsADBunFC4KqBl9Tpaw,22259
|
11
|
+
pwb_toolbox/performance/__init__.py,sha256=ds47RiOSL3iIwRE0S8dnGINcVPlZw_I9D21ueTSVP-I,2925
|
12
|
+
pwb_toolbox/performance/metrics.py,sha256=szY8m45dZeJHciF4NxPxXlDyc78_5cLyIweRQJ_8lCE,15255
|
13
|
+
pwb_toolbox/performance/plots.py,sha256=R6OV-SxJaJnBuJGh8XmsF58a7ERwn2Irf4zEqzGMRz4,12886
|
14
|
+
pwb_toolbox/performance/trade_stats.py,sha256=I-iboKMwVLij6pc2r-KfNDnyF3LZV_LzzpgjIcJtgFw,4940
|
15
|
+
pwb_toolbox-0.1.8.dist-info/licenses/LICENSE.txt,sha256=_Wjz7o7St3iVSPBRzE0keS8XSqSJ03A3NZ6cMlTaSK8,1079
|
16
|
+
pwb_toolbox-0.1.8.dist-info/METADATA,sha256=tVOCTxHNoDRAXG1mzp2NVzUn92OMQmwtwx2RUv7mWJU,7130
|
17
|
+
pwb_toolbox-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
18
|
+
pwb_toolbox-0.1.8.dist-info/top_level.txt,sha256=TZcXcF2AMkKkibZOuq6AYsHjajPgddHAGjQUT64OYGY,12
|
19
|
+
pwb_toolbox-0.1.8.dist-info/RECORD,,
|
@@ -1,14 +0,0 @@
|
|
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,,
|
File without changes
|
File without changes
|
File without changes
|