openseries 1.5.6__py3-none-any.whl → 1.6.0__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.
- openseries/__init__.py +43 -0
- openseries/_common_model.py +6 -5
- openseries/_risk.py +0 -61
- openseries/datefixer.py +21 -10
- openseries/frame.py +21 -589
- openseries/load_plotly.py +2 -0
- openseries/portfoliotools.py +613 -0
- openseries/series.py +6 -9
- openseries/simulation.py +7 -5
- openseries/types.py +20 -0
- {openseries-1.5.6.dist-info → openseries-1.6.0.dist-info}/METADATA +3 -3
- openseries-1.6.0.dist-info/RECORD +16 -0
- openseries-1.5.6.dist-info/RECORD +0 -15
- {openseries-1.5.6.dist-info → openseries-1.6.0.dist-info}/LICENSE.md +0 -0
- {openseries-1.5.6.dist-info → openseries-1.6.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,613 @@
|
|
1
|
+
"""Defining the portfolio tools for the OpenFrame class."""
|
2
|
+
|
3
|
+
# mypy: disable-error-code="index,assignment"
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
from inspect import stack
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import Callable, Optional, Union, cast
|
9
|
+
|
10
|
+
from numpy import (
|
11
|
+
append,
|
12
|
+
array,
|
13
|
+
dot,
|
14
|
+
float64,
|
15
|
+
inf,
|
16
|
+
linspace,
|
17
|
+
nan,
|
18
|
+
sqrt,
|
19
|
+
zeros,
|
20
|
+
)
|
21
|
+
from numpy import (
|
22
|
+
sum as npsum,
|
23
|
+
)
|
24
|
+
from numpy.typing import NDArray
|
25
|
+
from pandas import (
|
26
|
+
DataFrame,
|
27
|
+
Series,
|
28
|
+
concat,
|
29
|
+
)
|
30
|
+
from plotly.graph_objs import Figure # type: ignore[import-untyped,unused-ignore]
|
31
|
+
from plotly.io import to_html # type: ignore[import-untyped,unused-ignore]
|
32
|
+
from plotly.offline import plot # type: ignore[import-untyped,unused-ignore]
|
33
|
+
from pydantic import DirectoryPath
|
34
|
+
from scipy.optimize import minimize # type: ignore[import-untyped,unused-ignore]
|
35
|
+
|
36
|
+
# noinspection PyProtectedMember
|
37
|
+
from openseries.frame import OpenFrame
|
38
|
+
from openseries.load_plotly import load_plotly_dict
|
39
|
+
from openseries.series import OpenTimeSeries
|
40
|
+
from openseries.simulation import _random_generator
|
41
|
+
from openseries.types import (
|
42
|
+
LiteralLinePlotMode,
|
43
|
+
LiteralMinimizeMethods,
|
44
|
+
LiteralPlotlyJSlib,
|
45
|
+
LiteralPlotlyOutput,
|
46
|
+
ValueType,
|
47
|
+
)
|
48
|
+
|
49
|
+
__all__ = [
|
50
|
+
"constrain_optimized_portfolios",
|
51
|
+
"efficient_frontier",
|
52
|
+
"prepare_plot_data",
|
53
|
+
"sharpeplot",
|
54
|
+
"simulate_portfolios",
|
55
|
+
]
|
56
|
+
|
57
|
+
|
58
|
+
def simulate_portfolios(
|
59
|
+
simframe: OpenFrame,
|
60
|
+
num_ports: int,
|
61
|
+
seed: int,
|
62
|
+
) -> DataFrame:
|
63
|
+
"""
|
64
|
+
Generate random weights for simulated portfolios.
|
65
|
+
|
66
|
+
Parameters
|
67
|
+
----------
|
68
|
+
simframe: OpenFrame
|
69
|
+
Return data for portfolio constituents
|
70
|
+
num_ports: int
|
71
|
+
Number of possible portfolios to simulate
|
72
|
+
seed: int
|
73
|
+
The seed for the random process
|
74
|
+
|
75
|
+
Returns
|
76
|
+
-------
|
77
|
+
pandas.DataFrame
|
78
|
+
The resulting data
|
79
|
+
|
80
|
+
"""
|
81
|
+
copi = simframe.from_deepcopy()
|
82
|
+
|
83
|
+
if any(
|
84
|
+
x == ValueType.PRICE for x in copi.tsdf.columns.get_level_values(1).to_numpy()
|
85
|
+
):
|
86
|
+
copi.value_to_ret()
|
87
|
+
log_ret = copi.tsdf.copy()[1:]
|
88
|
+
else:
|
89
|
+
log_ret = copi.tsdf.copy()
|
90
|
+
|
91
|
+
log_ret.columns = log_ret.columns.droplevel(level=1)
|
92
|
+
|
93
|
+
randomizer = _random_generator(seed=seed)
|
94
|
+
|
95
|
+
all_weights = zeros((num_ports, simframe.item_count))
|
96
|
+
ret_arr = zeros(num_ports)
|
97
|
+
vol_arr = zeros(num_ports)
|
98
|
+
sharpe_arr = zeros(num_ports)
|
99
|
+
|
100
|
+
for x in range(num_ports):
|
101
|
+
weights = array(randomizer.random(simframe.item_count))
|
102
|
+
weights = weights / npsum(weights)
|
103
|
+
all_weights[x, :] = weights
|
104
|
+
|
105
|
+
vol_arr[x] = sqrt(
|
106
|
+
dot(
|
107
|
+
weights.T,
|
108
|
+
dot(log_ret.cov() * simframe.periods_in_a_year, weights),
|
109
|
+
),
|
110
|
+
)
|
111
|
+
|
112
|
+
ret_arr[x] = npsum(log_ret.mean() * weights * simframe.periods_in_a_year)
|
113
|
+
|
114
|
+
sharpe_arr[x] = ret_arr[x] / vol_arr[x]
|
115
|
+
|
116
|
+
# noinspection PyUnreachableCode
|
117
|
+
simdf = concat(
|
118
|
+
[
|
119
|
+
DataFrame({"stdev": vol_arr, "ret": ret_arr, "sharpe": sharpe_arr}),
|
120
|
+
DataFrame(all_weights, columns=simframe.columns_lvl_zero),
|
121
|
+
],
|
122
|
+
axis="columns",
|
123
|
+
)
|
124
|
+
simdf = simdf.replace([inf, -inf], nan)
|
125
|
+
return simdf.dropna()
|
126
|
+
|
127
|
+
|
128
|
+
# noinspection PyUnusedLocal
|
129
|
+
def efficient_frontier( # noqa: C901
|
130
|
+
eframe: OpenFrame,
|
131
|
+
num_ports: int = 5000,
|
132
|
+
seed: int = 71,
|
133
|
+
bounds: Optional[tuple[tuple[float]]] = None,
|
134
|
+
frontier_points: int = 200,
|
135
|
+
minimize_method: LiteralMinimizeMethods = "SLSQP",
|
136
|
+
*,
|
137
|
+
tweak: bool = True,
|
138
|
+
) -> tuple[DataFrame, DataFrame, NDArray[float64]]:
|
139
|
+
"""
|
140
|
+
Identify an efficient frontier.
|
141
|
+
|
142
|
+
Parameters
|
143
|
+
----------
|
144
|
+
eframe: OpenFrame
|
145
|
+
Portfolio data
|
146
|
+
num_ports: int, default: 5000
|
147
|
+
Number of possible portfolios to simulate
|
148
|
+
seed: int, default: 71
|
149
|
+
The seed for the random process
|
150
|
+
bounds: tuple[tuple[float]], optional
|
151
|
+
The range of minumum and maximum allowed allocations for each asset
|
152
|
+
frontier_points: int, default: 200
|
153
|
+
number of points along frontier to optimize
|
154
|
+
minimize_method: LiteralMinimizeMethods, default: SLSQP
|
155
|
+
The method passed into the scipy.minimize function
|
156
|
+
tweak: bool, default: True
|
157
|
+
cutting the frontier to exclude multiple points with almost the same risk
|
158
|
+
|
159
|
+
Returns
|
160
|
+
-------
|
161
|
+
tuple[DataFrame, DataFrame, NDArray[float]]
|
162
|
+
The efficient frontier data, simulation data and optimal portfolio
|
163
|
+
|
164
|
+
"""
|
165
|
+
if eframe.weights is None:
|
166
|
+
eframe.weights = [1.0 / eframe.item_count] * eframe.item_count
|
167
|
+
|
168
|
+
copi = eframe.from_deepcopy()
|
169
|
+
|
170
|
+
if any(
|
171
|
+
x == ValueType.PRICE for x in copi.tsdf.columns.get_level_values(1).to_numpy()
|
172
|
+
):
|
173
|
+
copi.value_to_ret()
|
174
|
+
log_ret = copi.tsdf.copy()[1:]
|
175
|
+
else:
|
176
|
+
log_ret = copi.tsdf.copy()
|
177
|
+
|
178
|
+
log_ret.columns = log_ret.columns.droplevel(level=1)
|
179
|
+
|
180
|
+
simulated = simulate_portfolios(simframe=copi, num_ports=num_ports, seed=seed)
|
181
|
+
|
182
|
+
frontier_min = simulated.loc[simulated["stdev"].idxmin()]["ret"]
|
183
|
+
arithmetic_mean = log_ret.mean() * copi.periods_in_a_year
|
184
|
+
frontier_max = 0.0
|
185
|
+
if isinstance(arithmetic_mean, Series):
|
186
|
+
frontier_max = arithmetic_mean.max()
|
187
|
+
|
188
|
+
def _check_sum(weights: NDArray[float64]) -> float64:
|
189
|
+
return cast(float64, npsum(weights) - 1)
|
190
|
+
|
191
|
+
def _get_ret_vol_sr(
|
192
|
+
lg_ret: DataFrame,
|
193
|
+
weights: NDArray[float64],
|
194
|
+
per_in_yr: float,
|
195
|
+
) -> NDArray[float64]:
|
196
|
+
ret = npsum(lg_ret.mean() * weights) * per_in_yr
|
197
|
+
volatility = sqrt(dot(weights.T, dot(lg_ret.cov() * per_in_yr, weights)))
|
198
|
+
sr = ret / volatility
|
199
|
+
return cast(NDArray[float64], array([ret, volatility, sr]))
|
200
|
+
|
201
|
+
def _diff_return(
|
202
|
+
lg_ret: DataFrame,
|
203
|
+
weights: NDArray[float64],
|
204
|
+
per_in_yr: float,
|
205
|
+
poss_return: float,
|
206
|
+
) -> float64:
|
207
|
+
return cast(
|
208
|
+
float64,
|
209
|
+
_get_ret_vol_sr(lg_ret=lg_ret, weights=weights, per_in_yr=per_in_yr)[0]
|
210
|
+
- poss_return,
|
211
|
+
)
|
212
|
+
|
213
|
+
def _neg_sharpe(weights: NDArray[float64]) -> float64:
|
214
|
+
return cast(
|
215
|
+
float64,
|
216
|
+
_get_ret_vol_sr(
|
217
|
+
lg_ret=log_ret,
|
218
|
+
weights=weights,
|
219
|
+
per_in_yr=eframe.periods_in_a_year,
|
220
|
+
)[2]
|
221
|
+
* -1,
|
222
|
+
)
|
223
|
+
|
224
|
+
def _minimize_volatility(
|
225
|
+
weights: NDArray[float64],
|
226
|
+
) -> float64:
|
227
|
+
return cast(
|
228
|
+
float64,
|
229
|
+
_get_ret_vol_sr(
|
230
|
+
lg_ret=log_ret,
|
231
|
+
weights=weights,
|
232
|
+
per_in_yr=eframe.periods_in_a_year,
|
233
|
+
)[1],
|
234
|
+
)
|
235
|
+
|
236
|
+
constraints = {"type": "eq", "fun": _check_sum}
|
237
|
+
if not bounds:
|
238
|
+
bounds = tuple((0.0, 1.0) for _ in range(eframe.item_count))
|
239
|
+
init_guess = array(eframe.weights)
|
240
|
+
|
241
|
+
opt_results = minimize(
|
242
|
+
fun=_neg_sharpe,
|
243
|
+
x0=init_guess,
|
244
|
+
method=minimize_method,
|
245
|
+
bounds=bounds,
|
246
|
+
constraints=constraints,
|
247
|
+
)
|
248
|
+
|
249
|
+
optimal = _get_ret_vol_sr(
|
250
|
+
lg_ret=log_ret,
|
251
|
+
weights=opt_results.x,
|
252
|
+
per_in_yr=eframe.periods_in_a_year,
|
253
|
+
)
|
254
|
+
|
255
|
+
frontier_y = linspace(start=frontier_min, stop=frontier_max, num=frontier_points)
|
256
|
+
frontier_x = []
|
257
|
+
frontier_weights = []
|
258
|
+
|
259
|
+
for possible_return in frontier_y:
|
260
|
+
cons = cast(
|
261
|
+
dict[str, Union[str, Callable[[float, NDArray[float64]], float64]]],
|
262
|
+
(
|
263
|
+
{"type": "eq", "fun": _check_sum},
|
264
|
+
{
|
265
|
+
"type": "eq",
|
266
|
+
"fun": lambda w, poss_return=possible_return: _diff_return(
|
267
|
+
lg_ret=log_ret,
|
268
|
+
weights=w,
|
269
|
+
per_in_yr=eframe.periods_in_a_year,
|
270
|
+
poss_return=poss_return,
|
271
|
+
),
|
272
|
+
},
|
273
|
+
),
|
274
|
+
)
|
275
|
+
|
276
|
+
result = minimize(
|
277
|
+
fun=_minimize_volatility,
|
278
|
+
x0=init_guess,
|
279
|
+
method=minimize_method,
|
280
|
+
bounds=bounds,
|
281
|
+
constraints=cons,
|
282
|
+
)
|
283
|
+
|
284
|
+
frontier_x.append(result["fun"])
|
285
|
+
frontier_weights.append(result["x"])
|
286
|
+
|
287
|
+
# noinspection PyUnreachableCode
|
288
|
+
line_df = concat(
|
289
|
+
[
|
290
|
+
DataFrame(data=frontier_weights, columns=eframe.columns_lvl_zero),
|
291
|
+
DataFrame({"stdev": frontier_x, "ret": frontier_y}),
|
292
|
+
],
|
293
|
+
axis="columns",
|
294
|
+
)
|
295
|
+
line_df["sharpe"] = line_df.ret / line_df.stdev
|
296
|
+
|
297
|
+
limit_small = 0.0001
|
298
|
+
line_df = line_df.mask(line_df.abs() < limit_small, 0.0)
|
299
|
+
line_df["text"] = line_df.apply(
|
300
|
+
lambda c: "<br><br>Weights:<br>"
|
301
|
+
+ "<br>".join(
|
302
|
+
[f"{c[nm]:.1%} {nm}" for nm in eframe.columns_lvl_zero],
|
303
|
+
),
|
304
|
+
axis="columns",
|
305
|
+
)
|
306
|
+
|
307
|
+
if tweak:
|
308
|
+
limit_tweak = 0.001
|
309
|
+
line_df["stdev_diff"] = line_df.stdev.pct_change()
|
310
|
+
line_df = line_df.loc[line_df.stdev_diff.abs() > limit_tweak]
|
311
|
+
line_df = line_df.drop(columns="stdev_diff")
|
312
|
+
|
313
|
+
return line_df, simulated, append(optimal, opt_results.x)
|
314
|
+
|
315
|
+
|
316
|
+
def constrain_optimized_portfolios(
|
317
|
+
data: OpenFrame,
|
318
|
+
serie: OpenTimeSeries,
|
319
|
+
portfolioname: str = "Current Portfolio",
|
320
|
+
simulations: int = 10000,
|
321
|
+
curve_points: int = 200,
|
322
|
+
bounds: Optional[tuple[tuple[float]]] = None,
|
323
|
+
minimize_method: LiteralMinimizeMethods = "SLSQP",
|
324
|
+
) -> tuple[OpenFrame, OpenTimeSeries, OpenFrame, OpenTimeSeries]:
|
325
|
+
"""
|
326
|
+
Constrain optimized portfolios to those that improve on the current one.
|
327
|
+
|
328
|
+
Parameters
|
329
|
+
----------
|
330
|
+
data: OpenFrame
|
331
|
+
Portfolio data
|
332
|
+
serie: OpenTimeSeries
|
333
|
+
A
|
334
|
+
portfolioname: str, default: "Current Portfolio"
|
335
|
+
Name of the portfolio
|
336
|
+
simulations: int, default: 10000
|
337
|
+
Number of possible portfolios to simulate
|
338
|
+
curve_points: int, default: 200
|
339
|
+
Number of optimal portfolios on the efficient frontier
|
340
|
+
bounds: tuple[tuple[float]], optional
|
341
|
+
The range of minumum and maximum allowed allocations for each asset
|
342
|
+
minimize_method: LiteralMinimizeMethods, default: SLSQP
|
343
|
+
The method passed into the scipy.minimize function
|
344
|
+
|
345
|
+
Returns
|
346
|
+
-------
|
347
|
+
tuple[OpenFrame, OpenTimeSeries, OpenFrame, OpenTimeSeries]
|
348
|
+
The constrained optimal portfolio data
|
349
|
+
|
350
|
+
"""
|
351
|
+
lr_frame = data.from_deepcopy()
|
352
|
+
mv_frame = data.from_deepcopy()
|
353
|
+
|
354
|
+
if not bounds:
|
355
|
+
bounds = tuple((0.0, 1.0) for _ in range(data.item_count))
|
356
|
+
|
357
|
+
front_frame, sim_frame, optimal = efficient_frontier(
|
358
|
+
eframe=data,
|
359
|
+
num_ports=simulations,
|
360
|
+
frontier_points=curve_points,
|
361
|
+
bounds=bounds,
|
362
|
+
minimize_method=minimize_method,
|
363
|
+
)
|
364
|
+
|
365
|
+
condition_least_ret = front_frame.ret > serie.arithmetic_ret
|
366
|
+
# noinspection PyArgumentList
|
367
|
+
least_ret_frame = front_frame[condition_least_ret].sort_values(by="stdev")
|
368
|
+
least_ret_port = least_ret_frame.iloc[0]
|
369
|
+
least_ret_port_name = f"Minimize vol & target return of {portfolioname}"
|
370
|
+
least_ret_weights = [least_ret_port[c] for c in lr_frame.columns_lvl_zero]
|
371
|
+
lr_frame.weights = least_ret_weights
|
372
|
+
resleast = OpenTimeSeries.from_df(lr_frame.make_portfolio(least_ret_port_name))
|
373
|
+
|
374
|
+
condition_most_vol = front_frame.stdev < serie.vol
|
375
|
+
# noinspection PyArgumentList
|
376
|
+
most_vol_frame = front_frame[condition_most_vol].sort_values(
|
377
|
+
by="ret",
|
378
|
+
ascending=False,
|
379
|
+
)
|
380
|
+
most_vol_port = most_vol_frame.iloc[0]
|
381
|
+
most_vol_port_name = f"Maximize return & target risk of {portfolioname}"
|
382
|
+
most_vol_weights = [most_vol_port[c] for c in mv_frame.columns_lvl_zero]
|
383
|
+
mv_frame.weights = most_vol_weights
|
384
|
+
resmost = OpenTimeSeries.from_df(mv_frame.make_portfolio(most_vol_port_name))
|
385
|
+
|
386
|
+
return lr_frame, resleast, mv_frame, resmost
|
387
|
+
|
388
|
+
|
389
|
+
def prepare_plot_data(
|
390
|
+
assets: OpenFrame,
|
391
|
+
current: OpenTimeSeries,
|
392
|
+
optimized: NDArray[float64],
|
393
|
+
) -> DataFrame:
|
394
|
+
"""
|
395
|
+
Prepare date to be used as point_frame in the sharpeplot function.
|
396
|
+
|
397
|
+
Parameters
|
398
|
+
----------
|
399
|
+
assets: OpenFrame
|
400
|
+
Portfolio data with individual assets and a weighted portfolio
|
401
|
+
current: OpenTimeSeries
|
402
|
+
The current or initial portfolio based on given weights
|
403
|
+
optimized: DataFrame
|
404
|
+
Data optimized with the efficient_frontier method
|
405
|
+
|
406
|
+
Returns
|
407
|
+
-------
|
408
|
+
DataFrame
|
409
|
+
The data prepared with mean returns, volatility and weights
|
410
|
+
|
411
|
+
"""
|
412
|
+
txt = "<br><br>Weights:<br>" + "<br>".join(
|
413
|
+
[
|
414
|
+
f"{wgt:.1%} {nm}"
|
415
|
+
for wgt, nm in zip(
|
416
|
+
cast(list[float], assets.weights),
|
417
|
+
assets.columns_lvl_zero,
|
418
|
+
)
|
419
|
+
],
|
420
|
+
)
|
421
|
+
|
422
|
+
opt_text_list = [
|
423
|
+
f"{wgt:.1%} {nm}" for wgt, nm in zip(optimized[3:], assets.columns_lvl_zero)
|
424
|
+
]
|
425
|
+
opt_text = "<br><br>Weights:<br>" + "<br>".join(opt_text_list)
|
426
|
+
vol: Series[float] = assets.vol
|
427
|
+
plotframe = DataFrame(
|
428
|
+
data=[
|
429
|
+
assets.arithmetic_ret,
|
430
|
+
vol,
|
431
|
+
Series(
|
432
|
+
data=[""] * assets.item_count,
|
433
|
+
index=vol.index,
|
434
|
+
),
|
435
|
+
],
|
436
|
+
index=["ret", "stdev", "text"],
|
437
|
+
)
|
438
|
+
plotframe.columns = plotframe.columns.droplevel(level=1)
|
439
|
+
plotframe["Max Sharpe Portfolio"] = [optimized[0], optimized[1], opt_text]
|
440
|
+
plotframe[current.label] = [current.arithmetic_ret, current.vol, txt]
|
441
|
+
|
442
|
+
return plotframe
|
443
|
+
|
444
|
+
|
445
|
+
def sharpeplot( # noqa: C901
|
446
|
+
sim_frame: Optional[DataFrame] = None,
|
447
|
+
line_frame: Optional[DataFrame] = None,
|
448
|
+
point_frame: Optional[DataFrame] = None,
|
449
|
+
point_frame_mode: LiteralLinePlotMode = "markers",
|
450
|
+
filename: Optional[str] = None,
|
451
|
+
directory: Optional[DirectoryPath] = None,
|
452
|
+
titletext: Optional[str] = None,
|
453
|
+
output_type: LiteralPlotlyOutput = "file",
|
454
|
+
include_plotlyjs: LiteralPlotlyJSlib = "cdn",
|
455
|
+
*,
|
456
|
+
title: bool = True,
|
457
|
+
add_logo: bool = True,
|
458
|
+
auto_open: bool = True,
|
459
|
+
) -> tuple[Figure, str]:
|
460
|
+
"""
|
461
|
+
Create scatter plot coloured by Sharpe Ratio.
|
462
|
+
|
463
|
+
Parameters
|
464
|
+
----------
|
465
|
+
sim_frame: DataFrame, optional
|
466
|
+
Data from the simulate_portfolios method.
|
467
|
+
line_frame: DataFrame, optional
|
468
|
+
Data from the efficient_frontier method.
|
469
|
+
point_frame: DataFrame, optional
|
470
|
+
Data to highlight current and efficient portfolios.
|
471
|
+
point_frame_mode: LiteralLinePlotMode, default: markers
|
472
|
+
Which type of scatter to use.
|
473
|
+
filename: str, optional
|
474
|
+
Name of the Plotly html file
|
475
|
+
directory: DirectoryPath, optional
|
476
|
+
Directory where Plotly html file is saved
|
477
|
+
titletext: str, optional
|
478
|
+
Text for the plot title
|
479
|
+
output_type: LiteralPlotlyOutput, default: "file"
|
480
|
+
Determines output type
|
481
|
+
include_plotlyjs: LiteralPlotlyJSlib, default: "cdn"
|
482
|
+
Determines how the plotly.js library is included in the output
|
483
|
+
title: bool, default: True
|
484
|
+
Whether to add standard plot title
|
485
|
+
add_logo: bool, default: True
|
486
|
+
Whether to add Captor logo
|
487
|
+
auto_open: bool, default: True
|
488
|
+
Determines whether to open a browser window with the plot
|
489
|
+
|
490
|
+
Returns
|
491
|
+
-------
|
492
|
+
Figure
|
493
|
+
The scatter plot with simulated and optimized results
|
494
|
+
|
495
|
+
"""
|
496
|
+
returns = []
|
497
|
+
risk = []
|
498
|
+
|
499
|
+
if directory:
|
500
|
+
dirpath = Path(directory).resolve()
|
501
|
+
elif Path.home().joinpath("Documents").exists():
|
502
|
+
dirpath = Path.home().joinpath("Documents")
|
503
|
+
else:
|
504
|
+
dirpath = Path(stack()[1].filename).parent
|
505
|
+
|
506
|
+
if not filename:
|
507
|
+
filename = "sharpeplot.html"
|
508
|
+
plotfile = dirpath.joinpath(filename)
|
509
|
+
|
510
|
+
fig, logo = load_plotly_dict()
|
511
|
+
figure = Figure(fig)
|
512
|
+
|
513
|
+
if sim_frame is not None:
|
514
|
+
returns.extend(list(sim_frame.loc[:, "ret"]))
|
515
|
+
risk.extend(list(sim_frame.loc[:, "stdev"]))
|
516
|
+
figure.add_scatter(
|
517
|
+
x=sim_frame.loc[:, "stdev"],
|
518
|
+
y=sim_frame.loc[:, "ret"],
|
519
|
+
hoverinfo="skip",
|
520
|
+
marker={
|
521
|
+
"size": 10,
|
522
|
+
"opacity": 0.5,
|
523
|
+
"color": sim_frame.loc[:, "sharpe"],
|
524
|
+
"colorscale": "Jet",
|
525
|
+
"reversescale": True,
|
526
|
+
"colorbar": {"thickness": 20, "title": "Ratio<br>ret / vol"},
|
527
|
+
},
|
528
|
+
mode="markers",
|
529
|
+
name="simulated portfolios",
|
530
|
+
)
|
531
|
+
if line_frame is not None:
|
532
|
+
returns.extend(list(line_frame.loc[:, "ret"]))
|
533
|
+
risk.extend(list(line_frame.loc[:, "stdev"]))
|
534
|
+
figure.add_scatter(
|
535
|
+
x=line_frame.loc[:, "stdev"],
|
536
|
+
y=line_frame.loc[:, "ret"],
|
537
|
+
text=line_frame.loc[:, "text"],
|
538
|
+
xhoverformat=".2%",
|
539
|
+
yhoverformat=".2%",
|
540
|
+
hovertemplate="Return %{y}<br>Vol %{x}%{text}",
|
541
|
+
hoverlabel_align="right",
|
542
|
+
line={"width": 2.5, "dash": "solid"},
|
543
|
+
mode="lines",
|
544
|
+
name="Efficient frontier",
|
545
|
+
)
|
546
|
+
|
547
|
+
colorway = fig["layout"].get("colorway")[ # type: ignore[union-attr]
|
548
|
+
: len(cast(DataFrame, point_frame).columns)
|
549
|
+
]
|
550
|
+
|
551
|
+
if point_frame is not None:
|
552
|
+
for col, clr in zip(point_frame.columns, colorway):
|
553
|
+
returns.extend([point_frame.loc["ret", col]])
|
554
|
+
risk.extend([point_frame.loc["stdev", col]])
|
555
|
+
figure.add_scatter(
|
556
|
+
x=[point_frame.loc["stdev", col]],
|
557
|
+
y=[point_frame.loc["ret", col]],
|
558
|
+
xhoverformat=".2%",
|
559
|
+
yhoverformat=".2%",
|
560
|
+
hovertext=[point_frame.loc["text", col]],
|
561
|
+
hovertemplate="Return %{y}<br>Vol %{x}%{hovertext}",
|
562
|
+
hoverlabel_align="right",
|
563
|
+
marker={"size": 20, "color": clr},
|
564
|
+
mode=point_frame_mode,
|
565
|
+
name=col,
|
566
|
+
text=col,
|
567
|
+
textfont={"size": 14},
|
568
|
+
textposition="bottom center",
|
569
|
+
)
|
570
|
+
|
571
|
+
figure.update_layout(
|
572
|
+
xaxis={"tickformat": ".1%"},
|
573
|
+
xaxis_title="volatility",
|
574
|
+
yaxis={
|
575
|
+
"tickformat": ".1%",
|
576
|
+
"scaleanchor": "x",
|
577
|
+
"scaleratio": 1,
|
578
|
+
},
|
579
|
+
yaxis_title="annual return",
|
580
|
+
showlegend=False,
|
581
|
+
)
|
582
|
+
if title:
|
583
|
+
if titletext is None:
|
584
|
+
titletext = "<b>Risk and Return</b><br>"
|
585
|
+
figure.update_layout(title={"text": titletext, "font": {"size": 32}})
|
586
|
+
|
587
|
+
if add_logo:
|
588
|
+
figure.add_layout_image(logo)
|
589
|
+
|
590
|
+
if output_type == "file":
|
591
|
+
plot(
|
592
|
+
figure_or_data=figure,
|
593
|
+
filename=str(plotfile),
|
594
|
+
auto_open=auto_open,
|
595
|
+
auto_play=False,
|
596
|
+
link_text="",
|
597
|
+
include_plotlyjs=cast(bool, include_plotlyjs),
|
598
|
+
config=fig["config"],
|
599
|
+
output_type=output_type,
|
600
|
+
)
|
601
|
+
string_output = str(plotfile)
|
602
|
+
else:
|
603
|
+
div_id = filename.split(sep=".")[0]
|
604
|
+
string_output = to_html(
|
605
|
+
fig=figure,
|
606
|
+
config=fig["config"],
|
607
|
+
auto_play=False,
|
608
|
+
include_plotlyjs=cast(bool, include_plotlyjs),
|
609
|
+
full_html=False,
|
610
|
+
div_id=div_id,
|
611
|
+
)
|
612
|
+
|
613
|
+
return figure, string_output
|
openseries/series.py
CHANGED
@@ -15,6 +15,7 @@ from numpy import (
|
|
15
15
|
isnan,
|
16
16
|
log,
|
17
17
|
sqrt,
|
18
|
+
square,
|
18
19
|
)
|
19
20
|
from pandas import (
|
20
21
|
DataFrame,
|
@@ -28,9 +29,6 @@ from pydantic import model_validator
|
|
28
29
|
from typing_extensions import Self
|
29
30
|
|
30
31
|
from openseries._common_model import _CommonModel
|
31
|
-
from openseries._risk import (
|
32
|
-
_ewma_calc,
|
33
|
-
)
|
34
32
|
from openseries.datefixer import date_fix, do_resample_to_business_period_ends
|
35
33
|
from openseries.types import (
|
36
34
|
Countries,
|
@@ -48,6 +46,8 @@ from openseries.types import (
|
|
48
46
|
ValueType,
|
49
47
|
)
|
50
48
|
|
49
|
+
__all__ = ["OpenTimeSeries", "timeseries_chain"]
|
50
|
+
|
51
51
|
TypeOpenTimeSeries = TypeVar("TypeOpenTimeSeries", bound="OpenTimeSeries")
|
52
52
|
|
53
53
|
|
@@ -675,13 +675,10 @@ class OpenTimeSeries(_CommonModel):
|
|
675
675
|
]
|
676
676
|
|
677
677
|
for item in data.loc[:, cast(int, (self.label, ValueType.RTRN))].iloc[1:]:
|
678
|
-
|
678
|
+
prev = rawdata[-1]
|
679
679
|
rawdata.append(
|
680
|
-
|
681
|
-
|
682
|
-
prev_ewma=previous,
|
683
|
-
time_factor=time_factor,
|
684
|
-
lmbda=lmbda,
|
680
|
+
sqrt(
|
681
|
+
square(item) * time_factor * (1 - lmbda) + square(prev) * lmbda,
|
685
682
|
),
|
686
683
|
)
|
687
684
|
|
openseries/simulation.py
CHANGED
@@ -29,8 +29,10 @@ from openseries.types import (
|
|
29
29
|
ValueType,
|
30
30
|
)
|
31
31
|
|
32
|
+
__all__ = ["ReturnSimulation"]
|
32
33
|
|
33
|
-
|
34
|
+
|
35
|
+
def _random_generator(seed: Optional[int]) -> Generator:
|
34
36
|
"""
|
35
37
|
Make a Numpy Random Generator object.
|
36
38
|
|
@@ -187,7 +189,7 @@ class ReturnSimulation(BaseModel):
|
|
187
189
|
|
188
190
|
"""
|
189
191
|
if not randomizer:
|
190
|
-
randomizer =
|
192
|
+
randomizer = _random_generator(seed=seed)
|
191
193
|
|
192
194
|
returns = randomizer.normal(
|
193
195
|
loc=mean_annual_return / trading_days_in_year,
|
@@ -244,7 +246,7 @@ class ReturnSimulation(BaseModel):
|
|
244
246
|
|
245
247
|
"""
|
246
248
|
if not randomizer:
|
247
|
-
randomizer =
|
249
|
+
randomizer = _random_generator(seed=seed)
|
248
250
|
|
249
251
|
returns = (
|
250
252
|
randomizer.lognormal(
|
@@ -303,7 +305,7 @@ class ReturnSimulation(BaseModel):
|
|
303
305
|
|
304
306
|
"""
|
305
307
|
if not randomizer:
|
306
|
-
randomizer =
|
308
|
+
randomizer = _random_generator(seed=seed)
|
307
309
|
|
308
310
|
drift = (mean_annual_return - 0.5 * mean_annual_vol**2.0) * (
|
309
311
|
1.0 / trading_days_in_year
|
@@ -375,7 +377,7 @@ class ReturnSimulation(BaseModel):
|
|
375
377
|
|
376
378
|
"""
|
377
379
|
if not randomizer:
|
378
|
-
randomizer =
|
380
|
+
randomizer = _random_generator(seed=seed)
|
379
381
|
|
380
382
|
normal_mean = 0.0
|
381
383
|
wiener = randomizer.normal(
|