SmartestHome/digest-engine/ingest/financial.py

143 lines
4.9 KiB
Python

"""Financial ingestion — FRED (macro series) + Stooq (market/commodity prices).
Stooq is used instead of Alpha Vantage because it needs no key at all and has no
25-request/day cap, which a 4x/day digest would otherwise burn through
(docs/project-plan.md Phase 12 step 4).
Adding series/symbols is env-only, no code change:
FRED_SERIES — comma-separated FRED series IDs, default "UNRATE" (US
unemployment rate). Browse/search IDs at
https://fred.stlouisfed.org — e.g. "UNRATE,CPIAUCSL,FEDFUNDS"
for unemployment + CPI + the federal funds rate.
STOOQ_SYMBOLS — comma-separated Stooq symbols, default "^spx,^dax,cl.f".
Stooq notation: "^spx"/"^dax"/"^ndq" for indices, bare tickers
like "aapl.us" for US equities, and futures like "cl.f" (WTI
crude), "gc.f" (gold), "ng.f" (natural gas).
"""
import csv
import io
import logging
import os
from datetime import datetime, timedelta, timezone
import requests
LOG = logging.getLogger(__name__)
FRED_URL = "https://api.stlouisfed.org/fred/series/observations"
STOOQ_URL = "https://stooq.com/q/d/l/"
HTTP_TIMEOUT = 30
STOOQ_HISTORY_DAYS = 45
FRED_HISTORY_DAYS = 400
def _csv_list(name, default):
raw = os.environ.get(name, default)
return [item.strip() for item in raw.split(",") if item.strip()]
def _fetch_fred(series_ids, api_key):
observations = []
start = (datetime.now(timezone.utc) - timedelta(days=FRED_HISTORY_DAYS)).date()
for series_id in series_ids:
try:
response = requests.get(
FRED_URL,
params={
"series_id": series_id,
"api_key": api_key,
"file_type": "json",
"observation_start": start.isoformat(),
"sort_order": "desc",
"limit": 6,
},
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
points = [
{"date": item["date"], "value": item["value"]}
for item in response.json().get("observations", [])
if item.get("value") not in (None, ".")
]
if not points:
LOG.warning("financial: FRED series %s returned no usable observations", series_id)
continue
observations.append(
{
"source": "fred",
"series_id": series_id,
"latest_date": points[0]["date"],
"latest_value": points[0]["value"],
"previous_value": points[1]["value"] if len(points) > 1 else None,
"recent": points,
}
)
except Exception:
LOG.warning("financial: FRED series %s failed, skipping", series_id, exc_info=True)
return observations
def _fetch_stooq(symbols):
quotes = []
end = datetime.now(timezone.utc).date()
start = end - timedelta(days=STOOQ_HISTORY_DAYS)
for symbol in symbols:
try:
response = requests.get(
STOOQ_URL,
params={
"s": symbol,
"i": "d",
"d1": start.strftime("%Y%m%d"),
"d2": end.strftime("%Y%m%d"),
},
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
rows = [row for row in csv.DictReader(io.StringIO(response.text)) if row.get("Close")]
if not rows:
LOG.warning("financial: Stooq symbol %s returned no rows", symbol)
continue
latest = rows[-1]
previous = rows[-2] if len(rows) > 1 else None
first = rows[0]
quotes.append(
{
"source": "stooq",
"symbol": symbol,
"date": latest["Date"],
"close": float(latest["Close"]),
"previous_close": float(previous["Close"]) if previous else None,
"close_days_ago": float(first["Close"]),
"window_days": STOOQ_HISTORY_DAYS,
}
)
except Exception:
LOG.warning("financial: Stooq symbol %s failed, skipping", symbol, exc_info=True)
return quotes
def fetch(lookback_hours):
del lookback_hours # macro series and daily bars move slower than the digest cadence
results = []
fred_key = os.environ.get("FRED_API_KEY", "").strip()
fred_series = _csv_list("FRED_SERIES", "UNRATE")
if fred_key:
results.extend(_fetch_fred(fred_series, fred_key))
else:
LOG.warning("financial: FRED_API_KEY not set, skipping macro series")
results.extend(_fetch_stooq(_csv_list("STOOQ_SYMBOLS", "^spx,^dax,cl.f")))
LOG.info("financial: %d indicator(s)", len(results))
return results