mainsequence 2.0.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.
- mainsequence/__init__.py +0 -0
- mainsequence/__main__.py +9 -0
- mainsequence/cli/__init__.py +1 -0
- mainsequence/cli/api.py +157 -0
- mainsequence/cli/cli.py +442 -0
- mainsequence/cli/config.py +78 -0
- mainsequence/cli/ssh_utils.py +126 -0
- mainsequence/client/__init__.py +17 -0
- mainsequence/client/base.py +431 -0
- mainsequence/client/data_sources_interfaces/__init__.py +0 -0
- mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
- mainsequence/client/data_sources_interfaces/timescale.py +479 -0
- mainsequence/client/models_helpers.py +113 -0
- mainsequence/client/models_report_studio.py +412 -0
- mainsequence/client/models_tdag.py +2276 -0
- mainsequence/client/models_vam.py +1983 -0
- mainsequence/client/utils.py +387 -0
- mainsequence/dashboards/__init__.py +0 -0
- mainsequence/dashboards/streamlit/__init__.py +0 -0
- mainsequence/dashboards/streamlit/assets/config.toml +12 -0
- mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
- mainsequence/dashboards/streamlit/assets/logo.png +0 -0
- mainsequence/dashboards/streamlit/core/__init__.py +0 -0
- mainsequence/dashboards/streamlit/core/theme.py +212 -0
- mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
- mainsequence/dashboards/streamlit/scaffold.py +220 -0
- mainsequence/instrumentation/__init__.py +7 -0
- mainsequence/instrumentation/utils.py +101 -0
- mainsequence/instruments/__init__.py +1 -0
- mainsequence/instruments/data_interface/__init__.py +10 -0
- mainsequence/instruments/data_interface/data_interface.py +361 -0
- mainsequence/instruments/instruments/__init__.py +3 -0
- mainsequence/instruments/instruments/base_instrument.py +85 -0
- mainsequence/instruments/instruments/bond.py +447 -0
- mainsequence/instruments/instruments/european_option.py +74 -0
- mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
- mainsequence/instruments/instruments/json_codec.py +585 -0
- mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
- mainsequence/instruments/instruments/position.py +475 -0
- mainsequence/instruments/instruments/ql_fields.py +239 -0
- mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
- mainsequence/instruments/pricing_models/__init__.py +0 -0
- mainsequence/instruments/pricing_models/black_scholes.py +49 -0
- mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
- mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
- mainsequence/instruments/pricing_models/indices.py +350 -0
- mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
- mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
- mainsequence/instruments/settings.py +175 -0
- mainsequence/instruments/utils.py +29 -0
- mainsequence/logconf.py +284 -0
- mainsequence/reportbuilder/__init__.py +0 -0
- mainsequence/reportbuilder/__main__.py +0 -0
- mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
- mainsequence/reportbuilder/model.py +713 -0
- mainsequence/reportbuilder/slide_templates.py +532 -0
- mainsequence/tdag/__init__.py +8 -0
- mainsequence/tdag/__main__.py +0 -0
- mainsequence/tdag/config.py +129 -0
- mainsequence/tdag/data_nodes/__init__.py +12 -0
- mainsequence/tdag/data_nodes/build_operations.py +751 -0
- mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
- mainsequence/tdag/data_nodes/persist_managers.py +812 -0
- mainsequence/tdag/data_nodes/run_operations.py +543 -0
- mainsequence/tdag/data_nodes/utils.py +24 -0
- mainsequence/tdag/future_registry.py +25 -0
- mainsequence/tdag/utils.py +40 -0
- mainsequence/virtualfundbuilder/__init__.py +45 -0
- mainsequence/virtualfundbuilder/__main__.py +235 -0
- mainsequence/virtualfundbuilder/agent_interface.py +77 -0
- mainsequence/virtualfundbuilder/config_handling.py +86 -0
- mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
- mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
- mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
- mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
- mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
- mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
- mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
- mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
- mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
- mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
- mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
- mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
- mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
- mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
- mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
- mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
- mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
- mainsequence/virtualfundbuilder/data_nodes.py +637 -0
- mainsequence/virtualfundbuilder/enums.py +23 -0
- mainsequence/virtualfundbuilder/models.py +282 -0
- mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
- mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
- mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
- mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
- mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
- mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
- mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
- mainsequence/virtualfundbuilder/utils.py +381 -0
- mainsequence-2.0.0.dist-info/METADATA +105 -0
- mainsequence-2.0.0.dist-info/RECORD +110 -0
- mainsequence-2.0.0.dist-info/WHEEL +5 -0
- mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
- mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,713 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from enum import Enum
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import List, Optional, Union, Type, Any,Literal
|
6
|
+
|
7
|
+
from pydantic import BaseModel, Field, validator, ValidationError,root_validator
|
8
|
+
from jinja2 import Environment
|
9
|
+
|
10
|
+
from typing import Callable, Dict
|
11
|
+
|
12
|
+
from pydantic import HttpUrl
|
13
|
+
|
14
|
+
|
15
|
+
# ────────────────────────────── Common enums ──────────────────────────────
|
16
|
+
|
17
|
+
class HorizontalAlign(str, Enum):
|
18
|
+
left = "left"
|
19
|
+
center = "center"
|
20
|
+
right = "right"
|
21
|
+
|
22
|
+
class VerticalAlign(str, Enum):
|
23
|
+
top = "top"
|
24
|
+
center = "center"
|
25
|
+
bottom = "bottom"
|
26
|
+
|
27
|
+
class FontWeight(str, Enum):
|
28
|
+
normal = "normal"
|
29
|
+
bold = "bold"
|
30
|
+
|
31
|
+
class Anchor(str, Enum):
|
32
|
+
top_left = "top_left"
|
33
|
+
top_right = "top_right"
|
34
|
+
bottom_left = "bottom_left"
|
35
|
+
bottom_right = "bottom_right"
|
36
|
+
center = "center"
|
37
|
+
|
38
|
+
class Size(BaseModel):
|
39
|
+
width: Optional[str] = None # "300px", "40%", …
|
40
|
+
height: Optional[str] = None
|
41
|
+
|
42
|
+
@validator("width", "height", pre=True)
|
43
|
+
def _coerce_to_str(cls, v): # noqa: N805
|
44
|
+
if v is None:
|
45
|
+
return v
|
46
|
+
return f"{v}px" if isinstance(v, int) else str(v)
|
47
|
+
|
48
|
+
def css(self) -> str:
|
49
|
+
return "".join(
|
50
|
+
f"{dim}:{val};"
|
51
|
+
for dim, val in (("width", self.width), ("height", self.height))
|
52
|
+
if val is not None
|
53
|
+
)
|
54
|
+
|
55
|
+
class Position(BaseModel):
|
56
|
+
top: Optional[str] = None
|
57
|
+
left: Optional[str] = None
|
58
|
+
right: Optional[str] = None
|
59
|
+
bottom: Optional[str] = None
|
60
|
+
anchor: Optional[Anchor] = None
|
61
|
+
|
62
|
+
@validator("top", "left", "right", "bottom", pre=True)
|
63
|
+
def _coerce_to_str(cls, v):
|
64
|
+
if v is None:
|
65
|
+
return v
|
66
|
+
return f"{v}px" if isinstance(v, int) else str(v)
|
67
|
+
|
68
|
+
def css(self) -> str:
|
69
|
+
if self.anchor and any([self.top, self.left, self.right, self.bottom]):
|
70
|
+
raise ValueError("Specify either 'anchor' or explicit offsets - not both")
|
71
|
+
if self.anchor:
|
72
|
+
return {
|
73
|
+
Anchor.top_left: "top:0;left:0;",
|
74
|
+
Anchor.top_right: "top:0;right:0;",
|
75
|
+
Anchor.bottom_left: "bottom:0;left:0;",
|
76
|
+
Anchor.bottom_right:"bottom:0;right:0;",
|
77
|
+
Anchor.center: "top:50%;left:50%;transform:translate(-50%,-50%);",
|
78
|
+
}[self.anchor]
|
79
|
+
|
80
|
+
return "".join(
|
81
|
+
f"{side}:{val};"
|
82
|
+
for side, val in (
|
83
|
+
("top", self.top), ("left", self.left),
|
84
|
+
("right", self.right), ("bottom", self.bottom)
|
85
|
+
)
|
86
|
+
if val is not None
|
87
|
+
)
|
88
|
+
|
89
|
+
class ElementBase(BaseModel):
|
90
|
+
id: str = Field(default_factory=lambda: f"elem_{id(object())}")
|
91
|
+
z_index: int = 1
|
92
|
+
css_class: Optional[str] = None
|
93
|
+
|
94
|
+
class Config:
|
95
|
+
arbitrary_types_allowed = True
|
96
|
+
|
97
|
+
def render(self) -> str:
|
98
|
+
raise NotImplementedError
|
99
|
+
|
100
|
+
class TextElement(ElementBase):
|
101
|
+
text: str
|
102
|
+
|
103
|
+
# 1) semantic element type
|
104
|
+
element_type: Literal["h1", "h2", "h3", "h4", "h5", "h6", "p"] = "p"
|
105
|
+
|
106
|
+
font_weight: FontWeight = FontWeight.normal
|
107
|
+
h_align: HorizontalAlign = HorizontalAlign.left
|
108
|
+
v_align: VerticalAlign = VerticalAlign.top
|
109
|
+
color: Optional[str] = None
|
110
|
+
line_height: Optional[str] = None
|
111
|
+
size: Size = Field(default_factory=Size)
|
112
|
+
position: Optional[Position] = None
|
113
|
+
|
114
|
+
style_theme: Optional[ThemeMode] = None
|
115
|
+
|
116
|
+
def render(self, override_theme_mode_if_none) -> str:
|
117
|
+
if self.style_theme is None:
|
118
|
+
self.style_theme = override_theme_mode_if_none
|
119
|
+
|
120
|
+
settings = self.style_theme
|
121
|
+
style = []
|
122
|
+
|
123
|
+
if self.position:
|
124
|
+
style.append("position:absolute;")
|
125
|
+
style.append(self.position.css())
|
126
|
+
style.append(self.size.css())
|
127
|
+
|
128
|
+
# choose font-size and default color
|
129
|
+
if self.element_type.startswith("h"):
|
130
|
+
ff = settings.font_family_headings
|
131
|
+
default_color = settings.heading_color
|
132
|
+
else:
|
133
|
+
ff = settings.font_family_paragraphs
|
134
|
+
default_color = settings.paragraph_color
|
135
|
+
|
136
|
+
# use explicit color if given, else default
|
137
|
+
c = self.color if self.color is not None else default_color
|
138
|
+
|
139
|
+
style.append(f"font-weight:{self.font_weight.value};")
|
140
|
+
style.append(f"font-family:{ff};")
|
141
|
+
style.append(f"color:{c};")
|
142
|
+
style.append(f"text-align:{self.h_align.value};")
|
143
|
+
|
144
|
+
if self.line_height:
|
145
|
+
style.append(f"line-height:{self.line_height};")
|
146
|
+
|
147
|
+
class_attr = f'class="text-element-{self.element_type}"' if self.element_type else ""
|
148
|
+
tag = self.element_type
|
149
|
+
|
150
|
+
return (
|
151
|
+
f'<{tag} id="{self.id}" {class_attr} '
|
152
|
+
f'style="{"".join(style)}">{self.text}</{tag}>'
|
153
|
+
)
|
154
|
+
|
155
|
+
class TextH1(TextElement):
|
156
|
+
# force element_type to always be "h1" and disallow overrides
|
157
|
+
element_type: Literal["h1"] = Field("h1", literal=True)
|
158
|
+
|
159
|
+
def __init__(self, **data):
|
160
|
+
# Remove any attempt to pass element_type in data
|
161
|
+
data.pop("element_type", None)
|
162
|
+
super().__init__(**data)
|
163
|
+
|
164
|
+
class TextH2(TextElement):
|
165
|
+
# force element_type to always be "h1" and disallow overrides
|
166
|
+
element_type: Literal["h2"] = Field("h2", literal=True)
|
167
|
+
|
168
|
+
def __init__(self, **data):
|
169
|
+
# Remove any attempt to pass element_type in data
|
170
|
+
data.pop("element_type", None)
|
171
|
+
super().__init__(**data)
|
172
|
+
|
173
|
+
class ImageElement(ElementBase):
|
174
|
+
src: str
|
175
|
+
alt: str = ""
|
176
|
+
size: Size = Field(default_factory=lambda: Size(width="100%", height="auto"))
|
177
|
+
position: Optional[Position] = None
|
178
|
+
object_fit: str = "contain"
|
179
|
+
style_theme: Optional[ThemeMode]=None
|
180
|
+
|
181
|
+
def render(self,override_theme_mode_if_none) -> str:
|
182
|
+
if self.style_theme is None:
|
183
|
+
self.style_theme = override_theme_mode_if_none
|
184
|
+
style = []
|
185
|
+
if self.position:
|
186
|
+
style.append("position:absolute;")
|
187
|
+
style.append(self.position.css())
|
188
|
+
style.append(self.size.css())
|
189
|
+
style.append(f"object-fit:{self.object_fit};")
|
190
|
+
|
191
|
+
class_attr = f'class="{self.css_class}"' if self.css_class else ""
|
192
|
+
return (
|
193
|
+
f'<img id="{self.id}" {class_attr} src="{self.src}" alt="{self.alt}" '
|
194
|
+
f'style="{"".join(style)}" crossOrigin="anonymous" />'
|
195
|
+
)
|
196
|
+
|
197
|
+
class HtmlElement(ElementBase):
|
198
|
+
html: str
|
199
|
+
style_theme: Optional[ThemeMode]=None
|
200
|
+
|
201
|
+
def render(self, override_theme_mode_if_none) -> str:
|
202
|
+
if self.style_theme is None:
|
203
|
+
self.style_theme = override_theme_mode_if_none
|
204
|
+
class_attr = f'class="{self.css_class}"' if self.css_class else ""
|
205
|
+
return f'<div id="{self.id}" {class_attr}">{self.html}</div>'
|
206
|
+
|
207
|
+
|
208
|
+
BaseElements = Union[TextElement, ImageElement, HtmlElement]
|
209
|
+
|
210
|
+
class GridCell(BaseModel):
|
211
|
+
row: int
|
212
|
+
col: int
|
213
|
+
row_span: int = 1
|
214
|
+
col_span: int = 1
|
215
|
+
element: BaseElements
|
216
|
+
padding: Optional[str] = None
|
217
|
+
background_color: Optional[str] = None
|
218
|
+
align_self: Optional[str] = None
|
219
|
+
justify_self: Optional[str] = None
|
220
|
+
content_v_align: Optional[VerticalAlign] = None # For vertical alignment of content WITHIN the cell
|
221
|
+
content_h_align: Optional[HorizontalAlign] = None # For horizontal alignment of content WITHIN the cell
|
222
|
+
|
223
|
+
@validator("row", "col", "row_span", "col_span", pre=True)
|
224
|
+
def _positive(cls, v):
|
225
|
+
if isinstance(v, str) and v.isdigit():
|
226
|
+
v = int(v)
|
227
|
+
if not isinstance(v, int) or v < 1:
|
228
|
+
raise ValueError("row/col/row_span/col_span must be positive integers >= 1")
|
229
|
+
return v
|
230
|
+
|
231
|
+
|
232
|
+
class GridLayout(BaseModel):
|
233
|
+
row_definitions: List[str] = Field(default_factory=lambda: ["1fr"])
|
234
|
+
col_definitions: List[str] = Field(default_factory=lambda: ["1fr"])
|
235
|
+
gap: int = 10
|
236
|
+
cells: List[GridCell]
|
237
|
+
width: Optional[str] = "100%"
|
238
|
+
height: Optional[str] = "100%"
|
239
|
+
style_theme: Optional[ThemeMode] =None
|
240
|
+
|
241
|
+
@validator("gap",pre=True)
|
242
|
+
def _coerce_gap_to_int(cls,v):
|
243
|
+
if isinstance(v,str) and v.endswith("px"):
|
244
|
+
return int(v[:-2])
|
245
|
+
if isinstance(v,str) and v.isdigit():
|
246
|
+
return int(v)
|
247
|
+
if isinstance(v,int):
|
248
|
+
return v
|
249
|
+
raise ValueError("gap must be an int or string like '10px'")
|
250
|
+
|
251
|
+
@validator("cells", each_item=True)
|
252
|
+
def _within_grid(cls, cell: GridCell, values: Dict[str, Any]) -> GridCell:
|
253
|
+
row_defs = values.get("row_definitions")
|
254
|
+
col_defs = values.get("col_definitions")
|
255
|
+
if row_defs and cell.row + cell.row_span - 1 > len(row_defs):
|
256
|
+
raise ValueError(f"GridCell definition (row={cell.row}, row_span={cell.row_span}) exceeds row count ({len(row_defs)})")
|
257
|
+
if col_defs and cell.col + cell.col_span - 1 > len(col_defs):
|
258
|
+
raise ValueError(f"GridCell definition (col={cell.col}, col_span={cell.col_span}) exceeds column count ({len(col_defs)})")
|
259
|
+
return cell
|
260
|
+
|
261
|
+
def render(self,) -> str:
|
262
|
+
grid_style_parts = [
|
263
|
+
"display:grid;",
|
264
|
+
f"grid-template-columns:{' '.join(self.col_definitions)};",
|
265
|
+
f"grid-template-rows:{' '.join(self.row_definitions)};",
|
266
|
+
f"gap:{self.gap}px;",
|
267
|
+
"position:relative;"
|
268
|
+
]
|
269
|
+
if self.width:
|
270
|
+
grid_style_parts.append(f"width:{self.width};")
|
271
|
+
if self.height:
|
272
|
+
grid_style_parts.append(f"height:{self.height};")
|
273
|
+
grid_style = "".join(grid_style_parts)
|
274
|
+
|
275
|
+
html_parts: List[str] = [f'<div class="slide-grid" style="{grid_style}">']
|
276
|
+
for cell in self.cells:
|
277
|
+
cell_styles_list = [
|
278
|
+
f"grid-column:{cell.col}/span {cell.col_span};",
|
279
|
+
f"grid-row:{cell.row}/span {cell.row_span};",
|
280
|
+
"position:relative;",
|
281
|
+
"display:flex;",
|
282
|
+
]
|
283
|
+
|
284
|
+
align_items_css_value = "flex-start"
|
285
|
+
if cell.content_v_align:
|
286
|
+
if cell.content_v_align == VerticalAlign.center:
|
287
|
+
align_items_css_value = "center"
|
288
|
+
elif cell.content_v_align == VerticalAlign.bottom:
|
289
|
+
align_items_css_value = "flex-end"
|
290
|
+
elif cell.content_v_align == VerticalAlign.top:
|
291
|
+
align_items_css_value = "flex-start"
|
292
|
+
elif isinstance(cell.element, TextElement) and cell.element.v_align:
|
293
|
+
if cell.element.v_align == VerticalAlign.center:
|
294
|
+
align_items_css_value = "center"
|
295
|
+
elif cell.element.v_align == VerticalAlign.bottom:
|
296
|
+
align_items_css_value = "flex-end"
|
297
|
+
|
298
|
+
justify_content_css_value = "flex-start"
|
299
|
+
if cell.content_h_align:
|
300
|
+
if cell.content_h_align == HorizontalAlign.center:
|
301
|
+
justify_content_css_value = "center"
|
302
|
+
elif cell.content_h_align == HorizontalAlign.right:
|
303
|
+
justify_content_css_value = "flex-end"
|
304
|
+
elif cell.content_h_align == HorizontalAlign.left:
|
305
|
+
justify_content_css_value = "flex-start"
|
306
|
+
elif isinstance(cell.element, TextElement) and cell.element.h_align:
|
307
|
+
if cell.element.h_align == HorizontalAlign.center:
|
308
|
+
justify_content_css_value = "center"
|
309
|
+
elif cell.element.h_align == HorizontalAlign.right:
|
310
|
+
justify_content_css_value = "flex-end"
|
311
|
+
|
312
|
+
cell_styles_list.append(f"align-items: {align_items_css_value};")
|
313
|
+
cell_styles_list.append(f"justify-content: {justify_content_css_value};")
|
314
|
+
|
315
|
+
if cell.padding:
|
316
|
+
cell_styles_list.append(f"padding:{cell.padding};")
|
317
|
+
if cell.background_color:
|
318
|
+
cell_styles_list.append(f"background-color:{cell.background_color};")
|
319
|
+
|
320
|
+
if cell.align_self:
|
321
|
+
cell_styles_list.append(f"align-self:{cell.align_self};")
|
322
|
+
if cell.justify_self:
|
323
|
+
cell_styles_list.append(f"justify-self:{cell.justify_self};")
|
324
|
+
|
325
|
+
final_cell_style = "".join(cell_styles_list)
|
326
|
+
try:
|
327
|
+
html_parts.append(f'<div style="{final_cell_style}">{cell.element.render(self.style_theme)}</div>')
|
328
|
+
except Exception as e:
|
329
|
+
raise e
|
330
|
+
html_parts.append("</div>")
|
331
|
+
return "".join(html_parts)
|
332
|
+
|
333
|
+
class Slide(BaseModel):
|
334
|
+
title: str
|
335
|
+
layout: GridLayout
|
336
|
+
notes: Optional[str] = None
|
337
|
+
include_logo_in_header: bool = True
|
338
|
+
footer_info: str = ""
|
339
|
+
body_margin_top: int = 5
|
340
|
+
|
341
|
+
style_theme: Optional[StyleSettings] = None
|
342
|
+
|
343
|
+
def _section_style(self) -> str:
|
344
|
+
# only background color; size determined by container
|
345
|
+
return f"background-color:{self.style_theme.background_color};"
|
346
|
+
|
347
|
+
def _render_header(self) -> str:
|
348
|
+
title_class = "text-element-h2"
|
349
|
+
title_inline_style = f"color: {self.style_theme.heading_color};" # Only color here
|
350
|
+
|
351
|
+
logo_html = self.style_theme.logo_img_html() if self.include_logo_in_header else ""
|
352
|
+
return (
|
353
|
+
f'<div class="slide-header">'
|
354
|
+
f' <div class="slide-title {title_class} fw-bold" style="{title_inline_style}">'
|
355
|
+
f'{self.title}</div>'
|
356
|
+
f'{logo_html}'
|
357
|
+
f'</div>'
|
358
|
+
)
|
359
|
+
|
360
|
+
def _render_body(self) -> str:
|
361
|
+
style = (
|
362
|
+
f"flex:1; display:flex; flex-direction:column;"
|
363
|
+
f" margin-top:{self.body_margin_top}px;"
|
364
|
+
)
|
365
|
+
return (
|
366
|
+
f'<div class="slide-body" style="{style}">'
|
367
|
+
f'{self.layout.render()}'
|
368
|
+
f'</div>'
|
369
|
+
)
|
370
|
+
|
371
|
+
def _render_footer(self, slide_number: int, total: int, ) -> str:
|
372
|
+
text_style = f"color: {self.style_theme.light_paragraph_color};"
|
373
|
+
return (
|
374
|
+
f'<div class="slide-footer">'
|
375
|
+
f'<div class="slide-date" style="{text_style}">{self.footer_info}</div>'
|
376
|
+
f'<div class="slide-number" style="{text_style}">{slide_number} / {total}</div>'
|
377
|
+
f'</div>'
|
378
|
+
)
|
379
|
+
|
380
|
+
def _override_theme(self,theme_mode:ThemeMode):
|
381
|
+
if self.style_theme is None:
|
382
|
+
self.style_theme = theme_mode
|
383
|
+
if self.layout.style_theme is None:
|
384
|
+
self.layout.style_theme=theme_mode
|
385
|
+
|
386
|
+
def render(self, slide_number: int, total: int,
|
387
|
+
override_theme_mode_if_none:ThemeMode
|
388
|
+
) -> str:
|
389
|
+
self._override_theme(override_theme_mode_if_none)
|
390
|
+
header = self._render_header()
|
391
|
+
body = self._render_body()
|
392
|
+
footer = self._render_footer(slide_number, total, )
|
393
|
+
section_style = self._section_style()
|
394
|
+
|
395
|
+
return (
|
396
|
+
f'<section class="slide" style="{section_style}">'
|
397
|
+
f'{header}{body}{footer}'
|
398
|
+
f'</section>'
|
399
|
+
)
|
400
|
+
|
401
|
+
class VerticalImageSlide(Slide):
|
402
|
+
image_url: HttpUrl = Field(
|
403
|
+
..., description="URL for the right-column image"
|
404
|
+
)
|
405
|
+
image_width_pct: int = Field(
|
406
|
+
50,
|
407
|
+
ge=0,
|
408
|
+
le=100,
|
409
|
+
description="Percentage width of the right-column image"
|
410
|
+
)
|
411
|
+
image_fit: Literal["cover", "contain"] = Field(
|
412
|
+
"cover",
|
413
|
+
description="How the image should fit its container"
|
414
|
+
)
|
415
|
+
|
416
|
+
def render(self, slide_number: int, total: int,
|
417
|
+
override_theme_mode_if_none: ThemeMode
|
418
|
+
) -> str:
|
419
|
+
self._override_theme(override_theme_mode_if_none)
|
420
|
+
header = self._render_header()
|
421
|
+
body = self._render_body()
|
422
|
+
footer = self._render_footer(slide_number, total )
|
423
|
+
|
424
|
+
# Determine inline widths
|
425
|
+
left_pct = 100 - self.image_width_pct
|
426
|
+
left_style = f"width:{left_pct}%;"
|
427
|
+
right_style = f"width:{self.image_width_pct}%; padding:0;"
|
428
|
+
img_style = f"width:100%; height:100%; object-fit:{self.image_fit};"
|
429
|
+
|
430
|
+
# Compose columns
|
431
|
+
left_html = (
|
432
|
+
f'<div class="left-column" style="{left_style}">'
|
433
|
+
f'{body}'
|
434
|
+
f'</div>'
|
435
|
+
)
|
436
|
+
right_html = (
|
437
|
+
f'<div class="right-column" style="{right_style}">'
|
438
|
+
f' <img src="{self.image_url}" alt="" style="{img_style}" />'
|
439
|
+
f'</div>'
|
440
|
+
)
|
441
|
+
|
442
|
+
# Section tag uses both classes and background style
|
443
|
+
section_style = self._section_style()
|
444
|
+
return (
|
445
|
+
f'<section class="slide vertical-image-slide" style="{section_style}">'
|
446
|
+
f'{left_html}{right_html}'
|
447
|
+
f'</section>'
|
448
|
+
)
|
449
|
+
|
450
|
+
class ThemeMode(str, Enum):
|
451
|
+
light = "light"
|
452
|
+
dark = "dark"
|
453
|
+
|
454
|
+
class StyleSettings(BaseModel):
|
455
|
+
"""
|
456
|
+
Pydantic model for theme-based style settings.
|
457
|
+
Provides a semantic typographic scale (h1–h6, p), separate font families for headings and paragraphs,
|
458
|
+
and chart palettes. Colors and palettes are auto-filled based on `mode`.
|
459
|
+
"""
|
460
|
+
# theme switch
|
461
|
+
mode: ThemeMode = ThemeMode.light
|
462
|
+
|
463
|
+
# semantic typographic scale
|
464
|
+
font_size_h1: int = 32
|
465
|
+
font_size_h2: int = 28
|
466
|
+
font_size_h3: int = 24
|
467
|
+
font_size_h4: int = 20
|
468
|
+
font_size_h5: int = 16
|
469
|
+
font_size_h6: int = 14
|
470
|
+
font_size_p: int = 12
|
471
|
+
|
472
|
+
# default font families
|
473
|
+
font_family_headings: str = "Montserrat, sans-serif"
|
474
|
+
font_family_paragraphs: str = "Lato, Arial, Helvetica, sans-serif"
|
475
|
+
|
476
|
+
# layout
|
477
|
+
title_column_width: str = "150px"
|
478
|
+
chart_label_font_size: int = 12
|
479
|
+
logo_url: Optional[str] = None
|
480
|
+
|
481
|
+
# theme-driven colors (auto-filled)
|
482
|
+
primary_color: Optional[str] = Field(None)
|
483
|
+
secondary_color: Optional[str] = Field(None)
|
484
|
+
accent_color_1: Optional[str] = Field(None)
|
485
|
+
accent_color_2: Optional[str] = Field(None)
|
486
|
+
heading_color: Optional[str] = Field(None)
|
487
|
+
paragraph_color: Optional[str] = Field(None)
|
488
|
+
background_color: Optional[str] = Field(None)
|
489
|
+
light_paragraph_color: Optional[str] = Field(None, description="Paragraph text color on light backgrounds")
|
490
|
+
|
491
|
+
# chart color palettes
|
492
|
+
chart_palette_sequential: Optional[List[str]] = Field(None)
|
493
|
+
chart_palette_diverging: Optional[List[str]] = Field(None)
|
494
|
+
chart_palette_categorical: Optional[List[str]] = Field(None)
|
495
|
+
|
496
|
+
def logo_img_html(self, position: str = "slide-logo") -> str:
|
497
|
+
return f'<div class="{position}"><img src="{self.logo_url}" alt="logo" crossOrigin="anonymous"></div>' if self.logo_url else ""
|
498
|
+
|
499
|
+
@root_validator(pre=True)
|
500
|
+
def _fill_theme_defaults(cls, values: Dict) -> Dict:
|
501
|
+
palettes = {
|
502
|
+
ThemeMode.light: {
|
503
|
+
# base colors
|
504
|
+
"primary_color": "#c0d8fb",
|
505
|
+
"secondary_color": "#1254ff",
|
506
|
+
"accent_color_1": "#553ffe",
|
507
|
+
"accent_color_2": "#aea06c",
|
508
|
+
"heading_color": "#c0d8fb",
|
509
|
+
"paragraph_color": "#303238",
|
510
|
+
"background_color": "#FFFFFF",
|
511
|
+
"light_paragraph_color": "#303238",
|
512
|
+
|
513
|
+
# chart palettes
|
514
|
+
"chart_palette_sequential": ["#f7fbff","#deebf7","#9ecae1","#3182bd"],
|
515
|
+
"chart_palette_diverging": ["#d7191c","#fdae61","#ffffbf","#abdda4","#2b83ba"],
|
516
|
+
"chart_palette_categorical": ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e"],
|
517
|
+
},
|
518
|
+
ThemeMode.dark: {
|
519
|
+
"primary_color": "#E0E0E0", # light gray for primary text
|
520
|
+
"secondary_color": "#BB86FC", # soft purple accent
|
521
|
+
"accent_color_1": "#03DAC6", # vibrant teal
|
522
|
+
"accent_color_2": "#CF6679", # warm pink/red
|
523
|
+
"heading_color": "#FFFFFF", # pure white for headings
|
524
|
+
"paragraph_color": "#E0E0E0", # slightly muted white for body text
|
525
|
+
"background_color": "#121212", # deep charcoal
|
526
|
+
"light_paragraph_color": "#E0E0E0",
|
527
|
+
|
528
|
+
"chart_palette_sequential": [
|
529
|
+
"#37474F", # slate blue-gray
|
530
|
+
"#455A64",
|
531
|
+
"#546E7A",
|
532
|
+
"#607D8B", # progressively lighter
|
533
|
+
"#78909C"
|
534
|
+
],
|
535
|
+
"chart_palette_diverging": [
|
536
|
+
"#D32F2F", # strong red
|
537
|
+
"#F57C00", # orange
|
538
|
+
"#EEEEEE", # near-white neutral mid-point
|
539
|
+
"#0288D1", # bright blue
|
540
|
+
"#1976D2" # deeper blue
|
541
|
+
],
|
542
|
+
"chart_palette_categorical": [
|
543
|
+
"#F94144", # red
|
544
|
+
"#F3722C", # orange
|
545
|
+
"#F9C74F", # yellow
|
546
|
+
"#90BE6D", # green
|
547
|
+
"#577590", # indigo
|
548
|
+
"#43AA8B", # teal
|
549
|
+
"#8E44AD" # purple
|
550
|
+
],
|
551
|
+
}
|
552
|
+
}
|
553
|
+
mode = values.get("mode", ThemeMode.light)
|
554
|
+
for field, default in palettes.get(mode, {}).items():
|
555
|
+
values.setdefault(field, default)
|
556
|
+
return values
|
557
|
+
|
558
|
+
|
559
|
+
# ─── instantiate both themes ────────────────────────────────────────────
|
560
|
+
light_settings: StyleSettings = StyleSettings(mode=ThemeMode.light)
|
561
|
+
dark_settings: StyleSettings = StyleSettings(mode=ThemeMode.dark)
|
562
|
+
|
563
|
+
|
564
|
+
def get_theme_settings(mode: ThemeMode) -> StyleSettings:
|
565
|
+
"""
|
566
|
+
Retrieve the global light or dark settings instance.
|
567
|
+
"""
|
568
|
+
return light_settings if mode is ThemeMode.light else dark_settings
|
569
|
+
|
570
|
+
|
571
|
+
def update_settings_from_dict(overrides: dict, mode: ThemeMode) -> None:
|
572
|
+
"""
|
573
|
+
Update either `light_settings` or `dark_settings` in-place from dict `overrides`.
|
574
|
+
|
575
|
+
- `overrides` may include any fields (colors, fonts, layout, palettes).
|
576
|
+
- `mode` selects which settings instance to modify.
|
577
|
+
"""
|
578
|
+
# select global instance
|
579
|
+
instance = get_theme_settings(mode)
|
580
|
+
# merge current values with overrides
|
581
|
+
merged = instance.dict()
|
582
|
+
merged.update(overrides)
|
583
|
+
# create a temporary instance to re-apply root defaults and validation
|
584
|
+
temp = StyleSettings(**merged)
|
585
|
+
# mutate the existing settings instance so imports remain valid
|
586
|
+
for key, value in temp.dict().items():
|
587
|
+
setattr(instance, key, value)
|
588
|
+
|
589
|
+
|
590
|
+
class Presentation(BaseModel):
|
591
|
+
title: str
|
592
|
+
slides: List[Slide]
|
593
|
+
style_theme: ThemeMode = Field(default_factory=lambda: light_settings)
|
594
|
+
|
595
|
+
def render(self) -> str:
|
596
|
+
slides_html = []
|
597
|
+
|
598
|
+
# add the slide template
|
599
|
+
# self.slides.append(self._slide_template())
|
600
|
+
for slide in self.slides:
|
601
|
+
if slide.style_theme is None:
|
602
|
+
slide.style_theme = self.style_theme
|
603
|
+
|
604
|
+
total = len(self.slides)-1 # do not add the final template slide
|
605
|
+
|
606
|
+
slides_html += [s.render(i + 1, total,
|
607
|
+
override_theme_mode_if_none=s.style_theme
|
608
|
+
) for i, s in enumerate(self.slides)]
|
609
|
+
return BASE_TEMPLATE.render(
|
610
|
+
title=self.title,
|
611
|
+
font_family=self.style_theme.font_family_paragraphs,
|
612
|
+
slides="".join(slides_html),
|
613
|
+
)
|
614
|
+
|
615
|
+
def _slide_template(self) -> Slide:
|
616
|
+
|
617
|
+
# 1) Four rows:
|
618
|
+
# - First row “auto” for our split tutorial
|
619
|
+
# - Then the three demo rows (100px, 2fr, 1fr)
|
620
|
+
row_definitions = ["auto", "100px", "2fr", "1fr"]
|
621
|
+
|
622
|
+
# 2) Twelve columns mixing px and fr
|
623
|
+
col_definitions = [
|
624
|
+
"50px", "1fr", "2fr", "100px", "3fr", "1fr",
|
625
|
+
"200px", "2fr", "1fr", "150px", "4fr", "1fr"
|
626
|
+
]
|
627
|
+
|
628
|
+
# 3) Tutorial cells in row 1:
|
629
|
+
cells: List[GridCell] = [
|
630
|
+
# Left tutorial text (cols 1–6) with detailed fr explanation
|
631
|
+
GridCell(
|
632
|
+
row=1, col=1, col_span=6,
|
633
|
+
element=TextElement(
|
634
|
+
text=(
|
635
|
+
"<strong>Tutorial: How fr Units Are Calculated</strong><br><br>"
|
636
|
+
"1. <em>Start with total container height</em> (e.g. 800px).<br>"
|
637
|
+
"2. <em>Subtract auto/fixed rows</em>:<br>"
|
638
|
+
" • Row 1 (auto) → measured by content, say 200px<br>"
|
639
|
+
" • Row 2 (fixed) → exactly 100px<br>"
|
640
|
+
" → Used: 300px<br>"
|
641
|
+
"3. <em>Free space</em> = 800px − 300px = 500px<br>"
|
642
|
+
"4. <em>Total fr shares</em> = 2fr + 1fr = 3 shares<br>"
|
643
|
+
"5. <em>One share</em> = 500px ÷ 3 ≈ 166.67px<br>"
|
644
|
+
"6. <em>Allocate</em>:<br>"
|
645
|
+
" • Row 3 (2fr) → 2×166.67px ≈ 333.33px<br>"
|
646
|
+
" • Row 4 (1fr) → 1×166.67px ≈ 166.67px<br><br>"
|
647
|
+
"→ That’s how 2fr can take twice the free space and still leave one share for 1fr!"
|
648
|
+
),
|
649
|
+
font_size=14,
|
650
|
+
h_align=HorizontalAlign.left,
|
651
|
+
v_align=VerticalAlign.top
|
652
|
+
),
|
653
|
+
padding="12px",
|
654
|
+
background_color="#f9f9f9"
|
655
|
+
),
|
656
|
+
# Right tutorial code (cols 7–12)
|
657
|
+
GridCell(
|
658
|
+
row=1, col=7, col_span=6,
|
659
|
+
element=TextElement(
|
660
|
+
text=(
|
661
|
+
"<pre style=\"font-size:12px; white-space:pre-wrap;\">"
|
662
|
+
"row_defs = ['auto', '100px', '2fr', '1fr']\n"
|
663
|
+
"col_defs = ['50px','1fr','2fr','100px','3fr','1fr',\n"
|
664
|
+
" '200px','2fr','1fr','150px','4fr','1fr']\n\n"
|
665
|
+
"slide = GridLayout(\n"
|
666
|
+
" row_definitions=row_defs,\n"
|
667
|
+
" col_definitions=col_defs,\n"
|
668
|
+
" gap='10px',\n"
|
669
|
+
" cells=... # see demo rows below\n"
|
670
|
+
")\n"
|
671
|
+
"</pre>"
|
672
|
+
),
|
673
|
+
font_size=12,
|
674
|
+
h_align=HorizontalAlign.left,
|
675
|
+
v_align=VerticalAlign.top
|
676
|
+
),
|
677
|
+
padding="12px",
|
678
|
+
background_color="#ffffff"
|
679
|
+
)
|
680
|
+
]
|
681
|
+
|
682
|
+
# 4) Demo cells for rows 2–4
|
683
|
+
for r in range(2, 5): # rows 2, 3, 4
|
684
|
+
for c in range(1, 13): # cols 1–12
|
685
|
+
label = f"R{r}({row_definitions[r - 1]}), C{c}({col_definitions[c - 1]})"
|
686
|
+
cells.append(
|
687
|
+
GridCell(
|
688
|
+
row=r,
|
689
|
+
col=c,
|
690
|
+
element=TextElement(
|
691
|
+
text=label,
|
692
|
+
font_size=12,
|
693
|
+
h_align=HorizontalAlign.center,
|
694
|
+
v_align=VerticalAlign.center
|
695
|
+
)
|
696
|
+
)
|
697
|
+
)
|
698
|
+
|
699
|
+
# 5) Build and render the layout
|
700
|
+
slide_layout = GridLayout(
|
701
|
+
row_definitions=row_definitions,
|
702
|
+
col_definitions=col_definitions,
|
703
|
+
gap="10px",
|
704
|
+
cells=cells,
|
705
|
+
width="100%",
|
706
|
+
height="100%"
|
707
|
+
)
|
708
|
+
|
709
|
+
return Slide(
|
710
|
+
title="Slide Template",
|
711
|
+
layout=slide_layout,
|
712
|
+
|
713
|
+
)
|