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