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/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
- from openseries.types import CaptorLogoType, PlotlyLayoutType
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__).resolve().parent.parent
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 Path.open(layoutfile, mode="r", encoding="utf-8") as layout_file:
65
+ with layoutfile.open(mode="r", encoding="utf-8") as layout_file:
64
66
  fig = load(layout_file)
65
- with Path.open(logofile, mode="r", encoding="utf-8") as logo_file:
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