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:
@@ -19,6 +19,7 @@ import RiskDashboard from './pages/RiskDashboard'
|
||||
import SystemLogs from './pages/SystemLogs'
|
||||
import VaRAnalysis from './pages/VaRAnalysis'
|
||||
import PositionHistory from './pages/PositionHistory'
|
||||
import InstitutionalReports from './pages/InstitutionalReports'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
function GlobalWatcher() {
|
||||
@@ -53,6 +54,7 @@ export default function App() {
|
||||
<Route path="/var" element={<VaRAnalysis />} />
|
||||
<Route path="/position-history" element={<PositionHistory />} />
|
||||
<Route path="/logs" element={<SystemLogs />} />
|
||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
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'
|
||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
@@ -24,6 +24,7 @@ const nav = [
|
||||
{ to: '/position-history', icon: GitCompare, label: 'Position History' },
|
||||
{ to: '/backtest', icon: History, label: 'Backtest' },
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
||||
]
|
||||
|
||||
@@ -914,3 +914,32 @@ export const useRemoveWatchlistTicker = () =>
|
||||
useMutation({
|
||||
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),
|
||||
})
|
||||
|
||||
366
frontend/src/pages/InstitutionalReports.tsx
Normal file
366
frontend/src/pages/InstitutionalReports.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user