Files

208 lines
5.6 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
market_quote.py
Fetch the latest quote for:
- Stocks/ETFs/indices (via yfinance)
- FX pairs (via ExchangeRate-API open access)
Usage:
python scripts/market_quote.py AAPL
python scripts/market_quote.py ^GSPC
python scripts/market_quote.py USD/ZAR
"""
from __future__ import annotations
import argparse
import json
import os
import re
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
import requests
CACHE_DIR = os.path.join(".cache", "market-tracker")
os.makedirs(CACHE_DIR, exist_ok=True)
# Conservative defaults to reduce rate-limit pain.
DEFAULT_TTL_SECONDS_STOCKS = 60
DEFAULT_TTL_SECONDS_FX = 12 * 60 * 60 # open FX endpoint updates daily
@dataclass
class Quote:
symbol: str
kind: str # "stock" | "fx"
price: float
currency: Optional[str]
asof_unix: int
source: str
extra: Dict[str, Any]
def _cache_path(key: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", key)
return os.path.join(CACHE_DIR, f"{safe}.json")
def _cache_get(key: str, ttl: int) -> Optional[Dict[str, Any]]:
path = _cache_path(key)
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
if int(time.time()) - int(payload.get("_cached_at", 0)) <= ttl:
return payload
except Exception:
return None
return None
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
payload["_cached_at"] = int(time.time())
with open(_cache_path(key), "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def _parse_fx_pair(s: str) -> Optional[Tuple[str, str]]:
s = s.strip().upper()
s = s.replace(" ", "")
s = s.replace("-", "/")
if "/" in s:
parts = s.split("/")
if len(parts) == 2 and len(parts[0]) == 3 and len(parts[1]) == 3:
return parts[0], parts[1]
return None
# e.g. EURUSD
if len(s) == 6 and s.isalpha():
return s[:3], s[3:]
return None
def _fetch_fx(base: str, quote: str) -> Quote:
cache_key = f"fx_{base}_{quote}"
cached = _cache_get(cache_key, DEFAULT_TTL_SECONDS_FX)
if cached:
return Quote(**cached)
url = f"https://open.er-api.com/v6/latest/{base}"
r = requests.get(url, timeout=20)
r.raise_for_status()
data = r.json()
if data.get("result") != "success":
raise RuntimeError(f"FX provider error: {data}")
rates = data.get("rates") or {}
if quote not in rates:
raise RuntimeError(f"FX pair not supported by provider: {base}/{quote}")
q = Quote(
symbol=f"{base}/{quote}",
kind="fx",
price=float(rates[quote]),
currency=quote,
asof_unix=int(data.get("time_last_update_unix") or time.time()),
source="ExchangeRate-API Open Access (open.er-api.com)",
extra={
"base": base,
"quote": quote,
"time_last_update_utc": data.get("time_last_update_utc"),
"time_next_update_utc": data.get("time_next_update_utc"),
"attribution_required": True,
"provider": data.get("provider"),
"documentation": data.get("documentation"),
},
)
_cache_set(cache_key, q.__dict__)
return q
def _fetch_stock(symbol: str) -> Quote:
cache_key = f"stk_{symbol}"
cached = _cache_get(cache_key, DEFAULT_TTL_SECONDS_STOCKS)
if cached:
return Quote(**cached)
# Import inside to keep startup light if user only does FX.
import yfinance as yf
t = yf.Ticker(symbol)
# Fast path: try fast_info first (often less fragile than scraping-heavy calls)
price = None
currency = None
extra: Dict[str, Any] = {}
try:
fi = getattr(t, "fast_info", None)
if fi:
price = fi.get("lastPrice") or fi.get("last_price")
currency = fi.get("currency")
extra["exchange"] = fi.get("exchange")
extra["timezone"] = fi.get("timezone")
except Exception:
pass
# Fallback: use 1d history
if price is None:
try:
hist = t.history(period="1d", interval="1m")
if hist is not None and len(hist) > 0:
price = float(hist["Close"].iloc[-1])
except Exception as e:
raise RuntimeError(f"Yahoo/yfinance fetch failed for {symbol}: {e}")
if price is None:
raise RuntimeError(f"Could not resolve price for symbol: {symbol}")
# Try to enrich
try:
info = t.get_info() # can be heavier / more likely to rate-limit
if isinstance(info, dict):
currency = currency or info.get("currency")
extra["shortName"] = info.get("shortName") or info.get("longName")
extra["marketState"] = info.get("marketState")
except Exception:
# Non-fatal: still return price
pass
q = Quote(
symbol=symbol,
kind="stock",
price=float(price),
currency=currency,
asof_unix=int(time.time()),
source="Yahoo Finance via yfinance (unofficial; best-effort)",
extra=extra,
)
_cache_set(cache_key, q.__dict__)
return q
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("symbol", help="Ticker (AAPL, ^GSPC) or FX pair (USD/ZAR, EURUSD)")
args = ap.parse_args()
s = args.symbol.strip()
fx = _parse_fx_pair(s)
if fx:
base, quote = fx
q = _fetch_fx(base, quote)
else:
q = _fetch_stock(s)
# Print as JSON so the calling agent can format nicely.
print(json.dumps(q.__dict__, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()