208 lines
5.6 KiB
Python
208 lines
5.6 KiB
Python
#!/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()
|