"""Market-data tools. Alpaca for prices/bars, Finnhub for fundamentals/news, yfinance as a keyless fallback. Every tool is defensive: network/API failures return a short error string, never raise into the agent loop. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from functools import lru_cache from langchain_core.tools import tool from config.settings import get_settings # --- lazy clients (so a missing key never breaks import) --- @lru_cache def _alpaca_data(): from alpaca.data.historical import StockHistoricalDataClient key, secret = get_settings().alpaca_data_creds() return StockHistoricalDataClient(key, secret) @lru_cache def _finnhub(): import finnhub return finnhub.Client(api_key=get_settings().finnhub_api_key) _TIMEFRAMES = { "1min": (1, "Minute"), "5min": (5, "Minute"), "15min": (15, "Minute"), "1hour": (1, "Hour"), "1day": (1, "Day"), "1week": (1, "Week"), } def _timeframe(name: str): from alpaca.data.timeframe import TimeFrame, TimeFrameUnit amount, unit = _TIMEFRAMES.get(name.lower().replace(" ", ""), (1, "Day")) return TimeFrame(amount, getattr(TimeFrameUnit, unit)) def latest_price(ticker: str) -> float | None: """Plain latest price (Alpaca trade -> yfinance fallback). None if unavailable. Shared by the get_quote tool and the morning executor.""" ticker = ticker.strip().upper() try: from alpaca.data.requests import StockLatestTradeRequest resp = _alpaca_data().get_stock_latest_trade( StockLatestTradeRequest(symbol_or_symbols=ticker) ) return float(resp[ticker].price) except Exception: pass try: import yfinance as yf return float(yf.Ticker(ticker).fast_info["last_price"]) except Exception: return None @tool def get_quote(ticker: str) -> str: """Get the latest price for a stock ticker (e.g. 'NVDA'). Returns last trade price.""" ticker = ticker.strip().upper() px = latest_price(ticker) return f"{ticker} last price: ${px:.2f}" if px else f"ERROR: could not get quote for {ticker}" _YF_INTERVAL = { "1min": "1m", "5min": "5m", "15min": "15m", "1hour": "1h", "1day": "1d", "1week": "1wk", } def _summarize_bars(ticker, timeframe, closes, highs, lows, vols, source="") -> str: first, last = closes[0], closes[-1] change = (last - first) / first * 100 if first else 0.0 hi, lo = max(highs), min(lows) avg_vol = (sum(vols) / len(vols)) if vols else 0 recent = ", ".join(f"{c:.2f}" for c in closes[-5:]) tag = f", {source}" if source else "" return ( f"{ticker} {timeframe} ({len(closes)} bars{tag}): last={last:.2f}, " f"change={change:+.2f}%, high={hi:.2f}, low={lo:.2f}, avg_vol={avg_vol:,.0f}\n" f"recent closes: [{recent}]" ) @tool def get_bars(ticker: str, timeframe: str = "1day", lookback: int = 30) -> str: """Get recent OHLCV price bars and a compact summary for a ticker. timeframe: one of 1min, 5min, 15min, 1hour, 1day, 1week. lookback: number of bars. Returns period change %, high/low, avg volume, and the most recent closes. """ ticker = ticker.strip().upper() # Alpaca (primary — needs API keys) try: from alpaca.data.requests import StockBarsRequest start = datetime.now(timezone.utc) - timedelta(days=max(lookback * 2, 5)) req = StockBarsRequest( symbol_or_symbols=ticker, timeframe=_timeframe(timeframe), start=start ) bars = _alpaca_data().get_stock_bars(req).data.get(ticker, []) if bars: bars = bars[-lookback:] return _summarize_bars( ticker, timeframe, [b.close for b in bars], [b.high for b in bars], [b.low for b in bars], [b.volume for b in bars], ) except Exception: pass # yfinance fallback (keyless) try: import yfinance as yf interval = _YF_INTERVAL.get(timeframe.lower().replace(" ", ""), "1d") if interval == "1d": period = f"{lookback + 10}d" elif interval == "1wk": period = f"{lookback * 7 + 30}d" else: period = "5d" # yfinance caps intraday history hist = yf.Ticker(ticker).history(period=period, interval=interval) if hist is None or hist.empty: return f"No bars returned for {ticker}." hist = hist.tail(lookback) return _summarize_bars( ticker, timeframe, hist["Close"].tolist(), hist["High"].tolist(), hist["Low"].tolist(), hist["Volume"].tolist(), source="yfinance", ) except Exception as e: return f"ERROR: could not get bars for {ticker}: {e}" @tool def get_news(ticker: str, limit: int = 5) -> str: """Get recent news headlines + summaries for a ticker (last ~7 days).""" ticker = ticker.strip().upper() try: today = datetime.now(timezone.utc).date() frm = (today - timedelta(days=7)).isoformat() items = _finnhub().company_news(ticker, _from=frm, to=today.isoformat()) if not items: return f"No recent news for {ticker}." out = [] for it in items[:limit]: head = it.get("headline", "").strip() summ = (it.get("summary", "") or "").strip()[:240] src = it.get("source", "") out.append(f"- {head} ({src})\n {summ}") return f"{ticker} recent news:\n" + "\n".join(out) except Exception as e: return f"ERROR: could not get news for {ticker}: {e}" @tool def get_fundamentals(ticker: str) -> str: """Get key fundamentals for a ticker: profile, market cap, valuation and quality metrics.""" ticker = ticker.strip().upper() try: fh = _finnhub() prof = fh.company_profile2(symbol=ticker) or {} fin = fh.company_basic_financials(ticker, "all") or {} m = fin.get("metric", {}) or {} def g(key: str) -> str: v = m.get(key) return f"{v:.2f}" if isinstance(v, (int, float)) else "n/a" name = prof.get("name", ticker) industry = prof.get("finnhubIndustry", "n/a") mcap = prof.get("marketCapitalization") mcap_s = f"${mcap:,.0f}M" if isinstance(mcap, (int, float)) else "n/a" return ( f"{ticker} — {name} ({industry})\n" f"market cap: {mcap_s}\n" f"P/E (TTM): {g('peTTM')}, P/B: {g('pbAnnual')}, P/S (TTM): {g('psTTM')}\n" f"ROE (TTM): {g('roeTTM')}%, gross margin: {g('grossMarginTTM')}%\n" f"rev growth YoY (TTM): {g('revenueGrowthTTMYoy')}%\n" f"52wk high/low: {g('52WeekHigh')} / {g('52WeekLow')}" ) except Exception as e: # yfinance fallback for the basics try: import yfinance as yf info = yf.Ticker(ticker).info return ( f"{ticker} — {info.get('shortName', ticker)} ({info.get('industry', 'n/a')})\n" f"market cap: {info.get('marketCap', 'n/a')}\n" f"trailing P/E: {info.get('trailingPE', 'n/a')}, " f"forward P/E: {info.get('forwardPE', 'n/a')}\n" f"profit margin: {info.get('profitMargins', 'n/a')}" ) except Exception as e2: return f"ERROR: could not get fundamentals for {ticker}: {e} / {e2}"