Initial commit with translated description
This commit is contained in:
207
scripts/market_quote.py
Normal file
207
scripts/market_quote.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/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()
|
||||
86
scripts/market_series.py
Normal file
86
scripts/market_series.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
market_series.py
|
||||
|
||||
Fetch historical series and print CSV to stdout.
|
||||
|
||||
Stocks/ETFs/indices: yfinance history (daily)
|
||||
FX: derived from ExchangeRate-API open endpoint (daily snapshots not provided here),
|
||||
so we return a simple "latest only" line unless you add a historical FX provider.
|
||||
|
||||
Usage:
|
||||
python scripts/market_series.py AAPL --days 30
|
||||
python scripts/market_series.py ^GSPC --days 120
|
||||
python scripts/market_series.py USD/ZAR --days 30
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
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
|
||||
if len(s) == 6 and s.isalpha():
|
||||
return s[:3], s[3:]
|
||||
return None
|
||||
|
||||
|
||||
def _stock_series(symbol: str, days: int) -> pd.DataFrame:
|
||||
import yfinance as yf
|
||||
|
||||
end = datetime.utcnow()
|
||||
start = end - timedelta(days=days)
|
||||
|
||||
df = yf.download(symbol, start=start.date().isoformat(), end=end.date().isoformat(), progress=False)
|
||||
if df is None or len(df) == 0:
|
||||
raise RuntimeError(f"No data returned for symbol: {symbol}")
|
||||
|
||||
df = df.reset_index()
|
||||
# Normalize column names (handle MultiIndex tuples from yfinance)
|
||||
cols = {}
|
||||
for c in df.columns:
|
||||
if isinstance(c, tuple):
|
||||
# Flatten tuple to string (e.g., ('Close', 'AAPL') -> 'close')
|
||||
name = "_".join(str(x) for x in c if x).lower().replace(" ", "_")
|
||||
else:
|
||||
name = str(c).lower().replace(" ", "_")
|
||||
cols[c] = name
|
||||
df = df.rename(columns=cols)
|
||||
return df
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("symbol", help="Ticker or FX pair")
|
||||
ap.add_argument("--days", type=int, default=30)
|
||||
args = ap.parse_args()
|
||||
|
||||
fx = _parse_fx_pair(args.symbol)
|
||||
if fx:
|
||||
# Open access endpoint does not provide true historical in this mode.
|
||||
# We intentionally fail loudly so the agent explains the limitation.
|
||||
raise SystemExit(
|
||||
"FX historical series is not supported with the no-key open endpoint. "
|
||||
"Use latest quotes, or add a historical FX provider (paid or other source)."
|
||||
)
|
||||
|
||||
df = _stock_series(args.symbol, args.days)
|
||||
df.to_csv(sys.stdout, index=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
124
scripts/market_watchlist.py
Normal file
124
scripts/market_watchlist.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
market_watchlist.py
|
||||
|
||||
Maintain a local watchlist and summarize current quotes.
|
||||
|
||||
Usage:
|
||||
python scripts/market_watchlist.py add AAPL MSFT USD/ZAR
|
||||
python scripts/market_watchlist.py remove MSFT
|
||||
python scripts/market_watchlist.py list
|
||||
python scripts/market_watchlist.py summary
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
WATCHLIST_PATH = os.path.join(".cache", "market-tracker", "watchlist.json")
|
||||
os.makedirs(os.path.dirname(WATCHLIST_PATH), exist_ok=True)
|
||||
|
||||
|
||||
def load_watchlist() -> List[str]:
|
||||
if not os.path.exists(WATCHLIST_PATH):
|
||||
return []
|
||||
with open(WATCHLIST_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
items = data.get("items") or []
|
||||
# de-dupe, preserve order
|
||||
seen = set()
|
||||
out = []
|
||||
for x in items:
|
||||
x = str(x).strip()
|
||||
if x and x not in seen:
|
||||
out.append(x)
|
||||
seen.add(x)
|
||||
return out
|
||||
|
||||
|
||||
def save_watchlist(items: List[str]) -> None:
|
||||
with open(WATCHLIST_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump({"items": items}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def cmd_add(symbols: List[str]) -> None:
|
||||
items = load_watchlist()
|
||||
for s in symbols:
|
||||
s = s.strip()
|
||||
if s and s not in items:
|
||||
items.append(s)
|
||||
save_watchlist(items)
|
||||
print(json.dumps({"ok": True, "items": items}, indent=2))
|
||||
|
||||
|
||||
def cmd_remove(symbols: List[str]) -> None:
|
||||
items = load_watchlist()
|
||||
rm = set(s.strip() for s in symbols if s.strip())
|
||||
items = [x for x in items if x not in rm]
|
||||
save_watchlist(items)
|
||||
print(json.dumps({"ok": True, "items": items}, indent=2))
|
||||
|
||||
|
||||
def cmd_list() -> None:
|
||||
print(json.dumps({"items": load_watchlist()}, indent=2))
|
||||
|
||||
|
||||
def cmd_summary() -> None:
|
||||
items = load_watchlist()
|
||||
if not items:
|
||||
print(json.dumps({"items": [], "summary": []}, indent=2))
|
||||
return
|
||||
|
||||
results = []
|
||||
for sym in items:
|
||||
# Call market_quote.py to keep logic centralized
|
||||
p = subprocess.run(
|
||||
[sys.executable, os.path.join(os.path.dirname(__file__), "market_quote.py"), sym],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
results.append({"symbol": sym, "error": p.stderr.strip() or p.stdout.strip()})
|
||||
continue
|
||||
try:
|
||||
results.append(json.loads(p.stdout))
|
||||
except Exception:
|
||||
results.append({"symbol": sym, "error": "Invalid JSON from market_quote.py", "raw": p.stdout[:500]})
|
||||
|
||||
print(json.dumps({"items": items, "summary": results}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sp_add = sub.add_parser("add")
|
||||
sp_add.add_argument("symbols", nargs="+")
|
||||
|
||||
sp_rm = sub.add_parser("remove")
|
||||
sp_rm.add_argument("symbols", nargs="+")
|
||||
|
||||
sub.add_parser("list")
|
||||
sub.add_parser("summary")
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.cmd == "add":
|
||||
cmd_add(args.symbols)
|
||||
elif args.cmd == "remove":
|
||||
cmd_remove(args.symbols)
|
||||
elif args.cmd == "list":
|
||||
cmd_list()
|
||||
elif args.cmd == "summary":
|
||||
cmd_summary()
|
||||
else:
|
||||
raise SystemExit("Unknown command")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user