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