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}"