feat: 4 remaining institutional reports — Earnings, VX curve, Central Bank RSS, Sentiment

New fetchers (no API keys required):
- earnings_fetcher.py: yfinance EPS calendar + surprise tracking for 23 geo-relevant tickers
- vx_fetcher.py: VIX term structure (^VIX/^VXV/^VXMT) + CBOE delayed futures, regime detection
- central_bank_fetcher.py: Fed + ECB RSS feeds, keyword-based hawkish/dovish classification
- sentiment_fetcher.py: CNN Fear & Greed (primary) + NAAIM + AAII (optional fallbacks)

Wiring:
- institutional_scheduler.py: all 4 now scheduled daily (≥08:00 UTC), deduplicated per day
- institutional.py /refresh: all 6 types handled with _run() helper
- ai_analyzer.py build_institutional_block(): limit 6→12, generic header text
- InstitutionalReports.tsx: 6-type color map, individual refresh buttons, expanded filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 14:26:19 +02:00
parent d178615c74
commit acc8bef29d
8 changed files with 1193 additions and 103 deletions

View File

@@ -81,10 +81,27 @@ function AbsorptionBadge({ score }: { score: number | null }) {
)
}
const TYPE_COLORS: Record<string, string> = {
cot: 'bg-purple-900/30 text-purple-300 border-purple-700/40',
eia: 'bg-orange-900/30 text-orange-300 border-orange-700/40',
earnings: 'bg-blue-900/30 text-blue-300 border-blue-700/40',
vx_curve: 'bg-rose-900/30 text-rose-300 border-rose-700/40',
central_bank: 'bg-cyan-900/30 text-cyan-300 border-cyan-700/40',
sentiment: 'bg-yellow-900/30 text-yellow-300 border-yellow-700/40',
}
const TYPE_LABELS: Record<string, string> = {
cot: 'COT',
eia: 'EIA',
earnings: 'Earnings',
vx_curve: 'VX Curve',
central_bank: 'Central Banks',
sentiment: 'Sentiment',
}
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'
const typeColor = TYPE_COLORS[report.report_type] ?? 'bg-slate-800 text-slate-400 border-slate-700/40'
return (
<div className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden hover:border-slate-600/60 transition-colors">
@@ -93,7 +110,7 @@ function ReportCard({ report }: { report: InstitutionalReport }) {
<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}
{TYPE_LABELS[report.report_type] ?? report.report_type}
</span>
<ImportanceStars n={report.importance} />
<span className="text-slate-500 text-xs">
@@ -214,40 +231,44 @@ export default function InstitutionalReports() {
<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
COT · EIA · Earnings · VX Curve · Central Banks · Sentiment scheduled daily/weekly
</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 className="flex flex-wrap gap-1.5">
{([
{ type: 'cot', label: 'COT', cls: 'bg-purple-900/40 text-purple-300 border-purple-700/40 hover:bg-purple-800/50' },
{ type: 'eia', label: 'EIA', cls: 'bg-orange-900/40 text-orange-300 border-orange-700/40 hover:bg-orange-800/50' },
{ type: 'earnings', label: 'Earn.', cls: 'bg-blue-900/40 text-blue-300 border-blue-700/40 hover:bg-blue-800/50' },
{ type: 'vx_curve', label: 'VX', cls: 'bg-rose-900/40 text-rose-300 border-rose-700/40 hover:bg-rose-800/50' },
{ type: 'central_bank', label: 'CB RSS', cls: 'bg-cyan-900/40 text-cyan-300 border-cyan-700/40 hover:bg-cyan-800/50' },
{ type: 'sentiment', label: 'Senti.', cls: 'bg-yellow-900/40 text-yellow-300 border-yellow-700/40 hover:bg-yellow-800/50' },
] as const).map(({ type, label, cls }) => (
<button
key={type}
onClick={() => handleRefresh(type)}
disabled={refresh.isPending}
className={clsx('flex items-center gap-1 px-2.5 py-1.5 text-xs border rounded disabled:opacity-50', cls)}
>
<RefreshCw className={clsx('w-3 h-3', refresh.isPending && 'animate-spin')} />
{label}
</button>
))}
</div>
</div>
{/* Stats bar */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
{[
{ 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 }) => (
{ label: 'COT', value: stats.by_type?.cot ?? 0, cls: 'text-purple-400' },
{ label: 'EIA', value: stats.by_type?.eia ?? 0, cls: 'text-orange-400' },
{ label: 'Earnings', value: stats.by_type?.earnings ?? 0, cls: 'text-blue-400' },
{ label: 'VX Curve', value: stats.by_type?.vx_curve ?? 0, cls: 'text-rose-400' },
{ label: 'Central Bank', value: stats.by_type?.central_bank ?? 0, cls: 'text-cyan-400' },
{ label: 'Sentiment', value: stats.by_type?.sentiment ?? 0, cls: 'text-yellow-400' },
].map(({ label, value, cls }) => (
<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={clsx('text-xl font-bold', cls)}>{value}</div>
<div className="text-xs text-slate-500 mt-0.5">{label}</div>
</div>
))}
@@ -255,19 +276,21 @@ export default function InstitutionalReports() {
)}
{/* 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>}
{stats && (
<div className="flex flex-wrap gap-4 text-xs text-slate-500">
{stats.latest_cot && <span>COT: <span className="text-purple-400">{stats.latest_cot}</span></span>}
{stats.latest_eia && <span>EIA: <span className="text-orange-400">{stats.latest_eia}</span></span>}
{stats.by_type?.earnings ? <span>Earnings: <span className="text-blue-400">{Object.keys(stats.by_type).includes('earnings') ? 'tracked' : ''}</span></span> : null}
<span className="text-slate-600">Total: {stats.total} · Absorbed: {stats.absorbed_count}</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">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">Type:</span>
<div className="flex gap-1">
{['', 'cot', 'eia'].map(t => (
<div className="flex flex-wrap gap-1">
{(['', 'cot', 'eia', 'earnings', 'vx_curve', 'central_bank', 'sentiment'] as const).map(t => (
<button
key={t}
onClick={() => setFilterType(t)}
@@ -278,16 +301,16 @@ export default function InstitutionalReports() {
: 'bg-dark-700 text-slate-400 border-slate-700/40 hover:border-slate-600'
)}
>
{t === '' ? 'All' : t.toUpperCase()}
{t === '' ? 'All' : (TYPE_LABELS[t] ?? t)}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">Category:</span>
<div className="flex gap-1">
{['', 'energy', 'metals', 'equities', 'forex', 'multi'].map(c => (
<div className="flex flex-wrap gap-1">
{['', 'energy', 'metals', 'equities', 'forex', 'volatility', 'macro', 'sentiment', 'multi'].map(c => (
<button
key={c}
onClick={() => setFilterCategory(c)}
@@ -349,8 +372,8 @@ export default function InstitutionalReports() {
<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.
Use the refresh buttons above to fetch reports. COT and EIA require API keys.
Earnings, VX, Central Bank RSS, and Sentiment fetch automatically.
</p>
</div>
) : (