Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DvdRZkyCy9NJpHYDdPqmS
214 lines
7.3 KiB
Python
214 lines
7.3 KiB
Python
"""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}"
|