feat: institutional reports — CFTC COT + EIA petroleum weekly

- 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>
This commit is contained in:
OpenSquared
2026-06-22 13:45:07 +02:00
parent 4423ad91db
commit 3edbd6b0b7
12 changed files with 1228 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ from routers import market_data, geopolitical, options, backtest, ai, portfolio,
from routers import logs as logs_router from routers import logs as logs_router
from routers import var as var_router from routers import var as var_router
from routers import reports as reports_router from routers import reports as reports_router
from routers import institutional as institutional_router
from services.database import init_db, get_config, cleanup_stale_running_cycles from services.database import init_db, get_config, cleanup_stale_running_cycles
import os import os
import logging import logging
@@ -80,6 +81,9 @@ def startup():
from services.var_scheduler import start_var_scheduler, start_pnl_scheduler from services.var_scheduler import start_var_scheduler, start_pnl_scheduler
start_var_scheduler() start_var_scheduler()
start_pnl_scheduler() start_pnl_scheduler()
# Start institutional reports weekly scheduler (COT Fridays, EIA Wednesdays)
from services.institutional_scheduler import start_institutional_scheduler
start_institutional_scheduler()
@app.on_event("shutdown") @app.on_event("shutdown")
@@ -89,6 +93,8 @@ def shutdown():
from services.var_scheduler import stop_var_scheduler, stop_pnl_scheduler from services.var_scheduler import stop_var_scheduler, stop_pnl_scheduler
stop_var_scheduler() stop_var_scheduler()
stop_pnl_scheduler() stop_pnl_scheduler()
from services.institutional_scheduler import stop_institutional_scheduler
stop_institutional_scheduler()
app.include_router(market_data.router) app.include_router(market_data.router)
@@ -110,6 +116,7 @@ app.include_router(risk_router.router)
app.include_router(logs_router.router) app.include_router(logs_router.router)
app.include_router(var_router.router) app.include_router(var_router.router)
app.include_router(reports_router.router) app.include_router(reports_router.router)
app.include_router(institutional_router.router)
@app.get("/") @app.get("/")

View File

@@ -0,0 +1,222 @@
"""
Institutional reports router — CFTC COT + EIA petroleum weekly.
GET /api/institutional/reports — list with filters
GET /api/institutional/reports/{id} — single report detail
POST /api/institutional/refresh — trigger fetch
GET /api/institutional/stats — counts + latest dates
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from services.database import get_conn, get_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/institutional", tags=["institutional"])
# ── DB helpers ─────────────────────────────────────────────────────────────────
def _row_to_dict(row) -> Dict:
d = dict(row)
for field in ("raw_data_json", "key_points_json"):
raw = d.pop(field, None) or ("[]" if "key_points" in field else "{}")
try:
d[field.replace("_json", "")] = json.loads(raw)
except Exception:
d[field.replace("_json", "")] = [] if "key_points" in field else {}
return d
def save_institutional_report(report: Dict) -> Optional[int]:
"""Insert or update an institutional report. Returns row id."""
conn = get_conn()
try:
existing = conn.execute(
"SELECT id FROM institutional_reports WHERE report_type=? AND report_date=?",
(report["report_type"], report["report_date"]),
).fetchone()
raw_json = json.dumps(report.get("raw_data", {}), ensure_ascii=False)
kp_json = json.dumps(report.get("key_points", []), ensure_ascii=False)
if existing:
conn.execute(
"""UPDATE institutional_reports SET
fetch_date=datetime('now'), raw_data_json=?, key_points_json=?,
trading_implications=?, signal_energy=?, signal_metals=?,
signal_indices=?, signal_forex=?, ai_summary=?, importance=?,
title=?
WHERE id=?""",
(
raw_json, kp_json,
report.get("trading_implications", ""),
report.get("signal_energy", "neutral"),
report.get("signal_metals", "neutral"),
report.get("signal_indices", "neutral"),
report.get("signal_forex", "neutral"),
report.get("ai_summary", ""),
report.get("importance", 2),
report.get("title", ""),
existing["id"],
),
)
conn.commit()
return existing["id"]
cursor = conn.execute(
"""INSERT INTO institutional_reports
(report_type, report_date, title, source, importance, category,
raw_data_json, key_points_json, trading_implications,
signal_energy, signal_metals, signal_indices, signal_forex, ai_summary)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
report["report_type"],
report["report_date"],
report.get("title", ""),
report.get("source", ""),
report.get("importance", 2),
report.get("category", "multi"),
raw_json, kp_json,
report.get("trading_implications", ""),
report.get("signal_energy", "neutral"),
report.get("signal_metals", "neutral"),
report.get("signal_indices", "neutral"),
report.get("signal_forex", "neutral"),
report.get("ai_summary", ""),
),
)
conn.commit()
return cursor.lastrowid
finally:
conn.close()
# ── Routes ─────────────────────────────────────────────────────────────────────
@router.get("/reports")
def list_reports(
report_type: Optional[str] = Query(None),
category: Optional[str] = Query(None),
importance: Optional[int] = Query(None),
days: int = Query(90),
limit: int = Query(50),
):
conn = get_conn()
try:
conditions = ["1=1"]
params: List[Any] = []
if report_type:
conditions.append("report_type = ?")
params.append(report_type)
if category:
conditions.append("category = ?")
params.append(category)
if importance:
conditions.append("importance >= ?")
params.append(importance)
if days:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
conditions.append("report_date >= ?")
params.append(cutoff)
where = " AND ".join(conditions)
rows = conn.execute(
f"SELECT * FROM institutional_reports WHERE {where} "
f"ORDER BY report_date DESC, importance DESC LIMIT ?",
params + [limit],
).fetchall()
return [_row_to_dict(r) for r in rows]
finally:
conn.close()
@router.get("/reports/{report_id}")
def get_report(report_id: int):
conn = get_conn()
try:
row = conn.execute(
"SELECT * FROM institutional_reports WHERE id=?", (report_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Report not found")
return _row_to_dict(row)
finally:
conn.close()
@router.post("/refresh")
def refresh_reports(report_type: Optional[str] = Query(None)):
"""Trigger a live fetch of COT and/or EIA reports."""
results: Dict[str, str] = {}
if not report_type or report_type == "cot":
try:
from services.cot_fetcher import fetch_cot_report
report = fetch_cot_report()
if report:
save_institutional_report(report)
results["cot"] = "ok"
logger.info(f"[Institutional] COT report saved: {report['report_date']}")
else:
results["cot"] = "no_data"
except Exception as e:
logger.warning(f"[Institutional] COT refresh failed: {e}")
results["cot"] = f"error: {str(e)[:100]}"
if not report_type or report_type == "eia":
try:
eia_key = get_config("eia_api_key") or ""
if eia_key:
from services.eia_fetcher import fetch_eia_report
report = fetch_eia_report(eia_key)
if report:
save_institutional_report(report)
results["eia"] = "ok"
logger.info(f"[Institutional] EIA report saved: {report['report_date']}")
else:
results["eia"] = "no_data"
else:
results["eia"] = "no_api_key"
except Exception as e:
logger.warning(f"[Institutional] EIA refresh failed: {e}")
results["eia"] = f"error: {str(e)[:100]}"
return {"status": "done", "results": results}
@router.get("/stats")
def get_stats():
conn = get_conn()
try:
total = conn.execute("SELECT COUNT(*) FROM institutional_reports").fetchone()[0]
by_type = dict(conn.execute(
"SELECT report_type, COUNT(*) FROM institutional_reports GROUP BY report_type"
).fetchall())
latest_cot = conn.execute(
"SELECT MAX(report_date) FROM institutional_reports WHERE report_type='cot'"
).fetchone()[0]
latest_eia = conn.execute(
"SELECT MAX(report_date) FROM institutional_reports WHERE report_type='eia'"
).fetchone()[0]
absorbed = conn.execute(
"SELECT COUNT(*) FROM institutional_reports "
"WHERE absorbed_score IS NOT NULL AND absorbed_score > 0.3"
).fetchone()[0]
high_importance = conn.execute(
"SELECT COUNT(*) FROM institutional_reports WHERE importance=3"
).fetchone()[0]
return {
"total": total,
"by_type": by_type,
"latest_cot": latest_cot,
"latest_eia": latest_eia,
"absorbed_count": absorbed,
"high_importance_count": high_importance,
}
finally:
conn.close()

View File

@@ -393,6 +393,7 @@ def score_patterns_with_context(
fred_block: str = "", fred_block: str = "",
price_discovery_block: str = "", price_discovery_block: str = "",
portfolio_context_block: str = "", portfolio_context_block: str = "",
institutional_block: str = "",
run_id: str = "", run_id: str = "",
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o.""" """Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
@@ -616,6 +617,7 @@ Scoring instructions:
_fred_sc_section = f"\n{fred_block}\n" if fred_block else "" _fred_sc_section = f"\n{fred_block}\n" if fred_block else ""
_pd_sc_section = f"\n{price_discovery_block}\n" if price_discovery_block else "" _pd_sc_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
_portfolio_sc_section = f"\n{portfolio_context_block}\n" if portfolio_context_block else "" _portfolio_sc_section = f"\n{portfolio_context_block}\n" if portfolio_context_block else ""
_inst_sc_section = f"\n{institutional_block}\n" if institutional_block else ""
user = f"""GLOBAL CONTEXT: user = f"""GLOBAL CONTEXT:
- Geopolitical risk score: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) - Geopolitical risk score: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
@@ -625,6 +627,7 @@ Scoring instructions:
{_fred_sc_section} {_fred_sc_section}
{_pd_sc_section} {_pd_sc_section}
{_tech_sc_section} {_tech_sc_section}
{_inst_sc_section}
{_portfolio_sc_section} {_portfolio_sc_section}
SCORING TEMPLATE: SCORING TEMPLATE:
{scoring_template} {scoring_template}
@@ -1194,6 +1197,51 @@ def _build_temporal_news_block(partitioned: Dict[str, List], cycle_meta: Dict) -
return block return block
def build_institutional_block(days: int = 7) -> str:
"""Build a concise institutional reports block for injection into AI prompts."""
try:
from services.database import get_conn
conn = get_conn()
try:
from datetime import datetime as _dt, timedelta as _td
cutoff = (_dt.utcnow() - _td(days=days)).strftime("%Y-%m-%d")
rows = conn.execute(
"SELECT report_type, report_date, key_points_json, trading_implications, "
"signal_energy, signal_metals, signal_indices, signal_forex, importance "
"FROM institutional_reports WHERE report_date >= ? ORDER BY report_date DESC LIMIT 6",
(cutoff,),
).fetchall()
finally:
conn.close()
if not rows:
return ""
import json as _json
lines = ["## INSTITUTIONAL REPORTS (CFTC COT + EIA — last 7 days)"]
for r in rows:
rtype = r["report_type"].upper()
rdate = r["report_date"]
importance_str = "★★★" if r["importance"] == 3 else "★★" if r["importance"] == 2 else ""
signals = (
f"Energy={r['signal_energy']} | Metals={r['signal_metals']} | "
f"Indices={r['signal_indices']} | Forex={r['signal_forex']}"
)
lines.append(f"\n### {rtype} {rdate} {importance_str}{signals}")
try:
kps = _json.loads(r["key_points_json"] or "[]")
for kp in kps[:4]:
lines.append(f"{kp}")
except Exception:
pass
if r["trading_implications"]:
lines.append(f" → Implications: {r['trading_implications'][:200]}")
return "\n".join(lines)
except Exception as _e:
return ""
def suggest_patterns_from_market_context( def suggest_patterns_from_market_context(
news: List[Dict], news: List[Dict],
quotes_by_class: Dict[str, List[Dict]], quotes_by_class: Dict[str, List[Dict]],

View File

@@ -14,7 +14,7 @@ Cycle steps:
import logging import logging
import threading import threading
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -551,6 +551,16 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
except Exception as _cbe: except Exception as _cbe:
logger.warning(f"[Cycle] Convergence block build failed (non-blocking): {_cbe}") logger.warning(f"[Cycle] Convergence block build failed (non-blocking): {_cbe}")
# ── Build institutional reports block ──────────────────────────
_institutional_block = ""
try:
from services.ai_analyzer import build_institutional_block
_institutional_block = build_institutional_block(days=7)
if _institutional_block:
logger.info(f"[Cycle {run_id[:16]}] Institutional block injected")
except Exception as _ibe:
logger.warning(f"[Cycle] Institutional block failed (non-blocking): {_ibe}")
suggestions = suggest_patterns_from_market_context( suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons, portfolio_lessons=portfolio_lessons,
@@ -705,6 +715,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
fred_block=_fred_block, fred_block=_fred_block,
price_discovery_block=_price_discovery_block, price_discovery_block=_price_discovery_block,
portfolio_context_block=_portfolio_block, portfolio_context_block=_portfolio_block,
institutional_block=_institutional_block,
run_id=run_id, run_id=run_id,
) )
scored_with_id = [s for s in scored if s.get("pattern_id")] scored_with_id = [s for s in scored if s.get("pattern_id")]
@@ -991,6 +1002,56 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
_current_status["last_run_at"] = datetime.utcnow().isoformat() _current_status["last_run_at"] = datetime.utcnow().isoformat()
logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored") logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored")
# ── Step 6.1: Institutional report absorption tracking ───────────────
try:
from services.database import get_conn as _get_conn
import json as _json
_commentary_text = ""
if commentary:
try:
_c = _json.loads(commentary) if isinstance(commentary, str) else commentary
_commentary_text = " ".join([
str(_c.get("narrative", "")),
str(_c.get("key_catalyst", "")),
" ".join(str(x) for x in (_c.get("risks", []) or [])),
]).lower()
except Exception:
_commentary_text = str(commentary).lower()
if _commentary_text:
_conn = _get_conn()
try:
cutoff = (datetime.utcnow() - timedelta(days=14)).strftime("%Y-%m-%d")
_inst_rows = _conn.execute(
"SELECT id, key_points_json FROM institutional_reports "
"WHERE report_date >= ? AND (absorbed_score IS NULL OR absorbed_score = 0)",
(cutoff,),
).fetchall()
for _ir in _inst_rows:
try:
_kps = _json.loads(_ir["key_points_json"] or "[]")
_kp_words = set(
w for kp in _kps for w in kp.lower().split()
if len(w) > 4
)
if not _kp_words:
continue
_overlap = sum(1 for w in _kp_words if w in _commentary_text)
_score = min(1.0, _overlap / max(len(_kp_words), 1))
if _score > 0:
_conn.execute(
"UPDATE institutional_reports SET absorbed_score=?, injected_in_cycle_id=? WHERE id=?",
(round(_score, 3), run_id, _ir["id"]),
)
except Exception:
pass
_conn.commit()
logger.info(f"[Cycle {run_id[:16]}] Absorption tracked for {len(_inst_rows)} institutional reports")
finally:
_conn.close()
except Exception as _abs_e:
logger.warning(f"[Cycle] Absorption tracking failed (non-blocking): {_abs_e}")
# ── Step 6.5: Generate full cycle report ───────────────────────────── # ── Step 6.5: Generate full cycle report ─────────────────────────────
try: try:
_cycle_report = _generate_cycle_report( _cycle_report = _generate_cycle_report(

View File

@@ -0,0 +1,194 @@
"""
CFTC Commitment of Traders (COT) weekly fetcher.
Data from CFTC Socrata public API — no API key required.
"""
import logging
import math
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
SOCRATA_URL = "https://publicreporting.cftc.gov/resource/6dca-aqww.json"
COT_INSTRUMENTS = {
"088691": {"name": "Gold", "category": "metals", "signal_field": "signal_metals"},
"084691": {"name": "Silver", "category": "metals", "signal_field": "signal_metals"},
"085692": {"name": "Copper", "category": "metals", "signal_field": "signal_metals"},
"067651": {"name": "Crude Oil (WTI)", "category": "energy", "signal_field": "signal_energy"},
"023651": {"name": "Natural Gas", "category": "energy", "signal_field": "signal_energy"},
"13874+": {"name": "S&P 500", "category": "equities", "signal_field": "signal_indices"},
"099741": {"name": "EUR/USD", "category": "forex", "signal_field": "signal_forex"},
}
_SIGNAL_PRIORITY = {"bullish": 3, "bearish": 3, "neutral": 0}
def _fetch_cot_history(contract_code: str, weeks: int = 56) -> List[Dict]:
cutoff = (datetime.utcnow() - timedelta(weeks=weeks)).strftime("%Y-%m-%dT00:00:00.000")
params = {
"$where": f"cftc_contract_market_code='{contract_code}' AND report_date_as_yyyy_mm_dd>='{cutoff}'",
"$order": "report_date_as_yyyy_mm_dd DESC",
"$limit": weeks + 5,
"$select": (
"report_date_as_yyyy_mm_dd,"
"noncomm_positions_long_all,noncomm_positions_short_all,"
"comm_positions_long_all,comm_positions_short_all,"
"open_interest_all"
),
}
try:
resp = requests.get(SOCRATA_URL, params=params, timeout=25)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.warning(f"[COT] Failed to fetch {contract_code}: {e}")
return []
def _compute_net_and_zscore(rows: List[Dict]) -> Optional[Dict]:
if not rows:
return None
nets = []
for row in rows:
try:
longs = float(row.get("noncomm_positions_long_all") or 0)
shorts = float(row.get("noncomm_positions_short_all") or 0)
nets.append(longs - shorts)
except (TypeError, ValueError):
continue
if not nets:
return None
current_net = nets[0]
latest_date = rows[0].get("report_date_as_yyyy_mm_dd", "")[:10]
history_52w = nets[:52]
if len(history_52w) >= 4:
mean = sum(history_52w) / len(history_52w)
variance = sum((x - mean) ** 2 for x in history_52w) / len(history_52w)
std = math.sqrt(variance) if variance > 0 else 1.0
z_score = (current_net - mean) / std
else:
mean = current_net
z_score = 0.0
week_change = current_net - nets[1] if len(nets) > 1 else 0.0
return {
"report_date": latest_date,
"net_positioning": round(current_net),
"week_change": round(week_change),
"z_score": round(z_score, 2),
"mean_52w": round(mean),
"history_count": len(history_52w),
}
def _signal_from_zscore(z: float) -> str:
if z >= 1.5:
return "bullish"
elif z <= -1.5:
return "bearish"
return "neutral"
def fetch_cot_report() -> Optional[Dict]:
"""Fetch latest COT data for all tracked instruments and return structured report dict."""
results: Dict[str, Any] = {}
report_dates = []
for code, meta in COT_INSTRUMENTS.items():
rows = _fetch_cot_history(code)
analysis = _compute_net_and_zscore(rows)
if analysis:
results[meta["name"]] = {
**analysis,
"category": meta["category"],
"signal_field": meta["signal_field"],
"signal": _signal_from_zscore(analysis["z_score"]),
}
if analysis["report_date"]:
report_dates.append(analysis["report_date"])
if not results:
logger.warning("[COT] No results returned from CFTC API")
return None
report_date = max(report_dates) if report_dates else datetime.utcnow().strftime("%Y-%m-%d")
signals = {
"signal_energy": "neutral",
"signal_metals": "neutral",
"signal_indices": "neutral",
"signal_forex": "neutral",
}
key_points: List[str] = []
extremes: List[str] = []
for name, data in results.items():
z = data["z_score"]
net_k = round(data["net_positioning"] / 1000, 1)
change_k = round(data["week_change"] / 1000, 1)
kp = f"{name}: net {net_k:+.0f}k contracts (z-score {z:+.2f})"
if abs(z) >= 1.5:
kp += " — EXTREME"
extremes.append(name)
elif abs(z) >= 0.8:
kp += " — notable"
if abs(change_k) >= 5:
dir_str = "+" if change_k >= 0 else ""
kp += f", WoW {dir_str}{change_k:.0f}k"
key_points.append(kp)
sf = data["signal_field"]
new_sig = data["signal"]
if _SIGNAL_PRIORITY.get(new_sig, 0) > _SIGNAL_PRIORITY.get(signals.get(sf, "neutral"), 0):
signals[sf] = new_sig
implications: List[str] = []
for name, data in results.items():
z = data["z_score"]
if z >= 1.5:
implications.append(
f"Extreme long spec positioning in {name} — crowded trade, watch for reversal"
)
elif z <= -1.5:
implications.append(
f"Extreme short spec positioning in {name} — short squeeze risk"
)
elif z >= 1.0:
implications.append(f"Bullish spec momentum building in {name}")
elif z <= -1.0:
implications.append(f"Bearish spec momentum in {name}")
if not implications:
implications = ["COT positioning broadly neutral — no directional extreme this week"]
importance = 3 if extremes else 2
ai_summary = (
f"CFTC COT weekly ({report_date}). "
f"Tracked {len(results)} instruments. "
+ (f"Extreme readings: {', '.join(extremes)}. " if extremes else "")
+ f"Energy={signals['signal_energy']}, Metals={signals['signal_metals']}, "
f"Indices={signals['signal_indices']}, Forex={signals['signal_forex']}."
)
return {
"report_type": "cot",
"report_date": report_date,
"title": f"CFTC COT Weekly — {report_date}",
"source": "CFTC Socrata API",
"importance": importance,
"category": "multi",
"raw_data": results,
"key_points": key_points,
"trading_implications": " | ".join(implications),
**signals,
"ai_summary": ai_summary,
}

View File

@@ -549,6 +549,33 @@ def init_db():
except Exception: except Exception:
pass pass
c.execute("""CREATE TABLE IF NOT EXISTS institutional_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_type TEXT NOT NULL,
report_date TEXT NOT NULL,
fetch_date TEXT NOT NULL DEFAULT (datetime('now')),
title TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
importance INTEGER NOT NULL DEFAULT 2,
category TEXT NOT NULL DEFAULT 'multi',
raw_data_json TEXT DEFAULT '{}',
key_points_json TEXT DEFAULT '[]',
trading_implications TEXT DEFAULT '',
signal_energy TEXT DEFAULT 'neutral',
signal_metals TEXT DEFAULT 'neutral',
signal_indices TEXT DEFAULT 'neutral',
signal_forex TEXT DEFAULT 'neutral',
ai_summary TEXT DEFAULT '',
injected_in_cycle_id TEXT DEFAULT NULL,
absorbed_score REAL DEFAULT NULL,
UNIQUE(report_type, report_date)
)""")
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_inst_type_date ON institutional_reports(report_type, report_date DESC)")
c.execute("CREATE INDEX IF NOT EXISTS idx_inst_importance ON institutional_reports(importance DESC, report_date DESC)")
except Exception:
pass
try: try:
c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)")
c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)")

View File

@@ -0,0 +1,165 @@
"""
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,
}

View File

@@ -0,0 +1,104 @@
"""
Weekly scheduler for institutional reports:
- CFTC COT: released every Friday ~15:30 ET → fetch Saturday UTC
- EIA Petroleum Weekly: released every Wednesday ~10:30 ET → fetch Wednesday afternoon UTC
Checks once per hour; only fetches when the day matches and report not yet fetched this week.
"""
import logging
import threading
from datetime import datetime, timedelta
from typing import Optional
logger = logging.getLogger(__name__)
_stop_event = threading.Event()
_thread: Optional[threading.Thread] = None
_CHECK_INTERVAL_S = 3600 # check every hour
def _should_fetch_cot() -> bool:
"""Saturday UTC = day after COT release."""
now = datetime.utcnow()
return now.weekday() == 5 # Saturday
def _should_fetch_eia() -> bool:
"""Wednesday afternoon UTC."""
now = datetime.utcnow()
return now.weekday() == 2 and now.hour >= 16 # Wednesday ≥16:00 UTC
def _last_fetch_date(report_type: str) -> Optional[str]:
try:
from services.database import get_conn
conn = get_conn()
try:
row = conn.execute(
"SELECT MAX(fetch_date) FROM institutional_reports WHERE report_type=?",
(report_type,),
).fetchone()
return (row[0] or "")[:10] if row else None
finally:
conn.close()
except Exception:
return None
def _run_loop():
logger.info("[InstitutionalScheduler] Started")
while not _stop_event.is_set():
try:
today = datetime.utcnow().strftime("%Y-%m-%d")
if _should_fetch_cot():
last = _last_fetch_date("cot")
if last != today:
logger.info("[InstitutionalScheduler] Fetching COT...")
try:
from services.cot_fetcher import fetch_cot_report
from routers.institutional import save_institutional_report
report = fetch_cot_report()
if report:
save_institutional_report(report)
logger.info(f"[InstitutionalScheduler] COT saved: {report['report_date']}")
except Exception as e:
logger.warning(f"[InstitutionalScheduler] COT fetch failed: {e}")
if _should_fetch_eia():
last = _last_fetch_date("eia")
if last != today:
logger.info("[InstitutionalScheduler] Fetching EIA...")
try:
from services.database import get_config
from services.eia_fetcher import fetch_eia_report
from routers.institutional import save_institutional_report
key = get_config("eia_api_key") or ""
if key:
report = fetch_eia_report(key)
if report:
save_institutional_report(report)
logger.info(f"[InstitutionalScheduler] EIA saved: {report['report_date']}")
else:
logger.info("[InstitutionalScheduler] EIA skipped — no API key configured")
except Exception as e:
logger.warning(f"[InstitutionalScheduler] EIA fetch failed: {e}")
except Exception as e:
logger.warning(f"[InstitutionalScheduler] Loop error: {e}")
_stop_event.wait(_CHECK_INTERVAL_S)
logger.info("[InstitutionalScheduler] Stopped")
def start_institutional_scheduler():
global _thread
_stop_event.clear()
_thread = threading.Thread(target=_run_loop, daemon=True, name="institutional-scheduler")
_thread.start()
def stop_institutional_scheduler():
_stop_event.set()
if _thread and _thread.is_alive():
_thread.join(timeout=5)

View File

@@ -19,6 +19,7 @@ import RiskDashboard from './pages/RiskDashboard'
import SystemLogs from './pages/SystemLogs' import SystemLogs from './pages/SystemLogs'
import VaRAnalysis from './pages/VaRAnalysis' import VaRAnalysis from './pages/VaRAnalysis'
import PositionHistory from './pages/PositionHistory' import PositionHistory from './pages/PositionHistory'
import InstitutionalReports from './pages/InstitutionalReports'
import { useCycleWatcher } from './hooks/useApi' import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() { function GlobalWatcher() {
@@ -53,6 +54,7 @@ export default function App() {
<Route path="/var" element={<VaRAnalysis />} /> <Route path="/var" element={<VaRAnalysis />} />
<Route path="/position-history" element={<PositionHistory />} /> <Route path="/position-history" element={<PositionHistory />} />
<Route path="/logs" element={<SystemLogs />} /> <Route path="/logs" element={<SystemLogs />} />
<Route path="/institutional" element={<InstitutionalReports />} />
</Routes> </Routes>
</main> </main>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { import {
LayoutDashboard, Globe, BarChart2, FlaskConical, LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2
} from 'lucide-react' } from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx' import clsx from 'clsx'
@@ -24,6 +24,7 @@ const nav = [
{ to: '/position-history', icon: GitCompare, label: 'Position History' }, { to: '/position-history', icon: GitCompare, label: 'Position History' },
{ to: '/backtest', icon: History, label: 'Backtest' }, { to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendar' }, { to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' }, { to: '/logs', icon: ScrollText, label: 'System Logs' },
{ to: '/config', icon: Settings, label: 'Configuration' }, { to: '/config', icon: Settings, label: 'Configuration' },
] ]

View File

@@ -914,3 +914,32 @@ export const useRemoveWatchlistTicker = () =>
useMutation({ useMutation({
mutationFn: (ticker: string) => api.delete(`/options-vol/watchlist-tickers/${encodeURIComponent(ticker)}`).then(r => r.data), mutationFn: (ticker: string) => api.delete(`/options-vol/watchlist-tickers/${encodeURIComponent(ticker)}`).then(r => r.data),
}) })
// ── Institutional Reports ─────────────────────────────────────────────────────
export const useInstitutionalReports = (params: {
report_type?: string
category?: string
importance?: number
days?: number
}) =>
useQuery({
queryKey: ['institutional-reports', params],
queryFn: () =>
api.get('/institutional/reports', { params }).then(r => r.data),
refetchInterval: 3_600_000,
})
export const useInstitutionalStats = () =>
useQuery({
queryKey: ['institutional-stats'],
queryFn: () => api.get('/institutional/stats').then(r => r.data),
staleTime: 300_000,
})
export const useRefreshInstitutionalReports = () =>
useMutation({
mutationFn: (report_type?: string) =>
api.post('/institutional/refresh', null, { params: report_type ? { report_type } : {} }).then(r => r.data),
})

View File

@@ -0,0 +1,366 @@
import { useState } from 'react'
import { useInstitutionalReports, useInstitutionalStats, useRefreshInstitutionalReports } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import { RefreshCw, TrendingUp, TrendingDown, Minus, AlertTriangle, Star, CheckCircle, Clock } from 'lucide-react'
import clsx from 'clsx'
import { format } from 'date-fns'
interface InstitutionalReport {
id: number
report_type: string
report_date: string
fetch_date: string
title: string
source: string
importance: number
category: string
key_points: string[]
trading_implications: string
signal_energy: string
signal_metals: string
signal_indices: string
signal_forex: string
ai_summary: string
absorbed_score: number | null
injected_in_cycle_id: string | null
}
const SIGNAL_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
bullish: { label: 'Bullish', color: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40', icon: <TrendingUp className="w-3 h-3" /> },
bearish: { label: 'Bearish', color: 'text-red-400 bg-red-900/30 border-red-700/40', icon: <TrendingDown className="w-3 h-3" /> },
neutral: { label: 'Neutral', color: 'text-slate-400 bg-slate-800/50 border-slate-700/40', icon: <Minus className="w-3 h-3" /> },
}
const ASSET_CLASSES = ['energy', 'metals', 'indices', 'forex'] as const
function SignalBadge({ signal, label }: { signal: string; label: string }) {
const cfg = SIGNAL_CONFIG[signal] ?? SIGNAL_CONFIG.neutral
return (
<span className={clsx('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-xs font-medium', cfg.color)}>
{cfg.icon}
<span className="text-slate-400 mr-0.5">{label}:</span>
{cfg.label}
</span>
)
}
function ImportanceStars({ n }: { n: number }) {
return (
<span className="flex items-center gap-0.5">
{[1, 2, 3].map(i => (
<Star
key={i}
className={clsx('w-3 h-3', i <= n ? 'text-amber-400 fill-amber-400' : 'text-slate-700')}
/>
))}
</span>
)
}
function AbsorptionBadge({ score }: { score: number | null }) {
if (score === null || score === undefined) {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-slate-700/40 bg-slate-800/50 text-slate-500 text-xs">
<Clock className="w-3 h-3" />
Not absorbed
</span>
)
}
const pct = Math.round(score * 100)
const isAbsorbed = pct >= 30
return (
<span className={clsx(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-xs font-medium',
isAbsorbed
? 'text-blue-400 bg-blue-900/30 border-blue-700/40'
: 'text-slate-500 bg-slate-800/50 border-slate-700/40'
)}>
<CheckCircle className="w-3 h-3" />
{isAbsorbed ? `Absorbed ${pct}%` : `Low ${pct}%`}
</span>
)
}
function ReportCard({ report }: { report: InstitutionalReport }) {
const [expanded, setExpanded] = useState(false)
const isCot = report.report_type === 'cot'
const typeColor = isCot ? 'bg-purple-900/30 text-purple-300 border-purple-700/40' : 'bg-orange-900/30 text-orange-300 border-orange-700/40'
return (
<div className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden hover:border-slate-600/60 transition-colors">
{/* Header */}
<div className="p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<span className={clsx('px-2 py-0.5 rounded border text-xs font-bold uppercase tracking-wider', typeColor)}>
{report.report_type}
</span>
<ImportanceStars n={report.importance} />
<span className="text-slate-500 text-xs">
{report.report_date}
</span>
<AbsorptionBadge score={report.absorbed_score} />
</div>
<div className="text-white font-medium text-sm truncate">{report.title}</div>
<div className="text-slate-500 text-xs mt-0.5">{report.source}</div>
</div>
<button
onClick={() => setExpanded(e => !e)}
className="text-xs text-slate-400 hover:text-white shrink-0 border border-slate-700/40 rounded px-2 py-1"
>
{expanded ? 'Collapse' : 'Expand'}
</button>
</div>
{/* Signal badges row */}
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
<SignalBadge signal={report.signal_energy} label="Energy" />
<SignalBadge signal={report.signal_metals} label="Metals" />
<SignalBadge signal={report.signal_indices} label="Indices" />
<SignalBadge signal={report.signal_forex} label="Forex" />
</div>
{/* Key points preview (always visible) */}
{report.key_points?.length > 0 && (
<div className="px-4 pb-3">
<ul className="space-y-1">
{(expanded ? report.key_points : report.key_points.slice(0, 3)).map((kp, i) => {
const isExtreme = kp.includes('EXTREME')
return (
<li key={i} className={clsx(
'text-xs flex items-start gap-1.5',
isExtreme ? 'text-amber-300' : 'text-slate-300'
)}>
{isExtreme
? <AlertTriangle className="w-3 h-3 text-amber-400 shrink-0 mt-0.5" />
: <span className="w-3 text-center text-slate-600 shrink-0"></span>
}
{kp}
</li>
)
})}
{!expanded && report.key_points.length > 3 && (
<li className="text-xs text-slate-600">+{report.key_points.length - 3} more</li>
)}
</ul>
</div>
)}
{/* Expanded: trading implications + AI summary */}
{expanded && (
<div className="border-t border-slate-700/40 p-4 space-y-3">
{report.trading_implications && (
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
Trading Implications
</div>
<div className="bg-amber-900/10 border border-amber-700/30 rounded p-2.5">
{report.trading_implications.split(' | ').map((impl, i) => (
<p key={i} className="text-xs text-amber-200 leading-relaxed">{impl}</p>
))}
</div>
</div>
)}
{report.ai_summary && (
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
AI Summary
</div>
<p className="text-xs text-slate-400 leading-relaxed">{report.ai_summary}</p>
</div>
)}
{report.injected_in_cycle_id && (
<div className="text-xs text-slate-600">
Injected in cycle: <span className="font-mono">{report.injected_in_cycle_id.slice(0, 16)}</span>
</div>
)}
</div>
)}
</div>
)
}
export default function InstitutionalReports() {
const qc = useQueryClient()
const [filterType, setFilterType] = useState<string>('')
const [filterCategory, setFilterCategory] = useState<string>('')
const [filterImportance, setFilterImportance] = useState<number>(0)
const [filterDays, setFilterDays] = useState<number>(90)
const params = {
...(filterType ? { report_type: filterType } : {}),
...(filterCategory ? { category: filterCategory } : {}),
...(filterImportance ? { importance: filterImportance } : {}),
days: filterDays,
}
const { data: reports = [], isLoading, isFetching } = useInstitutionalReports(params)
const { data: stats } = useInstitutionalStats()
const refresh = useRefreshInstitutionalReports()
const handleRefresh = (type?: string) => {
refresh.mutate(type, {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['institutional-reports'] })
qc.invalidateQueries({ queryKey: ['institutional-stats'] })
},
})
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-white">Institutional Reports</h1>
<p className="text-slate-400 text-sm mt-0.5">
CFTC COT (weekly) + EIA Petroleum (weekly) positioning & supply signals
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleRefresh('cot')}
disabled={refresh.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-purple-900/40 text-purple-300 border border-purple-700/40 rounded hover:bg-purple-800/50 disabled:opacity-50"
>
<RefreshCw className={clsx('w-3.5 h-3.5', refresh.isPending && 'animate-spin')} />
Refresh COT
</button>
<button
onClick={() => handleRefresh('eia')}
disabled={refresh.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40 rounded hover:bg-orange-800/50 disabled:opacity-50"
>
<RefreshCw className={clsx('w-3.5 h-3.5', refresh.isPending && 'animate-spin')} />
Refresh EIA
</button>
</div>
</div>
{/* Stats bar */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Total reports', value: stats.total },
{ label: 'COT reports', value: stats.by_type?.cot ?? 0 },
{ label: 'EIA reports', value: stats.by_type?.eia ?? 0 },
{ label: 'Absorbed (>30%)', value: stats.absorbed_count },
].map(({ label, value }) => (
<div key={label} className="bg-dark-800 border border-slate-700/40 rounded p-3 text-center">
<div className="text-2xl font-bold text-white">{value}</div>
<div className="text-xs text-slate-500 mt-0.5">{label}</div>
</div>
))}
</div>
)}
{/* Latest dates */}
{stats && (stats.latest_cot || stats.latest_eia) && (
<div className="flex gap-4 text-xs text-slate-500">
{stats.latest_cot && <span>Latest COT: <span className="text-slate-300">{stats.latest_cot}</span></span>}
{stats.latest_eia && <span>Latest EIA: <span className="text-slate-300">{stats.latest_eia}</span></span>}
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-3 bg-dark-800 border border-slate-700/40 rounded-lg p-3">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Type:</span>
<div className="flex gap-1">
{['', 'cot', 'eia'].map(t => (
<button
key={t}
onClick={() => setFilterType(t)}
className={clsx(
'px-2.5 py-1 rounded text-xs font-medium border transition-colors',
filterType === t
? 'bg-blue-600 text-white border-blue-500'
: 'bg-dark-700 text-slate-400 border-slate-700/40 hover:border-slate-600'
)}
>
{t === '' ? 'All' : t.toUpperCase()}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Category:</span>
<div className="flex gap-1">
{['', 'energy', 'metals', 'equities', 'forex', 'multi'].map(c => (
<button
key={c}
onClick={() => setFilterCategory(c)}
className={clsx(
'px-2.5 py-1 rounded text-xs font-medium border transition-colors',
filterCategory === c
? 'bg-blue-600 text-white border-blue-500'
: 'bg-dark-700 text-slate-400 border-slate-700/40 hover:border-slate-600'
)}
>
{c === '' ? 'All' : c.charAt(0).toUpperCase() + c.slice(1)}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Min importance:</span>
<div className="flex gap-1">
{[0, 1, 2, 3].map(n => (
<button
key={n}
onClick={() => setFilterImportance(n)}
className={clsx(
'px-2.5 py-1 rounded text-xs font-medium border transition-colors',
filterImportance === n
? 'bg-blue-600 text-white border-blue-500'
: 'bg-dark-700 text-slate-400 border-slate-700/40 hover:border-slate-600'
)}
>
{n === 0 ? 'Any' : '★'.repeat(n)}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Period:</span>
<select
value={filterDays}
onChange={e => setFilterDays(Number(e.target.value))}
className="bg-dark-700 border border-slate-700/40 text-slate-300 text-xs rounded px-2 py-1"
>
{[30, 60, 90, 180, 365].map(d => (
<option key={d} value={d}>{d}d</option>
))}
</select>
</div>
</div>
{/* Report list */}
{isLoading || isFetching ? (
<div className="text-center py-12 text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
Loading reports
</div>
) : reports.length === 0 ? (
<div className="text-center py-16 space-y-3">
<div className="text-slate-600 text-4xl">📋</div>
<div className="text-slate-400 font-medium">No institutional reports yet</div>
<p className="text-slate-600 text-sm max-w-sm mx-auto">
Click <strong className="text-slate-400">Refresh COT</strong> to fetch the latest CFTC positioning data,
or configure your EIA API key in <strong className="text-slate-400">Configuration</strong> to enable petroleum reports.
</p>
</div>
) : (
<div className="space-y-3">
<div className="text-xs text-slate-500">{reports.length} report{reports.length > 1 ? 's' : ''}</div>
{(reports as InstitutionalReport[]).map(r => (
<ReportCard key={r.id} report={r} />
))}
</div>
)}
</div>
)
}