scripts/stocks_client.py
scripts/stocks_client.pyBrowse 2 files
5,969 tokens
25,033 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3stocks_client.py - Stock market data CLI tool for the Hermes Agent project.4Zero external dependencies - Python stdlib only.5"""6 7import argparse8import json9import os10import sys11import time12import urllib.error13import urllib.parse14import urllib.request15from datetime import datetime, timezone16from http.cookiejar import CookieJar17 18# ---------------------------------------------------------------------------19# Constants20# ---------------------------------------------------------------------------21 22USER_AGENT = "Mozilla/5.0 (compatible; HermesAgent/1.0)"23YF_BASE = "https://query1.finance.yahoo.com"24YF_BASE2 = "https://query2.finance.yahoo.com"25AV_BASE = "https://www.alphavantage.co/query"26 27MAX_RETRIES = 328BACKOFF_BASE = 1.5 # seconds29 30# Global cookie jar + opener (handles Yahoo Finance session cookies)31_cookie_jar = CookieJar()32_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_cookie_jar))33_crumb: str | None = None34 35# ---------------------------------------------------------------------------36# Utilities37# ---------------------------------------------------------------------------38 39 40def print_json(data: dict | list) -> None:41 print(json.dumps(data, indent=2, ensure_ascii=False))42 43 44def fmt_price(value) -> str | None:45 if value is None:46 return None47 try:48 return f"{float(value):.2f}"49 except (TypeError, ValueError):50 return None51 52 53def fmt_large(value) -> str | None:54 """Format large numbers with B/T suffix."""55 if value is None:56 return None57 try:58 v = float(value)59 except (TypeError, ValueError):60 return None61 if abs(v) >= 1e12:62 return f"{v / 1e12:.2f}T"63 if abs(v) >= 1e9:64 return f"{v / 1e9:.2f}B"65 if abs(v) >= 1e6:66 return f"{v / 1e6:.2f}M"67 return str(int(v))68 69 70def fmt_pct(value) -> str | None:71 if value is None:72 return None73 try:74 return f"{float(value):.2f}%"75 except (TypeError, ValueError):76 return None77 78 79def safe_get(d: dict, *keys, default=None):80 """Safely traverse nested dict."""81 cur = d82 for k in keys:83 if not isinstance(cur, dict):84 return default85 cur = cur.get(k, default)86 if cur is None:87 return default88 return cur89 90 91def ts_to_date(ts) -> str | None:92 """Convert Unix timestamp to ISO date string."""93 if ts is None:94 return None95 try:96 return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d")97 except (OSError, ValueError, TypeError):98 return None99 100 101# ---------------------------------------------------------------------------102# HTTP layer with retry + exponential backoff103# ---------------------------------------------------------------------------104 105 106def _build_request(url: str, headers: dict | None = None) -> urllib.request.Request:107 req = urllib.request.Request(url)108 req.add_header("User-Agent", USER_AGENT)109 req.add_header("Accept", "application/json, */*")110 req.add_header("Accept-Language", "en-US,en;q=0.9")111 if headers:112 for k, v in headers.items():113 req.add_header(k, v)114 return req115 116 117def fetch_url(url: str, headers: dict | None = None, retries: int = MAX_RETRIES) -> dict | list | None:118 """Fetch a URL, parse JSON, retry on transient errors."""119 last_err = None120 for attempt in range(retries):121 try:122 req = _build_request(url, headers)123 with _opener.open(req, timeout=15) as resp:124 raw = resp.read()125 return json.loads(raw.decode("utf-8", errors="replace"))126 except urllib.error.HTTPError as e:127 last_err = e128 if e.code in {404, 400}:129 break # no point retrying130 wait = BACKOFF_BASE ** attempt131 time.sleep(wait)132 except urllib.error.URLError as e:133 last_err = e134 wait = BACKOFF_BASE ** attempt135 time.sleep(wait)136 except json.JSONDecodeError as e:137 last_err = e138 break139 return None140 141 142# ---------------------------------------------------------------------------143# Yahoo Finance crumb / cookie management144# ---------------------------------------------------------------------------145 146 147def _fetch_crumb() -> str | None:148 """149 Yahoo Finance v8 requires a crumb + consent cookie.150 We hit the consent page once to grab cookies, then fetch the crumb.151 """152 global _crumb153 if _crumb is not None:154 return _crumb155 156 # Step 1: touch Yahoo Finance to get cookies157 try:158 req = _build_request("https://finance.yahoo.com/")159 with _opener.open(req, timeout=10) as resp:160 resp.read()161 except Exception:162 pass163 164 # Step 2: fetch crumb165 crumb_url = f"{YF_BASE}/v1/test/getcrumb"166 try:167 req = _build_request(crumb_url)168 with _opener.open(req, timeout=10) as resp:169 crumb_raw = resp.read().decode("utf-8").strip()170 if crumb_raw and crumb_raw != "":171 _crumb = crumb_raw172 return _crumb173 except Exception:174 pass175 176 return None177 178 179def yf_url(path: str, params: dict | None = None) -> str:180 """Build a Yahoo Finance URL, injecting crumb if available."""181 crumb = _fetch_crumb()182 if params is None:183 params = {}184 if crumb:185 params["crumb"] = crumb186 qs = urllib.parse.urlencode(params)187 base = f"{YF_BASE}{path}"188 return f"{base}?{qs}" if qs else base189 190 191# ---------------------------------------------------------------------------192# Yahoo Finance API calls193# ---------------------------------------------------------------------------194 195 196def yf_chart(symbol: str, interval: str = "1d", range_: str = "1d") -> dict | None:197 params = {"interval": interval, "range": range_}198 crumb = _fetch_crumb()199 if crumb:200 params["crumb"] = crumb201 qs = urllib.parse.urlencode(params)202 url = f"{YF_BASE}/v8/finance/chart/{urllib.parse.quote(symbol)}?{qs}"203 data = fetch_url(url)204 if data is None:205 # fallback to query2206 url2 = f"{YF_BASE2}/v8/finance/chart/{urllib.parse.quote(symbol)}?{qs}"207 data = fetch_url(url2)208 return data209 210 211def yf_search(query: str, count: int = 5) -> dict | None:212 params = {"q": query, "quotesCount": count, "newsCount": 0}213 crumb = _fetch_crumb()214 if crumb:215 params["crumb"] = crumb216 qs = urllib.parse.urlencode(params)217 url = f"{YF_BASE}/v1/finance/search?{qs}"218 data = fetch_url(url)219 if data is None:220 url2 = f"{YF_BASE2}/v1/finance/search?{qs}"221 data = fetch_url(url2)222 return data223 224 225def yf_quote_summary(symbol: str) -> dict | None:226 """Fetch detailed quote summary (quoteSummary) for PE, market cap, etc."""227 modules = "summaryDetail,defaultKeyStatistics,price"228 params = {"modules": modules}229 crumb = _fetch_crumb()230 if crumb:231 params["crumb"] = crumb232 qs = urllib.parse.urlencode(params)233 url = f"{YF_BASE}/v11/finance/quoteSummary/{urllib.parse.quote(symbol)}?{qs}"234 data = fetch_url(url)235 if data is None:236 url2 = f"{YF_BASE2}/v11/finance/quoteSummary/{urllib.parse.quote(symbol)}?{qs}"237 data = fetch_url(url2)238 return data239 240 241# ---------------------------------------------------------------------------242# Alpha Vantage (optional, requires API key)243# ---------------------------------------------------------------------------244 245 246def av_overview(symbol: str) -> dict | None:247 key = os.environ.get("ALPHA_VANTAGE_KEY")248 if not key:249 return None250 params = {"function": "OVERVIEW", "symbol": symbol, "apikey": key}251 qs = urllib.parse.urlencode(params)252 url = f"{AV_BASE}?{qs}"253 data = fetch_url(url)254 if isinstance(data, dict) and data.get("Symbol"):255 return data256 return None257 258 259# ---------------------------------------------------------------------------260# Data extraction helpers261# ---------------------------------------------------------------------------262 263 264def extract_quote_from_chart(symbol: str, chart_data: dict) -> dict:265 """Extract current quote info from v8 chart response."""266 result = {267 "symbol": symbol.upper(),268 "price": None,269 "change": None,270 "change_pct": None,271 "volume": None,272 "market_cap": None,273 "pe_ratio": None,274 "52w_high": None,275 "52w_low": None,276 "currency": None,277 "exchange": None,278 "short_name": None,279 }280 281 chart = safe_get(chart_data, "chart", "result")282 if not chart or not isinstance(chart, list) or len(chart) == 0:283 return result284 285 r = chart[0]286 meta = r.get("meta", {})287 288 result["currency"] = meta.get("currency")289 result["exchange"] = meta.get("exchangeName")290 result["short_name"] = meta.get("shortName") or meta.get("longName")291 292 # Price293 price = meta.get("regularMarketPrice") or meta.get("chartPreviousClose")294 result["price"] = fmt_price(price)295 296 # Change297 prev_close = meta.get("previousClose") or meta.get("chartPreviousClose")298 if price and prev_close:299 chg = float(price) - float(prev_close)300 chg_pct = (chg / float(prev_close)) * 100301 result["change"] = fmt_price(chg)302 result["change_pct"] = fmt_pct(chg_pct)303 304 result["volume"] = meta.get("regularMarketVolume")305 result["52w_high"] = fmt_price(meta.get("fiftyTwoWeekHigh"))306 result["52w_low"] = fmt_price(meta.get("fiftyTwoWeekLow"))307 308 return result309 310 311def extract_quote_summary_fields(qs_data: dict) -> dict:312 """Extract PE, market cap, etc. from quoteSummary response."""313 out = {314 "market_cap": None,315 "pe_ratio": None,316 "52w_high": None,317 "52w_low": None,318 "volume": None,319 "short_name": None,320 }321 322 result = safe_get(qs_data, "quoteSummary", "result")323 if not result or not isinstance(result, list) or len(result) == 0:324 return out325 326 r = result[0]327 328 # price module329 price_mod = r.get("price", {})330 out["market_cap"] = fmt_large(safe_get(price_mod, "marketCap", "raw"))331 out["short_name"] = price_mod.get("shortName") or price_mod.get("longName")332 333 # summaryDetail334 sd = r.get("summaryDetail", {})335 pe_raw = safe_get(sd, "trailingPE", "raw")336 out["pe_ratio"] = fmt_price(pe_raw) if pe_raw else None337 out["52w_high"] = fmt_price(safe_get(sd, "fiftyTwoWeekHigh", "raw"))338 out["52w_low"] = fmt_price(safe_get(sd, "fiftyTwoWeekLow", "raw"))339 out["volume"] = safe_get(sd, "volume", "raw") or safe_get(sd, "regularMarketVolume", "raw")340 341 # defaultKeyStatistics342 ks = r.get("defaultKeyStatistics", {})343 if out["pe_ratio"] is None:344 pe_raw = safe_get(ks, "trailingEps", "raw")345 # can't compute PE from EPS alone without price, skip346 347 return out348 349 350# ---------------------------------------------------------------------------351# Command: quote352# ---------------------------------------------------------------------------353 354 355def cmd_quote(symbols: list[str]) -> None:356 results = []357 358 for sym in symbols:359 sym = sym.upper().strip()360 entry = {"symbol": sym, "data_source": "Yahoo Finance"}361 362 # Fetch chart for price data363 chart_data = yf_chart(sym, interval="1d", range_="1d")364 if chart_data:365 q = extract_quote_from_chart(sym, chart_data)366 entry.update(q)367 368 # Fetch quoteSummary for enriched data369 qs_data = yf_quote_summary(sym)370 if qs_data:371 qs_fields = extract_quote_summary_fields(qs_data)372 # Prefer quoteSummary values if chart didn't have them373 for field in ("market_cap", "pe_ratio", "52w_high", "52w_low", "volume", "short_name"):374 if entry.get(field) is None and qs_fields.get(field) is not None:375 entry[field] = qs_fields[field]376 elif field == "market_cap" and qs_fields.get(field) is not None:377 # Always prefer formatted market cap from quoteSummary378 entry[field] = qs_fields[field]379 380 # Optionally enrich with Alpha Vantage381 av_key = os.environ.get("ALPHA_VANTAGE_KEY")382 if av_key:383 av_data = av_overview(sym)384 if av_data:385 entry["data_source"] = "Yahoo Finance + Alpha Vantage"386 if entry.get("pe_ratio") is None:387 pe = av_data.get("PERatio")388 entry["pe_ratio"] = pe if pe and pe != "None" and pe != "-" else None389 if entry.get("market_cap") is None:390 mc = av_data.get("MarketCapitalization")391 entry["market_cap"] = fmt_large(mc)392 if entry.get("52w_high") is None:393 entry["52w_high"] = av_data.get("52WeekHigh")394 if entry.get("52w_low") is None:395 entry["52w_low"] = av_data.get("52WeekLow")396 397 results.append(entry)398 399 if len(results) == 1:400 print_json(results[0])401 else:402 print_json(results)403 404 405# ---------------------------------------------------------------------------406# Command: search407# ---------------------------------------------------------------------------408 409 410def cmd_search(query: str) -> None:411 data = yf_search(query, count=5)412 if not data:413 print_json({"error": "Search failed or no results", "query": query, "data_source": "Yahoo Finance"})414 return415 416 quotes = data.get("quotes") or []417 if not quotes:418 print_json({"error": "No matches found", "query": query, "data_source": "Yahoo Finance"})419 return420 421 results = []422 for q in quotes[:5]:423 results.append({424 "symbol": q.get("symbol"),425 "name": q.get("longname") or q.get("shortname"),426 "exchange": q.get("exchange") or q.get("exchDisp"),427 "type": q.get("quoteType"),428 "sector": q.get("sector"),429 })430 431 output = {432 "query": query,433 "matches": results,434 "data_source": "Yahoo Finance",435 }436 print_json(output)437 438 439# ---------------------------------------------------------------------------440# Command: history441# ---------------------------------------------------------------------------442 443 444def cmd_history(symbol: str, range_: str = "1mo") -> None:445 valid_ranges = ("1mo", "3mo", "6mo", "1y", "5y")446 if range_ not in valid_ranges:447 print_json({"error": f"Invalid range '{range_}'. Valid: {', '.join(valid_ranges)}"})448 return449 450 sym = symbol.upper().strip()451 chart_data = yf_chart(sym, interval="1d", range_=range_)452 453 if not chart_data:454 print_json({"error": f"Failed to fetch history for {sym}", "data_source": "Yahoo Finance"})455 return456 457 chart = safe_get(chart_data, "chart", "result")458 if not chart or not isinstance(chart, list) or len(chart) == 0:459 err = safe_get(chart_data, "chart", "error", "description") or "Unknown error"460 print_json({"error": err, "symbol": sym, "data_source": "Yahoo Finance"})461 return462 463 r = chart[0]464 timestamps = r.get("timestamp") or []465 indicators = r.get("indicators", {})466 quote_list = indicators.get("quote") or [{}]467 ohlcv = quote_list[0] if quote_list else {}468 469 opens = ohlcv.get("open") or []470 closes = ohlcv.get("close") or []471 highs = ohlcv.get("high") or []472 lows = ohlcv.get("low") or []473 volumes = ohlcv.get("volume") or []474 475 history = []476 for i, ts in enumerate(timestamps):477 def _v(lst, idx):478 try:479 val = lst[idx]480 return round(val, 2) if val is not None else None481 except IndexError:482 return None483 484 entry = {485 "date": ts_to_date(ts),486 "open": _v(opens, i),487 "close": _v(closes, i),488 "high": _v(highs, i),489 "low": _v(lows, i),490 "volume": _v(volumes, i),491 }492 history.append(entry)493 494 # Stats495 valid_closes = [c["close"] for c in history if c["close"] is not None]496 stats = {}497 if valid_closes:498 stats["min"] = fmt_price(min(valid_closes))499 stats["max"] = fmt_price(max(valid_closes))500 stats["avg"] = fmt_price(sum(valid_closes) / len(valid_closes))501 if len(valid_closes) >= 2:502 total_return = ((valid_closes[-1] - valid_closes[0]) / valid_closes[0]) * 100503 stats["total_return_pct"] = fmt_pct(total_return)504 else:505 stats["total_return_pct"] = None506 507 meta = r.get("meta", {})508 output = {509 "symbol": sym,510 "range": range_,511 "currency": meta.get("currency"),512 "exchange": meta.get("exchangeName"),513 "data_points": len(history),514 "stats": stats,515 "history": history,516 "data_source": "Yahoo Finance",517 }518 print_json(output)519 520 521# ---------------------------------------------------------------------------522# Command: compare523# ---------------------------------------------------------------------------524 525 526def cmd_compare(symbols: list[str]) -> None:527 if len(symbols) < 2:528 print_json({"error": "compare requires at least 2 symbols"})529 return530 531 comparisons = []532 533 for sym in symbols:534 sym = sym.upper().strip()535 entry = {536 "symbol": sym,537 "name": None,538 "price": None,539 "change_pct": None,540 "market_cap": None,541 "pe_ratio": None,542 "52w_high": None,543 "52w_low": None,544 "52w_performance_pct": None,545 }546 547 # Chart data548 chart_data = yf_chart(sym, interval="1d", range_="1d")549 if chart_data:550 q = extract_quote_from_chart(sym, chart_data)551 entry["name"] = q.get("short_name")552 entry["price"] = q.get("price")553 entry["change_pct"] = q.get("change_pct")554 entry["52w_high"] = q.get("52w_high")555 entry["52w_low"] = q.get("52w_low")556 557 # quoteSummary for enrichment558 qs_data = yf_quote_summary(sym)559 if qs_data:560 qs = extract_quote_summary_fields(qs_data)561 if qs.get("market_cap"):562 entry["market_cap"] = qs["market_cap"]563 if qs.get("pe_ratio"):564 entry["pe_ratio"] = qs["pe_ratio"]565 if entry["52w_high"] is None and qs.get("52w_high"):566 entry["52w_high"] = qs["52w_high"]567 if entry["52w_low"] is None and qs.get("52w_low"):568 entry["52w_low"] = qs["52w_low"]569 if entry["name"] is None and qs.get("short_name"):570 entry["name"] = qs["short_name"]571 572 # 52w performance: (current - 52w_low) / (52w_high - 52w_low)573 try:574 price_f = float(entry["price"]) if entry["price"] else None575 high_f = float(entry["52w_high"]) if entry["52w_high"] else None576 low_f = float(entry["52w_low"]) if entry["52w_low"] else None577 if price_f and low_f and price_f > 0 and low_f > 0:578 perf = ((price_f - low_f) / low_f) * 100579 entry["52w_performance_pct"] = fmt_pct(perf)580 except (ValueError, TypeError, ZeroDivisionError):581 pass582 583 comparisons.append(entry)584 585 output = {586 "comparison": comparisons,587 "symbols": [s.upper() for s in symbols],588 "data_source": "Yahoo Finance",589 }590 print_json(output)591 592 593# ---------------------------------------------------------------------------594# Command: crypto595# ---------------------------------------------------------------------------596 597 598def cmd_crypto(symbol: str, vs: str = "USD") -> None:599 sym = symbol.upper().strip()600 vs = vs.upper().strip()601 602 # If user already passed BTC-USD, keep as-is; otherwise append603 if "-" not in sym:604 ticker = f"{sym}-{vs}"605 else:606 ticker = sym607 608 chart_data = yf_chart(ticker, interval="1d", range_="1d")609 610 if not chart_data:611 print_json({612 "error": f"Failed to fetch crypto data for {ticker}",613 "symbol": ticker,614 "data_source": "Yahoo Finance",615 })616 return617 618 chart = safe_get(chart_data, "chart", "result")619 if not chart or not isinstance(chart, list) or len(chart) == 0:620 err = safe_get(chart_data, "chart", "error", "description") or "Symbol not found"621 print_json({"error": err, "symbol": ticker, "data_source": "Yahoo Finance"})622 return623 624 r = chart[0]625 meta = r.get("meta", {})626 627 price = meta.get("regularMarketPrice") or meta.get("chartPreviousClose")628 prev_close = meta.get("previousClose") or meta.get("chartPreviousClose")629 630 change = None631 change_pct = None632 if price and prev_close:633 try:634 chg = float(price) - float(prev_close)635 chg_pct = (chg / float(prev_close)) * 100636 change = fmt_price(chg)637 change_pct = fmt_pct(chg_pct)638 except (TypeError, ValueError, ZeroDivisionError):639 pass640 641 # 24h stats from indicators642 indicators = r.get("indicators", {})643 quote_list = indicators.get("quote") or [{}]644 ohlcv = quote_list[0] if quote_list else {}645 highs = [h for h in (ohlcv.get("high") or []) if h is not None]646 lows = [l for l in (ohlcv.get("low") or []) if l is not None]647 volumes = [v for v in (ohlcv.get("volume") or []) if v is not None]648 649 output = {650 "symbol": ticker,651 "base": sym if "-" not in sym else sym.split("-")[0],652 "quote_currency": vs,653 "price": fmt_price(price),654 "change": change,655 "change_pct": change_pct,656 "day_high": fmt_price(max(highs)) if highs else None,657 "day_low": fmt_price(min(lows)) if lows else None,658 "volume": fmt_large(sum(volumes)) if volumes else None,659 "52w_high": fmt_price(meta.get("fiftyTwoWeekHigh")),660 "52w_low": fmt_price(meta.get("fiftyTwoWeekLow")),661 "exchange": meta.get("exchangeName"),662 "short_name": meta.get("shortName") or meta.get("longName"),663 "data_source": "Yahoo Finance",664 }665 print_json(output)666 667 668# ---------------------------------------------------------------------------669# CLI entry point670# ---------------------------------------------------------------------------671 672 673def build_parser() -> argparse.ArgumentParser:674 parser = argparse.ArgumentParser(675 prog="stocks_client",676 description="Stock & crypto market data CLI — Hermes Agent",677 formatter_class=argparse.RawDescriptionHelpFormatter,678 epilog="""679Examples:680 stocks_client.py quote AAPL MSFT GOOGL681 stocks_client.py search "Tesla"682 stocks_client.py history AAPL --range 3mo683 stocks_client.py compare AAPL MSFT GOOGL AMZN684 stocks_client.py crypto BTC685 stocks_client.py crypto ETH --vs EUR686 ALPHA_VANTAGE_KEY=yourkey stocks_client.py quote AAPL687 """,688 )689 690 sub = parser.add_subparsers(dest="command", required=True)691 692 # quote693 p_quote = sub.add_parser("quote", help="Get current quote for one or more symbols")694 p_quote.add_argument("symbols", nargs="+", metavar="SYMBOL", help="Stock ticker symbol(s)")695 696 # search697 p_search = sub.add_parser("search", help="Search for stocks by name or symbol")698 p_search.add_argument("query", help="Search query (company name or partial symbol)")699 700 # history701 p_history = sub.add_parser("history", help="Price history for a symbol")702 p_history.add_argument("symbol", metavar="SYMBOL", help="Stock ticker symbol")703 p_history.add_argument(704 "--range",705 dest="range_",706 default="1mo",707 choices=["1mo", "3mo", "6mo", "1y", "5y"],708 help="Date range (default: 1mo)",709 )710 711 # compare712 p_compare = sub.add_parser("compare", help="Compare multiple stocks side by side")713 p_compare.add_argument("symbols", nargs="+", metavar="SYMBOL", help="At least 2 stock symbols")714 715 # crypto716 p_crypto = sub.add_parser("crypto", help="Crypto price (BTC, ETH, SOL, etc.)")717 p_crypto.add_argument("symbol", metavar="SYMBOL", help="Crypto symbol (e.g. BTC, ETH, SOL)")718 p_crypto.add_argument(719 "--vs",720 default="USD",721 metavar="CURRENCY",722 help="Quote currency (default: USD)",723 )724 725 return parser726 727 728def main() -> None:729 parser = build_parser()730 args = parser.parse_args()731 732 try:733 if args.command == "quote":734 cmd_quote(args.symbols)735 elif args.command == "search":736 cmd_search(args.query)737 elif args.command == "history":738 cmd_history(args.symbol, range_=args.range_)739 elif args.command == "compare":740 cmd_compare(args.symbols)741 elif args.command == "crypto":742 cmd_crypto(args.symbol, vs=args.vs)743 else:744 parser.print_help()745 sys.exit(1)746 except KeyboardInterrupt:747 print_json({"error": "Interrupted by user"})748 sys.exit(130)749 except Exception as e:750 print_json({"error": f"Unexpected error: {e}", "type": type(e).__name__})751 sys.exit(1)752 753 754if __name__ == "__main__":755 main()756