File size: 4,868 Bytes
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""analytics/valuation.py β€” public trading multiples and calendar catalysts.

Runtime-only (yfinance), cached via storage/earnings_cache.py (same TTL-cache
pattern as ingestion/analyst.py). Deliberately narrow: trading multiples and
scheduled dates only β€” no DCF, no fair-value opinion, no price target. That
matches the hard constraint in CLAUDE.md: public data only, no buy/sell
recommendations, no price targets, ever.

Streamlit-free and session-free, like dashboard/signal_feed.py β€” the render
layer (dashboard/verdict.py, dashboard/financials.py) owns all HTML.
"""
from __future__ import annotations

from typing import Optional

# Curated peer sets β€” small, editable MVP list (CLAUDE.md scopes this app to
# 5-10 manually curated large-caps). A ticker absent from this map simply
# renders without a peer comparison rather than failing.
PEERS: dict[str, list[str]] = {
    "NVDA": ["AMD", "AVGO"],
    "AAPL": ["MSFT", "GOOGL"],
    "MSFT": ["AAPL", "GOOGL"],
    "AMZN": ["MSFT", "GOOGL"],
    "GOOGL": ["MSFT", "META"],
    "META": ["GOOGL", "SNAP"],
    "TSLA": ["GM", "F"],
    "AMD": ["NVDA", "INTC"],
    "AVGO": ["NVDA", "QCOM"],
}


def _safe_float(v) -> Optional[float]:
    try:
        f = float(v)
        return None if f != f else f
    except (TypeError, ValueError):
        return None


def fetch_multiples(ticker: str) -> tuple[Optional[dict], Optional[str]]:
    """Fetch current public trading multiples for *ticker* via yfinance.

    Returns (data, error). data has trailing_pe, forward_pe, ev_to_sales,
    ev_to_ebitda, market_cap β€” any may be None if yfinance doesn't expose it
    for this ticker. Cached 6h to avoid hammering the API.
    """
    from storage.earnings_cache import get as cache_get, set as cache_set
    cache_key = f"VALUATION:v1:{ticker.upper()}"
    cached = cache_get(cache_key, ttl_hours=6)
    if cached is not None:
        return cached, None

    try:
        import yfinance as yf
    except ImportError as e:
        return None, f"yfinance not installed: {e}"

    try:
        info = yf.Ticker(ticker.upper()).info or {}
        result = {
            "trailing_pe": _safe_float(info.get("trailingPE")),
            "forward_pe": _safe_float(info.get("forwardPE")),
            "ev_to_sales": _safe_float(info.get("enterpriseToRevenue")),
            "ev_to_ebitda": _safe_float(info.get("enterpriseToEbitda")),
            "market_cap": _safe_float(info.get("marketCap")),
        }
        if not any(v is not None for v in result.values()):
            return None, "no valuation data available"
        cache_set(cache_key, result)
        return result, None
    except Exception as e:
        return None, f"yfinance valuation fetch failed: {e}"


def fetch_peer_multiples(ticker: str) -> list[dict]:
    """Fetch multiples for *ticker* plus its curated peers.

    Never raises β€” a peer whose fetch fails is simply omitted. First row is
    always the primary ticker (if its own fetch succeeded).
    """
    peers = PEERS.get(ticker.upper(), [])
    rows: list[dict] = []
    for t in [ticker.upper()] + peers:
        data, _err = fetch_multiples(t)
        if data:
            rows.append({"ticker": t, **data})
    return rows


def next_catalysts(ticker: str) -> dict:
    """Best-effort next scheduled dates: earnings and ex-dividend.

    Returns {"next_earnings_date": str|None, "next_ex_dividend_date": str|None}.
    Never raises; yfinance's calendar schema varies by version, so every
    lookup is defensive and a miss simply yields None for that field.
    """
    from storage.earnings_cache import get as cache_get, set as cache_set
    cache_key = f"CALENDAR:v1:{ticker.upper()}"
    cached = cache_get(cache_key, ttl_hours=24)
    if cached is not None:
        return cached

    result = {"next_earnings_date": None, "next_ex_dividend_date": None}
    try:
        import yfinance as yf
    except ImportError:
        return result

    try:
        t = yf.Ticker(ticker.upper())
        cal = t.calendar
        earnings_raw = None
        if isinstance(cal, dict):
            raw = cal.get("Earnings Date")
            if isinstance(raw, (list, tuple)) and raw:
                earnings_raw = raw[0]
            ex_div = cal.get("Ex-Dividend Date")
            if ex_div:
                result["next_ex_dividend_date"] = str(ex_div)[:10]
        elif cal is not None and not getattr(cal, "empty", True):
            index = list(getattr(cal, "index", []))
            if "Earnings Date" in index:
                row = cal.loc["Earnings Date"]
                earnings_raw = row.iloc[0] if hasattr(row, "iloc") else row
        if earnings_raw is not None:
            result["next_earnings_date"] = str(earnings_raw)[:10]
    except Exception:
        pass

    try:
        cache_set(cache_key, result)
    except Exception:
        pass
    return result