Financial_Agent / tools.py
luisejdm's picture
fix inflation series
9c36bbe
from dataclasses import dataclass, field
from typing import Callable
import numpy as np
from scipy.optimize import minimize
import yfinance as yf
import requests
import datetime
import pandas as pd
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
import torch.nn.functional as F
import os
from dotenv import load_dotenv
load_dotenv()
BANXICO_TOKEN = os.getenv("BANXICO_TOKEN")
HF_LOGIN_KEY = os.getenv("HF_LOGIN_KEY")
if HF_LOGIN_KEY:
from huggingface_hub import login
login(HF_LOGIN_KEY)
ToolFunction = Callable[..., object]
_DEFAULT_TOOL_FUNCTIONS: dict[str, ToolFunction] = {}
def tool(name: str | None = None):
"""Decorator that registers a function in the default tool registry."""
def decorator(function: ToolFunction) -> ToolFunction:
_DEFAULT_TOOL_FUNCTIONS[name or function.__name__] = function
return function
return decorator
@dataclass
class ToolRegistry:
tools: dict[str, ToolFunction] = field(default_factory=dict)
def register(self, name: str, function: ToolFunction) -> None:
self.tools[name] = function
def execute(self, name: str, *args) -> object:
if name not in self.tools:
raise KeyError(f"tool '{name}' does not exist")
return self.tools[name](*args)
def names(self) -> list[str]:
return list(self.tools.keys())
def build_default_tool_registry() -> ToolRegistry:
registry = ToolRegistry()
for name, function in _DEFAULT_TOOL_FUNCTIONS.items():
registry.register(name, function)
return registry
@tool("get_price_on_date")
def get_price_on_date(ticker, date=None):
t = yf.Ticker(ticker)
use_default_date = date is None
if date is None:
date = datetime.date.today()
else:
date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
data = pd.DataFrame(t.history(start=date - datetime.timedelta(days=5), end=date + datetime.timedelta(days=5))['Close'])
if data.empty:
return f"No price data available for {t.ticker} around {date}."
data['Date'] = data.index.date
data['DateDiff'] = np.abs(data['Date'] - date)
nearest_row = data.loc[data['DateDiff'].idxmin()]
price = nearest_row['Close']
actual_date = nearest_row['Date']
official_name = t.info['longName']
if use_default_date:
return f"The last price of {official_name} ({t.ticker}) is ${price:.2f} as of {actual_date}."
else:
return f"The price of {official_name} ({t.ticker}) nearest to {date} was ${price:.2f} on {actual_date}."
@tool("get_company_profile")
def get_company_profile_tool(ticker: str) -> str:
t = yf.Ticker(ticker)
info = t.info
official_name = info['longName']
sector = info.get('sector', 'N/A')
industry = info.get('industry', 'N/A')
description = info.get('longBusinessSummary', 'No description available.')
return (
f"{official_name} operates in the {sector} sector and {industry} industry. "
f"Company profile: {description}"
)
@tool("min_variance_portfolio")
def min_variance_portfolio(*tickers: str) -> str:
ticker_list = list(tickers)
data = yf.download(ticker_list, period="2y", progress=False)['Close'][ticker_list]
returns = data.pct_change().dropna()
cov_matrix = returns.cov()
mean_rt = returns.mean()
variance = lambda w: w.T @ cov_matrix @ w
x0 = np.ones(len(ticker_list)) / len(ticker_list)
bounds = [(0, 3)] * len(ticker_list)
constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
result = minimize(variance, x0, bounds=bounds, constraints=constraints, tol=1e-16, method='SLSQP')
return (
f"Optimal weights for minimum variance portfolio:\n"
f"{ {ticker_list[i]: round(w, 4) for i, w in enumerate(result.x)} }\n"
f"Expected annual return: {(mean_rt @ result.x * 252):.2%}\n"
f"Annualized volatility: {(np.sqrt(result.x.T @ cov_matrix @ result.x) * np.sqrt(252)):.2%}"
)
@tool("max_sharpe_portfolio")
def max_sharpe_portfolio(*tickers: str) -> str:
ticker_list = list(tickers)
data = yf.download(ticker_list, period="2y", progress=False)['Close'][ticker_list]
returns = data.pct_change().dropna()
cov_matrix = returns.cov()
mean_rt = returns.mean()
sharpe = lambda w: -(mean_rt @ w) / np.sqrt(w.T @ cov_matrix @ w)
x0 = np.ones(len(ticker_list)) / len(ticker_list)
bounds = [(0, 3)] * len(ticker_list)
constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
result = minimize(sharpe, x0, bounds=bounds, constraints=constraints, tol=1e-16, method='SLSQP')
return (
f"Optimal weights for maximum Sharpe ratio portfolio:\n"
f"{ {ticker_list[i]: round(w, 4) for i, w in enumerate(result.x)} }\n"
f"Expected annual return: {(mean_rt @ result.x * 252):.2%}\n"
f"Annualized volatility: {(np.sqrt(result.x.T @ cov_matrix @ result.x) * np.sqrt(252)):.2%}"
)
@tool("min_target_semivariance_portfolio")
def min_target_semivariance_portfolio(*tickers: str) -> str:
ticker_list = list(tickers)
data = yf.download(ticker_list, period="2y", progress=False)['Close'][ticker_list]
returns = data.pct_change().dropna()
corr = returns.corr()
cov_matrix = returns.cov()
benchmark = yf.download("^GSPC", period="2y", progress=False)['Close'].pct_change().dropna()
differences = returns - benchmark.values
below_zero_target = differences[differences < 0].fillna(0)
target_downside = np.array(below_zero_target.std())
target_semivariance = np.multiply(target_downside.reshape(len(target_downside), 1), target_downside) * corr
semivariance = lambda w: w.T @ target_semivariance @ w
x0 = np.ones(len(ticker_list)) / len(ticker_list)
bounds = [(0, 3)] * len(ticker_list)
constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
result = minimize(semivariance, x0, bounds=bounds, constraints=constraints, tol=1e-16, method='SLSQP')
return (
f"Optimal weights for minimum target semivariance portfolio:\n"
f"{ {ticker_list[i]: round(w, 4) for i, w in enumerate(result.x)} }\n"
f"Expected annual return: {(returns.mean() @ result.x * 252):.2%}\n"
f"Annualized volatility: {(np.sqrt(result.x.T @ cov_matrix @ result.x) * np.sqrt(252)):.2%}"
)
CETES_SERIES = {
28: "SF43936",
91: "SF43939",
182: "SF43942",
364: "SF43945",
728: "SF349785",
}
@tool("get_cetes_rate")
def get_cetes_rate(term_days: int, date: str | None = None) -> str:
#valid days are the ones displayed above
if term_days not in CETES_SERIES:
valid = ", ".join(str(k) for k in CETES_SERIES)
return f"Invalid term '{term_days}'. Valid options are: {valid}."
series_id = CETES_SERIES[term_days]
url = f"https://www.banxico.org.mx/SieAPIRest/service/v1/series/{series_id}/datos"
headers = {
"Bmx-Token": BANXICO_TOKEN,
"Content-Type": "application/json",
}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
obs_list = response.json()["bmx"]["series"][0]["datos"]
if date is None:
obs = obs_list[-1]
else:
target = datetime.datetime.strptime(date, "%Y-%m-%d")
obs = min(
obs_list,
key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
)
fecha = datetime.datetime.strptime(obs["fecha"], "%d/%m/%Y").strftime("%Y-%m-%d")
value = float(obs["dato"])
label = f"nearest to {date}" if date else "most recent"
return f"The CETES {term_days}-day rate ({label}) is {value:.4f}% as of {fecha}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching CETES {term_days}-day rate: {exc}"
@tool("get_mensual_inflation_mexico")
def get_mensual_inflation_mexico(date: str | None = None) -> str:
URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SP30577/datos"
headers = {
"Bmx-Token": BANXICO_TOKEN,
"Content-Type": "application/json",
}
try:
response = requests.get(URL, headers=headers)
response.raise_for_status()
obs_list = response.json()["bmx"]["series"][0]["datos"]
if date is None:
obs = obs_list[-1]
else:
target = datetime.datetime.strptime(date, "%Y-%m-%d")
obs = min(
obs_list,
key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
)
fecha = obs["fecha"]
fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
value = float(obs["dato"])
label = f"nearest to {date}" if date else "most recent"
return f"The monthly inflation rate in Mexico ({label}) is {value:.4f}% as of {fecha}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching monthly inflation rate in Mexico: {exc}"
@tool("get_inflation_mexico")
def get_inflation_mexico(date: str | None = None) -> str:
URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SP30578/datos"
headers = {
"Bmx-Token": BANXICO_TOKEN,
"Content-Type": "application/json",
}
try:
response = requests.get(URL, headers=headers)
response.raise_for_status()
obs_list = response.json()["bmx"]["series"][0]["datos"]
if date is None:
obs = obs_list[-1]
else:
target = datetime.datetime.strptime(date, "%Y-%m-%d")
obs = min(
obs_list,
key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
)
fecha = obs["fecha"]
fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
value = float(obs["dato"])
label = f"nearest to {date}" if date else "most recent"
return f"The annual inflation rate in Mexico ({label}) is {value:.4f}% as of {fecha}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching annual inflation rate in Mexico: {exc}"
@tool("get_udis")
def get_udis(date: str | None = None) -> str:
URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SP68257/datos"
headers = {
"Bmx-Token": BANXICO_TOKEN,
"Content-Type": "application/json",
}
try:
response = requests.get(URL, headers=headers)
response.raise_for_status()
obs_list = response.json()["bmx"]["series"][0]["datos"]
if date is None:
obs = obs_list[-1]
else:
target = datetime.datetime.strptime(date, "%Y-%m-%d")
obs = min(
obs_list,
key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
)
fecha = obs["fecha"]
fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
value = float(obs["dato"])
label = f"nearest to {date}" if date else "most recent"
return f"The value of UDIs in Mexico ({label}) is {value:.4f} MXN as of {fecha}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching UDIs value in Mexico: {exc}"
TIIE_SERIES = {
28: "SF43783",
91: "SF43878",
182: "SF111916",
}
@tool("get_tiie_rate")
def get_tiie_rate(term_days: int, date: str | None = None) -> str:
"""
Fetches the TIIE (Tasa de Interés Interbancaria de Equilibrio) rate
for a given term in days from Banxico.
Valid terms are: 28, 91, 182.
"""
if term_days not in TIIE_SERIES:
valid = ", ".join(str(k) for k in TIIE_SERIES)
return f"Invalid term '{term_days}'. Valid options are: {valid}."
series_id = TIIE_SERIES[term_days]
url = f"https://www.banxico.org.mx/SieAPIRest/service/v1/series/{series_id}/datos"
headers = {
"Bmx-Token": BANXICO_TOKEN,
"Content-Type": "application/json",
}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
obs_list = response.json()["bmx"]["series"][0]["datos"]
if date is None:
obs = obs_list[-1]
else:
target = datetime.datetime.strptime(date, "%Y-%m-%d")
obs = min(
obs_list,
key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
)
fecha = datetime.datetime.strptime(obs["fecha"], "%d/%m/%Y").strftime("%Y-%m-%d")
value = float(obs["dato"])
label = f"nearest to {date}" if date else "most recent"
return f"The TIIE {term_days}-day rate ({label}) is {value:.4f}% as of {fecha}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching TIIE {term_days}-day rate: {exc}"
@tool("get_target_interest_rate_mexico")
def get_target_interest_rate_mexico(date: str | None = None) -> str:
URL = "https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF61745/datos"
headers = {
"Bmx-Token": BANXICO_TOKEN,
"Content-Type": "application/json",
}
try:
response = requests.get(URL, headers=headers)
response.raise_for_status()
obs_list = response.json()["bmx"]["series"][0]["datos"]
if date is None:
obs = obs_list[-1]
else:
target = datetime.datetime.strptime(date, "%Y-%m-%d")
obs = min(
obs_list,
key=lambda o: abs(datetime.datetime.strptime(o["fecha"], "%d/%m/%Y") - target),
)
fecha = obs["fecha"]
fecha = datetime.datetime.strptime(fecha, "%d/%m/%Y").strftime("%Y-%m-%d")
value = float(obs["dato"])
label = f"nearest to {date}" if date else "most recent"
return f"The target interest rate in Mexico ({label}) is {value:.4f}% as of {fecha}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching target interest rate in Mexico: {exc}"
@tool("get_exchange_rate")
def get_exchange_rate(base: str, quote: str, date: str | None = None) -> str:
base = base.strip().upper()
quote = quote.strip().upper()
ticker_symbol = f"{base}{quote}=X"
try:
if date is None:
target_date = datetime.date.today()
else:
target_date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
t = yf.Ticker(ticker_symbol)
data = t.history(
start=target_date - datetime.timedelta(days=7),
end=target_date + datetime.timedelta(days=7),
)
if data.empty:
return (
f"No exchange rate data found for {base}/{quote} ({ticker_symbol}). "
f"Verify that both currency codes are valid ISO 4217 codes."
)
data["Date"] = data.index.date
data["DateDiff"] = data["Date"].apply(lambda d: abs((d - target_date).days))
nearest = data.loc[data["DateDiff"].idxmin()]
rate = nearest["Close"]
actual_date = nearest["Date"]
date_label = f"nearest to {date}" if date else "most recent"
return f"The exchange rate for {base}/{quote} ({date_label}) is {rate:.6f} as of {actual_date}."
except ValueError:
return f"Invalid date format '{date}'. Please use YYYY-MM-DD (e.g. 2024-01-15)."
except Exception as exc:
return f"Error fetching exchange rate for {base}/{quote}: {exc}"
def _get_news(ticker: str) -> list[dict]:
t = yf.Ticker(ticker)
news = t.news
formated_news = []
for i in range(len(news)):
item = news[i]['content']
formated_news.append({
"pub_date": item.get("pubDate", ""),
"content_type": item.get("contentType", ""),
"title": item.get("title", ""),
"summary": item.get("summary", ""),
"provider": item.get("provider", {}).get("displayName", "N/A"),
})
return formated_news
_finbert_tokenizer = None
_finbert_model = None
def _load_finbert():
global _finbert_tokenizer, _finbert_model
if _finbert_model is None:
_finbert_tokenizer = AutoTokenizer.from_pretrained("ProsusAI/finbert")
_finbert_model = AutoModelForSequenceClassification.from_pretrained("ProsusAI/finbert")
_finbert_model.eval()
return _finbert_tokenizer, _finbert_model
_LABEL_TO_SCORE = {
"positive": 1,
"neutral": 0,
"negative": -1
}
_SCORE_TO_LABEL = {
lambda s: s > 0.15: "positive",
lambda s: s < -0.15: "negative",
}
def _bucket_label(score: float) -> str:
if score > 0.15:
return "positive"
if score < -0.15:
return "negative"
return "neutral"
def _recency_weights(pub_dates: list[str]) -> list[float]:
decay = 0.01
parsed = []
for d in pub_dates:
try:
dt = datetime.datetime.fromisoformat(d.replace("Z", "+00:00"))
parsed.append(dt)
except (ValueError, AttributeError):
parsed.append(None)
valid = [dt for dt in parsed if dt is not None]
if not valid:
return [1.0] * len(pub_dates)
most_recent = max(valid)
weights = []
for dt in parsed:
if dt is None:
weights.append(0.5)
else:
hours_old = (most_recent - dt).total_seconds() / 3600
weights.append(float(np.exp(-decay * hours_old)))
return weights
def _score_texts(texts: list[str]) -> list[dict]:
"""Returns a list of {label, confidence} dicts, one per input text."""
tokenizer, model = _load_finbert()
results = []
with torch.no_grad():
for text in texts:
inputs = tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=512,
padding=True,
)
logits = model(**inputs).logits
probs = F.softmax(logits, dim=-1).squeeze()
# FinBERT label order: positive=0, negative=1, neutral=2
label_map = {0: "positive", 1: "negative", 2: "neutral"}
pred_idx = int(probs.argmax())
results.append({
"label": label_map[pred_idx],
"confidence": float(probs[pred_idx]),
})
return results
@tool("get_news_sentiment")
def get_news_sentiment(ticker: str) -> str:
"""Fetches recent news for a ticker and returns a FinBERT-based
sentiment score aggregated across all available headlines."""
articles = _get_news(ticker)
comp_name = yf.Ticker(ticker).info.get("longName", ticker)
if not articles:
return f"No recent news found for {ticker}."
texts = [f"{a['title']}. {a['summary']}".strip() for a in articles]
scores = _score_texts(texts)
weights = _recency_weights([a["pub_date"] for a in articles])
weighted_sum = 0.0
total_weight = 0.0
scored_articles = []
for article, score, weight in zip(articles, scores, weights):
numeric = _LABEL_TO_SCORE[score["label"]]
contribution = numeric * score["confidence"] * weight
weighted_sum += contribution
total_weight += weight
scored_articles.append({
"title": article["title"],
"provider": article["provider"],
"label": score["label"],
"confidence": score["confidence"],
"weight": round(weight, 4),
"pub_date": article["pub_date"],
})
composite = weighted_sum / total_weight if total_weight > 0 else 0.0
composite = max(-1.0, min(1.0, composite))
label = _bucket_label(composite)
label = label.upper()
scored_articles.sort(
key=lambda x: abs(_LABEL_TO_SCORE[x["label"]] * x["confidence"] * x["weight"]),
reverse=True,
)
top_headlines = " --- ".join(
f"[{a['label'].upper()} {a['confidence']:.0%}] {a['title']} ({a['provider']})"
for a in scored_articles[:5]
)
return (
f"Sentiment analysis for {comp_name} ({ticker.upper()}) across {len(articles)} recent articles: "
f"Composite score: {composite:+.4f} ({label}). "
f"Top influencing headlines: {top_headlines}"
)
@tool("calculate_inflation_impact")
def calculate_inflation_impact(amount: float, months: int, annual_inflation_rate: float) -> str:
monthly_rate = (1 + annual_inflation_rate / 100) ** (1 / 12) - 1
future_equivalent = amount * (1 + monthly_rate) ** months
purchasing_power_loss = future_equivalent - amount
effective_value = amount - purchasing_power_loss
return (
f"With an annual inflation rate of {annual_inflation_rate:.2f}%, "
f"{amount:.2f} pesos today will have the purchasing power of "
f"{effective_value:.2f} pesos after {months} month(s). "
f"That is a loss of {purchasing_power_loss:.2f} pesos in real value."
)
@tool("multiply")
def multiply(a: float, b: float) -> str:
result = a * b
return f"The result of {a} × {b} is {result}."
# --------------- sector-adjusted valuation thresholds -------------------------
# Tuple layout: (pe_strong, pe_weak, pb_strong, pb_weak, ev_ebitda_strong, ev_ebitda_weak)
# "strong" means the value that earns the maximum score of 2.
# "weak" means the value that earns the minimum score of 0.
# Values between the two thresholds score 1 (neutral).
_SECTOR_VALUATION_THRESHOLDS: dict[str, tuple] = {
"technology": (25, 45, 4.0, 10.0, 15, 30),
"healthcare": (20, 35, 3.0, 8.0, 14, 25),
"financial-services": (12, 20, 1.0, 2.5, 10, 18),
"consumer-cyclical": (18, 30, 2.5, 6.0, 12, 22),
"consumer-defensive": (18, 28, 3.0, 6.0, 12, 20),
"energy": (10, 20, 1.5, 3.0, 6, 14),
"basic-materials": (12, 22, 1.5, 3.5, 8, 16),
"industrials": (18, 30, 2.5, 5.0, 12, 20),
"real-estate": (30, 55, 1.5, 3.5, 18, 30),
"utilities": (15, 25, 1.5, 3.0, 10, 18),
"default": (18, 35, 2.5, 6.0, 12, 22),
}
def _valuation_thresholds(sector_key: str | None) -> tuple:
key = (sector_key or "").lower()
return _SECTOR_VALUATION_THRESHOLDS.get(key, _SECTOR_VALUATION_THRESHOLDS["default"])
def _score_metric(value: float, strong_threshold: float, weak_threshold: float,
lower_is_better: bool = True) -> int:
"""
Scores a single metric on a 0–2 scale.
For lower_is_better metrics (P/E, D/E, EV/EBITDA …):
value <= strong_threshold → 2
value >= weak_threshold → 0
in between → 1
For higher_is_better metrics (ROE, margins, FCF yield …):
value >= strong_threshold → 2
value <= weak_threshold → 0
in between → 1
"""
if lower_is_better:
if value <= strong_threshold:
return 2
if value >= weak_threshold:
return 0
return 1
else:
if value >= strong_threshold:
return 2
if value <= weak_threshold:
return 0
return 1
@tool("get_fundamental_analysis")
def get_fundamental_analysis(ticker: str) -> str:
"""
Performs a quantitative fundamental analysis scorecard for a given ticker.
Evaluates 15 metrics across four categories:
- Valuation (P/E, P/B, EV/EBITDA, PEG) max 8 pts
- Profitability (ROE, ROA, Gross margin, Net margin) max 8 pts
- Financial Health (D/E, Current ratio, IC, FCF yield) max 8 pts
- Growth (Revenue growth, Earnings growth, Div yield) max 6 pts
──────────
TOTAL max 30 pts
Scoring per metric: 2 = strong, 1 = neutral / data unavailable, 0 = weak.
Valuation thresholds are sector-adjusted via yfinance sectorKey.
Composite: ≥70% → BUY | 40–69% → HOLD | <40% → SELL.
Apply the same exchange suffix rules as get_price_on_date (e.g. BIMBOA.MX).
"""
t = yf.Ticker(ticker)
info = t.info
company_name = info.get("longName", ticker)
sector_key = info.get("sectorKey", None)
sector_label = info.get("sector", "Unknown sector")
pe_s, pe_w, pb_s, pb_w, ev_s, ev_w = _valuation_thresholds(sector_key)
def safe(key: str, scale: float = 1.0):
"""Returns (scaled_value, is_available). Missing or non-numeric → (None, False)."""
raw = info.get(key)
if raw is None or not isinstance(raw, (int, float)):
return None, False
return raw * scale, True
# ── Valuation (max 8 pts) ──────────────────────────────────────────────────
pe, pe_ok = safe("trailingPE")
pb, pb_ok = safe("priceToBook")
ev_ebitda, ev_ok = safe("enterpriseToEbitda")
peg, peg_ok = safe("pegRatio")
pe_score = _score_metric(pe, pe_s, pe_w, lower_is_better=True) if pe_ok else 1
pb_score = _score_metric(pb, pb_s, pb_w, lower_is_better=True) if pb_ok else 1
ev_score = _score_metric(ev_ebitda, ev_s, ev_w, lower_is_better=True) if ev_ok else 1
peg_score = _score_metric(peg, 1.0, 2.0, lower_is_better=True) if peg_ok else 1
valuation_score = pe_score + pb_score + ev_score + peg_score # max 8
# ── Profitability (max 8 pts) ──────────────────────────────────────────────
roe, roe_ok = safe("returnOnEquity", scale=100)
roa, roa_ok = safe("returnOnAssets", scale=100)
gross_m, gm_ok = safe("grossMargins", scale=100)
net_m, nm_ok = safe("profitMargins", scale=100)
roe_score = _score_metric(roe, 15.0, 8.0, lower_is_better=False) if roe_ok else 1
roa_score = _score_metric(roa, 5.0, 2.0, lower_is_better=False) if roa_ok else 1
gm_score = _score_metric(gross_m, 40.0, 20.0, lower_is_better=False) if gm_ok else 1
nm_score = _score_metric(net_m, 10.0, 3.0, lower_is_better=False) if nm_ok else 1
profit_score = roe_score + roa_score + gm_score + nm_score # max 8
# ── Financial Health (max 8 pts) ───────────────────────────────────────────
de, de_ok = safe("debtToEquity")
cr, cr_ok = safe("currentRatio")
ebitda, ebit_ok = safe("ebitda")
int_exp, ie_ok = safe("interestExpense")
fcf, fcf_ok = safe("freeCashflow")
mktcap, mc_ok = safe("marketCap")
# yfinance returns D/E as a percentage (e.g. 150 means 1.50); normalise to ratio.
de_adj = de / 100.0 if de_ok else None
de_score = _score_metric(de_adj, 0.5, 1.5, lower_is_better=True) if de_ok else 1
cr_score = _score_metric(cr, 2.0, 1.0, lower_is_better=False) if cr_ok else 1
# Interest coverage = EBITDA / |interest expense|; higher is better.
if ebit_ok and ie_ok and int_exp != 0:
ic = abs(ebitda) / abs(int_exp)
ic_score = _score_metric(ic, 5.0, 2.0, lower_is_better=False)
else:
ic = None
ic_score = 1
# FCF yield = FCF / market cap (%); >5% strong, <0% weak.
if fcf_ok and mc_ok and mktcap > 0:
fcf_yield = (fcf / mktcap) * 100
fcf_score = _score_metric(fcf_yield, 5.0, 0.0, lower_is_better=False)
else:
fcf_yield = None
fcf_score = 1
health_score = de_score + cr_score + ic_score + fcf_score # max 8
# ── Growth (max 6 pts) ─────────────────────────────────────────────────────
rev_g, rg_ok = safe("revenueGrowth", scale=100)
earn_g, eg_ok = safe("earningsGrowth", scale=100)
div_y, dy_ok = safe("dividendYield", scale=100)
rev_score = _score_metric(rev_g, 10.0, 0.0, lower_is_better=False) if rg_ok else 1
earn_score = _score_metric(earn_g, 10.0, 0.0, lower_is_better=False) if eg_ok else 1
# Dividend yield: 2–5.5% is the ideal income range.
# Below 0.5% is neutral (growth company, no penalty). Above 6% may signal distress.
if dy_ok:
if 2.0 <= div_y <= 5.5:
div_score = 2
elif div_y > 6.0 or div_y < 0.5:
div_score = 0
else:
div_score = 1
else:
div_score = 1 # no dividend data → neutral
growth_score = rev_score + earn_score + div_score # max 6
# ── Composite & recommendation ─────────────────────────────────────────────
MAX_SCORE = 30
composite = valuation_score + profit_score + health_score + growth_score
pct = composite / MAX_SCORE
if pct >= 0.70:
recommendation = "BUY"
rationale = "the company scores strongly across most fundamental dimensions"
elif pct >= 0.40:
recommendation = "HOLD"
rationale = "the fundamentals are mixed with no compelling entry or exit signal"
else:
recommendation = "SELL"
rationale = "the company shows material weakness across multiple fundamental dimensions"
def fmt(value, decimals: int = 2, suffix: str = "") -> str:
return "N/A" if value is None else f"{value:.{decimals}f}{suffix}"
return (
f"Fundamental analysis scorecard for {company_name} ({ticker.upper()}) | Sector: {sector_label}. "
f"VALUATION ({valuation_score}/8): "
f"P/E {fmt(pe)}x [score {pe_score}/2], "
f"P/B {fmt(pb)}x [score {pb_score}/2], "
f"EV/EBITDA {fmt(ev_ebitda)}x [score {ev_score}/2], "
f"PEG {fmt(peg)} [score {peg_score}/2]. "
f"PROFITABILITY ({profit_score}/8): "
f"ROE {fmt(roe)}% [score {roe_score}/2], "
f"ROA {fmt(roa)}% [score {roa_score}/2], "
f"Gross margin {fmt(gross_m)}% [score {gm_score}/2], "
f"Net margin {fmt(net_m)}% [score {nm_score}/2]. "
f"FINANCIAL HEALTH ({health_score}/8): "
f"D/E {fmt(de_adj)} [score {de_score}/2], "
f"Current ratio {fmt(cr)} [score {cr_score}/2], "
f"Interest coverage {fmt(ic)}x [score {ic_score}/2], "
f"FCF yield {fmt(fcf_yield)}% [score {fcf_score}/2]. "
f"GROWTH ({growth_score}/6): "
f"Revenue growth {fmt(rev_g)}% [score {rev_score}/2], "
f"Earnings growth {fmt(earn_g)}% [score {earn_score}/2], "
f"Dividend yield {fmt(div_y)}% [score {div_score}/2]. "
f"COMPOSITE SCORE: {composite}/{MAX_SCORE} ({pct:.0%}). "
f"RECOMMENDATION: {recommendation}{rationale}."
)
@tool("respond_to_greeting")
def respond_to_greeting() -> str:
return "Hello! I'm a financial data agent. How can I assist you today?"
@tool("respond_no_available_tool")
def respond_no_available_tool(tool_name: str) -> str:
return f"Sorry, currently i'm capable of doing that. Check the list of avaiable tools for more information."