Warrior_EA/research/altdata/fred.py
AnimateDread d83cecc011 feat(altdata): wire instrument-specific implied vol; fix FRED vintage path
Wires the screen_ivol survivors (41d726c). New per-symbol `ivolSeries` in the
catalog feeds a generic `ivol_chg5` feature from whichever CBOE vol index the
instrument owns, so one code path serves every symbol:

  XAUUSD  + ivol_chg5 (GVZ)  - MI|vol 0.01971 p=0.002, 3.6x the positive
                               control and 4.6x the vix_chg5 gold had alone.
                               vix_chg5 KEPT: this appends, it does not replace.
  EURUSD  + vix_chg5         - screened, incremental p<=0.006, and its first
                               real feature ever (it had only exploratory EIA).
  USDJPY  + vix_chg5         - screened, incremental.
  NAS100 / US30 / US2000 + ivol_chg5 (VXN / VXD / RVX) - exploratory by analogy.
  XTIUSD / XBRUSD + ivol_chg5 (OVX) - exploratory, no oil bars to screen yet.
  SP500 unchanged - its features already screened clean and VXN/VIX3M edging
  out VIX is a correlated within-family best-of-N, not a real ranking.

On EURUSD/USDJPY the screen put VXD marginally above VIX, but they are
near-duplicates and the gap sits inside the noise, so the tie is broken by a
rule rather than by the number: take the series already in the fetch path.

Also fixes a real collector bug: fetch_vintaged built ALFRED realtime windows
out to 2028, and FRED rejects realtime_end after today - so every REVISED
series (unemployment, CPI, GDP: exactly the ones needing the vintage path) was
unreachable, while unrevised series never noticed because they bail earlier.
UNRATE and CPIAUCSL now return first prints correctly.

Adds screen_macro.py (rates, curve, breakevens, Fed/ECB policy differential,
plus monthly country stats) with a `distinct` column that reports the honest
effective sample size - a monthly series pasted onto D1 bars is a step
function, and that column is what decides whether it can clear a gate at all.
Not yet run: the Market Data bars directory is being regenerated right now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:14:03 -04:00

135 lines
5.6 KiB
Python

"""FRED / ALFRED collector - free API key (https://fred.stlouisfed.org/docs/api/api_key.html).
Why ALFRED and not plain FRED: most macro series are REVISED. Training on
today's FRED values leaks the revision (same trap as the economic calendar's
post-revision actual_value - see calendar recorder memory). The ALFRED
`realtime_start/realtime_end` window returns each observation AS FIRST
PUBLISHED, and `realtime_start` of each vintage row IS the publication date,
which becomes our `published` column.
Series set (daily unless noted) - chosen for SP500/gold/FX/oil relevance:
VIXCLS VIX close
DGS2 2y treasury yield
DGS10 10y treasury yield
T10Y2Y 10y-2y spread
T5YIE 5y breakeven inflation
DTWEXBGS broad dollar index
DCOILWTICO WTI spot
DFF fed funds effective
NFCI Chicago Fed financial conditions (weekly)
WALCL Fed balance sheet (weekly)
Usage:
python -m altdata.fred # all series, full history
python -m altdata.fred --series VIXCLS
Key: env ALTDATA_FRED_KEY or "fred" in Market Data/altdata/keys.json.
"""
import argparse
import datetime as dt
import pandas as pd
from .common import DATA_ROOT, api_key, ensure_dirs, get
API = "https://api.stlouisfed.org/fred/series/observations"
SERIES = ["VIXCLS", "DGS2", "DGS10", "T10Y2Y", "T5YIE",
"DTWEXBGS", "DCOILWTICO", "DFF", "NFCI", "WALCL"]
def fetch_unrevised(series_id: str, key: str) -> pd.DataFrame:
"""Plain FRED for series with no ALFRED archive (market data, never revised).
For these the current value IS the first print. `published` = observed + 1
day: daily market series post after their session close; +1 day makes the
as-of join conservative by construction.
"""
print(f" ({series_id}: no ALFRED archive -> unrevised path)")
params = {"series_id": series_id, "api_key": key, "file_type": "json",
"limit": 100000, "offset": 0}
rows = []
while True:
j = get(API, params=params).json()
rows.extend(j["observations"])
params["offset"] += params["limit"]
if params["offset"] >= j["count"]:
break
df = pd.DataFrame(rows)
df["value"] = pd.to_numeric(df["value"], errors="coerce")
df = df.dropna(subset=["value"])
df["observed"] = pd.to_datetime(df["date"])
df["published"] = df["observed"] + pd.Timedelta(days=1)
return df[["observed", "published", "value"]].reset_index(drop=True)
def fetch_vintaged(series_id: str, key: str) -> pd.DataFrame:
"""First prints via ALFRED output_type=4 (initial release only).
Each observation appears once, valued as FIRST PUBLISHED, and its
realtime_start IS the publication date. Avoids the 2000-vintage cap that a
full-vintage query hits on long daily series.
"""
# the API caps vintage dates per realtime period at 2000, so chunk the
# realtime axis: everything pre-2000 (sparse archives) then 3y windows.
# realtime_end MUST NOT be after today or FRED 400s - which silently made
# every REVISED series (unemployment, CPI, GDP: the ones that actually need
# the vintage path) unreachable, while unrevised series never noticed
# because they bail to fetch_unrevised before the window is used.
today = dt.date.today().isoformat()
windows = [("1776-07-04", "1999-12-31")]
for y in range(2000, 2100, 3):
start = f"{y}-01-01"
if start > today:
break
windows.append((start, min(f"{y + 2}-12-31", today)))
rows = []
for rt_start, rt_end in windows:
params = {
"series_id": series_id, "api_key": key, "file_type": "json",
"realtime_start": rt_start, "realtime_end": rt_end,
"output_type": 4, "limit": 100000, "offset": 0,
}
while True:
r = get(API, params=params, ok_codes=(200, 400))
if r.status_code == 400:
if "does not exist in ALFRED" in r.text:
return fetch_unrevised(series_id, key)
raise RuntimeError(f"FRED 400 for {series_id}: {r.text[:200]}")
j = r.json()
rows.extend(j["observations"])
params["offset"] += params["limit"]
if params["offset"] >= j["count"]:
break
df = pd.DataFrame(rows)
df["value"] = pd.to_numeric(df["value"], errors="coerce")
df = df.dropna(subset=["value"])
df["observed"] = pd.to_datetime(df["date"])
df["published"] = pd.to_datetime(df["realtime_start"])
df = df.sort_values("observed").drop_duplicates("observed", keep="first")
# a publication date earlier than the observed period is a data error;
# clamp to observed so the as-of join can never look ahead of the period
df["published"] = df[["published", "observed"]].max(axis=1)
return df[["observed", "published", "value"]].reset_index(drop=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--series", nargs="*", default=SERIES)
args = ap.parse_args()
key = api_key("fred")
if not key:
raise SystemExit("No FRED key. Set ALTDATA_FRED_KEY or add 'fred' to "
f"{DATA_ROOT / 'keys.json'} (free: fred.stlouisfed.org)")
ensure_dirs()
out_dir = DATA_ROOT / "fred"
out_dir.mkdir(parents=True, exist_ok=True)
for sid in args.series:
df = fetch_vintaged(sid, key)
dest = out_dir / f"{sid}.csv"
df.to_csv(dest, index=False)
print(f"{sid}: {len(df)} first-print rows "
f"{df['observed'].min():%Y-%m-%d} -> {df['observed'].max():%Y-%m-%d} => {dest.name}")
if __name__ == "__main__":
main()