- New institutional_reports table (DB) with importance, signals per asset class, key points, absorption tracking
- cot_fetcher.py: CFTC Socrata API (6dca-aqww), 7 instruments (Gold/Silver/Copper/WTI/NatGas/SP500/EURUSD), net positioning + 52-week z-score
- eia_fetcher.py: EIA API v2, 4 series (crude/Cushing/gasoline/distillates), WoW surprise detection
- institutional.py router: GET /reports, GET /reports/{id}, POST /refresh, GET /stats
- institutional_scheduler.py: weekly auto-fetch (COT Saturdays, EIA Wednesday afternoons)
- ai_analyzer.py: build_institutional_block() + institutional_block param injected into AI scoring prompt
- auto_cycle.py: inject institutional block into suggestion + scoring, absorption tracking via keyword overlap after each cycle commentary
- InstitutionalReports.tsx: full page with filter bar (type/category/importance/period), cards with key point bullets, EXTREME alerts highlighted, signal badges, absorption badge, trading implications, expandable detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
6.3 KiB
Python
166 lines
6.3 KiB
Python
"""
|
|
EIA Petroleum Weekly Status Report fetcher.
|
|
Uses EIA API v2 — free key stored in DB config as 'eia_api_key'.
|
|
"""
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EIA_BASE_URL = "https://api.eia.gov/v2/petroleum/stoc/wstk/data/"
|
|
|
|
EIA_SERIES: Dict[str, Dict[str, str]] = {
|
|
"WCRSTUS1": {"name": "US Crude Oil Stocks (ex-SPR)", "unit": "Mb"},
|
|
"WCUOK1A": {"name": "Cushing, OK Crude Stocks", "unit": "Mb"},
|
|
"WGTSTUS1": {"name": "US Total Gasoline Stocks", "unit": "Mb"},
|
|
"WDISTUS1": {"name": "US Distillate Fuel Oil Stocks", "unit": "Mb"},
|
|
}
|
|
|
|
SURPRISE_THRESHOLD_MB = 2.0
|
|
|
|
|
|
def _fetch_series(series_id: str, api_key: str, num_weeks: int = 8) -> List[Dict]:
|
|
params = {
|
|
"api_key": api_key,
|
|
"frequency": "weekly",
|
|
"data[]": "value",
|
|
"facets[series][]": series_id,
|
|
"sort[0][column]": "period",
|
|
"sort[0][direction]": "desc",
|
|
"length": num_weeks,
|
|
"offset": 0,
|
|
}
|
|
try:
|
|
resp = requests.get(EIA_BASE_URL, params=params, timeout=20)
|
|
resp.raise_for_status()
|
|
return resp.json().get("response", {}).get("data", [])
|
|
except Exception as e:
|
|
logger.warning(f"[EIA] Failed to fetch {series_id}: {e}")
|
|
return []
|
|
|
|
|
|
def fetch_eia_report(api_key: str) -> Optional[Dict]:
|
|
"""Fetch latest EIA petroleum weekly stocks. Returns None if key missing or all fetches fail."""
|
|
if not api_key:
|
|
logger.warning("[EIA] No API key — skipping")
|
|
return None
|
|
|
|
results: Dict[str, Any] = {}
|
|
report_dates: List[str] = []
|
|
|
|
for sid, meta in EIA_SERIES.items():
|
|
rows = _fetch_series(sid, api_key)
|
|
if not rows:
|
|
continue
|
|
current = rows[0]
|
|
prior = rows[1] if len(rows) > 1 else None
|
|
cur_val = float(current.get("value") or 0)
|
|
pri_val = float(prior.get("value") or 0) if prior else None
|
|
wow = round(cur_val - pri_val, 1) if pri_val is not None else None
|
|
|
|
results[sid] = {
|
|
"name": meta["name"],
|
|
"period": (current.get("period") or "")[:10],
|
|
"value_mb": round(cur_val, 1),
|
|
"prior_mb": round(pri_val, 1) if pri_val is not None else None,
|
|
"wow_change_mb": wow,
|
|
}
|
|
if current.get("period"):
|
|
report_dates.append(current["period"][:10])
|
|
|
|
if not results:
|
|
logger.warning("[EIA] No data returned")
|
|
return None
|
|
|
|
report_date = max(report_dates) if report_dates else datetime.utcnow().strftime("%Y-%m-%d")
|
|
|
|
key_points: List[str] = []
|
|
implications: List[str] = []
|
|
surprises: List[str] = []
|
|
signal_energy = "neutral"
|
|
|
|
crude = results.get("WCRSTUS1")
|
|
if crude:
|
|
wow_str = f"{crude['wow_change_mb']:+.1f} Mb" if crude["wow_change_mb"] is not None else "N/A"
|
|
kp = f"US crude oil stocks: {crude['value_mb']:.1f} Mb (WoW {wow_str})"
|
|
if crude["wow_change_mb"] is not None and abs(crude["wow_change_mb"]) >= SURPRISE_THRESHOLD_MB:
|
|
direction = "draw" if crude["wow_change_mb"] < 0 else "build"
|
|
kp += f" — significant {direction}"
|
|
surprises.append("crude")
|
|
signal_energy = "bullish" if crude["wow_change_mb"] < 0 else "bearish"
|
|
key_points.append(kp)
|
|
|
|
cushing = results.get("WCUOK1A")
|
|
if cushing:
|
|
wow_str = f"{cushing['wow_change_mb']:+.1f} Mb" if cushing["wow_change_mb"] is not None else "N/A"
|
|
key_points.append(f"Cushing, OK: {cushing['value_mb']:.1f} Mb (WoW {wow_str})")
|
|
|
|
gasoline = results.get("WGTSTUS1")
|
|
if gasoline:
|
|
wow_str = f"{gasoline['wow_change_mb']:+.1f} Mb" if gasoline["wow_change_mb"] is not None else "N/A"
|
|
kp = f"US gasoline stocks: {gasoline['value_mb']:.1f} Mb (WoW {wow_str})"
|
|
if gasoline["wow_change_mb"] is not None and abs(gasoline["wow_change_mb"]) >= SURPRISE_THRESHOLD_MB:
|
|
surprises.append("gasoline")
|
|
key_points.append(kp)
|
|
|
|
distillates = results.get("WDISTUS1")
|
|
if distillates:
|
|
wow_str = f"{distillates['wow_change_mb']:+.1f} Mb" if distillates["wow_change_mb"] is not None else "N/A"
|
|
kp = f"US distillate stocks: {distillates['value_mb']:.1f} Mb (WoW {wow_str})"
|
|
if distillates["wow_change_mb"] is not None and abs(distillates["wow_change_mb"]) >= SURPRISE_THRESHOLD_MB:
|
|
surprises.append("distillates")
|
|
key_points.append(kp)
|
|
|
|
crude_wow = (crude or {}).get("wow_change_mb")
|
|
if crude_wow is not None:
|
|
if crude_wow < -SURPRISE_THRESHOLD_MB:
|
|
implications.append(
|
|
f"Crude draw of {abs(crude_wow):.1f} Mb — bullish for WTI/Brent spot"
|
|
)
|
|
elif crude_wow > SURPRISE_THRESHOLD_MB:
|
|
implications.append(
|
|
f"Crude build of {crude_wow:.1f} Mb — bearish for WTI/Brent spot"
|
|
)
|
|
|
|
gasoline_wow = (gasoline or {}).get("wow_change_mb")
|
|
if gasoline_wow is not None and abs(gasoline_wow) >= SURPRISE_THRESHOLD_MB:
|
|
demand_str = "strong" if gasoline_wow < 0 else "weak"
|
|
implications.append(
|
|
f"Gasoline {'draw' if gasoline_wow < 0 else 'build'} ({gasoline_wow:+.1f} Mb) — {demand_str} driving demand"
|
|
)
|
|
|
|
if not implications:
|
|
implications = ["EIA petroleum stocks in line with seasonal norms — no major surprise"]
|
|
|
|
importance = 3 if len(surprises) >= 2 else 2 if len(surprises) == 1 else 1
|
|
crude_val_str = f"{crude['value_mb']:.1f}" if crude else "N/A"
|
|
crude_wow_str = f" (WoW {crude['wow_change_mb']:+.1f} Mb)" if crude and crude["wow_change_mb"] is not None else ""
|
|
|
|
ai_summary = (
|
|
f"EIA Petroleum Weekly ({report_date}). "
|
|
f"Crude: {crude_val_str} Mb{crude_wow_str}. "
|
|
f"Signal: {signal_energy.upper()}. "
|
|
+ (f"Surprises: {', '.join(surprises)}. " if surprises else "")
|
|
+ implications[0]
|
|
)
|
|
|
|
return {
|
|
"report_type": "eia",
|
|
"report_date": report_date,
|
|
"title": f"EIA Petroleum Weekly — {report_date}",
|
|
"source": "EIA API v2",
|
|
"importance": importance,
|
|
"category": "energy",
|
|
"raw_data": results,
|
|
"key_points": key_points,
|
|
"trading_implications": " | ".join(implications),
|
|
"signal_energy": signal_energy,
|
|
"signal_metals": "neutral",
|
|
"signal_indices": "neutral",
|
|
"signal_forex": "neutral",
|
|
"ai_summary": ai_summary,
|
|
}
|