Spaces:
Sleeping
Sleeping
File size: 8,694 Bytes
3ee7903 8ed954c 3ee7903 8ed954c a2cbcac 8ed954c 95aba55 3c57e36 8ed954c 95aba55 3c57e36 8ed954c 3c57e36 8ed954c 95aba55 3c57e36 8ed954c 95aba55 8ed954c 95aba55 8ed954c 3453310 8ed954c 3453310 8ed954c 3453310 95aba55 8ed954c 3c57e36 a2cbcac 8ed954c c51ac99 8ed954c 3c57e36 8ed954c 3c57e36 8ed954c 3c57e36 8ed954c 3c57e36 8ed954c 3c57e36 8ed954c 95aba55 3c57e36 8ed954c 3c57e36 084eeb2 8ed954c 95aba55 3453310 8ed954c a2cbcac 95aba55 8ed954c 95aba55 8ed954c 3c57e36 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c 3ee7903 8ed954c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | import os
import finnhub
import requests
from datetime import datetime, timedelta
from langchain_core.tools import tool
from src.core.logger import get_logger
logger = get_logger(__name__)
# --- SECTOR-SPECIFIC RULES ---
SECTOR_CONFIG = {
"Financial Services": {
"type": "bank",
"require_pb_under_one": True,
"check_debt": False,
"zombie_filter": False,
},
"Technology": {
"type": "growth",
"require_pb_under_one": False,
"check_debt": True,
"debt_max_ebitda": 3.5,
"zombie_filter": True,
},
"Healthcare": {
"type": "growth",
"require_pb_under_one": False,
"check_debt": True,
"debt_max_ebitda": 3.5,
"zombie_filter": True,
},
"Default": {
"type": "standard",
"require_pb_under_one": False,
"check_debt": True,
"debt_max_ebitda": 3.5,
"zombie_filter": False,
},
}
def calculate_graham_number(info: dict) -> float:
"""Classic Value Investing: sqrt(22.5 * EPS * BookValue)."""
try:
eps = info.get("trailingEps", 0) or 0
bvps = info.get("bookValue", 0) or 0
if eps <= 0 or bvps <= 0:
return 0
return (22.5 * eps * bvps) ** 0.5
except (TypeError, ValueError):
return 0
def check_financial_health(ticker: str, info: dict) -> dict:
"""Evaluate a company's financial health based on its sector.
Returns:
{"status": "PASS"/"FAIL", "reason": "...", "metrics": {...}}
"""
try:
sector = info.get("sector", "Default")
config = SECTOR_CONFIG.get(sector, SECTOR_CONFIG["Default"])
current_price = info.get("currentPrice", 0) or info.get("regularMarketPrice", 0) or 0
# NOTE: Pence→Pounds conversion is handled upstream by normalize_price()
# in agent.py before lean_info is passed here. Do NOT divide by 100 again.
# 1. Financial Services (Banks)
if config["type"] == "bank":
pb_ratio = info.get("priceToBook", 0)
if config["require_pb_under_one"] and (pb_ratio is None or pb_ratio > 1.2):
return {
"status": "FAIL",
"reason": f"Financials Reject: P/B is {pb_ratio} (needs near or under 1.0)",
"metrics": {"sector": sector},
}
current_ratio = info.get("currentRatio")
if current_ratio and current_ratio < 0.8:
return {
"status": "FAIL",
"reason": f"Bank Reject: low liquidity (Current Ratio {current_ratio} < 0.8)",
"metrics": {"sector": sector},
}
# 2. Zombie Filter (Tech/Healthcare cash runway)
if config["zombie_filter"]:
fcf = info.get("freeCashflow", 0)
cash = info.get("totalCash", 0)
if fcf is not None and cash is not None and fcf < 0:
yearly_burn = abs(fcf)
if yearly_burn > 0:
runway_years = cash / yearly_burn
if runway_years < 0.5:
return {
"status": "FAIL",
"reason": "Zombie Reject: burning cash with < 6 months runway",
"metrics": {"sector": sector, "runway_years": round(runway_years, 2)},
}
# 3. Classic Debt Filter (Industrials/Default)
if config["check_debt"] and config["type"] == "standard":
ebitda = info.get("ebitda")
debt = info.get("totalDebt")
cash = info.get("totalCash")
if ebitda and debt and ebitda > 0:
net_debt_ebitda = (debt - (cash or 0)) / ebitda
if net_debt_ebitda > config["debt_max_ebitda"]:
return {
"status": "FAIL",
"reason": f"Debt Reject: Net Debt/EBITDA is {net_debt_ebitda:.2f}x > {config['debt_max_ebitda']}x",
"metrics": {"sector": sector},
}
# 4. Intrinsic Value & Safety Margin
intrinsic_val = calculate_graham_number(info)
margin_of_safety = "N/A"
if intrinsic_val > 0 and current_price > 0:
raw_margin = (intrinsic_val - current_price) / intrinsic_val * 100
margin_of_safety = f"{round(raw_margin, 1)}%"
elif intrinsic_val == 0:
margin_of_safety = "No Value (Unprofitable)"
metrics = {
"sector": sector,
"current_price": current_price,
"intrinsic_value": round(intrinsic_val, 2),
"margin_of_safety": margin_of_safety,
}
return {"status": "PASS", "reason": f"Passed {sector} Gatekeeper.", "metrics": metrics}
except Exception as exc:
logger.error("Health check error for %s: %s", ticker, exc)
return {"status": "FAIL", "reason": f"Data Extraction Error: {exc}", "metrics": {}}
# --- FINNHUB TOOLS ---
def get_finnhub_client():
api_key = os.getenv("FINNHUB_API_KEY")
if not api_key:
raise ValueError("FINNHUB_API_KEY not set")
return finnhub.Client(api_key=api_key)
@tool
def get_insider_sentiment(ticker: str) -> str:
"""Fetch recent insider sentiment and trading behavior for a US stock."""
try:
if "." in ticker:
return f"Insider data not supported for non-US ticker {ticker}."
api_key = os.getenv("FINNHUB_API_KEY")
if not api_key:
return "FINNHUB_API_KEY missing."
url = f"https://finnhub.io/api/v1/stock/insider-sentiment?symbol={ticker}&from=2024-01-01&to=2026-12-31&token={api_key}"
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
if "data" not in data or not data["data"]:
return f"No recent insider sentiment data for {ticker}."
recent = data["data"][0]
msp = recent.get("mspr", 0)
if msp > 0:
sentiment = "Positive (Insiders Buying)"
elif msp < 0:
sentiment = "Negative (Insiders Selling)"
else:
sentiment = "Neutral"
return f"Insider Sentiment for {ticker}: {sentiment}. MSPR Score: {msp}."
except requests.exceptions.RequestException as exc:
logger.warning("Insider sentiment request failed for %s: %s", ticker, exc)
return f"Error fetching insider data: {exc}"
except Exception as exc:
logger.error("Unexpected insider sentiment error for %s: %s", ticker, exc)
return f"Error: {exc}"
@tool
def get_company_news(ticker: str) -> str:
"""Fetch the top 3 most recent financial news headlines for a US stock."""
try:
if "." in ticker:
return f"Finnhub news not supported for non-US ticker {ticker}."
client = get_finnhub_client()
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
news = client.company_news(ticker, _from=start_date, to=end_date)
if not news:
return f"No recent news for {ticker}."
headlines = []
for i, article in enumerate(news[:3]):
headlines.append(
f"{i + 1}. {article.get('headline', 'No Headline')} - {article.get('summary', '')}"
)
return f"Recent News for {ticker}:\n" + "\n".join(headlines)
except Exception as exc:
logger.warning("Company news error for %s: %s", ticker, exc)
return f"Error fetching news: {exc}"
@tool
def get_basic_financials(ticker: str) -> str:
"""Fetch deep fundamental metrics for a US stock."""
try:
if "." in ticker:
return f"Finnhub fundamentals not supported for non-US ticker {ticker}."
client = get_finnhub_client()
data = client.company_basic_financials(ticker, "all")
if not data or "metric" not in data:
return f"No fundamental data for {ticker}."
metrics = data["metric"]
report = f"Fundamentals for {ticker}:\n"
report += f"- 52 Week High: ${metrics.get('52WeekHigh', 'N/A')}\n"
report += f"- 52 Week Low: ${metrics.get('52WeekLow', 'N/A')}\n"
report += f"- Beta: {metrics.get('beta', 'N/A')}\n"
report += f"- Gross Margin TTM: {metrics.get('grossMarginTTM', 'N/A')}%\n"
report += f"- ROE TTM: {metrics.get('roeTTM', 'N/A')}%\n"
return report
except Exception as exc:
logger.warning("Basic financials error for %s: %s", ticker, exc)
return f"Error fetching financials: {exc}"
|