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.
Files changed (110) hide show
  1. mainsequence/__init__.py +0 -0
  2. mainsequence/__main__.py +9 -0
  3. mainsequence/cli/__init__.py +1 -0
  4. mainsequence/cli/api.py +157 -0
  5. mainsequence/cli/cli.py +442 -0
  6. mainsequence/cli/config.py +78 -0
  7. mainsequence/cli/ssh_utils.py +126 -0
  8. mainsequence/client/__init__.py +17 -0
  9. mainsequence/client/base.py +431 -0
  10. mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  11. mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
  12. mainsequence/client/data_sources_interfaces/timescale.py +479 -0
  13. mainsequence/client/models_helpers.py +113 -0
  14. mainsequence/client/models_report_studio.py +412 -0
  15. mainsequence/client/models_tdag.py +2276 -0
  16. mainsequence/client/models_vam.py +1983 -0
  17. mainsequence/client/utils.py +387 -0
  18. mainsequence/dashboards/__init__.py +0 -0
  19. mainsequence/dashboards/streamlit/__init__.py +0 -0
  20. mainsequence/dashboards/streamlit/assets/config.toml +12 -0
  21. mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
  22. mainsequence/dashboards/streamlit/assets/logo.png +0 -0
  23. mainsequence/dashboards/streamlit/core/__init__.py +0 -0
  24. mainsequence/dashboards/streamlit/core/theme.py +212 -0
  25. mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
  26. mainsequence/dashboards/streamlit/scaffold.py +220 -0
  27. mainsequence/instrumentation/__init__.py +7 -0
  28. mainsequence/instrumentation/utils.py +101 -0
  29. mainsequence/instruments/__init__.py +1 -0
  30. mainsequence/instruments/data_interface/__init__.py +10 -0
  31. mainsequence/instruments/data_interface/data_interface.py +361 -0
  32. mainsequence/instruments/instruments/__init__.py +3 -0
  33. mainsequence/instruments/instruments/base_instrument.py +85 -0
  34. mainsequence/instruments/instruments/bond.py +447 -0
  35. mainsequence/instruments/instruments/european_option.py +74 -0
  36. mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
  37. mainsequence/instruments/instruments/json_codec.py +585 -0
  38. mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
  39. mainsequence/instruments/instruments/position.py +475 -0
  40. mainsequence/instruments/instruments/ql_fields.py +239 -0
  41. mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
  42. mainsequence/instruments/pricing_models/__init__.py +0 -0
  43. mainsequence/instruments/pricing_models/black_scholes.py +49 -0
  44. mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
  45. mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
  46. mainsequence/instruments/pricing_models/indices.py +350 -0
  47. mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
  48. mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
  49. mainsequence/instruments/settings.py +175 -0
  50. mainsequence/instruments/utils.py +29 -0
  51. mainsequence/logconf.py +284 -0
  52. mainsequence/reportbuilder/__init__.py +0 -0
  53. mainsequence/reportbuilder/__main__.py +0 -0
  54. mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
  55. mainsequence/reportbuilder/model.py +713 -0
  56. mainsequence/reportbuilder/slide_templates.py +532 -0
  57. mainsequence/tdag/__init__.py +8 -0
  58. mainsequence/tdag/__main__.py +0 -0
  59. mainsequence/tdag/config.py +129 -0
  60. mainsequence/tdag/data_nodes/__init__.py +12 -0
  61. mainsequence/tdag/data_nodes/build_operations.py +751 -0
  62. mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
  63. mainsequence/tdag/data_nodes/persist_managers.py +812 -0
  64. mainsequence/tdag/data_nodes/run_operations.py +543 -0
  65. mainsequence/tdag/data_nodes/utils.py +24 -0
  66. mainsequence/tdag/future_registry.py +25 -0
  67. mainsequence/tdag/utils.py +40 -0
  68. mainsequence/virtualfundbuilder/__init__.py +45 -0
  69. mainsequence/virtualfundbuilder/__main__.py +235 -0
  70. mainsequence/virtualfundbuilder/agent_interface.py +77 -0
  71. mainsequence/virtualfundbuilder/config_handling.py +86 -0
  72. mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
  73. mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
  74. mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
  75. mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
  76. mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
  77. mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
  78. mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
  79. mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
  80. mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
  81. mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
  82. mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
  83. mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
  84. mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
  85. mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
  86. mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
  87. mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
  88. mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
  89. mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
  90. mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
  91. mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
  92. mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
  93. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
  94. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
  95. mainsequence/virtualfundbuilder/data_nodes.py +637 -0
  96. mainsequence/virtualfundbuilder/enums.py +23 -0
  97. mainsequence/virtualfundbuilder/models.py +282 -0
  98. mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
  99. mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
  100. mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
  101. mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
  102. mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
  103. mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
  104. mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
  105. mainsequence/virtualfundbuilder/utils.py +381 -0
  106. mainsequence-2.0.0.dist-info/METADATA +105 -0
  107. mainsequence-2.0.0.dist-info/RECORD +110 -0
  108. mainsequence-2.0.0.dist-info/WHEEL +5 -0
  109. mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
  110. mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import base64
4
+ from io import BytesIO
5
+ from datetime import datetime, timedelta
6
+ from typing import List, Dict, Any, Optional
7
+
8
+ import pandas as pd
9
+ import plotly.graph_objs as go
10
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
11
+
12
+ # Assuming TDAGAgent is correctly set up and accessible in the execution environment
13
+ from mainsequence.virtualfundbuilder.agent_interface import TDAGAgent
14
+ from pydantic import BaseModel, Field
15
+ from polygon import RESTClient
16
+ from jinja2 import Environment, FileSystemLoader
17
+
18
+ from mainsequence.client import AssetCategory
19
+ from mainsequence.client.models_tdag import Artifact
20
+ from mainsequence.virtualfundbuilder.resource_factory.app_factory import BaseApp, register_app
21
+
22
+ logger = get_vfb_logger()
23
+
24
+ POLYGON_API_KEY = os.getenv("POLYGON_API_KEY")
25
+
26
+ class SentimentReportConfig(BaseModel):
27
+ """Pydantic model defining parameters for the Sentiment Report."""
28
+ asset_category_unique_identifier: str = "magnificent_7"
29
+ report_days: int = 14
30
+ report_title: str = "Multi-Ticker News Sentiment & Headlines Report"
31
+ bucket_name: str = "SentimentReports" # Optional: For artifact storage
32
+ authors: str = "Automated Analysis (Main Sequence AI)"
33
+ sector: str = "Technology Focus"
34
+ region: str = "Global"
35
+ news_items_per_day_limit: int = 5
36
+ report_id: Optional[str] = "MS_SentimentReport"
37
+
38
+ @register_app()
39
+ class SentimentReport(BaseApp):
40
+ """
41
+ Generates an HTML report summarizing news sentiment and headlines
42
+ for a list of stock tickers using data from Polygon.io.
43
+ Additionally, fetches the first 100 words of each article (if possible)
44
+ and generates a single combined summary displayed below the combined chart.
45
+ """
46
+ configuration_class = SentimentReportConfig
47
+
48
+ def __init__(self, *args, **kwargs):
49
+ super().__init__(*args, **kwargs)
50
+
51
+ if not POLYGON_API_KEY:
52
+ raise ValueError("Warning: POLYGON_API_KEY environment variable not set. Data fetching will fail.")
53
+
54
+ self.tdag_agent = TDAGAgent()
55
+
56
+ logger.info(f"Initializing Sentiment Report with configuration {self.configuration.model_dump()}")
57
+
58
+ end_date_dt = datetime.now()
59
+ start_date_dt = end_date_dt - timedelta(days=self.configuration.report_days -1)
60
+ self.start_date = start_date_dt.strftime("%Y-%m-%d")
61
+ self.end_date = end_date_dt.strftime("%Y-%m-%d")
62
+
63
+ category = AssetCategory.get(unique_identifier=self.configuration.asset_category_unique_identifier)
64
+ self.tickers = [a.ticker for a in category.get_assets()]
65
+ self.category_name = category.display_name
66
+
67
+ # Initialize Polygon client once if API key exists
68
+ self.polygon_client = RESTClient(POLYGON_API_KEY) if POLYGON_API_KEY else None
69
+
70
+ # Setup Jinja2 environment once
71
+ self._setup_jinja()
72
+
73
+ def _setup_jinja(self):
74
+ """Initializes the Jinja2 environment."""
75
+ template_dir = os.path.join(os.path.dirname(__file__), "templates")
76
+ if not os.path.isdir(template_dir):
77
+ raise FileNotFoundError(f"Jinja2 template directory not found: {template_dir}")
78
+ report_template_path = os.path.join(template_dir, "report.html")
79
+ if not os.path.isfile(report_template_path):
80
+ raise FileNotFoundError(f"Jinja2 report template not found: {report_template_path}")
81
+ self.jinja_env = Environment(loader=FileSystemLoader(template_dir), autoescape=True)
82
+
83
+ def _fetch_data(self) -> (Dict[str, pd.DataFrame], Dict[str, Dict[str, List[Dict]]]):
84
+ """
85
+ Fetches sentiment counts and news headlines for configured tickers and date range.
86
+ Returns:
87
+ Tuple[Dict[str, pd.DataFrame], Dict[str, Dict[str, List[Dict]]]]:
88
+ - Sentiment data per ticker (date-indexed DataFrame).
89
+ - News items (title, url) per ticker per date.
90
+ """
91
+ if not self.polygon_client:
92
+ logger.info("Error: Polygon API key not configured. Cannot fetch data.")
93
+ empty_sentiment = {ticker: pd.DataFrame() for ticker in self.tickers}
94
+ empty_news = {ticker: {} for ticker in self.tickers}
95
+ return empty_sentiment, empty_news
96
+
97
+ tickers = self.tickers
98
+ start_date = self.start_date
99
+ end_date = self.end_date
100
+
101
+ results = {}
102
+ all_news = {}
103
+ date_range = pd.date_range(start=start_date, end=end_date)
104
+
105
+ logger.info(f"Fetching data for tickers: {tickers} from {start_date} to {end_date}")
106
+
107
+ for ticker in tickers:
108
+ logger.info(f" -> Fetching for {ticker}...")
109
+ sentiment_count = []
110
+ ticker_news_by_date = {}
111
+
112
+ for day in date_range:
113
+ day_str = day.strftime("%Y-%m-%d")
114
+ try:
115
+ # Fetch news for the day
116
+ daily_news_response = list(
117
+ self.polygon_client.list_ticker_news(
118
+ ticker=ticker, published_utc=day_str, limit=100 # Limit news fetched per day
119
+ )
120
+ )
121
+ except Exception as e:
122
+ logger.info(f" Error fetching news for {ticker} on {day_str}: {e}")
123
+ daily_news_response = []
124
+
125
+ daily_sentiment = {"date": day, "positive": 0, "negative": 0, "neutral": 0}
126
+ daily_news_items_for_report = [] # Store dicts with 'title' & 'url'
127
+
128
+ for article in daily_news_response:
129
+ # Extract headline and URL for the report list
130
+ if hasattr(article, 'title') and hasattr(article, 'article_url'):
131
+ daily_news_items_for_report.append({'title': article.title, 'url': article.article_url})
132
+
133
+ # Extract sentiment from insights
134
+ if hasattr(article, "insights") and article.insights:
135
+ for insight in article.insights:
136
+ if hasattr(insight, 'sentiment'):
137
+ sentiment = insight.sentiment
138
+ if sentiment == "positive": daily_sentiment["positive"] += 1
139
+ elif sentiment == "negative": daily_sentiment["negative"] += 1
140
+ elif sentiment == "neutral": daily_sentiment["neutral"] += 1
141
+
142
+ sentiment_count.append(daily_sentiment)
143
+
144
+ if daily_news_items_for_report:
145
+ ticker_news_by_date[day_str] = daily_news_items_for_report
146
+
147
+ # Prepare the sentiment DataFrame for this ticker
148
+ if sentiment_count:
149
+ df_sentiment = pd.DataFrame(sentiment_count)
150
+ df_sentiment["date"] = pd.to_datetime(df_sentiment["date"])
151
+ df_sentiment.set_index("date", inplace=True)
152
+ # Ensure all dates in the range are present
153
+ df_sentiment = df_sentiment.reindex(date_range, fill_value=0)
154
+ results[ticker] = df_sentiment
155
+ all_news[ticker] = ticker_news_by_date
156
+ else:
157
+ # No sentiment data found
158
+ logger.info(f" No sentiment data found for {ticker} in the date range.")
159
+ results[ticker] = pd.DataFrame(index=date_range, columns=['positive','negative','neutral']).fillna(0)
160
+ all_news[ticker] = {}
161
+
162
+ return results, all_news
163
+
164
+ def _download_article_previews(self, all_news_data, words_per_article=50, articles_per_day=2):
165
+ from newspaper import Article
166
+
167
+ article_snippets = []
168
+ if self.polygon_client and Article is not None:
169
+ logger.info("\nGathering first 50 words from each article...")
170
+ for ticker, date_dict in all_news_data.items():
171
+ for date_str, articles in date_dict.items():
172
+ # restrict to 2 items per day
173
+ for article_info in articles[:articles_per_day]:
174
+ url = article_info.get('url')
175
+ if not url:
176
+ continue
177
+ try:
178
+ art = Article(url)
179
+ art.download()
180
+ art.parse()
181
+ # Grab first 100 words
182
+ words = art.text.split()
183
+ snippet = " ".join(words[:words_per_article])
184
+ if snippet:
185
+ article_snippets.append(snippet)
186
+ except Exception as e:
187
+ logger.info(f" Could not retrieve/parse text from {url} for {ticker} due to: {e}")
188
+ else:
189
+ logger.info("\nSkipping article text retrieval (Polygon client or newspaper not available).")
190
+
191
+ return article_snippets
192
+
193
+ def _generate_plot(self, df_sentiment: pd.DataFrame, chart_title: str) -> Optional[str]:
194
+ """
195
+ Generates a Plotly sentiment chart and returns it as a Base64 encoded PNG string.
196
+ Returns None if no data to plot or if image generation fails.
197
+ """
198
+ if df_sentiment.empty or (df_sentiment[['positive','negative','neutral']].sum().sum() == 0):
199
+ logger.info(f" No data to plot for '{chart_title}'. Skipping chart generation.")
200
+ return None
201
+
202
+ x_axis_data = df_sentiment.index.to_pydatetime()
203
+
204
+ fig = go.Figure()
205
+ fig.add_trace(go.Scatter(
206
+ x=x_axis_data, y=df_sentiment["positive"],
207
+ mode="lines+markers", name="Positive",
208
+ line=dict(color="green"), marker=dict(size=5))
209
+ )
210
+ fig.add_trace(go.Scatter(
211
+ x=x_axis_data, y=df_sentiment["negative"],
212
+ mode="lines+markers", name="Negative",
213
+ line=dict(color="red"), marker=dict(size=5))
214
+ )
215
+ fig.add_trace(go.Scatter(
216
+ x=x_axis_data, y=df_sentiment["neutral"],
217
+ mode="lines+markers", name="Neutral",
218
+ line=dict(color="gray", dash="dash"), marker=dict(size=5))
219
+ )
220
+
221
+ fig.update_layout(
222
+ title=f"{chart_title} News Sentiment Over Time",
223
+ xaxis_title="Date", yaxis_title="Sentiment Count", legend_title="Sentiment",
224
+ width=850, height=450, margin=dict(l=40, r=40, t=60, b=40),
225
+ xaxis=dict(
226
+ type='date',
227
+ tickformat="%Y-%m-%d"
228
+ )
229
+ )
230
+
231
+ buf = BytesIO()
232
+ try:
233
+ fig.write_image(buf, format="png", scale=2)
234
+ buf.seek(0)
235
+ encoded_plot = base64.b64encode(buf.read()).decode("utf-8")
236
+ return encoded_plot
237
+ except Exception as e:
238
+ logger.info(f" Error generating PNG for '{chart_title}': {e}")
239
+ return None
240
+
241
+
242
+ def _format_ticker_sections(self, all_sentiment_data, all_news_data):
243
+ ticker_sections_html = ""
244
+ logger.info("\nGenerating individual ticker sections...")
245
+ for ticker in self.tickers:
246
+ df_sentiment = all_sentiment_data.get(ticker)
247
+ ticker_news = all_news_data.get(ticker, {})
248
+ ticker_html = f"<h2>{ticker} Sentiment & News</h2>\n"
249
+ logger.info(f" -> Processing {ticker}")
250
+
251
+ # Plot for this ticker
252
+ chart_base64 = self._generate_plot(df_sentiment, ticker)
253
+ if chart_base64:
254
+ ticker_html += f"""
255
+ <div style="text-align: center; margin-bottom: 20px;">
256
+ <img alt="{ticker} Sentiment Chart" src="data:image/png;base64,{chart_base64}"
257
+ style="max-width:850px; width:100%; display: block; margin:auto;">
258
+ </div>"""
259
+ else:
260
+ ticker_html += f"<p>No plottable sentiment data available for {ticker}.</p>\n"
261
+
262
+ # Recent News Headlines (Details)
263
+ ticker_html += "<h5>News Headlines</h5>\n"
264
+ if ticker_news:
265
+ sorted_dates = sorted(ticker_news.keys(), reverse=True)
266
+ news_list_html = ""
267
+ for date_str in sorted_dates:
268
+ news_items = ticker_news[date_str]
269
+ if news_items:
270
+ items_to_show = news_items[:self.configuration.news_items_per_day_limit]
271
+ if items_to_show:
272
+ news_list_html += f"{date_str}\n<ul class='list-unstyled'>\n"
273
+ for item in items_to_show:
274
+ safe_title = item.get('title', 'No Title').replace('<', '&lt;').replace('>', '&gt;')
275
+ url = item.get('url', '#')
276
+ news_list_html += (
277
+ f" <li><a href='{url}' target='_blank' rel='noopener noreferrer'>"
278
+ f"{safe_title}</a></li>\n"
279
+ )
280
+ news_list_html += "</ul>\n"
281
+ ticker_html += news_list_html if news_list_html else "<p>No recent news headlines found based on limits.</p>\n"
282
+ else:
283
+ ticker_html += "<p>No news headlines found for this period.</p>\n"
284
+
285
+ ticker_html += '<hr style="margin: 30px 0;">\n'
286
+ ticker_sections_html += ticker_html
287
+ return ticker_sections_html
288
+
289
+ def run(self) -> str:
290
+ """
291
+ Orchestrates the report generation process:
292
+ 1. Fetch data,
293
+ 2. Create plots,
294
+ 3. Attempt to retrieve article text (first 100 words) for all articles,
295
+ 4. Generate a single combined summary from those snippets,
296
+ 5. Render HTML,
297
+ 6. Upload artifact.
298
+ """
299
+ logger.info(f"Running Sentiment Report with configuration: {self.configuration.model_dump()}")
300
+
301
+ # Step 1: Fetch sentiment and news data
302
+ all_sentiment_data, all_news_data = self._fetch_data()
303
+
304
+ # Step 2: Create a combined (all tickers) sentiment chart
305
+ valid_dfs = [df for df in all_sentiment_data.values() if not df.empty]
306
+ combined_chart_base64 = None
307
+ if valid_dfs:
308
+ combined_df = pd.concat(valid_dfs).groupby(level=0).sum()
309
+ combined_chart_base64 = self._generate_plot(combined_df, "All Tickers (Combined)")
310
+
311
+ if combined_chart_base64:
312
+ combined_chart_html = f"""
313
+ <h2>Combined Sentiment Across All Tickers</h2>
314
+ <p style="text-align:center;">
315
+ <img alt="All Tickers Combined Sentiment Chart"
316
+ src="data:image/png;base64,{combined_chart_base64}"
317
+ style="max-width:850px; width:100%; display: block; margin:auto;">
318
+ </p><hr style="margin: 30px 0;">"""
319
+ else:
320
+ combined_chart_html = "<h2>Combined Sentiment</h2><p>No combined sentiment data available.</p><hr style='margin: 30px 0;'>"
321
+
322
+ # Step 3: Attempt to retrieve the first 100 words from each article and accumulate
323
+ article_snippets = self._download_article_previews(all_news_data=all_news_data)
324
+
325
+ # Step 4: Generate one single combined summary from all article snippets
326
+ combined_article_snippets_summary_html = ""
327
+ if article_snippets:
328
+ # Combine all snippets into one string
329
+ combined_text = "\n".join(article_snippets)
330
+ summary_prompt = (
331
+ f"Please summarize the following text in about 150 words, focus on the assets {self.tickers}:\n\n"
332
+ f"{combined_text}"
333
+ )
334
+ logger.info("\nGenerating combined summary of article snippets...")
335
+ try:
336
+ combined_summary_text = self.tdag_agent.query_agent(summary_prompt)
337
+ combined_article_snippets_summary_html = f"""
338
+ <h3>Summary (AI-Generated)</h3>
339
+ <p>{combined_summary_text}</p>
340
+ <hr style="margin: 30px 0;">"""
341
+ except Exception as e:
342
+ logger.info(f" Error generating combined summary: {e}")
343
+ combined_article_snippets_summary_html = (
344
+ "<h3>Summary (AI-Generated)</h3>"
345
+ f"<p>Error generating summary: {e}</p><hr>"
346
+ )
347
+ else:
348
+ combined_article_snippets_summary_html = (
349
+ "<h3>Summary (AI-Generated)</h3>"
350
+ "<p>No article snippets found to summarize.</p>"
351
+ "<hr>"
352
+ )
353
+
354
+ # Step 5: Build up the per-ticker sections
355
+ ticker_sections_html = self._format_ticker_sections(all_sentiment_data, all_news_data)
356
+
357
+ # Construct the overall report HTML
358
+ report_content_html = f"""
359
+ <h2>Overview</h2>
360
+ <p>This report summarizes daily sentiment counts (positive/negative/neutral)
361
+ derived from Polygon.io news article insights for each requested ticker,
362
+ within the date range {self.start_date} to {self.end_date}.</p>
363
+
364
+ {combined_chart_html}
365
+ {combined_article_snippets_summary_html}
366
+
367
+ {ticker_sections_html}
368
+ """
369
+
370
+ template_context = {
371
+ "report_title": self.configuration.report_title,
372
+ "report_id": self.configuration.report_id,
373
+ "current_date": datetime.now().strftime('%Y-%m-%d'),
374
+ "authors": self.configuration.authors,
375
+ "sector": self.configuration.sector,
376
+ "region": self.configuration.region,
377
+ "topics": ["Sentiment Analysis", "News Aggregation", "Market Data", "Equities"],
378
+ "current_year": datetime.now().year,
379
+ "summary": (
380
+ f"Daily sentiment analysis, plus combined and per-ticker summaries, "
381
+ f"for the {self.category_name} category from {self.start_date} to {self.end_date}."
382
+ ),
383
+ "report_content": report_content_html,
384
+ "logo_location": "https://main-sequence.app/static/media/logos/MS_logo_long_white.png",
385
+ }
386
+
387
+ template = self.jinja_env.get_template("report.html")
388
+ rendered_html = template.render(template_context)
389
+
390
+ output_html_path = os.path.join(os.path.dirname(__file__), "multi_ticker_sentiment_report.html")
391
+ try:
392
+ with open(output_html_path, "w", encoding="utf-8") as f:
393
+ f.write(rendered_html)
394
+ logger.info(f"\nHTML report generated successfully: {output_html_path}")
395
+ logger.info(f"View the report at: file://{os.path.abspath(output_html_path)}")
396
+ except Exception as e:
397
+ logger.info(f"\nError writing HTML report to file: {e}")
398
+
399
+ html_artifact = None
400
+ try:
401
+ html_artifact = Artifact.upload_file(
402
+ filepath=output_html_path,
403
+ name=self.configuration.report_id + f"_{self.category_name}.html",
404
+ created_by_resource_name=self.__class__.__name__,
405
+ bucket_name=self.configuration.bucket_name
406
+ )
407
+ logger.info(f"Artifact uploaded successfully: {html_artifact.id if html_artifact else 'Failed'}")
408
+ except Exception as e:
409
+ logger.info(f"Error uploading artifact: {e}")
410
+
411
+ self.add_output(html_artifact)
412
+ return html_artifact
413
+
414
+ # --- Main Execution Guard ---
415
+ if __name__ == "__main__":
416
+ try:
417
+ import kaleido
418
+ except ImportError:
419
+ logger.info("Warning: 'kaleido' package not found. Plotly image export might fail.")
420
+ logger.info("Consider installing it: pip install kaleido")
421
+
422
+ # Example configuration
423
+ config = SentimentReportConfig(
424
+ asset_category_unique_identifier="magnificent_7",
425
+ report_days=7,
426
+ report_title="Magnificent 7 News Sentiment & Headlines Report (Last 7 Days)",
427
+ report_id="Mag7_SentimentReport_7d"
428
+ )
429
+
430
+ # Create the App instance with config
431
+ app = SentimentReport(config)
432
+ # Run the report
433
+ generated_artifact = app.run()
434
+ if generated_artifact:
435
+ logger.info(f"\nReport generation complete. Artifact ID: {generated_artifact.id}")
436
+ else:
437
+ logger.info("\nReport generation completed, but artifact upload failed or was skipped.")
@@ -0,0 +1,91 @@
1
+ import datetime
2
+ import os
3
+ from enum import Enum
4
+ from typing import List
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from mainsequence.client import Portfolio
9
+ from mainsequence.reportbuilder.model import StyleSettings, ThemeMode
10
+ from mainsequence.reportbuilder.slide_templates import generic_plotly_line_chart
11
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
12
+ from plotly.subplots import make_subplots
13
+
14
+ from pydantic import BaseModel
15
+ from mainsequence.virtualfundbuilder.resource_factory.app_factory import BaseApp, register_app, HtmlApp
16
+ import plotly.graph_objects as go
17
+
18
+ logger = get_vfb_logger()
19
+
20
+ portfolio_ids = [portfolio.id for portfolio in Portfolio.filter(local_time_serie__isnull=False)]
21
+
22
+ class PortfolioReportConfiguration(BaseModel):
23
+ report_title: str = "Portfolio Report"
24
+ portfolio_ids: List[int] = portfolio_ids
25
+ report_days: int = 365 * 5
26
+
27
+ @register_app()
28
+ class PortfolioReport(HtmlApp):
29
+ configuration_class = PortfolioReportConfiguration
30
+
31
+ def run(self) -> str:
32
+ styles = StyleSettings(mode=ThemeMode.light)
33
+ start_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=self.configuration.report_days)
34
+
35
+ series_data = []
36
+ all_dates = pd.Index([])
37
+
38
+ portfolio_data_map = {}
39
+ for portfolio_id in self.configuration.portfolio_ids:
40
+ try:
41
+ portfolio = Portfolio.get(id=portfolio_id)
42
+ data = portfolio.local_time_serie.get_data_between_dates_from_api()
43
+ data['time_index'] = pd.to_datetime(data['time_index'])
44
+ report_data = data[data['time_index'] >= start_date].copy().sort_values('time_index')
45
+
46
+ if not report_data.empty:
47
+ portfolio_data_map[portfolio_id] = report_data
48
+ all_dates = all_dates.union(report_data['time_index'])
49
+
50
+ except Exception as e:
51
+ logger.error(f"Could not process portfolio {portfolio_id}. Error: {e}")
52
+
53
+ # Second loop: process and normalize data
54
+ for portfolio_id in self.configuration.portfolio_ids:
55
+ if portfolio_id in portfolio_data_map:
56
+ report_data = portfolio_data_map[portfolio_id]
57
+ portfolio = Portfolio.get(id=portfolio_id)
58
+
59
+ # Reindex to common date range and forward-fill missing values
60
+ processed_data = report_data.set_index('time_index').reindex(all_dates).ffill().reset_index()
61
+
62
+ # Normalize to 100 at the start of the common date range
63
+ first_price = processed_data['close'].iloc[0]
64
+ normalized_close = (processed_data['close'] / first_price) * 100
65
+
66
+ series_data.append({
67
+ "name": portfolio.portfolio_name,
68
+ "y_values": normalized_close,
69
+ "color": styles.chart_palette_categorical[len(series_data) % len(styles.chart_palette_categorical)]
70
+ })
71
+
72
+ # Final check if any data was processed
73
+ if not series_data:
74
+ return "<html><body><h1>No data available for the selected portfolios and date range.</h1></body></html>"
75
+
76
+ # Call the generic function
77
+ html_chart = generic_plotly_line_chart(
78
+ x_values=list(all_dates),
79
+ series_data=series_data,
80
+ y_axis_title="Indexed Performance (Start = 100)",
81
+ theme_mode=styles.mode,
82
+ full_html=False,
83
+ include_plotlyjs = "cdn"
84
+ )
85
+ return html_chart
86
+
87
+ if __name__ == "__main__":
88
+ configuration = PortfolioReportConfiguration(
89
+ portfolio_ids=portfolio_ids[:1]
90
+ )
91
+ PortfolioReport(configuration).run()
@@ -0,0 +1,95 @@
1
+ import datetime
2
+ from enum import Enum
3
+ from typing import List, Union
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from mainsequence.client import Portfolio
8
+ from mainsequence.reportbuilder.model import StyleSettings, ThemeMode
9
+ from mainsequence.reportbuilder.slide_templates import generic_plotly_table
10
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
11
+ from pydantic import BaseModel
12
+ from mainsequence.virtualfundbuilder.resource_factory.app_factory import (
13
+ HtmlApp,
14
+ register_app,
15
+ )
16
+
17
+ logger = get_vfb_logger()
18
+
19
+ portfolio_ids = [portfolio.id for portfolio in Portfolio.filter(local_time_serie__isnull=False)]
20
+
21
+ class ReportType(Enum):
22
+ FIXED_INCOME = "fixed_income"
23
+ LIQUIDITY = "liquidity"
24
+
25
+ class PortfolioTableConfiguration(BaseModel):
26
+ report_title: str = "Portfolio Table"
27
+ report_type: ReportType = ReportType.FIXED_INCOME
28
+ portfolio_ids: List[int] = portfolio_ids
29
+ report_days: int = 365 * 5
30
+
31
+ @register_app()
32
+ class PortfolioTable(HtmlApp):
33
+ configuration_class = PortfolioTableConfiguration
34
+
35
+ def run(self) -> str:
36
+ style = StyleSettings(mode=ThemeMode.light)
37
+ shared_column_widths = [1.8, 1, 1, 1.5, 0.7, 0.8, 0.8, 0.7]
38
+ shared_cell_align: Union[str, List[str]] = ['left', 'right', 'right', 'right', 'right', 'right', 'right', 'right']
39
+ table_figure_width = 900
40
+
41
+ # Use paragraph font family for charts from the theme
42
+ chart_font_family = style.font_family_paragraphs
43
+ chart_label_font_size = style.chart_label_font_size
44
+
45
+ if self.configuration.report_type == ReportType.FIXED_INCOME:
46
+ fixed_income_local_headers = ["INSTRUMENT", "UNITS", "PRICE", "AMOUNT", "% TOTAL", "DURATION", "YIELD", "DxV"]
47
+ fixed_income_local_rows = [
48
+ ["Alpha Bond 2025", "350,000", "$99.50", "$34,825,000.00", "7.50%", "0.25", "9.05%", "90"],
49
+ ["Beta Note 2026", "160,000", "$99.80", "$15,968,000.00", "3.60%", "1.30", "9.15%", "530"],
50
+ ["Gamma Security 2026", "250,000", "$99.90", "$24,975,000.00", "5.60%", "1.50", "9.20%", "600"],
51
+ ["Delta Issue 2027", "245,000", "$100.10", "$24,524,500.00", "5.40%", "1.60", "9.25%", "630"],
52
+ ["Epsilon Paper 2026", "200,000", "$98.50", "$19,700,000.00", "4.40%", "0.80", "8.30%", "300"],
53
+ ["Zeta Bond 2029", "170,000", "$102.50", "$17,425,000.00", "3.90%", "3.30", "8.60%", "1,500"],
54
+ ["Eta Security 2030", "180,000", "$100.00", "$18,000,000.00", "4.00%", "3.80", "8.80%", "1,700"],
55
+ ["Theta Note 2034", "110,000", "$93.00", "$10,230,000.00", "2.30%", "6.30", "9.30%", "3,500"],
56
+ ["Iota UDI 2028", "40,000", "$98.00", "$33,600,000.00", "7.90%", "3.20", "4.90%", "1,300"],
57
+ ["Kappa C-Bill 2026A", "2,500,000", "$9.20", "$23,000,000.00", "5.10%", "0.85", "8.40%", "340"],
58
+ ["Lambda C-Bill 2026B", "3,300,000", "$8.80", "$29,040,000.00", "6.70%", "1.25", "8.50%", "520"],
59
+ ["TOTAL", "", "", "$251,287,500.00", "56.70%", "1.60", "8.55%", "480"]
60
+ ]
61
+
62
+
63
+
64
+ html_table = generic_plotly_table(
65
+ headers=fixed_income_local_headers, rows=fixed_income_local_rows,
66
+ column_widths=shared_column_widths, cell_align=shared_cell_align, fig_width=table_figure_width,
67
+ header_font_dict=dict(color=style.background_color, size=10, family=chart_font_family),
68
+ cell_font_dict=dict(size=chart_label_font_size, family=chart_font_family, color=style.paragraph_color),
69
+ theme_mode=style.mode, full_html=False, include_plotlyjs="cdn"
70
+ )
71
+ else:
72
+ liquidity_headers = ["INSTRUMENT", "UNITS", "PRICE", "AMOUNT", "% TOTAL", "DURATION", "YIELD", "DxV"]
73
+ liquidity_rows = [
74
+ ["Repo Agreement", "", "", "$55,000,000.00", "12.50%", "0.01", "9.50%", "5"],
75
+ ["Cash Equiv. (Local)", "", "", "$150.00", "0.00%", "", "", ""],
76
+ # ["Cash Equiv. (USD)", "50,000", "", "$1,000,000.00", "0.20%", "", "", ""],
77
+ # ["TOTAL", "", "", "$56,000,150.00", "12.70%", "", "", ""]
78
+ ]
79
+
80
+ html_table = generic_plotly_table(
81
+ headers=liquidity_headers, rows=liquidity_rows,
82
+ column_widths=shared_column_widths, cell_align=shared_cell_align, fig_width=table_figure_width,
83
+ header_font_dict=dict(color=style.background_color, size=10, family=chart_font_family),
84
+ cell_font_dict=dict(size=chart_label_font_size, family=chart_font_family, color=style.paragraph_color),
85
+ theme_mode=style.mode, full_html=False, include_plotlyjs="cdn"
86
+ )
87
+ return html_table
88
+
89
+
90
+ if __name__ == "__main__":
91
+ cfg = PortfolioTableConfiguration(
92
+ report_type=ReportType.LIQUIDITY,
93
+ portfolio_ids=portfolio_ids[:1]
94
+ )
95
+ PortfolioTable(cfg).run()
@@ -0,0 +1,45 @@
1
+ from enum import Enum
2
+
3
+ from mainsequence.virtualfundbuilder.models import PortfolioConfiguration
4
+ from mainsequence.virtualfundbuilder.portfolio_interface import PortfolioInterface
5
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
6
+
7
+ from pydantic import BaseModel
8
+ from mainsequence.client.models_tdag import Artifact
9
+ from mainsequence.virtualfundbuilder.resource_factory.app_factory import BaseApp, register_app
10
+
11
+ logger = get_vfb_logger()
12
+
13
+ class PortfolioRunParameters(BaseModel):
14
+ add_portfolio_to_markets_backend: bool = True
15
+ update_tree: bool = True
16
+
17
+ PortfolioNameEnum = Enum(
18
+ "PortfolioNameEnum",
19
+ {name: name for name in PortfolioInterface.list_configurations()},
20
+ type=str, # make each member a `str`, so validation works as before
21
+ )
22
+ class NamedPortfolioConfiguration(BaseModel):
23
+ portfolio_name: PortfolioNameEnum
24
+ portfolio_run_parameters: PortfolioRunParameters
25
+
26
+ @register_app()
27
+ class RunNamedPortfolio(BaseApp):
28
+ configuration_class = NamedPortfolioConfiguration
29
+
30
+ def __init__(self, configuration: NamedPortfolioConfiguration):
31
+ logger.info(f"Run Named Timeseries Configuration {configuration}")
32
+ self.configuration = configuration
33
+
34
+ def run(self) -> None:
35
+ from mainsequence.virtualfundbuilder.portfolio_interface import PortfolioInterface
36
+ portfolio = PortfolioInterface.load_from_configuration(self.configuration.portfolio_name)
37
+ res = portfolio.run(**self.configuration.portfolio_run_parameters.model_dump())
38
+ logger.info(f"Portfolio Run successful with results {res.head()}")
39
+
40
+ if __name__ == "__main__":
41
+ configuration = NamedPortfolioConfiguration(
42
+ portfolio_run_parameters=PortfolioRunParameters(),
43
+ portfolio_name="market_cap"
44
+ )
45
+ RunNamedPortfolio(configuration).run()