openseries 2.1.2__tar.gz → 2.1.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openseries
3
- Version: 2.1.2
3
+ Version: 2.1.4
4
4
  Summary: Tools for analyzing financial timeseries.
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE.md
@@ -0,0 +1,670 @@
1
+ """Functions related to HTML reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from inspect import stack
6
+ from itertools import cycle
7
+ from json import dumps as json_dumps
8
+ from logging import getLogger
9
+ from pathlib import Path
10
+ from secrets import choice
11
+ from string import ascii_letters
12
+ from typing import TYPE_CHECKING, Any, cast
13
+ from warnings import catch_warnings, simplefilter
14
+ from webbrowser import open as webbrowser_open
15
+
16
+ from pandas import DataFrame, Index, Series, Timestamp, concat, isna
17
+ from plotly.graph_objs import Bar, Figure, Scatter # type: ignore[import-untyped]
18
+ from plotly.utils import PlotlyJSONEncoder # type: ignore[import-untyped]
19
+
20
+ from .load_plotly import load_plotly_dict
21
+ from .owntypes import (
22
+ CaptorLogoType,
23
+ LiteralBizDayFreq,
24
+ LiteralFrameProps,
25
+ LiteralPlotlyJSlib,
26
+ LiteralPlotlyOutput,
27
+ ValueType,
28
+ )
29
+
30
+ if TYPE_CHECKING: # pragma: no cover
31
+ from .frame import OpenFrame
32
+
33
+ logger = getLogger(__name__)
34
+
35
+ __all__ = ["report_html"]
36
+
37
+
38
+ def calendar_period_returns(
39
+ data: OpenFrame,
40
+ freq: LiteralBizDayFreq = "BYE",
41
+ *,
42
+ relabel: bool = True,
43
+ ) -> DataFrame:
44
+ """Generate a table of returns with appropriate table labels."""
45
+ copied = data.from_deepcopy()
46
+ copied.resample_to_business_period_ends(freq=freq)
47
+ copied.value_to_ret()
48
+ cldr = copied.tsdf.iloc[1:].copy()
49
+ if relabel:
50
+ if freq.upper() == "BYE":
51
+ cldr.index = Index([d.year for d in cldr.index])
52
+ elif freq.upper() == "BQE":
53
+ cldr.index = Index(
54
+ [Timestamp(d).to_period("Q").strftime("Q%q %Y") for d in cldr.index],
55
+ )
56
+ else:
57
+ cldr.index = Index([d.strftime("%b %y") for d in cldr.index])
58
+ return cldr
59
+
60
+
61
+ def _dumps_plotly(obj: object) -> str:
62
+ return json_dumps(obj, cls=PlotlyJSONEncoder)
63
+
64
+
65
+ def _fmt_dates(idx: Index) -> list[str]:
66
+ return [Timestamp(d).strftime("%Y-%m-%d") for d in idx]
67
+
68
+
69
+ def _metrics_table_html(df: DataFrame) -> str:
70
+ return df.to_html(index=False, escape=False, classes=["metrics"], border=0)
71
+
72
+
73
+ def _get_report_properties_and_labels(
74
+ yearfrac: float,
75
+ ) -> tuple[list[str], list[str], list[str]]:
76
+ """Get properties and labels based on yearfrac."""
77
+ if yearfrac > 1.0:
78
+ properties = [
79
+ "geo_ret",
80
+ "vol",
81
+ "ret_vol_ratio",
82
+ "sortino_ratio",
83
+ "worst_month",
84
+ "first_indices",
85
+ "last_indices",
86
+ ]
87
+ labels_init = [
88
+ "Return (CAGR)",
89
+ "Volatility",
90
+ "Sharpe Ratio",
91
+ "Sortino Ratio",
92
+ "Worst Month",
93
+ "Comparison Start",
94
+ "Comparison End",
95
+ "Jensen's Alpha",
96
+ "Information Ratio",
97
+ "Tracking Error (weekly)",
98
+ "Capture Ratio (monthly)",
99
+ "Index Beta (weekly)",
100
+ ]
101
+ labels_final = [
102
+ "Return (CAGR)",
103
+ "Year-to-Date",
104
+ "Month-to-Date",
105
+ "Volatility",
106
+ "Sharpe Ratio",
107
+ "Sortino Ratio",
108
+ "Jensen's Alpha",
109
+ "Information Ratio",
110
+ "Tracking Error (weekly)",
111
+ "Index Beta (weekly)",
112
+ "Capture Ratio (monthly)",
113
+ "Worst Month",
114
+ "Comparison Start",
115
+ "Comparison End",
116
+ ]
117
+ else:
118
+ properties = [
119
+ "value_ret",
120
+ "vol",
121
+ "ret_vol_ratio",
122
+ "sortino_ratio",
123
+ "worst",
124
+ "first_indices",
125
+ "last_indices",
126
+ ]
127
+ labels_init = [
128
+ "Return (simple)",
129
+ "Volatility",
130
+ "Sharpe Ratio",
131
+ "Sortino Ratio",
132
+ "Worst Day",
133
+ "Comparison Start",
134
+ "Comparison End",
135
+ "Jensen's Alpha",
136
+ "Information Ratio",
137
+ "Tracking Error (weekly)",
138
+ "Index Beta (weekly)",
139
+ ]
140
+ labels_final = [
141
+ "Return (simple)",
142
+ "Year-to-Date",
143
+ "Month-to-Date",
144
+ "Volatility",
145
+ "Sharpe Ratio",
146
+ "Sortino Ratio",
147
+ "Jensen's Alpha",
148
+ "Information Ratio",
149
+ "Tracking Error (weekly)",
150
+ "Index Beta (weekly)",
151
+ "Worst Day",
152
+ "Comparison Start",
153
+ "Comparison End",
154
+ ]
155
+ return properties, labels_init, labels_final
156
+
157
+
158
+ def _create_line_traces(data: OpenFrame) -> list[Scatter]:
159
+ """Create line traces for the plot."""
160
+ x_line = _fmt_dates(data.tsdf.index)
161
+ line_traces: list[Scatter] = []
162
+ for item, lbl in enumerate(data.columns_lvl_zero):
163
+ line_traces.append(
164
+ Scatter(
165
+ x=x_line,
166
+ y=data.tsdf.iloc[:, item].tolist(),
167
+ hovertemplate=f"{lbl}<br>%{{y:.2%}}<br>%{{x}}<extra></extra>",
168
+ line={"width": 2.5, "dash": "solid"},
169
+ mode="lines",
170
+ name=lbl,
171
+ showlegend=True,
172
+ ),
173
+ )
174
+ return line_traces
175
+
176
+
177
+ def _create_bar_traces(
178
+ data: OpenFrame,
179
+ bar_freq: LiteralBizDayFreq,
180
+ ) -> list[Bar]:
181
+ """Create bar traces for the plot."""
182
+ quarter_of_year = 0.25
183
+ if data.yearfrac < quarter_of_year:
184
+ tmp = data.from_deepcopy()
185
+ bdf = tmp.value_to_ret().tsdf.iloc[1:]
186
+ else:
187
+ bdf = calendar_period_returns(data=data, freq=bar_freq)
188
+
189
+ x_bar = [str(x) for x in bdf.index]
190
+ bar_traces: list[Bar] = []
191
+ for item in range(data.item_count):
192
+ col_name = cast("tuple[str, ValueType]", bdf.iloc[:, item].name)
193
+ bar_traces.append(
194
+ Bar(
195
+ x=x_bar,
196
+ y=bdf.iloc[:, item].tolist(),
197
+ hovertemplate=f"{col_name[0]}<br>%{{y:.2%}}<br>%{{x}}<extra></extra>",
198
+ name=col_name[0],
199
+ showlegend=False,
200
+ ),
201
+ )
202
+ return bar_traces
203
+
204
+
205
+ def _add_jensen_alpha(
206
+ rpt_df: DataFrame,
207
+ data: OpenFrame,
208
+ ) -> DataFrame:
209
+ """Add Jensen's Alpha to the report dataframe."""
210
+ alpha_frame = data.from_deepcopy()
211
+ alpha_frame.to_cumret()
212
+ with catch_warnings():
213
+ simplefilter("ignore")
214
+ alphas: list[str | float] = [
215
+ alpha_frame.jensen_alpha(
216
+ asset=(aname, ValueType.PRICE),
217
+ market=(alpha_frame.columns_lvl_zero[-1], ValueType.PRICE),
218
+ riskfree_rate=0.0,
219
+ )
220
+ for aname in alpha_frame.columns_lvl_zero[:-1]
221
+ ]
222
+ alphas.append("")
223
+ ar = DataFrame(
224
+ data=alphas,
225
+ index=data.tsdf.columns,
226
+ columns=["Jensen's Alpha"],
227
+ ).T
228
+ return concat([rpt_df, ar])
229
+
230
+
231
+ def _add_information_ratio(
232
+ rpt_df: DataFrame,
233
+ data: OpenFrame,
234
+ ) -> DataFrame:
235
+ """Add Information Ratio to the report dataframe."""
236
+ ir = data.info_ratio_func()
237
+ ir.name = "Information Ratio"
238
+ ir.iloc[-1] = None
239
+ ir_df = ir.to_frame().T
240
+ return concat([rpt_df, ir_df])
241
+
242
+
243
+ def _add_tracking_error(
244
+ rpt_df: DataFrame,
245
+ data: OpenFrame,
246
+ ) -> DataFrame:
247
+ """Add Tracking Error to the report dataframe."""
248
+ te_frame = data.from_deepcopy()
249
+ te_frame.resample("7D")
250
+ with catch_warnings():
251
+ simplefilter("ignore")
252
+ te: Series[float] | Series[str] = te_frame.tracking_error_func()
253
+ if te.hasnans:
254
+ te = Series(
255
+ data=[""] * te_frame.item_count,
256
+ index=te_frame.tsdf.columns,
257
+ name="Tracking Error (weekly)",
258
+ )
259
+ else:
260
+ te.iloc[-1] = None
261
+ te.name = "Tracking Error (weekly)"
262
+ te_df = te.to_frame().T
263
+ return concat([rpt_df, te_df])
264
+
265
+
266
+ def _add_capture_ratio(
267
+ rpt_df: DataFrame,
268
+ data: OpenFrame,
269
+ formats: list[str],
270
+ ) -> tuple[DataFrame, list[str]]:
271
+ """Add Capture Ratio to the report dataframe."""
272
+ crm = data.from_deepcopy()
273
+ crm.resample("ME")
274
+ cru_save = Series(
275
+ data=[""] * crm.item_count,
276
+ index=crm.tsdf.columns,
277
+ name="Capture Ratio (monthly)",
278
+ )
279
+ with catch_warnings():
280
+ simplefilter("ignore")
281
+ try:
282
+ cru: Series[float] | Series[str] = crm.capture_ratio_func(ratio="both")
283
+ except ZeroDivisionError as exc: # pragma: no cover
284
+ msg = f"Capture ratio calculation error: {exc!s}" # pragma: no cover
285
+ logger.warning(msg) # pragma: no cover
286
+ cru = cru_save # pragma: no cover
287
+ if cru.hasnans:
288
+ cru = cru_save
289
+ else:
290
+ cru.iloc[-1] = None
291
+ cru.name = "Capture Ratio (monthly)"
292
+ cru_df = cru.to_frame().T
293
+ return concat([rpt_df, cru_df]), formats
294
+
295
+
296
+ def _add_beta(
297
+ rpt_df: DataFrame,
298
+ data: OpenFrame,
299
+ ) -> DataFrame:
300
+ """Add Index Beta to the report dataframe."""
301
+ beta_frame = data.from_deepcopy()
302
+ beta_frame.resample("7D").value_nan_handle("drop")
303
+ beta_frame.to_cumret()
304
+ betas: list[str | float] = [
305
+ beta_frame.beta(
306
+ asset=(bname, ValueType.PRICE),
307
+ market=(beta_frame.columns_lvl_zero[-1], ValueType.PRICE),
308
+ )
309
+ for bname in beta_frame.columns_lvl_zero[:-1]
310
+ ]
311
+ betas.append("")
312
+ br = DataFrame(
313
+ data=betas,
314
+ index=data.tsdf.columns,
315
+ columns=["Index Beta (weekly)"],
316
+ ).T
317
+ return concat([rpt_df, br])
318
+
319
+
320
+ def _add_ytd_mtd(
321
+ rpt_df: DataFrame,
322
+ data: OpenFrame,
323
+ ) -> DataFrame:
324
+ """Add Year-to-Date and Month-to-Date to the report dataframe."""
325
+ this_year = data.last_idx.year
326
+ this_month = data.last_idx.month
327
+ ytd = data.value_ret_calendar_period(year=this_year).map("{:.2%}".format)
328
+ ytd.name = "Year-to-Date"
329
+ mtd = data.value_ret_calendar_period(year=this_year, month=this_month).map(
330
+ "{:.2%}".format,
331
+ )
332
+ mtd.name = "Month-to-Date"
333
+ ytd_df = ytd.to_frame().T
334
+ mtd_df = mtd.to_frame().T
335
+ return concat([rpt_df, ytd_df, mtd_df])
336
+
337
+
338
+ def _get_output_directory(directory: Path | None) -> Path:
339
+ """Determine the output directory."""
340
+ if directory:
341
+ return Path(directory).resolve()
342
+ if Path.home().joinpath("Documents").exists():
343
+ return Path.home() / "Documents"
344
+ return Path(stack()[1].filename).parent
345
+
346
+
347
+ def _get_plotly_layouts(
348
+ layout_theme: dict[str, Any],
349
+ colorway: list[str],
350
+ item_count: int,
351
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
352
+ """Get line and bar layouts for plotly."""
353
+ line_layout = dict(layout_theme)
354
+ line_layout.update(
355
+ {
356
+ "colorway": colorway[:item_count] if colorway else None,
357
+ "margin": {"l": 50, "r": 20, "t": 20, "b": 40},
358
+ "xaxis": {"gridcolor": "#EEEEEE", "automargin": True, "tickangle": -45},
359
+ "yaxis": {"tickformat": ".2%", "gridcolor": "#EEEEEE", "automargin": True},
360
+ "showlegend": False,
361
+ },
362
+ )
363
+
364
+ bar_layout = dict(layout_theme)
365
+ bar_layout.update(
366
+ {
367
+ "barmode": "group",
368
+ "margin": {"l": 50, "r": 20, "t": 10, "b": 80},
369
+ "xaxis": {"gridcolor": "#EEEEEE", "automargin": True, "tickangle": -45},
370
+ "yaxis": {"tickformat": ".2%", "gridcolor": "#EEEEEE", "automargin": True},
371
+ "showlegend": False,
372
+ },
373
+ )
374
+
375
+ return line_layout, bar_layout
376
+
377
+
378
+ def _get_logo_html(logo: CaptorLogoType, *, add_logo: bool) -> str:
379
+ """Get logo HTML."""
380
+ if not add_logo:
381
+ return ""
382
+ try:
383
+ src = cast("dict[str, Any]", logo).get("source", "")
384
+ except (KeyError, AttributeError, TypeError):
385
+ src = ""
386
+ if src:
387
+ return f'<img src="{src}" alt="Captor" style="height:68px;" />'
388
+ return "CAPTOR"
389
+
390
+
391
+ def _get_legend_html(line_traces: list[Scatter], colorway: list[str]) -> str:
392
+ """Generate HTML for the legend at the bottom of the page."""
393
+ legend_items = []
394
+ color_cycle = cycle(colorway or ["#66725B"])
395
+ for trace in line_traces:
396
+ name = trace.name or ""
397
+ color = next(color_cycle)
398
+ legend_items.append(
399
+ f'<div class="legend-item">'
400
+ f'<div class="legend-color" style="background-color:{color};"></div>'
401
+ f"<span>{name}</span>"
402
+ f"</div>",
403
+ )
404
+ if legend_items:
405
+ return f'<div class="legend-container">{"".join(legend_items)}</div>'
406
+ return ""
407
+
408
+
409
+ def _get_css() -> str:
410
+ """Get CSS styles for the HTML report."""
411
+ return """
412
+ :root{--ink:#1f2a44;--muted:#6b778c;--header:#4a4a4a;--header2:#6a6a6a;--cell:#f3f3f3;--cell2:#e6e6e6;--paper:#ffffff;}
413
+ html,body{margin:0;padding:0;background:var(--paper);color:var(--ink);
414
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;}
415
+ .page{max-width:calc(100% - 64px);margin:0 auto;padding:32px;
416
+ padding-bottom:48px;}
417
+ .header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
418
+ align-items:start;}
419
+ h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
420
+ .layout{display:grid;grid-template-columns:1.2fr .9fr;
421
+ grid-template-areas:"charts table";gap:22px;align-items:start;margin-top:12px;}
422
+ .charts{grid-area:charts;display:grid;grid-template-rows:auto auto;gap:18px;}
423
+ .table{grid-area:table;}
424
+ .plot{width:100%;height:380px;}
425
+ .plot.bar{height:300px;}
426
+ @media (max-width:980px){
427
+ .page{padding:24px;padding-bottom:24px;}
428
+ .header{grid-template-columns:120px 1fr;}
429
+ h1{font-size:36px;}
430
+ .layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
431
+ .plot{height:380px;}
432
+ .plot.bar{height:300px;}
433
+ }
434
+ table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
435
+ border-radius:4px;overflow:hidden;}
436
+ table.metrics thead th{background:var(--header);color:white;padding:8px 10px;
437
+ font-weight:700;text-align:center;white-space:nowrap;}
438
+ table.metrics thead th:first-child{background:var(--header2);text-align:left;}
439
+ table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
440
+ border-right:1px solid white;text-align:center;background:var(--paper);}
441
+ table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
442
+ background:var(--header);width:42%;}
443
+ table.metrics tbody td:last-child{background:var(--cell2);}
444
+ .legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
445
+ display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
446
+ .legend-item{display:flex;align-items:center;gap:8px;font-size:14px;}
447
+ .legend-color{width:20px;height:3px;border-radius:2px;}
448
+ @media (min-width:981px){
449
+ html,body{overflow-y:auto;}
450
+ }
451
+ """
452
+
453
+
454
+ def _get_plotly_script(include_plotlyjs: LiteralPlotlyJSlib) -> str:
455
+ """Get plotly script tag."""
456
+ if include_plotlyjs == "cdn":
457
+ return '<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>'
458
+ return ""
459
+
460
+
461
+ def _write_html_file(
462
+ plotfile: Path,
463
+ html: str,
464
+ *,
465
+ auto_open: bool,
466
+ ) -> str:
467
+ """Write HTML file and optionally open it."""
468
+ plotfile.parent.mkdir(parents=True, exist_ok=True)
469
+ plotfile.write_text(html, encoding="utf-8")
470
+ if auto_open:
471
+ try:
472
+ webbrowser_open(plotfile.as_uri())
473
+ except OSError as exc:
474
+ logger.warning("Failed to open browser: %s", exc)
475
+ return str(plotfile)
476
+
477
+
478
+ def _generate_html(
479
+ title: str | None,
480
+ css: str,
481
+ plotly_script: str,
482
+ logo_html: str,
483
+ table_html: str,
484
+ line_payload: dict[str, Any],
485
+ bar_payload: dict[str, Any],
486
+ legend_html: str,
487
+ ) -> str:
488
+ """Generate the HTML string."""
489
+ return f"""<!doctype html>
490
+ <html lang="sv">
491
+ <head>
492
+ <meta charset="utf-8" />
493
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
494
+ <title>{title or ""}</title>
495
+ <style>{css}</style>
496
+ {plotly_script}
497
+ </head>
498
+ <body>
499
+ <div class="page">
500
+ <div class="header">
501
+ <div>{logo_html}</div>
502
+ <div><h1>{title or ""}</h1></div>
503
+ <div></div>
504
+ </div>
505
+ <div class="layout">
506
+ <div class="charts">
507
+ <div id="lineplot" class="plot"></div>
508
+ <div id="barplot" class="plot bar"></div>
509
+ </div>
510
+ <div class="table">{table_html}</div>
511
+ </div>
512
+ {legend_html}
513
+ </div>
514
+ <script>
515
+ const line = {_dumps_plotly(line_payload)};
516
+ const bar = {_dumps_plotly(bar_payload)};
517
+ Plotly.newPlot("lineplot", line.data, line.layout, line.config);
518
+ Plotly.newPlot("barplot", bar.data, bar.layout, bar.config);
519
+ window.addEventListener("resize", () => {{
520
+ Plotly.Plots.resize("lineplot");
521
+ Plotly.Plots.resize("barplot");
522
+ }});
523
+ </script>
524
+ </body>
525
+ </html>
526
+ """
527
+
528
+
529
+ def report_html(
530
+ data: OpenFrame,
531
+ bar_freq: LiteralBizDayFreq = "BYE",
532
+ filename: str | None = None,
533
+ title: str | None = None,
534
+ directory: Path | None = None,
535
+ output_type: LiteralPlotlyOutput = "file",
536
+ include_plotlyjs: LiteralPlotlyJSlib = "cdn",
537
+ *,
538
+ auto_open: bool = False,
539
+ add_logo: bool = True,
540
+ vertical_legend: bool = True, # noqa: ARG001
541
+ ) -> tuple[Figure, str]:
542
+ """Generate a responsive HTML report page with line and bar plots and a table."""
543
+ copied = data.from_deepcopy()
544
+ copied.trunc_frame().value_nan_handle().to_cumret()
545
+
546
+ properties, labels_init, labels_final = _get_report_properties_and_labels(
547
+ copied.yearfrac,
548
+ )
549
+
550
+ line_traces = _create_line_traces(copied)
551
+ bar_traces = _create_bar_traces(copied, bar_freq)
552
+
553
+ rpt_df = copied.all_properties(
554
+ properties=cast("list[LiteralFrameProps]", properties),
555
+ )
556
+ rpt_df = _add_jensen_alpha(rpt_df, copied)
557
+ rpt_df = _add_information_ratio(rpt_df, copied)
558
+ rpt_df = _add_tracking_error(rpt_df, copied)
559
+
560
+ if copied.yearfrac > 1.0:
561
+ rpt_df, _ = _add_capture_ratio(rpt_df, copied, [])
562
+
563
+ rpt_df = _add_beta(rpt_df, copied)
564
+ rpt_df.index = Index(labels_init)
565
+ rpt_df = _add_ytd_mtd(rpt_df, copied)
566
+ rpt_df = rpt_df.reindex(labels_final)
567
+
568
+ format_map = {
569
+ "Return (CAGR)": "{:.2%}",
570
+ "Return (simple)": "{:.2%}",
571
+ "Year-to-Date": "{:.2%}",
572
+ "Month-to-Date": "{:.2%}",
573
+ "Volatility": "{:.2%}",
574
+ "Sharpe Ratio": "{:.2f}",
575
+ "Sortino Ratio": "{:.2f}",
576
+ "Jensen's Alpha": "{:.2%}",
577
+ "Information Ratio": "{:.2f}",
578
+ "Tracking Error (weekly)": "{:.2%}",
579
+ "Index Beta (weekly)": "{:.2f}",
580
+ "Capture Ratio (monthly)": "{:.2f}",
581
+ "Worst Month": "{:.2%}",
582
+ "Worst Day": "{:.2%}",
583
+ "Comparison Start": "{:%Y-%m-%d}",
584
+ "Comparison End": "{:%Y-%m-%d}",
585
+ }
586
+ formats = [format_map.get(label, "{:.2f}") for label in labels_final]
587
+
588
+ for item, f in zip(rpt_df.index, formats, strict=False):
589
+ rpt_df.loc[item] = rpt_df.loc[item].apply(
590
+ lambda x, fmt=f: ""
591
+ if (
592
+ x is None
593
+ or (not isinstance(x, str) and isna(x))
594
+ or (isinstance(x, str) and x.lower() in ("nan", "nan%", ""))
595
+ )
596
+ else (
597
+ str(x)
598
+ if isinstance(x, str)
599
+ else (
600
+ Timestamp(x).strftime("%Y-%m-%d")
601
+ if "%Y-%m-%d" in fmt and not isinstance(x, str)
602
+ else fmt.format(x)
603
+ )
604
+ ),
605
+ )
606
+
607
+ rpt_df.index = Index([f"<b>{x}</b>" for x in rpt_df.index])
608
+ rpt_df = rpt_df.reset_index()
609
+
610
+ colmns = ["", *copied.columns_lvl_zero]
611
+ rpt_df.columns = colmns
612
+ table_html = _metrics_table_html(rpt_df)
613
+
614
+ dirpath = _get_output_directory(directory)
615
+
616
+ if not filename:
617
+ filename = "".join(choice(ascii_letters) for _ in range(6)) + ".html"
618
+
619
+ plotfile = dirpath / filename
620
+
621
+ fig_theme, logo = load_plotly_dict()
622
+ layout_theme = cast("dict[str, Any]", fig_theme.get("layout", {}))
623
+ colorway: list[str] = cast("dict[str, list[str]]", layout_theme).get(
624
+ "colorway", []
625
+ )
626
+
627
+ line_layout, bar_layout = _get_plotly_layouts(
628
+ layout_theme,
629
+ colorway,
630
+ copied.item_count,
631
+ )
632
+
633
+ config = cast("dict[str, Any]", fig_theme.get("config", {})) or {}
634
+ config = {**config, "responsive": True, "displayModeBar": False}
635
+
636
+ plotly_script = _get_plotly_script(include_plotlyjs)
637
+ logo_html = _get_logo_html(logo, add_logo=add_logo)
638
+ css = _get_css()
639
+
640
+ line_payload = {
641
+ "data": [t.to_plotly_json() for t in line_traces],
642
+ "layout": line_layout,
643
+ "config": config,
644
+ }
645
+ bar_payload = {
646
+ "data": [t.to_plotly_json() for t in bar_traces],
647
+ "layout": bar_layout,
648
+ "config": config,
649
+ }
650
+
651
+ legend_html = _get_legend_html(line_traces, colorway)
652
+
653
+ html = _generate_html(
654
+ title,
655
+ css,
656
+ plotly_script,
657
+ logo_html,
658
+ table_html,
659
+ line_payload,
660
+ bar_payload,
661
+ legend_html,
662
+ )
663
+
664
+ if output_type == "file":
665
+ output = _write_html_file(plotfile, html, auto_open=auto_open)
666
+ else:
667
+ output = html
668
+
669
+ fig_return = Figure(data=line_traces)
670
+ return fig_return, output
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openseries"
3
- version = "2.1.2"
3
+ version = "2.1.4"
4
4
  description = "Tools for analyzing financial timeseries."
5
5
  authors = [
6
6
  { name = "Martin Karrin", email = "martin.karrin@captor.se" },
@@ -61,9 +61,9 @@ dependencies = [
61
61
  "Release Notes" = "https://github.com/CaptorAB/openseries/releases"
62
62
 
63
63
  [tool.poetry.group.dev.dependencies]
64
- mypy = "1.19.0"
64
+ mypy = "1.19.1"
65
65
  pandas-stubs = ">=2.1.2"
66
- pre-commit = ">=4.5.0"
66
+ pre-commit = ">=4.5.1"
67
67
  pytest = ">=9.0.2"
68
68
  pytest-cov = ">=7.0.0"
69
69
  pytest-xdist = ">=3.8.0"