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,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('<', '<').replace('>', '>')
|
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()
|