openseries 2.1.4__tar.gz → 2.1.5__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.4
3
+ Version: 2.1.5
4
4
  Summary: Tools for analyzing financial timeseries.
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE.md
@@ -11,6 +11,7 @@ from .datefixer import (
11
11
  offset_business_days,
12
12
  )
13
13
  from .frame import OpenFrame
14
+ from .html_utils import export_plotly_figure
14
15
  from .load_plotly import load_plotly_dict
15
16
  from .owntypes import ValueType
16
17
  from .portfoliotools import (
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "date_fix",
34
35
  "date_offset_foll",
35
36
  "efficient_frontier",
37
+ "export_plotly_figure",
36
38
  "generate_calendar_date_range",
37
39
  "get_previous_business_day_before_today",
38
40
  "holiday_calendar",
@@ -21,7 +21,6 @@ from .owntypes import (
21
21
  DateAlignmentError,
22
22
  InitialValueZeroError,
23
23
  NumberOfItemsAndLabelsNotSameError,
24
- PlotlyConfigType,
25
24
  ResampleDataLossError,
26
25
  SeriesOrFloat_co,
27
26
  ValueType,
@@ -61,9 +60,7 @@ from pandas import (
61
60
  from pandas.tseries.offsets import CustomBusinessDay
62
61
  from plotly.figure_factory import create_distplot # type: ignore[import-untyped]
63
62
  from plotly.graph_objs import Figure # type: ignore[import-untyped]
64
- from plotly.io import to_html # type: ignore[import-untyped]
65
- from plotly.offline import plot # type: ignore[import-untyped]
66
- from pydantic import BaseModel, ConfigDict, DirectoryPath, ValidationError
63
+ from pydantic import BaseModel, ConfigDict, DirectoryPath
67
64
  from scipy.stats import (
68
65
  kurtosis,
69
66
  norm,
@@ -79,6 +76,7 @@ from .datefixer import (
79
76
  date_offset_foll,
80
77
  holiday_calendar,
81
78
  )
79
+ from .html_utils import export_plotly_figure
82
80
  from .load_plotly import load_plotly_dict
83
81
 
84
82
 
@@ -506,17 +504,19 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
506
504
  """
507
505
  mdddf = self.tsdf.copy()
508
506
  mdddf.index = DatetimeIndex(mdddf.index)
509
- result = (mdddf / mdddf.expanding(min_periods=1).max()).idxmin().dt.date # type: ignore[attr-defined,arg-type]
507
+ idxmin_result = cast(
508
+ "Series[Timestamp]",
509
+ (mdddf / mdddf.expanding(min_periods=1).max()).idxmin(),
510
+ )
511
+ result = idxmin_result.dt.date
510
512
 
511
513
  if self.tsdf.shape[1] == 1:
512
- return cast("dt.date", result.iloc[0])
513
- date_series = Series(
514
+ return result.iloc[0]
515
+ return Series(
514
516
  data=result,
515
517
  index=self.tsdf.columns,
516
518
  name="Max drawdown date",
517
- dtype="datetime64[ns]",
518
- ).dt.date # type: ignore[attr-defined]
519
- return cast("Series[dt.date]", date_series)
519
+ )
520
520
 
521
521
  @property
522
522
  def worst(self: Self) -> SeriesOrFloat_co:
@@ -547,12 +547,12 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
547
547
  """
548
548
  method: LiteralPandasReindexMethod = "nearest"
549
549
 
550
- try:
550
+ if hasattr(self, "constituents"):
551
+ countries = self.constituents[0].countries
552
+ markets = self.constituents[0].markets
553
+ else:
551
554
  countries = self.countries
552
555
  markets = self.markets
553
- except AttributeError:
554
- countries = self.constituents[0].countries # type: ignore[attr-defined]
555
- markets = self.constituents[0].markets # type: ignore[attr-defined]
556
556
 
557
557
  wmdf = self.tsdf.copy()
558
558
 
@@ -733,6 +733,54 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
733
733
 
734
734
  return earlier, later
735
735
 
736
+ def _get_or_set_countries(
737
+ self: Self, countries: CountriesType | None
738
+ ) -> CountriesType | None:
739
+ """Get or set countries attribute, handling both OpenTimeSeries and OpenFrame.
740
+
741
+ Args:
742
+ countries: Country code(s) to set, or None to get existing value.
743
+
744
+ Returns:
745
+ The countries value after getting or setting.
746
+ """
747
+ if countries:
748
+ if hasattr(self, "countries"):
749
+ self.countries = countries
750
+ else:
751
+ for serie in self.constituents: # type: ignore[attr-defined]
752
+ serie.countries = countries
753
+ elif hasattr(self, "countries"):
754
+ countries = self.countries
755
+ else:
756
+ countries = self.constituents[0].countries # type: ignore[attr-defined]
757
+
758
+ return countries
759
+
760
+ def _get_or_set_markets(
761
+ self: Self, markets: list[str] | str | None
762
+ ) -> list[str] | str | None:
763
+ """Get or set markets attribute, handling both OpenTimeSeries and OpenFrame.
764
+
765
+ Args:
766
+ markets: Market code(s) to set, or None to get existing value.
767
+
768
+ Returns:
769
+ The markets value after getting or setting.
770
+ """
771
+ if markets:
772
+ if hasattr(self, "markets"):
773
+ self.markets = markets
774
+ else:
775
+ for serie in self.constituents: # type: ignore[attr-defined]
776
+ serie.markets = markets
777
+ elif hasattr(self, "markets"):
778
+ markets = self.markets
779
+ else:
780
+ markets = self.constituents[0].markets # type: ignore[attr-defined]
781
+
782
+ return markets
783
+
736
784
  def align_index_to_local_cdays(
737
785
  self: Self,
738
786
  countries: CountriesType | None = None,
@@ -754,29 +802,8 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
754
802
  startyear = cast("int", to_datetime(self.tsdf.index[0]).year)
755
803
  endyear = cast("int", to_datetime(self.tsdf.index[-1]).year)
756
804
 
757
- if countries:
758
- try:
759
- self.countries = countries
760
- except ValidationError:
761
- for serie in self.constituents: # type: ignore[attr-defined]
762
- serie.countries = countries
763
- else:
764
- try:
765
- countries = self.countries
766
- except AttributeError:
767
- countries = self.constituents[0].countries # type: ignore[attr-defined]
768
-
769
- if markets:
770
- try:
771
- self.markets = markets
772
- except ValidationError:
773
- for serie in self.constituents: # type: ignore[attr-defined]
774
- serie.markets = markets
775
- else:
776
- try:
777
- markets = self.markets
778
- except AttributeError:
779
- markets = self.constituents[0].markets # type: ignore[attr-defined]
805
+ countries = self._get_or_set_countries(countries)
806
+ markets = self._get_or_set_markets(markets)
780
807
 
781
808
  calendar = holiday_calendar(
782
809
  startyear=startyear,
@@ -1046,76 +1073,44 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1046
1073
  def _apply_title_logo(
1047
1074
  figure: Figure,
1048
1075
  logo: CaptorLogoType,
1049
- title: str | None,
1050
1076
  *,
1051
1077
  add_logo: bool,
1052
- ) -> None:
1053
- """Apply optional title and logo to a Plotly Figure.
1078
+ ) -> str | None:
1079
+ """Apply optional logo to a Plotly Figure.
1054
1080
 
1055
1081
  Args:
1056
1082
  figure: Plotly figure to update.
1057
1083
  logo: Plotly layout image dict.
1058
- title: Optional plot title.
1059
1084
  add_logo: Whether to add the logo to the figure.
1085
+
1086
+ Returns:
1087
+ Logo source URL if logo should be displayed, None otherwise.
1060
1088
  """
1089
+ logo_url: str | None = None
1061
1090
  if add_logo:
1062
- figure.add_layout_image(logo)
1063
- if title:
1064
- figure.update_layout(
1065
- {"title": {"text": f"<b>{title}</b><br>", "font": {"size": 36}}},
1091
+ source = logo.get("source", "")
1092
+ logo_url = str(source) if source else None
1093
+ figure.add_layout_image(
1094
+ {
1095
+ "source": "",
1096
+ "x": 0,
1097
+ "y": 1,
1098
+ "xanchor": "left",
1099
+ "yanchor": "top",
1100
+ "xref": "paper",
1101
+ "yref": "paper",
1102
+ "sizex": 0,
1103
+ "sizey": 0,
1104
+ "opacity": 0,
1105
+ }
1066
1106
  )
1067
-
1068
- @staticmethod
1069
- def _emit_output(
1070
- figure: Figure,
1071
- fig_config: PlotlyConfigType,
1072
- output_type: LiteralPlotlyOutput,
1073
- plotfile: Path,
1074
- filename: str,
1075
- *,
1076
- include_plotlyjs_bool: LiteralPlotlyJSlib,
1077
- auto_open: bool,
1078
- ) -> str:
1079
- """Write a file or return inline HTML string from a Plotly Figure.
1080
-
1081
- Args:
1082
- figure: Plotly figure to render.
1083
- fig_config: Plotly config dict.
1084
- output_type: Output type: ``"file"`` or ``"div"``.
1085
- plotfile: Full path to the output html file.
1086
- filename: Output filename used for the ``div_id`` when inline.
1087
- include_plotlyjs_bool: How plotly.js is included.
1088
- auto_open: Whether to auto-open the file in a browser.
1089
-
1090
- Returns:
1091
- If ``output_type`` is ``"file"``, the path to the file; otherwise an
1092
- inline HTML string (div).
1093
- """
1094
- if output_type == "file":
1095
- plot(
1096
- figure_or_data=figure,
1097
- filename=str(plotfile),
1098
- auto_open=auto_open,
1099
- auto_play=False,
1100
- link_text="",
1101
- include_plotlyjs=include_plotlyjs_bool,
1102
- config=fig_config,
1103
- output_type=output_type,
1104
- )
1105
- return str(plotfile)
1106
-
1107
- div_id = filename.rsplit(".", 1)[0]
1108
- return cast(
1109
- "str",
1110
- to_html(
1111
- fig=figure,
1112
- config=fig_config,
1113
- auto_play=False,
1114
- include_plotlyjs=include_plotlyjs_bool,
1115
- full_html=False,
1116
- div_id=div_id,
1117
- ),
1107
+ figure.update_layout(
1108
+ {
1109
+ "margin": {"t": 20, "b": 60, "l": 60, "r": 60, "pad": 4},
1110
+ "autosize": True,
1111
+ },
1118
1112
  )
1113
+ return logo_url
1119
1114
 
1120
1115
  def plot_bars(
1121
1116
  self: Self,
@@ -1176,21 +1171,22 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1176
1171
  )
1177
1172
  figure.update_layout(barmode=mode, yaxis={"tickformat": tick_fmt})
1178
1173
 
1179
- self._apply_title_logo(
1174
+ logo_url = self._apply_title_logo(
1180
1175
  figure=figure,
1181
- title=title,
1182
1176
  add_logo=add_logo,
1183
1177
  logo=logo,
1184
1178
  )
1185
1179
 
1186
- string_output = self._emit_output(
1180
+ string_output = export_plotly_figure(
1187
1181
  figure=figure,
1188
1182
  fig_config=fig["config"],
1189
- include_plotlyjs_bool=include_plotlyjs,
1183
+ include_plotlyjs=include_plotlyjs,
1190
1184
  output_type=output_type,
1191
1185
  auto_open=auto_open,
1192
1186
  plotfile=plotfile,
1193
1187
  filename=filename,
1188
+ title=title,
1189
+ logo_url=logo_url,
1194
1190
  )
1195
1191
 
1196
1192
  return figure, string_output
@@ -1271,21 +1267,22 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1271
1267
  textposition="top center",
1272
1268
  )
1273
1269
 
1274
- self._apply_title_logo(
1270
+ logo_url = self._apply_title_logo(
1275
1271
  figure=figure,
1276
- title=title,
1277
1272
  add_logo=add_logo,
1278
1273
  logo=logo,
1279
1274
  )
1280
1275
 
1281
- string_output = self._emit_output(
1276
+ string_output = export_plotly_figure(
1282
1277
  figure=figure,
1283
1278
  fig_config=fig["config"],
1284
- include_plotlyjs_bool=include_plotlyjs,
1279
+ include_plotlyjs=include_plotlyjs,
1285
1280
  output_type=output_type,
1286
1281
  auto_open=auto_open,
1287
1282
  plotfile=plotfile,
1288
1283
  filename=filename,
1284
+ title=title,
1285
+ logo_url=logo_url,
1289
1286
  )
1290
1287
 
1291
1288
  return figure, string_output
@@ -1393,21 +1390,22 @@ class _CommonModel(BaseModel, Generic[SeriesOrFloat_co]):
1393
1390
  figure.update_xaxes(zeroline=True, zerolinewidth=2, zerolinecolor="lightgrey")
1394
1391
  figure.update_yaxes(zeroline=True, zerolinewidth=2, zerolinecolor="lightgrey")
1395
1392
 
1396
- self._apply_title_logo(
1393
+ logo_url = self._apply_title_logo(
1397
1394
  figure=figure,
1398
- title=title,
1399
1395
  add_logo=add_logo,
1400
1396
  logo=logo,
1401
1397
  )
1402
1398
 
1403
- string_output = self._emit_output(
1399
+ string_output = export_plotly_figure(
1404
1400
  figure=figure,
1405
1401
  fig_config=fig_dict["config"],
1406
- include_plotlyjs_bool=include_plotlyjs,
1402
+ include_plotlyjs=include_plotlyjs,
1407
1403
  output_type=output_type,
1408
1404
  auto_open=auto_open,
1409
1405
  plotfile=plotfile,
1410
1406
  filename=filename,
1407
+ title=title,
1408
+ logo_url=logo_url,
1411
1409
  )
1412
1410
 
1413
1411
  return figure, string_output
@@ -0,0 +1,314 @@
1
+ """Shared HTML utilities for responsive Plotly outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, cast
7
+ from webbrowser import open as webbrowser_open
8
+
9
+ from plotly.io import to_html # type: ignore[import-untyped]
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from plotly.graph_objs import Figure # type: ignore[import-untyped]
13
+
14
+ from .owntypes import LiteralPlotlyJSlib, LiteralPlotlyOutput, PlotlyConfigType
15
+
16
+ __all__ = [
17
+ "_generate_responsive_plot_html",
18
+ "_get_base_css",
19
+ "_get_plot_css",
20
+ "_get_plotly_script",
21
+ "export_plotly_figure",
22
+ ]
23
+
24
+
25
+ def _get_base_css() -> str:
26
+ """Get base CSS styles for responsive HTML reports."""
27
+ return """
28
+ :root{--ink:#1f2a44;--muted:#6b778c;--header:#4a4a4a;--header2:#6a6a6a;--cell:#f3f3f3;--cell2:#e6e6e6;--paper:#ffffff;}
29
+ html,body{margin:0;padding:0;background:var(--paper);color:var(--ink);
30
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;}
31
+ .page{max-width:calc(100% - 64px);margin:0 auto;padding:32px;
32
+ padding-bottom:48px;}
33
+ .header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
34
+ align-items:start;}
35
+ h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
36
+ .layout{display:grid;grid-template-columns:1.2fr .9fr;
37
+ grid-template-areas:"charts table";gap:22px;align-items:start;margin-top:12px;}
38
+ .charts{grid-area:charts;display:grid;grid-template-rows:auto auto;gap:18px;}
39
+ .table{grid-area:table;}
40
+ .plot{width:100%;height:380px;}
41
+ .plot.bar{height:300px;}
42
+ @media (max-width:980px){
43
+ .page{padding:24px;padding-bottom:24px;}
44
+ .header{grid-template-columns:120px 1fr;}
45
+ h1{font-size:36px;}
46
+ .layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
47
+ .plot{height:380px;}
48
+ .plot.bar{height:300px;}
49
+ .table{overflow-x:auto;}
50
+ table.metrics{table-layout:auto;font-size:14px;}
51
+ }
52
+ table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
53
+ border-radius:4px;overflow:hidden;table-layout:fixed;}
54
+ table.metrics thead th{background:var(--header);color:white;padding:8px 10px;
55
+ font-weight:700;text-align:center;word-wrap:break-word;word-break:break-word;}
56
+ table.metrics thead th:first-child{background:var(--header2);text-align:left;
57
+ width:180px;}
58
+ table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
59
+ border-right:1px solid white;text-align:center;background:var(--paper);}
60
+ table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
61
+ background:var(--header);width:180px;}
62
+ table.metrics tbody td:last-child{background:var(--cell2);}
63
+ .legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
64
+ display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
65
+ .legend-item{display:flex;align-items:center;gap:8px;font-size:14px;}
66
+ .legend-color{width:20px;height:3px;border-radius:2px;}
67
+ @media (min-width:981px){
68
+ html,body{overflow-y:auto;}
69
+ }
70
+ """
71
+
72
+
73
+ def _get_plot_css() -> str:
74
+ """Get CSS styles for full-screen responsive plots.
75
+
76
+ Returns:
77
+ CSS string for responsive plots.
78
+ """
79
+ return """
80
+ *{box-sizing:border-box;}
81
+ html,body{margin:0;padding:0;width:100%;height:100%;
82
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
83
+ overflow-x:hidden;}
84
+ .page{width:100%;height:100vh;display:flex;flex-direction:column;}
85
+ .title-container{width:100%;flex-shrink:0;padding:15px 20px 10px 20px;
86
+ display:flex;align-items:center;justify-content:center;background:white;z-index:10;
87
+ gap:15px;}
88
+ .title-logo{height:60px;width:auto;flex-shrink:0;}
89
+ .title-container h1{margin:0;padding:0;font-size:36px;font-weight:bold;
90
+ color:#253551;word-wrap:break-word;word-break:break-word;
91
+ white-space:normal;line-height:1.2;flex:1;text-align:center;}
92
+ .plot-container{width:100%;flex:1;position:relative;overflow:hidden;
93
+ min-height:0;}
94
+ .plot{width:100%;height:100%;display:block;position:absolute;top:0;left:0;}
95
+ .plot > div{width:100% !important;height:100% !important;}
96
+ .plot .js-plotly-plot{width:100% !important;height:100% !important;}
97
+ .plot .js-plotly-plot .plotly{width:100% !important;height:100% !important;}
98
+ @media (max-width: 980px) {
99
+ .title-container{padding:12px 15px 8px 15px;gap:12px;}
100
+ .title-container h1{font-size:24px;line-height:1.3;}
101
+ .title-logo{height:32px;}
102
+ }
103
+ @media (max-width: 480px) {
104
+ .title-container{padding:10px 12px 6px 12px;gap:10px;}
105
+ .title-container h1{font-size:18px;line-height:1.2;}
106
+ .title-logo{height:24px;}
107
+ }
108
+ """
109
+
110
+
111
+ def _get_plotly_script(include_plotlyjs: LiteralPlotlyJSlib) -> str:
112
+ """Get plotly script tag."""
113
+ if include_plotlyjs == "cdn":
114
+ return '<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>'
115
+ return ""
116
+
117
+
118
+ def _generate_responsive_plot_html(
119
+ title: str | None,
120
+ plot_div: str,
121
+ include_plotlyjs: LiteralPlotlyJSlib,
122
+ plot_id: str,
123
+ logo_url: str | None = None,
124
+ ) -> str:
125
+ """Generate responsive HTML for a single Plotly plot."""
126
+ css = _get_plot_css()
127
+ plotly_script = _get_plotly_script(include_plotlyjs)
128
+
129
+ plot_div = plot_div.replace(
130
+ f'<div id="{plot_id}"', f'<div id="{plot_id}" class="plot"'
131
+ )
132
+
133
+ title_html = ""
134
+ if (title is not None and title) or logo_url:
135
+ logo_html = ""
136
+ if logo_url:
137
+ logo_html = f'<img src="{logo_url}" alt="Logo" class="title-logo" />'
138
+ title_text = f"<h1><b>{title}</b></h1>" if title else ""
139
+ title_html = f'<div class="title-container">{logo_html}{title_text}</div>'
140
+
141
+ return f"""<!doctype html>
142
+ <html lang="en">
143
+ <head>
144
+ <meta charset="utf-8" />
145
+ <meta name="viewport" content="width=device-width,initial-scale=1,
146
+ maximum-scale=5,user-scalable=yes" />
147
+ <title>{title or ""}</title>
148
+ <style>{css}</style>
149
+ {plotly_script}
150
+ </head>
151
+ <body>
152
+ <div class="page">
153
+ {title_html}
154
+ <div class="plot-container">
155
+ {plot_div}
156
+ </div>
157
+ </div>
158
+ <script>
159
+ (function() {{
160
+ var plotDiv = document.getElementById("{plot_id}");
161
+ var container = plotDiv ? plotDiv.closest('.plot-container') : null;
162
+
163
+ function getContainerSize() {{
164
+ if (!container) return {{width: window.innerWidth, height: window.innerHeight}};
165
+ var rect = container.getBoundingClientRect();
166
+ return {{width: rect.width, height: rect.height}};
167
+ }}
168
+
169
+ function resizePlot() {{
170
+ if (typeof Plotly === 'undefined' || !Plotly.Plots || !plotDiv) return;
171
+
172
+ var size = getContainerSize();
173
+ if (size.width > 0 && size.height > 0) {{
174
+ Plotly.Plots.resize(plotDiv);
175
+ }}
176
+ }}
177
+
178
+ function debounceResize() {{
179
+ clearTimeout(window._resizeTimeout);
180
+ window._resizeTimeout = setTimeout(resizePlot, 150);
181
+ }}
182
+
183
+ window.addEventListener("resize", debounceResize);
184
+
185
+ window.addEventListener("orientationchange", function() {{
186
+ setTimeout(function() {{
187
+ resizePlot();
188
+ }}, 300);
189
+ }});
190
+
191
+ function initResize() {{
192
+ if (typeof Plotly !== 'undefined' && plotDiv) {{
193
+ setTimeout(resizePlot, 100);
194
+ setTimeout(resizePlot, 500);
195
+ setTimeout(resizePlot, 1000);
196
+ }} else {{
197
+ setTimeout(initResize, 100);
198
+ }}
199
+ }}
200
+
201
+ if (document.readyState === 'loading') {{
202
+ document.addEventListener('DOMContentLoaded', initResize);
203
+ }} else {{
204
+ initResize();
205
+ }}
206
+
207
+ window.addEventListener('load', function() {{
208
+ setTimeout(resizePlot, 200);
209
+ }});
210
+ }})();
211
+ </script>
212
+ </body>
213
+ </html>
214
+ """
215
+
216
+
217
+ def export_plotly_figure(
218
+ figure: Figure,
219
+ fig_config: PlotlyConfigType,
220
+ output_type: LiteralPlotlyOutput,
221
+ filename: str,
222
+ *,
223
+ include_plotlyjs: LiteralPlotlyJSlib,
224
+ auto_open: bool = False,
225
+ plotfile: Path | str,
226
+ title: str | None = None,
227
+ logo_url: str | None = None,
228
+ ) -> str:
229
+ """Export a Plotly figure to a mobile-responsive HTML file or inline div.
230
+
231
+ This function converts a Plotly figure to HTML output, with support for
232
+ mobile-responsive standalone HTML files or inline HTML divs. When exporting
233
+ to a file, the output includes proper viewport settings, responsive CSS,
234
+ and JavaScript for handling window resizing and orientation changes.
235
+
236
+ Args:
237
+ figure: Plotly figure to render.
238
+ fig_config: Plotly config dictionary.
239
+ output_type: Output type: ``"file"`` or ``"div"``.
240
+ filename: Output filename used for the ``div_id`` when inline, or to
241
+ derive the div_id when exporting to file.
242
+ include_plotlyjs: How plotly.js is included. Can be ``True`` (inline),
243
+ ``False`` (not included), or ``"cdn"`` (CDN link).
244
+ auto_open: Whether to auto-open the file in a browser (only used when
245
+ ``output_type="file"``). Defaults to ``False``.
246
+ plotfile: Full path to the output HTML file. Required when
247
+ ``output_type="file"``, ignored when ``output_type="div"``.
248
+ title: Title for the HTML page (used for file output). Defaults to
249
+ ``None``.
250
+ logo_url: Optional logo URL to display in the title container.
251
+ Defaults to ``None``.
252
+
253
+ Returns:
254
+ If ``output_type`` is ``"file"``, the path to the file as a string;
255
+ otherwise an inline HTML string (div).
256
+
257
+
258
+ Example:
259
+ Export a Plotly figure to a responsive HTML file:
260
+
261
+ >>> import plotly.graph_objects as go
262
+ >>> from openseries.html_utils import export_plotly_figure
263
+ >>> from pathlib import Path
264
+ >>>
265
+ >>> fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6]))
266
+ >>> output_path = export_plotly_figure(
267
+ ... figure=fig,
268
+ ... fig_config={},
269
+ ... output_type="file",
270
+ ... filename="my_plot.html",
271
+ ... include_plotlyjs="cdn",
272
+ ... plotfile=Path("output/my_plot.html"),
273
+ ... title="My Plot",
274
+ ... auto_open=True,
275
+ ... )
276
+ """
277
+ if output_type == "file":
278
+ plotfile_path = Path(plotfile) if isinstance(plotfile, str) else plotfile
279
+ div_id = filename.rsplit(".", 1)[0]
280
+ plot_div = cast(
281
+ "str",
282
+ to_html(
283
+ fig=figure,
284
+ config=fig_config,
285
+ auto_play=False,
286
+ include_plotlyjs=False,
287
+ full_html=False,
288
+ div_id=div_id,
289
+ ),
290
+ )
291
+ html_content = _generate_responsive_plot_html(
292
+ title=title,
293
+ plot_div=plot_div,
294
+ include_plotlyjs=include_plotlyjs,
295
+ plot_id=div_id,
296
+ logo_url=logo_url,
297
+ )
298
+ plotfile_path.write_text(html_content, encoding="utf-8")
299
+ if auto_open:
300
+ webbrowser_open(plotfile_path.as_uri())
301
+ return str(plotfile_path)
302
+
303
+ div_id = filename.rsplit(".", 1)[0]
304
+ return cast(
305
+ "str",
306
+ to_html(
307
+ fig=figure,
308
+ config=fig_config,
309
+ auto_play=False,
310
+ include_plotlyjs=include_plotlyjs,
311
+ full_html=False,
312
+ div_id=div_id,
313
+ ),
314
+ )
@@ -41,22 +41,26 @@
41
41
  "legend": {
42
42
  "bgcolor": "rgba(0,0,0,0)",
43
43
  "orientation": "h",
44
- "x": 0.98,
45
- "xanchor": "right",
46
- "y": -0.15,
47
- "yanchor": "bottom"
44
+ "x": 0.5,
45
+ "xanchor": "center",
46
+ "xref": "container",
47
+ "y": 1.05,
48
+ "yanchor": "bottom",
49
+ "yref": "container"
48
50
  },
49
51
  "paper_bgcolor": "rgba(255,255,255,1)",
50
52
  "plot_bgcolor": "rgba(255,255,255,1)",
51
53
  "showlegend": true,
52
54
  "title": {
53
55
  "font": {
54
- "size": 32
56
+ "size": 36
55
57
  },
56
58
  "x": 0.5,
57
59
  "xanchor": "center",
58
- "y": 0.95,
59
- "yanchor": "top"
60
+ "xref": "container",
61
+ "y": 0.93,
62
+ "yanchor": "bottom",
63
+ "yref": "container"
60
64
  },
61
65
  "xaxis": {
62
66
  "gridcolor": "#EEEEEE",
@@ -738,7 +738,7 @@ def _configure_figure_layout(
738
738
  if title:
739
739
  if titletext is None:
740
740
  titletext = "<b>Risk and Return</b><br>"
741
- figure.update_layout(title={"text": titletext, "font": {"size": 32}})
741
+ figure.update_layout(title={"text": titletext, "font": {"size": 36}})
742
742
 
743
743
  if add_logo:
744
744
  figure.add_layout_image(logo)
@@ -17,6 +17,7 @@ from pandas import DataFrame, Index, Series, Timestamp, concat, isna
17
17
  from plotly.graph_objs import Bar, Figure, Scatter # type: ignore[import-untyped]
18
18
  from plotly.utils import PlotlyJSONEncoder # type: ignore[import-untyped]
19
19
 
20
+ from .html_utils import _get_base_css, _get_plotly_script
20
21
  from .load_plotly import load_plotly_dict
21
22
  from .owntypes import (
22
23
  CaptorLogoType,
@@ -408,12 +409,10 @@ def _get_legend_html(line_traces: list[Scatter], colorway: list[str]) -> str:
408
409
 
409
410
  def _get_css() -> str:
410
411
  """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;}
412
+ base_css = _get_base_css()
413
+ return (
414
+ base_css
415
+ + """
417
416
  .header{display:grid;grid-template-columns:140px 1fr 140px;gap:12px;
418
417
  align-items:start;}
419
418
  h1{margin:0;text-align:center;font-size:45px;font-weight:800;}
@@ -430,16 +429,22 @@ def _get_css() -> str:
430
429
  .layout{grid-template-columns:1fr;grid-template-areas:"table" "charts";gap:16px;}
431
430
  .plot{height:380px;}
432
431
  .plot.bar{height:300px;}
432
+ table.metrics{table-layout:fixed;width:auto;}
433
+ table.metrics thead th{min-width:120px;width:120px;white-space:nowrap;}
434
+ table.metrics thead th:first-child{width:180px;}
435
+ table.metrics tbody td{min-width:120px;width:120px;}
436
+ table.metrics tbody td:first-child{width:180px;}
433
437
  }
434
438
  table.metrics{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;
435
- border-radius:4px;overflow:hidden;}
439
+ border-radius:4px;overflow:hidden;table-layout:fixed;}
436
440
  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;}
441
+ font-weight:700;text-align:center;word-wrap:break-word;word-break:break-word;}
442
+ table.metrics thead th:first-child{background:var(--header2);text-align:left;
443
+ width:180px;}
439
444
  table.metrics tbody td{padding:7px 10px;border-bottom:1px solid white;
440
445
  border-right:1px solid white;text-align:center;background:var(--paper);}
441
446
  table.metrics tbody td:first-child{text-align:left;font-weight:600;color:white;
442
- background:var(--header);width:42%;}
447
+ background:var(--header);width:180px;}
443
448
  table.metrics tbody td:last-child{background:var(--cell2);}
444
449
  .legend-container{margin-top:24px;padding-top:20px;padding-bottom:16px;
445
450
  display:flex;justify-content:center;flex-wrap:wrap;gap:24px;flex-shrink:0;}
@@ -449,13 +454,7 @@ def _get_css() -> str:
449
454
  html,body{overflow-y:auto;}
450
455
  }
451
456
  """
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 ""
457
+ )
459
458
 
460
459
 
461
460
  def _write_html_file(
@@ -611,7 +610,7 @@ def report_html(
611
610
  rpt_df.columns = colmns
612
611
  table_html = _metrics_table_html(rpt_df)
613
612
 
614
- dirpath = _get_output_directory(directory)
613
+ dirpath = _get_output_directory(directory=directory)
615
614
 
616
615
  if not filename:
617
616
  filename = "".join(choice(ascii_letters) for _ in range(6)) + ".html"
@@ -625,16 +624,16 @@ def report_html(
625
624
  )
626
625
 
627
626
  line_layout, bar_layout = _get_plotly_layouts(
628
- layout_theme,
629
- colorway,
630
- copied.item_count,
627
+ layout_theme=layout_theme,
628
+ colorway=colorway,
629
+ item_count=copied.item_count,
631
630
  )
632
631
 
633
632
  config = cast("dict[str, Any]", fig_theme.get("config", {})) or {}
634
633
  config = {**config, "responsive": True, "displayModeBar": False}
635
634
 
636
- plotly_script = _get_plotly_script(include_plotlyjs)
637
- logo_html = _get_logo_html(logo, add_logo=add_logo)
635
+ plotly_script = _get_plotly_script(include_plotlyjs=include_plotlyjs)
636
+ logo_html = _get_logo_html(logo=logo, add_logo=add_logo)
638
637
  css = _get_css()
639
638
 
640
639
  line_payload = {
@@ -648,21 +647,21 @@ def report_html(
648
647
  "config": config,
649
648
  }
650
649
 
651
- legend_html = _get_legend_html(line_traces, colorway)
650
+ legend_html = _get_legend_html(line_traces=line_traces, colorway=colorway)
652
651
 
653
652
  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,
653
+ title=title,
654
+ css=css,
655
+ plotly_script=plotly_script,
656
+ logo_html=logo_html,
657
+ table_html=table_html,
658
+ line_payload=line_payload,
659
+ bar_payload=bar_payload,
660
+ legend_html=legend_html,
662
661
  )
663
662
 
664
663
  if output_type == "file":
665
- output = _write_html_file(plotfile, html, auto_open=auto_open)
664
+ output = _write_html_file(plotfile=plotfile, html=html, auto_open=auto_open)
666
665
  else:
667
666
  output = html
668
667
 
@@ -625,7 +625,7 @@ class OpenTimeSeries(_CommonModel[float]):
625
625
  cast("Timestamp", earlier) : cast("Timestamp", later)
626
626
  ].copy()
627
627
 
628
- data.loc[:, (self.label, ValueType.RTRN)] = log( # type: ignore[index]
628
+ data.loc[:, (self.label, ValueType.RTRN)] = log(
629
629
  data.loc[:, self.tsdf.columns.to_numpy()[0]],
630
630
  ).diff()
631
631
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openseries"
3
- version = "2.1.4"
3
+ version = "2.1.5"
4
4
  description = "Tools for analyzing financial timeseries."
5
5
  authors = [
6
6
  { name = "Martin Karrin", email = "martin.karrin@captor.se" },
@@ -67,7 +67,7 @@ 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"
70
- ruff = "0.14.9"
70
+ ruff = "0.14.10"
71
71
  types-openpyxl = ">=3.1.2"
72
72
  scipy-stubs = ">=1.14.1.0"
73
73
  types-python-dateutil = ">=2.8.2"
File without changes
File without changes