luisejdm commited on
Commit
76cd8d1
·
1 Parent(s): 2de1d47
Files changed (4) hide show
  1. parsing.py +25 -0
  2. prompts.py +301 -0
  3. requirements.txt +10 -0
  4. tools.py +739 -0
parsing.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ def parse_response(text: str):
4
+ first_line = next((line.strip() for line in text.strip().splitlines() if line.strip()), "")
5
+
6
+ final_match = re.match(r"FINAL:\s*(.+)", first_line, re.IGNORECASE)
7
+ if final_match:
8
+ return "final", final_match.group(1).strip()
9
+
10
+ action_match = re.match(r"ACTION:\s*(\w+)\(([^)]*)\)", first_line, re.IGNORECASE)
11
+ if action_match:
12
+ tool_name = action_match.group(1).strip()
13
+ raw_args = [a.strip() for a in action_match.group(2).split(",") if a.strip()]
14
+ args = []
15
+ for a in raw_args:
16
+ if "=" in a:
17
+ a = a.split("=", 1)[1].strip()
18
+ a = a.strip("\"'")
19
+ try:
20
+ args.append(float(a))
21
+ except ValueError:
22
+ args.append(a)
23
+ return "action", (tool_name, args)
24
+
25
+ return "unknown", first_line
prompts.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DEFAULT_SYSTEM_PROMPT = """You are a financial data agent. You answer questions about companies and macroeconomic indicators using ONLY the results from your available tools. Never invent, estimate, or recall data from memory.
2
+
3
+ STRICT RULES:
4
+ 1. Output to the user using ONLY the tools provided. Do not attempt to answer questions without them.
5
+ 2. ALWAYS call a tool before giving a FINAL answer. FINAL without a prior tool call is forbidden.
6
+ 3. Choose the most appropriate tool for the user's question using the DATA SOURCE ROUTING rules below.
7
+ 4. Never call the same tool more than once per request.
8
+ 5. Once you receive a tool result, immediately output FINAL using only that result.
9
+
10
+ DATA SOURCE ROUTING — mandatory, no exceptions:
11
+ - CETES, TIE, UDIs, tasa objetivo, inflacion Mexico, or ANY Mexican indicator → use the specific Banxico tool.
12
+ Available Banxico tools: get_cetes_28, get_cetes_91, get_cetes_182, get_cetes_364, get_cetes_728,
13
+ get_tie_28, get_tie_91, get_tie_182, get_target_interest_rate_mexico,
14
+ get_mensual_inflation_mexico, get_inflation_mexico, get_udis.
15
+ - Cross rates(e.g. EUR/USD, GBP/JPY, USD/CAD) → use get_exchange_rate(base, quote, date). (date is optional)
16
+
17
+ RESPONSE FORMAT (choose exactly one per turn):
18
+ ACTION: tool_name(ARGUMENT)
19
+ FINAL: <your answer using only the tool result>
20
+
21
+ === AVAILABLE TOOLS ===
22
+
23
+ ### get_price_on_date(ticker, date)
24
+ Description: Retrieves the closing price of a stock nearest to the given date.
25
+ If no date is provided, it defaults to today and returns the most recent available price.
26
+ Pass a date in YYYY-MM-DD format to get the closing price nearest to that date.
27
+ TICKER CONSTRUCTION — apply exchange suffixes before calling:
28
+ - Mexican BMV-listed stocks → append .MX (e.g. BIMBOA.MX, CUERVO.MX, AMXL.MX)
29
+ - London Stock Exchange → append .L (e.g. SHEL.L)
30
+ - Toronto Stock Exchange → append .TO (e.g. RY.TO)
31
+ - Frankfurt (Xetra) → append .DE (e.g. BMW.DE)
32
+ - US-listed stocks (NYSE, NASDAQ) → no suffix (e.g. AAPL, MSFT, TSLA)
33
+ When a company name is given instead of a ticker, infer the correct ticker AND suffix
34
+ from the company's primary listing exchange before calling the tool.
35
+ Returns (no date): "The last price of <Company Name> (<TICKER>) is $<price> as of <actual_date>."
36
+ Returns (with date): "The price of <Company Name> (<TICKER>) nearest to <date> was $<price> on <actual_date>."
37
+ Example calls: ACTION: get_price_on_date(AAPL)
38
+ ACTION: get_price_on_date(AAPL, 2023-06-15)
39
+ ACTION: get_price_on_date(BIMBOA.MX)
40
+ ACTION: get_price_on_date(CUERVO.MX, 2024-03-01)
41
+
42
+ ### get_company_profile(ticker)
43
+ Description: Retrieves the sector, industry, and a long business description of a company.
44
+ Use this when the user asks what a company does, its sector, industry, or wants a profile/overview.
45
+ Apply the same exchange suffix rules as get_price_on_date.
46
+ Returns: "<Company Name> operates in the <Sector> sector and <Industry> industry. Company profile: <description>"
47
+ Example calls: ACTION: get_company_profile(TSLA)
48
+ ACTION: get_company_profile(BIMBOA.MX)
49
+
50
+ ### min_variance_portfolio(ticker1, ticker2, ...)
51
+ Description: Calculates the minimum variance portfolio weights for a list of stocks based on 2 years
52
+ of historical returns. Use this when the user asks how to allocate investments across
53
+ multiple stocks to minimize risk.
54
+ Pass each ticker as a separate argument — never as a single string.
55
+ Returns: "Optimal weights for minimum variance portfolio:
56
+ {TICKER: weight, ...}
57
+ Expected annual return: <return>
58
+ Annualized volatility: <volatility>"
59
+ Example call: ACTION: min_variance_portfolio(AAPL, MSFT, GOOGL)
60
+
61
+ ### max_sharpe_portfolio(ticker1, ticker2, ...)
62
+ Description: Calculates the maximum Sharpe ratio portfolio weights for a list of stocks based on 2 years
63
+ of historical returns. Use this when the user asks how to allocate investments across
64
+ multiple stocks to maximize risk-adjusted return.
65
+ Pass each ticker as a separate argument — never as a single string.
66
+ Returns: "Optimal weights for maximum Sharpe ratio portfolio:
67
+ {TICKER: weight, ...}
68
+ Expected annual return: <return>
69
+ Annualized volatility: <volatility>"
70
+ Example call: ACTION: max_sharpe_portfolio(AAPL, MSFT, GOOGL)
71
+
72
+ ### min_target_semivariance_portfolio(ticker1, ticker2, ...)
73
+ Description: Calculates the minimum target semivariance portfolio weights for a list of stocks based on 2 years
74
+ of historical returns. Downside risk is measured relative to the S&P 500 as the benchmark.
75
+ Use this when the user asks how to allocate investments to minimize underperformance
76
+ relative to the market.
77
+ Pass each ticker as a separate argument — never as a single string.
78
+ Returns: "Optimal weights for minimum target semivariance portfolio:
79
+ {TICKER: weight, ...}
80
+ Expected annual return: <return>
81
+ Annualized volatility: <volatility>"
82
+ Example call: ACTION: min_target_semivariance_portfolio(AAPL, MSFT, GOOGL)
83
+
84
+ ### get_cetes_28(date)
85
+ Description: Returns the CETES 28-day yield from Banxico.
86
+ If no date is provided, returns the most recent observation.
87
+ Pass a date in YYYY-MM-DD format to get the nearest available observation.
88
+ Returns: "The CETES 28-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
89
+ Example calls: ACTION: get_cetes_28()
90
+ ACTION: get_cetes_28(2024-01-15)
91
+
92
+ ### get_cetes_91(date)
93
+ Description: Returns the CETES 91-day yield from Banxico.
94
+ If no date is provided, returns the most recent observation.
95
+ Returns: "The CETES 91-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
96
+ Example calls: ACTION: get_cetes_91()
97
+ ACTION: get_cetes_91(2024-01-15)
98
+
99
+ ### get_cetes_182(date)
100
+ Description: Returns the CETES 182-day yield from Banxico.
101
+ If no date is provided, returns the most recent observation.
102
+ Returns: "The CETES 182-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
103
+ Example calls: ACTION: get_cetes_182()
104
+ ACTION: get_cetes_182(2024-01-15)
105
+
106
+ ### get_cetes_364(date)
107
+ Description: Returns the CETES 364-day yield from Banxico.
108
+ If no date is provided, returns the most recent observation.
109
+ Returns: "The CETES 364-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
110
+ Example calls: ACTION: get_cetes_364()
111
+ ACTION: get_cetes_364(2024-01-15)
112
+
113
+ ### get_cetes_728(date)
114
+ Description: Returns the CETES 728-day yield from Banxico.
115
+ If no date is provided, returns the most recent observation.
116
+ Returns: "The CETES 728-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
117
+ Example calls: ACTION: get_cetes_728()
118
+ ACTION: get_cetes_728(2024-01-15)
119
+
120
+ ### get_tie_28(date)
121
+ Description: Returns the TIE (Tasa de Interés de Equilibrio) 28-day rate from Banxico.
122
+ If no date is provided, returns the most recent observation.
123
+ Returns: "The TIE 28-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
124
+ Example calls: ACTION: get_tie_28()
125
+ ACTION: get_tie_28(2024-01-15)
126
+
127
+ ### get_tie_91(date)
128
+ Description: Returns the TIE 91-day rate from Banxico.
129
+ If no date is provided, returns the most recent observation.
130
+ Returns: "The TIE 91-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
131
+ Example calls: ACTION: get_tie_91()
132
+ ACTION: get_tie_91(2024-01-15)
133
+
134
+ ### get_tie_182(date)
135
+ Description: Returns the TIE 182-day rate from Banxico.
136
+ If no date is provided, returns the most recent observation.
137
+ Returns: "The TIE 182-day rate (<label>) is <value>% as of <DD/MM/YYYY>."
138
+ Example calls: ACTION: get_tie_182()
139
+ ACTION: get_tie_182(2024-01-15)
140
+
141
+ ### get_target_interest_rate_mexico(date)
142
+ Description: Returns the Banxico target interest rate (tasa objetivo).
143
+ If no date is provided, returns the most recent observation.
144
+ Returns: "The target interest rate in Mexico (<label>) is <value>% as of <DD/MM/YYYY>."
145
+ Example calls: ACTION: get_target_interest_rate_mexico()
146
+ ACTION: get_target_interest_rate_mexico(2024-06-01)
147
+
148
+ ### get_mensual_inflation_mexico(date)
149
+ Description: Returns the monthly inflation rate in Mexico from Banxico.
150
+ If no date is provided, returns the most recent observation.
151
+ Returns: "The monthly inflation rate in Mexico (<label>) is <value>% as of <DD/MM/YYYY>."
152
+ Example calls: ACTION: get_mensual_inflation_mexico()
153
+ ACTION: get_mensual_inflation_mexico(2024-06-01)
154
+
155
+ ### get_inflation_mexico(date)
156
+ Description: Returns the annual inflation rate in Mexico from Banxico.
157
+ If no date is provided, returns the most recent observation.
158
+ Returns: "The annual inflation rate in Mexico (<label>) is <value>% as of <DD/MM/YYYY>."
159
+ Example calls: ACTION: get_inflation_mexico()
160
+ ACTION: get_inflation_mexico(2024-06-01)
161
+
162
+ ### get_udis(date)
163
+ Description: Returns the value of UDIs (Unidades de Inversión) in MXN from Banxico.
164
+ If no date is provided, returns the most recent observation.
165
+ Returns: "The value of UDIs in Mexico (<label>) is <value> MXN as of <DD/MM/YYYY>."
166
+ Example calls: ACTION: get_udis()
167
+ ACTION: get_udis(2024-06-01)
168
+
169
+ ### get_exchange_rate(base, quote, date)
170
+ Description: Returns the market exchange rate between any two currencies using yfinance.
171
+ base and quote must be ISO 4217 codes (e.g. 'EUR', 'USD', 'GBP').
172
+ If no date is provided, returns the most recent available rate.
173
+ Pass a date in YYYY-MM-DD format to get the nearest available rate.
174
+ Returns: "The exchange rate for <BASE>/<QUOTE> is <VALUE> (<label>) as of <YYYY-MM-DD>."
175
+ Example calls: ACTION: get_exchange_rate(EUR, USD)
176
+ ACTION: get_exchange_rate(GBP, JPY, 2024-03-15)
177
+ ACTION: get_exchange_rate(USD, CAD)
178
+
179
+ ### get_news_sentiment(ticker)
180
+ Description: Fetches recent news articles for a stock ticker and returns a FinBERT-based
181
+ sentiment score aggregated across all available headlines.
182
+ Each article is scored (positive / neutral / negative) and weighted by recency
183
+ using exponential decay so the most recent news has the highest influence.
184
+ The composite score ranges from -1 (fully negative) to +1 (fully positive);
185
+ scores above 0.15 are labelled POSITIVE, below -0.15 are NEGATIVE, otherwise NEUTRAL.
186
+ Apply the same exchange suffix rules as get_price_on_date.
187
+ Use this when the user asks about market sentiment, news tone, or recent coverage of a stock.
188
+ Returns: "Sentiment analysis for <Company Name> (<TICKER>) across <N> recent articles:
189
+ Composite score: <score> (<LABEL>).
190
+ Top influencing headlines: [LABEL CONFIDENCE%] <title> (<provider>) --- ..."
191
+ Example calls: ACTION: get_news_sentiment(AAPL)
192
+ ACTION: get_news_sentiment(TSLA)
193
+ ACTION: get_news_sentiment(BIMBOA.MX)
194
+
195
+ ### respond_to_greeting()
196
+ Description: Responds to user greetings with a friendly introduction about the agent.
197
+ Use this when the user greets you or asks a general question like "Hi", "Hello", "What are you?".
198
+ Returns: "Hello! I'm a financial data agent. How can I assist you today?"
199
+ Example call: ACTION: respond_to_greeting()
200
+
201
+ ### respond_no_available_tool(tool_name)
202
+ Description: Responds when a user asks for a tool or action that is not available.
203
+ Use this when the user requests functionality that doesn't match any available tool.
204
+ Returns: "Sorry, currently i'm capable of doing that. Check the list of available tools with 'list_tools' command."
205
+ Example call: ACTION: respond_no_available_tool()
206
+
207
+ === END OF TOOLS ===
208
+
209
+ EXAMPLES:
210
+
211
+ User: What is the price of Microsoft?
212
+ ACTION: get_price_on_date(MSFT)
213
+ After tool result: FINAL: The last price of Microsoft Corporation (MSFT) is $415.20 as of 2026-04-29.
214
+
215
+ User: What was Apple's price on March 10 2023?
216
+ ACTION: get_price_on_date(AAPL, 2023-03-10)
217
+ After tool result: FINAL: Apple's closing price nearest to March 10, 2023 was $150.02 on 2023-03-10.
218
+
219
+ User: What is the price of Bimbo?
220
+ ACTION: get_price_on_date(BIMBOA.MX)
221
+ After tool result: FINAL: The last price of Grupo Bimbo S.A.B. de C.V. (BIMBOA.MX) is $X.XX as of 2026-04-29.
222
+
223
+ User: What was the price of Jose Cuervo on March 1 2024?
224
+ ACTION: get_price_on_date(CUERVO.MX, 2024-03-01)
225
+ After tool result: FINAL: The price of Jose Cuervo Internacional S.A.B. de C.V. (CUERVO.MX) nearest to March 1, 2024 was $X.XX on 2024-03-01.
226
+
227
+ User: What does Grupo México do?
228
+ ACTION: get_company_profile(GMEXICOB.MX)
229
+ After tool result: FINAL: Grupo México operates in the Basic Materials sector and Copper industry. The company is one of the largest mining groups in Latin America, focused on copper, silver, and zinc extraction.
230
+
231
+
232
+ ACTION: get_company_profile(NVDA)
233
+ After tool result: FINAL: Nvidia operates in the Technology sector and Semiconductors industry. The company designs GPUs and accelerated computing platforms for gaming, data centers, and artificial intelligence.
234
+
235
+ User: How should I allocate $100k between Apple, Nvidia, and Intel to minimize risk?
236
+ ACTION: min_variance_portfolio(AAPL, NVDA, INTC)
237
+ After tool result: FINAL: To minimize risk, the optimal weights across Apple, Nvidia, and Intel are AAPL: 45%, NVDA: 35%, INTC: 20%, with an expected annual return of 18.40% and annualized volatility of 22.10%.
238
+
239
+ User: How should I allocate $100k between Apple, Nvidia, and Intel to maximize risk-adjusted return?
240
+ ACTION: max_sharpe_portfolio(AAPL, NVDA, INTC)
241
+ After tool result: FINAL: To maximize risk-adjusted return, the optimal weights across Apple, Nvidia, and Intel are AAPL: 30%, NVDA: 50%, INTC: 20%, with an expected annual return of 20.20% and annualized volatility of 23.50%.
242
+
243
+ User: How should I allocate $100k between Apple, Nvidia, and Intel to minimize downside risk below the S&P 500?
244
+ ACTION: min_target_semivariance_portfolio(AAPL, NVDA, INTC)
245
+ After tool result: FINAL: To minimize downside risk relative to the S&P 500, the optimal weights across Apple, Nvidia, and Intel are AAPL: 40%, NVDA: 30%, INTC: 30%, with an expected annual return of 19.00% and annualized volatility of 15.80%.
246
+
247
+ User: What is the sentiment around Tesla news?
248
+ ACTION: get_news_sentiment(TSLA)
249
+ After tool result: FINAL: Sentiment analysis for Tesla, Inc. (TSLA) across 8 recent articles: Composite score: +0.3142 (POSITIVE). Top influencing headlines: [POSITIVE 92%] Tesla beats Q1 delivery estimates (Reuters) --- [NEGATIVE 85%] Tesla faces renewed pressure on margins (Bloomberg) --- ...
250
+
251
+ User: What is the market sentiment for Apple stock right now?
252
+ ACTION: get_news_sentiment(AAPL)
253
+ After tool result: FINAL: Sentiment analysis for Apple Inc. (AAPL) across 10 recent articles: Composite score: +0.1823 (POSITIVE). Top influencing headlines: [POSITIVE 88%] Apple reports record services revenue (CNBC) --- ...
254
+
255
+ User: What is the most recent CETES 28-day rate?
256
+ ACTION: get_cetes_28()
257
+ After tool result: FINAL: The CETES 28-day rate (most recent) is 8.9900% as of 27/03/2025.
258
+
259
+ User: What was the CETES 91-day rate in January 2024?
260
+ ACTION: get_cetes_91(2024-01-15)
261
+ After tool result: FINAL: The CETES 91-day rate nearest to January 15, 2024 was 11.3100% as of 11/01/2024.
262
+
263
+ User: What is the current Banxico target rate?
264
+ ACTION: get_target_interest_rate_mexico()
265
+ After tool result: FINAL: The Banxico target interest rate (most recent) is 9.0000% as of 20/03/2025.
266
+
267
+ User: What is the annual inflation rate in Mexico?
268
+ ACTION: get_inflation_mexico()
269
+ After tool result: FINAL: The annual inflation rate in Mexico (most recent) is 3.8000% as of 28/02/2025.
270
+
271
+ User: What was the monthly inflation in Mexico in mid-2023?
272
+ ACTION: get_mensual_inflation_mexico(2023-06-15)
273
+ After tool result: FINAL: The monthly inflation rate in Mexico nearest to June 15, 2023 was 0.2200% as of 15/06/2023.
274
+
275
+ User: What is the TIE 182-day rate?
276
+ ACTION: get_tie_182()
277
+ After tool result: FINAL: The TIE 182-day rate (most recent) is 9.4500% as of 15/04/2025.
278
+
279
+ User: What is the current UDI value?
280
+ ACTION: get_udis()
281
+ After tool result: FINAL: The value of UDIs in Mexico (most recent) is 8.2341 MXN as of 30/04/2025.
282
+
283
+ User: Hi there!
284
+ ACTION: respond_to_greeting()
285
+ After tool result: FINAL: Hello! I'm a financial data agent. How can I assist you today?
286
+
287
+ User: Can you tell me how many calories are in an apple?
288
+ ACTION: respond_no_available_tool()
289
+ After tool result: FINAL: Sorry, currently i'm capable of doing that. Check the list of avaiable tools for more information.
290
+
291
+ INCORRECT (never do this):
292
+ ACTION: get_price_on_date(BIMBOA) <- missing .MX suffix for BMV-listed stock
293
+ ACTION: get_price_on_date(CUERVO) <- missing .MX suffix; correct is CUERVO.MX
294
+ ACTION: get_last_price(AAPL) <- wrong tool name; the correct name is get_price_on_date
295
+ ACTION: get_price_on_date("Apple") <- use ticker symbol, not a company name string
296
+ ACTION: get_price_on_date(AAPL, MSFT) <- second argument must be a date, not another ticker
297
+ ACTION: get_price_on_date(AAPL, March 2023) <- date must be in YYYY-MM-DD format
298
+ ACTION: min_variance_portfolio("AAPL, MSFT, GOOGL") <- never pack tickers into one string argument
299
+ FINAL: Apple's price is around $210 <- invented value, no tool was called
300
+ FINAL: The CETES rate is roughly 9% <- recalled from memory, no tool was called
301
+ """
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.13.0
2
+ openai==2.33.0
3
+ yfinance==1.3.0
4
+ numpy==2.4.4
5
+ pandas==3.0.2
6
+ scipy==1.17.1
7
+ requests==2.33.1
8
+ transformers>=4.40.0
9
+ torch>=2.2.0
10
+ huggingface_hub>=0.20.0
tools.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import Callable
3
+
4
+ import numpy as np
5
+ from scipy.optimize import minimize
6
+ import yfinance as yf
7
+ import requests
8
+ import datetime
9
+ import pandas as pd
10
+
11
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+ import os
16
+
17
+ BANXICO_TOKEN = os.getenv("BANXICO_TOKEN")
18
+ HF_LOGIN_KEY = os.getenv("HF_LOGIN_KEY")
19
+ if HF_LOGIN_KEY:
20
+ from huggingface_hub import login
21
+ login(HF_LOGIN_KEY)
22
+
23
+ ToolFunction = Callable[..., object]
24
+
25
+ _DEFAULT_TOOL_FUNCTIONS: dict[str, ToolFunction] = {}
26
+
27
+
28
+ def tool(name: str | None = None):
29
+ """Decorator that registers a function in the default tool registry."""
30
+ def decorator(function: ToolFunction) -> ToolFunction:
31
+ _DEFAULT_TOOL_FUNCTIONS[name or function.__name__] = function
32
+ return function
33
+ return decorator
34
+
35
+
36
+ @dataclass
37
+ class ToolRegistry:
38
+ tools: dict[str, ToolFunction] = field(default_factory=dict)
39
+
40
+ def register(self, name: str, function: ToolFunction) -> None:
41
+ self.tools[name] = function
42
+
43
+ def execute(self, name: str, *args) -> object:
44
+ if name not in self.tools:
45
+ raise KeyError(f"tool '{name}' does not exist")
46
+ return self.tools[name](*args)
47
+
48
+ def names(self) -> list[str]:
49
+ return list(self.tools.keys())
50
+
51
+
52
+ def build_default_tool_registry() -> ToolRegistry:
53
+ registry = ToolRegistry()
54
+ for name, function in _DEFAULT_TOOL_FUNCTIONS.items():
55
+ registry.register(name, function)
56
+ return registry
57
+
58
+
59
+ @tool("get_price_on_date")
60
+ def get_price_on_date(ticker, date=None):
61
+ t = yf.Ticker(ticker)
62
+
63
+ use_default_date = date is None
64
+
65
+ if date is None:
66
+ date = datetime.date.today()
67
+ else:
68
+ date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
69
+
70
+ data = pd.DataFrame(t.history(start=date - datetime.timedelta(days=5), end=date + datetime.timedelta(days=5))['Close'])
71
+ if data.empty:
72
+ return f"No price data available for {t.ticker} around {date}."
73
+
74
+ data['Date'] = data.index.date
75
+ data['DateDiff'] = np.abs(data['Date'] - date)
76
+ nearest_row = data.loc[data['DateDiff'].idxmin()]
77
+ price = nearest_row['Close']
78
+ actual_date = nearest_row['Date']
79
+ official_name = t.info['longName']
80
+
81
+ if use_default_date:
82
+ return f"The last price of {official_name} ({t.ticker}) is ${price:.2f} as of {actual_date}."
83
+ else:
84
+ return f"The price of {official_name} ({t.ticker}) nearest to {date} was ${price:.2f} on {actual_date}."
85
+
86
+ @tool("get_company_profile")
87
+ def get_company_profile_tool(ticker: str) -> str:
88
+ t = yf.Ticker(ticker)
89
+ info = t.info
90
+ official_name = info['longName']
91
+ sector = info.get('sector', 'N/A')
92
+ industry = info.get('industry', 'N/A')
93
+ description = info.get('longBusinessSummary', 'No description available.')
94
+ return (
95
+ f"{official_name} operates in the {sector} sector and {industry} industry. "
96
+ f"Company profile: {description}"
97
+ )
98
+
99
+ @tool("min_variance_portfolio")
100
+ def min_variance_portfolio(*tickers: str) -> str:
101
+ ticker_list = list(tickers)
102
+ data = yf.download(ticker_list, period="2y", progress=False)['Close'][ticker_list]
103
+ returns = data.pct_change().dropna()
104
+ cov_matrix = returns.cov()
105
+ mean_rt = returns.mean()
106
+
107
+ variance = lambda w: w.T @ cov_matrix @ w
108
+ x0 = np.ones(len(ticker_list)) / len(ticker_list)
109
+ bounds = [(0, 3)] * len(ticker_list)
110
+ constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
111
+ result = minimize(variance, x0, bounds=bounds, constraints=constraints, tol=1e-16, method='SLSQP')
112
+ return (
113
+ f"Optimal weights for minimum variance portfolio:\n"
114
+ f"{ {ticker_list[i]: round(w, 4) for i, w in enumerate(result.x)} }\n"
115
+ f"Expected annual return: {(mean_rt @ result.x * 252):.2%}\n"
116
+ f"Annualized volatility: {(np.sqrt(result.x.T @ cov_matrix @ result.x) * np.sqrt(252)):.2%}"
117
+ )
118
+
119
+ @tool("max_sharpe_portfolio")
120
+ def max_sharpe_portfolio(*tickers: str) -> str:
121
+ ticker_list = list(tickers)
122
+ data = yf.download(ticker_list, period="2y", progress=False)['Close'][ticker_list]
123
+ returns = data.pct_change().dropna()
124
+ cov_matrix = returns.cov()
125
+ mean_rt = returns.mean()
126
+
127
+ sharpe = lambda w: -(mean_rt @ w) / np.sqrt(w.T @ cov_matrix @ w)
128
+ x0 = np.ones(len(ticker_list)) / len(ticker_list)
129
+ bounds = [(0, 3)] * len(ticker_list)
130
+ constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
131
+ result = minimize(sharpe, x0, bounds=bounds, constraints=constraints, tol=1e-16, method='SLSQP')
132
+ return (
133
+ f"Optimal weights for maximum Sharpe ratio portfolio:\n"
134
+ f"{ {ticker_list[i]: round(w, 4) for i, w in enumerate(result.x)} }\n"
135
+ f"Expected annual return: {(mean_rt @ result.x * 252):.2%}\n"
136
+ f"Annualized volatility: {(np.sqrt(result.x.T @ cov_matrix @ result.x) * np.sqrt(252)):.2%}"
137
+ )
138
+
139
+ @tool("min_target_semivariance_portfolio")
140
+ def min_target_semivariance_portfolio(*tickers: str) -> str:
141
+ ticker_list = list(tickers)
142
+ data = yf.download(ticker_list, period="2y", progress=False)['Close'][ticker_list]
143
+ returns = data.pct_change().dropna()
144
+ corr = returns.corr()
145
+ cov_matrix = returns.cov()
146
+ benchmark = yf.download("^GSPC", period="2y", progress=False)['Close'].pct_change().dropna()
147
+ differences = returns - benchmark.values
148
+ below_zero_target = differences[differences < 0].fillna(0)
149
+ target_downside = np.array(below_zero_target.std())
150
+ target_semivariance = np.multiply(target_downside.reshape(len(target_downside), 1), target_downside) * corr
151
+
152
+ semivariance = lambda w: w.T @ target_semivariance @ w
153
+ x0 = np.ones(len(ticker_list)) / len(ticker_list)
154
+ bounds = [(0, 3)] * len(ticker_list)
155
+ constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
156
+ result = minimize(semivariance, x0, bounds=bounds, constraints=constraints, tol=1e-16, method='SLSQP')
157
+ return (
158
+ f"Optimal weights for minimum target semivariance portfolio:\n"
159
+ f"{ {ticker_list[i]: round(w, 4) for i, w in enumerate(result.x)} }\n"
160
+ f"Expected annual return: {(returns.mean() @ result.x * 252):.2%}\n"
161
+ f"Annualized volatility: {(np.sqrt(result.x.T @ cov_matrix @ result.x) * np.sqrt(252)):.2%}"
162
+ )
163
+
164
+ @tool("get_cetes_28")
165
+ def get_cetes_28(date: str | None = None) -> str:
166
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43936/datos"
167
+ headers = {
168
+ "Bmx-Token": BANXICO_TOKEN,
169
+ "Content-Type": "application/json",
170
+ }
171
+ try:
172
+ response = requests.get(URL, headers=headers)
173
+ response.raise_for_status()
174
+
175
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
176
+
177
+ if date is None:
178
+ obs = obs_list[-1]
179
+ else:
180
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
181
+ obs = min(
182
+ obs_list,
183
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
184
+ )
185
+
186
+ fecha = obs["fecha"]
187
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
188
+ value = float(obs["dato"])
189
+ label = f"nearest to {date}" if date else "most recent"
190
+ return f"The CETES 28-day rate ({label}) is {value:.4f}% as of {fecha}."
191
+
192
+ except ValueError:
193
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
194
+ except Exception as exc:
195
+ return f"Error fetching CETES 28-day rate: {exc}"
196
+
197
+ @tool("get_cetes_91")
198
+ def get_cetes_91(date: str | None = None) -> str:
199
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43939/datos"
200
+ headers = {
201
+ "Bmx-Token": BANXICO_TOKEN,
202
+ "Content-Type": "application/json",
203
+ }
204
+ try:
205
+ response = requests.get(URL, headers=headers)
206
+ response.raise_for_status()
207
+
208
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
209
+
210
+ if date is None:
211
+ obs = obs_list[-1]
212
+ else:
213
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
214
+ obs = min(
215
+ obs_list,
216
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
217
+ )
218
+
219
+ fecha = obs["fecha"]
220
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
221
+ value = float(obs["dato"])
222
+ label = f"nearest to {date}" if date else "most recent"
223
+ return f"The CETES 91-day rate ({label}) is {value:.4f}% as of {fecha}."
224
+
225
+ except ValueError:
226
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
227
+ except Exception as exc:
228
+ return f"Error fetching CETES 91-day rate: {exc}"
229
+
230
+ @tool("get_cetes_182")
231
+ def get_cetes_182(date: str | None = None) -> str:
232
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43942/datos"
233
+ headers = {
234
+ "Bmx-Token": BANXICO_TOKEN,
235
+ "Content-Type": "application/json",
236
+ }
237
+ try:
238
+ response = requests.get(URL, headers=headers)
239
+ response.raise_for_status()
240
+
241
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
242
+
243
+ if date is None:
244
+ obs = obs_list[-1]
245
+ else:
246
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
247
+ obs = min(
248
+ obs_list,
249
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
250
+ )
251
+
252
+ fecha = obs["fecha"]
253
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
254
+ value = float(obs["dato"])
255
+ label = f"nearest to {date}" if date else "most recent"
256
+ return f"The CETES 182-day rate ({label}) is {value:.4f}% as of {fecha}."
257
+
258
+ except ValueError:
259
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
260
+ except Exception as exc:
261
+ return f"Error fetching CETES 182-day rate: {exc}"
262
+
263
+ @tool("get_cetes_364")
264
+ def get_cetes_364(date: str | None = None) -> str:
265
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43945/datos"
266
+ headers = {
267
+ "Bmx-Token": BANXICO_TOKEN,
268
+ "Content-Type": "application/json",
269
+ }
270
+ try:
271
+ response = requests.get(URL, headers=headers)
272
+ response.raise_for_status()
273
+
274
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
275
+
276
+ if date is None:
277
+ obs = obs_list[-1]
278
+ else:
279
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
280
+ obs = min(
281
+ obs_list,
282
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
283
+ )
284
+
285
+ fecha = obs["fecha"]
286
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
287
+ value = float(obs["dato"])
288
+ label = f"nearest to {date}" if date else "most recent"
289
+ return f"The CETES 364-day rate ({label}) is {value:.4f}% as of {fecha}."
290
+
291
+ except ValueError:
292
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
293
+ except Exception as exc:
294
+ return f"Error fetching CETES 364-day rate: {exc}"
295
+
296
+ @tool("get_cetes_728")
297
+ def get_cetes_728(date: str | None = None) -> str:
298
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF349785/datos"
299
+ headers = {
300
+ "Bmx-Token": BANXICO_TOKEN,
301
+ "Content-Type": "application/json",
302
+ }
303
+ try:
304
+ response = requests.get(URL, headers=headers)
305
+ response.raise_for_status()
306
+
307
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
308
+
309
+ if date is None:
310
+ obs = obs_list[-1]
311
+ else:
312
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
313
+ obs = min(
314
+ obs_list,
315
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
316
+ )
317
+
318
+ fecha = obs["fecha"]
319
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
320
+ value = float(obs["dato"])
321
+ label = f"nearest to {date}" if date else "most recent"
322
+ return f"The CETES 728-day rate ({label}) is {value:.4f}% as of {fecha}."
323
+
324
+ except ValueError:
325
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
326
+ except Exception as exc:
327
+ return f"Error fetching CETES 728-day rate: {exc}"
328
+
329
+ @tool("get_mensual_inflation_mexico")
330
+ def get_mensual_inflation_mexico(date: str | None = None) -> str:
331
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SP30578/datos"
332
+ headers = {
333
+ "Bmx-Token": BANXICO_TOKEN,
334
+ "Content-Type": "application/json",
335
+ }
336
+ try:
337
+ response = requests.get(URL, headers=headers)
338
+ response.raise_for_status()
339
+
340
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
341
+
342
+ if date is None:
343
+ obs = obs_list[-1]
344
+ else:
345
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
346
+ obs = min(
347
+ obs_list,
348
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
349
+ )
350
+
351
+ fecha = obs["fecha"]
352
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
353
+ value = float(obs["dato"])
354
+ label = f"nearest to {date}" if date else "most recent"
355
+ return f"The monthly inflation rate in Mexico ({label}) is {value:.4f}% as of {fecha}."
356
+
357
+ except ValueError:
358
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
359
+ except Exception as exc:
360
+ return f"Error fetching monthly inflation rate in Mexico: {exc}"
361
+
362
+ @tool("get_inflation_mexico")
363
+ def get_inflation_mexico(date: str | None = None) -> str:
364
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SP30577/datos"
365
+ headers = {
366
+ "Bmx-Token": BANXICO_TOKEN,
367
+ "Content-Type": "application/json",
368
+ }
369
+ try:
370
+ response = requests.get(URL, headers=headers)
371
+ response.raise_for_status()
372
+
373
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
374
+
375
+ if date is None:
376
+ obs = obs_list[-1]
377
+ else:
378
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
379
+ obs = min(
380
+ obs_list,
381
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
382
+ )
383
+
384
+ fecha = obs["fecha"]
385
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
386
+ value = float(obs["dato"])
387
+ label = f"nearest to {date}" if date else "most recent"
388
+ return f"The annual inflation rate in Mexico ({label}) is {value:.4f}% as of {fecha}."
389
+
390
+ except ValueError:
391
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
392
+ except Exception as exc:
393
+ return f"Error fetching annual inflation rate in Mexico: {exc}"
394
+
395
+ @tool("get_udis")
396
+ def get_udis(date: str | None = None) -> str:
397
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SP68257/datos"
398
+ headers = {
399
+ "Bmx-Token": BANXICO_TOKEN,
400
+ "Content-Type": "application/json",
401
+ }
402
+ try:
403
+ response = requests.get(URL, headers=headers)
404
+ response.raise_for_status()
405
+
406
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
407
+
408
+ if date is None:
409
+ obs = obs_list[-1]
410
+ else:
411
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
412
+ obs = min(
413
+ obs_list,
414
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
415
+ )
416
+
417
+ fecha = obs["fecha"]
418
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
419
+ value = float(obs["dato"])
420
+ label = f"nearest to {date}" if date else "most recent"
421
+ return f"The value of UDIs in Mexico ({label}) is {value:.4f} MXN as of {fecha}."
422
+
423
+ except ValueError:
424
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
425
+ except Exception as exc:
426
+ return f"Error fetching UDIs value in Mexico: {exc}"
427
+
428
+ @tool("get_tie_28")
429
+ def get_tie_28(date: str | None = None) -> str:
430
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43783/datos"
431
+ headers = {
432
+ "Bmx-Token": BANXICO_TOKEN,
433
+ "Content-Type": "application/json",
434
+ }
435
+ try:
436
+ response = requests.get(URL, headers=headers)
437
+ response.raise_for_status()
438
+
439
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
440
+
441
+ if date is None:
442
+ obs = obs_list[-1]
443
+ else:
444
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
445
+ obs = min(
446
+ obs_list,
447
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
448
+ )
449
+
450
+ fecha = obs["fecha"]
451
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
452
+ value = float(obs["dato"])
453
+ label = f"nearest to {date}" if date else "most recent"
454
+ return f"The TIE 28-day rate ({label}) is {value:.4f}% as of {fecha}."
455
+
456
+ except ValueError:
457
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
458
+ except Exception as exc:
459
+ return f"Error fetching TIE 28-day rate: {exc}"
460
+
461
+ @tool("get_tie_91")
462
+ def get_tie_91(date: str | None = None) -> str:
463
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF43878/datos"
464
+ headers = {
465
+ "Bmx-Token": BANXICO_TOKEN,
466
+ "Content-Type": "application/json",
467
+ }
468
+ try:
469
+ response = requests.get(URL, headers=headers)
470
+ response.raise_for_status()
471
+
472
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
473
+
474
+ if date is None:
475
+ obs = obs_list[-1]
476
+ else:
477
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
478
+ obs = min(
479
+ obs_list,
480
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
481
+ )
482
+
483
+ fecha = obs["fecha"]
484
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
485
+ value = float(obs["dato"])
486
+ label = f"nearest to {date}" if date else "most recent"
487
+ return f"The TIE 91-day rate ({label}) is {value:.4f}% as of {fecha}."
488
+
489
+ except ValueError:
490
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
491
+ except Exception as exc:
492
+ return f"Error fetching TIE 91-day rate: {exc}"
493
+
494
+ @tool("get_tie_182")
495
+ def get_tie_182(date: str | None = None) -> str:
496
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF111916/datos"
497
+ headers = {
498
+ "Bmx-Token": BANXICO_TOKEN,
499
+ "Content-Type": "application/json",
500
+ }
501
+ try:
502
+ response = requests.get(URL, headers=headers)
503
+ response.raise_for_status()
504
+
505
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
506
+
507
+ if date is None:
508
+ obs = obs_list[-1]
509
+ else:
510
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
511
+ obs = min(
512
+ obs_list,
513
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
514
+ )
515
+
516
+ fecha = obs["fecha"]
517
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
518
+ value = float(obs["dato"])
519
+ label = f"nearest to {date}" if date else "most recent"
520
+ return f"The TIE 182-day rate ({label}) is {value:.4f}% as of {fecha}."
521
+
522
+ except ValueError:
523
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
524
+ except Exception as exc:
525
+ return f"Error fetching TIE 182-day rate: {exc}"
526
+
527
+ @tool("get_target_interest_rate_mexico")
528
+ def get_target_interest_rate_mexico(date: str | None = None) -> str:
529
+ URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF61745/datos"
530
+ headers = {
531
+ "Bmx-Token": BANXICO_TOKEN,
532
+ "Content-Type": "application/json",
533
+ }
534
+ try:
535
+ response = requests.get(URL, headers=headers)
536
+ response.raise_for_status()
537
+
538
+ obs_list = response.json()["bmx"]["series"][0]["datos"]
539
+
540
+ if date is None:
541
+ obs = obs_list[-1]
542
+ else:
543
+ target = datetime.datetime.strptime(date, "%Y-%m-%d")
544
+ obs = min(
545
+ obs_list,
546
+ key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
547
+ )
548
+
549
+ fecha = obs["fecha"]
550
+ fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
551
+ value = float(obs["dato"])
552
+ label = f"nearest to {date}" if date else "most recent"
553
+ return f"The target interest rate in Mexico ({label}) is {value:.4f}% as of {fecha}."
554
+
555
+ except ValueError:
556
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
557
+ except Exception as exc:
558
+ return f"Error fetching target interest rate in Mexico: {exc}"
559
+
560
+ @tool("get_exchange_rate")
561
+ def get_exchange_rate(base: str, quote: str, date: str | None = None) -> str:
562
+ base = base.strip().upper()
563
+ quote = quote.strip().upper()
564
+ ticker_symbol = f"{base}{quote}=X"
565
+
566
+ try:
567
+ if date is None:
568
+ target_date = datetime.date.today()
569
+ else:
570
+ target_date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
571
+
572
+ t = yf.Ticker(ticker_symbol)
573
+ data = t.history(
574
+ start=target_date - datetime.timedelta(days=7),
575
+ end=target_date + datetime.timedelta(days=7),
576
+ )
577
+
578
+ if data.empty:
579
+ return (
580
+ f"No exchange rate data found for {base}/{quote} ({ticker_symbol}). "
581
+ f"Verify that both currency codes are valid ISO 4217 codes."
582
+ )
583
+
584
+ data["Date"] = data.index.date
585
+ data["DateDiff"] = data["Date"].apply(lambda d: abs((d - target_date).days))
586
+ nearest = data.loc[data["DateDiff"].idxmin()]
587
+ rate = nearest["Close"]
588
+ actual_date = nearest["Date"]
589
+ date_label = f"nearest to {date}" if date else "most recent"
590
+ return f"The exchange rate for {base}/{quote} ({date_label}) is {rate:.6f} as of {actual_date}."
591
+
592
+ except ValueError:
593
+ return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
594
+ except Exception as exc:
595
+ return f"Error fetching exchange rate for {base}/{quote}: {exc}"
596
+
597
+ def _get_news(ticker: str) -> list[dict]:
598
+ t = yf.Ticker(ticker)
599
+ news = t.news
600
+ formated_news = []
601
+
602
+ for i in range(len(news)):
603
+ item = news[i]['content']
604
+ formated_news.append({
605
+ "pub_date": item.get("pubDate", ""),
606
+ "content_type": item.get("contentType", ""),
607
+ "title": item.get("title", ""),
608
+ "summary": item.get("summary", ""),
609
+ "provider": item.get("provider", {}).get("displayName", "N/A"),
610
+ })
611
+ return formated_news
612
+
613
+ _finbert_tokenizer = None
614
+ _finbert_model = None
615
+
616
+ def _load_finbert():
617
+ global _finbert_tokenizer, _finbert_model
618
+ if _finbert_model is None:
619
+ _finbert_tokenizer = AutoTokenizer.from_pretrained("ProsusAI/finbert")
620
+ _finbert_model = AutoModelForSequenceClassification.from_pretrained("ProsusAI/finbert")
621
+ _finbert_model.eval()
622
+ return _finbert_tokenizer, _finbert_model
623
+
624
+ _LABEL_TO_SCORE = {
625
+ "positive": 1,
626
+ "neutral": 0,
627
+ "negative": -1
628
+ }
629
+
630
+ def _bucket_label(score: float) -> str:
631
+ if score > 0.15:
632
+ return "positive"
633
+ if score < -0.15:
634
+ return "negative"
635
+ return "neutral"
636
+
637
+ def _recency_weights(pub_dates: list[str]) -> list[float]:
638
+ decay = 0.01
639
+ parsed = []
640
+ for d in pub_dates:
641
+ try:
642
+ dt = datetime.datetime.fromisoformat(d.replace("Z", "+00:00"))
643
+ parsed.append(dt)
644
+ except (ValueError, AttributeError):
645
+ parsed.append(None)
646
+
647
+ valid = [dt for dt in parsed if dt is not None]
648
+ if not valid:
649
+ return [1.0] * len(pub_dates)
650
+
651
+ most_recent = max(valid)
652
+ weights = []
653
+ for dt in parsed:
654
+ if dt is None:
655
+ weights.append(0.5)
656
+ else:
657
+ hours_old = (most_recent - dt).total_seconds() / 3600
658
+ weights.append(float(np.exp(-decay * hours_old)))
659
+ return weights
660
+
661
+ def _score_texts(texts: list[str]) -> list[dict]:
662
+ tokenizer, model = _load_finbert()
663
+ results = []
664
+ with torch.no_grad():
665
+ for text in texts:
666
+ inputs = tokenizer(
667
+ text,
668
+ return_tensors="pt",
669
+ truncation=True,
670
+ max_length=512,
671
+ padding=True,
672
+ )
673
+ logits = model(**inputs).logits
674
+ probs = F.softmax(logits, dim=-1).squeeze()
675
+ # FinBERT label order: positive=0, negative=1, neutral=2
676
+ label_map = {0: "positive", 1: "negative", 2: "neutral"}
677
+ pred_idx = int(probs.argmax())
678
+ results.append({
679
+ "label": label_map[pred_idx],
680
+ "confidence": float(probs[pred_idx]),
681
+ })
682
+ return results
683
+
684
+ @tool("get_news_sentiment")
685
+ def get_news_sentiment(ticker: str) -> str:
686
+ articles = _get_news(ticker)
687
+ comp_name = yf.Ticker(ticker).info.get("longName", ticker)
688
+ if not articles:
689
+ return f"No recent news found for {ticker}."
690
+
691
+ texts = [f"{a['title']}. {a['summary']}".strip() for a in articles]
692
+ scores = _score_texts(texts)
693
+ weights = _recency_weights([a["pub_date"] for a in articles])
694
+
695
+ weighted_sum = 0.0
696
+ total_weight = 0.0
697
+ scored_articles = []
698
+
699
+ for article, score, weight in zip(articles, scores, weights):
700
+ numeric = _LABEL_TO_SCORE[score["label"]]
701
+ contribution = numeric * score["confidence"] * weight
702
+ weighted_sum += contribution
703
+ total_weight += weight
704
+ scored_articles.append({
705
+ "title": article["title"],
706
+ "provider": article["provider"],
707
+ "label": score["label"],
708
+ "confidence": score["confidence"],
709
+ "weight": round(weight, 4),
710
+ "pub_date": article["pub_date"],
711
+ })
712
+
713
+ composite = weighted_sum / total_weight if total_weight > 0 else 0.0
714
+ composite = max(-1.0, min(1.0, composite))
715
+ label = _bucket_label(composite)
716
+ label = label.upper()
717
+
718
+ scored_articles.sort(
719
+ key=lambda x: abs(_LABEL_TO_SCORE[x["label"]] * x["confidence"] * x["weight"]),
720
+ reverse=True,
721
+ )
722
+ top_headlines = " --- ".join(
723
+ f"[{a['label'].upper()} {a['confidence']:.0%}] {a['title']} ({a['provider']})"
724
+ for a in scored_articles[:5]
725
+ )
726
+
727
+ return (
728
+ f"Sentiment analysis for {comp_name} ({ticker.upper()}) across {len(articles)} recent articles: "
729
+ f"Composite score: {composite:+.4f} ({label}). "
730
+ f"Top influencing headlines: {top_headlines}"
731
+ )
732
+
733
+ @tool("respond_to_greeting")
734
+ def respond_to_greeting() -> str:
735
+ return "Hello! I'm a financial data agent. How can I assist you today?"
736
+
737
+ @tool("respond_no_available_tool")
738
+ def respond_no_available_tool(tool_name: str) -> str:
739
+ return f"Sorry, currently i'm capable of doing that. Check the list of avaiable tools for more information."