feat: Specialist Desks v2 — COT, Forward Curves, Surprise Index, Hawk/Dove scorer

- COT Positioning: CFTC disaggregated + financial futures (19 markets) via Socrata free API
  net MM position % OI + weekly change stored in cot_data table
- Forward Curves: yfinance front-month vs +3M slope (8 commodities)
  contango/backwardation/flat stored in forward_curve_data table
- Surprise Index: consensus_estimate + actual_value on specialist_reports
  auto-computes surprise_score = actual - consensus on save
- Hawk/Dove Text Scorer: GPT-4o-mini endpoint for CB statements
  score -1..+1, label, summary, key_phrases (forex/bonds: hawk/dove; commodities: bull/bear)
- AI context injection: COT net positioning, forward curve structure,
  surprise scores, upcoming consensus estimates injected into all desk blocks
- Frontend: COT panel (net% bars), Forward Curves panel, SurpriseInput
  on report cards, Hawk/Dove scorer in forex/bonds config tab
- auto_cycle.py: non-blocking COT + curve refresh before each cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-23 18:00:46 +02:00
parent 70a9e2b569
commit 3b7fa35456
9 changed files with 1965 additions and 40 deletions

View File

@@ -1252,3 +1252,107 @@ export const useUnlinkReportDesk = () => {
},
})
}
// ── COT / Forward Curves / Surprise Index / Hawk-Dove — new types ─────────────
export interface CotEntry {
id: number
market_name: string
commodity: string
asset_class: string
report_date: string
mm_long: number
mm_short: number
open_interest: number
net_position: number
net_pct_oi: number
change_net: number
fetched_at: string
}
export interface ForwardCurveEntry {
id: number
asset: string
asset_class: string
front_price: number | null
far_price: number | null
slope_pct: number | null
structure: 'contango' | 'backwardation' | 'flat' | 'unknown'
months_spread: number
fetched_at: string
}
export interface TextSentimentResult {
score: number
label: string
summary: string
key_phrases: string[]
confidence: 'high' | 'medium' | 'low'
}
export interface ReportResultUpdate {
actual_value?: number | null
consensus_estimate?: number | null
text_sentiment_score?: number | null
text_sentiment_label?: string | null
text_sentiment_summary?: string | null
}
// ── COT Data ──────────────────────────────────────────────────────────────────
export function useCotData() {
return useQuery<CotEntry[]>({
queryKey: ['specialist-desks', 'cot'],
queryFn: () => api.get('/specialist-desks/cot').then(r => r.data),
staleTime: 1000 * 60 * 60, // 1h
})
}
export function useFetchCot() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/specialist-desks/cot/fetch').then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['specialist-desks', 'cot'] }),
})
}
// ── Forward Curves ────────────────────────────────────────────────────────────
export function useForwardCurves() {
return useQuery<ForwardCurveEntry[]>({
queryKey: ['specialist-desks', 'forward-curves'],
queryFn: () => api.get('/specialist-desks/forward-curves').then(r => r.data),
staleTime: 1000 * 60 * 60,
})
}
export function useFetchForwardCurves() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/specialist-desks/forward-curves/fetch').then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['specialist-desks', 'forward-curves'] }),
})
}
// ── Surprise Index ────────────────────────────────────────────────────────────
export function useUpdateReportResult() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: ReportResultUpdate }) =>
api.put(`/specialist-desks/reports/${id}/result`, body).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['specialist-reports'] })
qc.invalidateQueries({ queryKey: ['desk-configs'] })
},
})
}
// ── Hawk/Dove Text Scorer ─────────────────────────────────────────────────────
export function useScoreText() {
return useMutation({
mutationFn: (body: { text: string; report_name: string; desk: string }) =>
api.post('/specialist-desks/score-text', body).then(r => r.data) as Promise<TextSentimentResult>,
})
}

View File

@@ -4,12 +4,17 @@ import {
useUpdateDeskConfig, useCreateSpecialistReport,
useUpdateSpecialistReport, useDeleteSpecialistReport,
useLinkReportDesk, useUnlinkReportDesk,
useCotData, useFetchCot, useForwardCurves, useFetchForwardCurves,
useUpdateReportResult, useScoreText,
DeskConfig, SpecialistReport, MacroSensitivity,
TextSentimentResult, ReportResultUpdate,
} from '../hooks/useApi'
import {
Users, FileText, Plus, Trash2, Save, X, Edit2,
ChevronRight, Star, Link, ExternalLink, RefreshCw,
AlertCircle, Calendar, BookOpen,
TrendingUp, TrendingDown, Minus, BarChart2, FlaskConical,
ArrowUp, ArrowDown, Activity,
} from 'lucide-react'
import clsx from 'clsx'
@@ -230,6 +235,79 @@ function MacroSensitivityEditor({
)
}
// ── Surprise Index inline component ───────────────────────────────────────────
function SurpriseInput({ report, onSave }: {
report: any
onSave: (data: ReportResultUpdate) => void
}) {
const [consensus, setConsensus] = useState<string>(report.consensus_estimate?.toString() ?? '')
const [actual, setActual] = useState<string>(report.actual_value?.toString() ?? '')
const [open, setOpen] = useState(false)
const surprise = actual !== '' && consensus !== ''
? parseFloat(actual) - parseFloat(consensus)
: null
return (
<div className="mt-1">
{!open && (
<button onClick={() => setOpen(true)} className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors flex items-center gap-1">
<FlaskConical className="w-2.5 h-2.5" />
{report.surprise_score != null
? <span className={clsx('font-mono', report.surprise_score > 0 ? 'text-emerald-500' : 'text-red-500')}>
surprise: {report.surprise_score >= 0 ? '+' : ''}{report.surprise_score}
</span>
: 'Add consensus / actual'}
</button>
)}
{open && (
<div className="mt-2 space-y-1.5 bg-dark-700/60 rounded p-2 border border-slate-700/40">
<div className="flex gap-2">
<div className="flex-1">
<div className="text-[9px] text-slate-600 mb-0.5">Consensus</div>
<input value={consensus} onChange={e => setConsensus(e.target.value)} type="number" step="any"
placeholder="0.0"
className="w-full px-2 py-1 text-[10px] bg-dark-800 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 font-mono" />
</div>
<div className="flex-1">
<div className="text-[9px] text-slate-600 mb-0.5">Actual</div>
<input value={actual} onChange={e => setActual(e.target.value)} type="number" step="any"
placeholder="0.0"
className="w-full px-2 py-1 text-[10px] bg-dark-800 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 font-mono" />
</div>
{surprise !== null && (
<div className="flex-1">
<div className="text-[9px] text-slate-600 mb-0.5">Surprise</div>
<div className={clsx('px-2 py-1 text-[10px] font-mono font-bold rounded border',
surprise > 0 ? 'text-emerald-400 border-emerald-800/40 bg-emerald-900/20' :
surprise < 0 ? 'text-red-400 border-red-800/40 bg-red-900/20' :
'text-slate-400 border-slate-700/40')}>
{surprise >= 0 ? '+' : ''}{surprise.toFixed(3)}
</div>
</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setOpen(false)} className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors">Cancel</button>
<button
onClick={() => {
onSave({
consensus_estimate: consensus !== '' ? parseFloat(consensus) : null,
actual_value: actual !== '' ? parseFloat(actual) : null,
})
setOpen(false)
}}
className="px-2 py-0.5 bg-violet-800 hover:bg-violet-700 text-white text-[10px] rounded transition-colors">
Save
</button>
</div>
</div>
)}
</div>
)
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function SpecialistDesks() {
@@ -242,12 +320,22 @@ export default function SpecialistDesks() {
const { mutate: linkReport } = useLinkReportDesk()
const { mutate: unlinkReport } = useUnlinkReportDesk()
const { data: cotData = [], isLoading: cotLoading } = useCotData()
const { data: curveData = [], isLoading: curvesLoading } = useForwardCurves()
const { mutate: fetchCot, isPending: fetchingCot } = useFetchCot()
const { mutate: fetchCurves, isPending: fetchingCurves } = useFetchForwardCurves()
const { mutateAsync: updateReportResult } = useUpdateReportResult()
const { mutateAsync: scoreText, isPending: scoringText } = useScoreText()
const [scoreTextInput, setScoreTextInput] = useState('')
const [scoreTextResult, setScoreTextResult] = useState<TextSentimentResult | null>(null)
const [scoreReportName, setScoreReportName] = useState('')
const orderedDesks = DESK_ORDER
.map(ac => desks.find(d => d.asset_class === ac))
.filter(Boolean) as DeskConfig[]
const [activeDeskAc, setActiveDeskAc] = useState<string>(DESK_ORDER[0])
const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports'>('config')
const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports' | 'cot' | 'curves'>('config')
// Desk edit state
const [editFund, setEditFund] = useState('')
@@ -362,10 +450,225 @@ export default function SpecialistDesks() {
<FileText className="w-3.5 h-3.5" />
All Reports ({allReports.length})
</button>
<button
onClick={() => setActiveTab('cot')}
className={clsx(
'px-4 py-3 flex items-center gap-2 border-t border-slate-700/40 transition-colors text-xs',
activeTab === 'cot'
? 'bg-slate-700/50 text-slate-200'
: 'text-slate-500 hover:text-slate-300'
)}>
<BarChart2 className="w-3.5 h-3.5" />
COT Positioning
</button>
<button
onClick={() => setActiveTab('curves')}
className={clsx(
'px-4 py-3 flex items-center gap-2 border-t border-slate-700/40 transition-colors text-xs',
activeTab === 'curves'
? 'bg-slate-700/50 text-slate-200'
: 'text-slate-500 hover:text-slate-300'
)}>
<Activity className="w-3.5 h-3.5" />
Forward Curves
</button>
</div>
{/* ── Right: desk detail / all reports ──────────────────────────────── */}
<div className="flex-1 overflow-y-auto">
{/* ══ COT VIEW ════════════════════════════════════════════ */}
{activeTab === 'cot' && (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold text-slate-100 flex items-center gap-2">
<BarChart2 className="w-5 h-5 text-cyan-400" />
COT Positioning
</h1>
<p className="text-xs text-slate-500 mt-0.5">CFTC Commitment of Traders Money Manager net positions (% of Open Interest)</p>
</div>
<button
onClick={() => fetchCot()}
disabled={fetchingCot}
className="flex items-center gap-1.5 bg-cyan-800 hover:bg-cyan-700 disabled:opacity-40 text-white px-3 py-2 rounded text-xs font-semibold transition-colors">
<RefreshCw className={clsx('w-3.5 h-3.5', fetchingCot && 'animate-spin')} />
{fetchingCot ? 'Fetching…' : 'Refresh CFTC'}
</button>
</div>
{cotLoading ? (
<div className="text-slate-500 text-sm flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin" /> Loading
</div>
) : cotData.length === 0 ? (
<div className="text-slate-500 text-sm p-8 text-center border border-dashed border-slate-700 rounded-lg">
No COT data yet. Click "Refresh CFTC" to fetch the latest weekly report.
</div>
) : (
<div className="space-y-4">
{(['energy', 'metals', 'agri', 'forex', 'bonds'] as const).map(ac => {
const entries = cotData.filter(c => c.asset_class === ac)
if (!entries.length) return null
const labels: Record<string, string> = { energy: '⚡ Energy', metals: '🥇 Metals', agri: '🌾 Agri & Softs', forex: '💱 Forex', bonds: '📉 Bonds' }
const reportDate = entries[0]?.report_date
return (
<div key={ac} className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-700/40 bg-dark-700/50">
<span className="text-xs font-bold text-slate-200">{labels[ac] || ac}</span>
{reportDate && <span className="text-[10px] text-slate-500 font-mono">COT week of {reportDate}</span>}
</div>
<div className="divide-y divide-slate-700/30">
{entries.map(entry => {
const pct = entry.net_pct_oi
const isLong = pct > 5
const isShort = pct < -5
const barWidth = Math.min(Math.abs(pct), 50) // cap at 50%
const chg = entry.change_net
return (
<div key={entry.id} className="px-4 py-3 flex items-center gap-4">
<div className="w-28 flex-shrink-0 text-xs font-medium text-slate-200">{entry.commodity}</div>
{/* Net position bar */}
<div className="flex-1 flex items-center gap-2">
<div className="w-24 h-2 bg-slate-700/50 rounded-full overflow-hidden relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-px h-full bg-slate-600" />
</div>
{pct > 0 ? (
<div className="absolute left-1/2 top-0 h-full bg-emerald-500 rounded-r"
style={{ width: `${barWidth}%` }} />
) : (
<div className="absolute right-1/2 top-0 h-full bg-red-500 rounded-l"
style={{ width: `${barWidth}%` }} />
)}
</div>
<span className={clsx(
'text-xs font-mono font-bold min-w-[50px]',
isLong ? 'text-emerald-400' : isShort ? 'text-red-400' : 'text-slate-400'
)}>
{pct >= 0 ? '+' : ''}{pct.toFixed(1)}%
</span>
<span className={clsx('text-[10px] px-1.5 py-0.5 rounded font-bold',
isLong ? 'bg-emerald-900/50 text-emerald-300' : isShort ? 'bg-red-900/50 text-red-300' : 'bg-slate-700/50 text-slate-400'
)}>
{isLong ? 'LONG' : isShort ? 'SHORT' : 'NEUTRAL'}
</span>
</div>
{/* Weekly change */}
{chg !== 0 && (
<div className={clsx('flex items-center gap-0.5 text-[10px] font-mono',
chg > 0 ? 'text-emerald-400' : 'text-red-400')}>
{chg > 0 ? <ArrowUp className="w-2.5 h-2.5" /> : <ArrowDown className="w-2.5 h-2.5" />}
{Math.abs(chg).toLocaleString()}
</div>
)}
{/* Net position (contracts) */}
<div className="text-[10px] font-mono text-slate-500 text-right w-28">
{entry.net_position.toLocaleString()} net
</div>
</div>
)
})}
</div>
</div>
)
})}
<p className="text-[10px] text-slate-600 text-center">
Source: CFTC Disaggregated Futures (commodities) + Legacy Financial (forex/bonds) published weekly on Fridays
</p>
</div>
)}
</div>
)}
{/* ══ FORWARD CURVES VIEW ══════════════════════════════════ */}
{activeTab === 'curves' && (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold text-slate-100 flex items-center gap-2">
<Activity className="w-5 h-5 text-amber-400" />
Forward Curves
</h1>
<p className="text-xs text-slate-500 mt-0.5">Contango / Backwardation spot vs +3M futures slope (yfinance)</p>
</div>
<button
onClick={() => fetchCurves()}
disabled={fetchingCurves}
className="flex items-center gap-1.5 bg-amber-800 hover:bg-amber-700 disabled:opacity-40 text-white px-3 py-2 rounded text-xs font-semibold transition-colors">
<RefreshCw className={clsx('w-3.5 h-3.5', fetchingCurves && 'animate-spin')} />
{fetchingCurves ? 'Fetching…' : 'Refresh Curves'}
</button>
</div>
{curvesLoading ? (
<div className="text-slate-500 text-sm flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin" /> Loading
</div>
) : curveData.length === 0 ? (
<div className="text-slate-500 text-sm p-8 text-center border border-dashed border-slate-700 rounded-lg">
No forward curve data yet. Click "Refresh Curves" to fetch.
</div>
) : (
<div className="space-y-4">
{(['energy', 'metals', 'agri'] as const).map(ac => {
const entries = curveData.filter(c => c.asset_class === ac)
if (!entries.length) return null
const labels: Record<string, string> = { energy: '⚡ Energy', metals: '🥇 Metals', agri: '🌾 Agri & Softs' }
return (
<div key={ac} className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
<div className="px-4 py-2.5 border-b border-slate-700/40 bg-dark-700/50">
<span className="text-xs font-bold text-slate-200">{labels[ac]}</span>
</div>
<div className="grid grid-cols-2 gap-0 divide-y divide-slate-700/30">
{entries.map(e => {
const isContango = e.structure === 'contango'
const isBackward = e.structure === 'backwardation'
const slope = e.slope_pct
return (
<div key={e.id} className="px-4 py-3 flex items-center gap-3 border-r border-slate-700/30 last:border-r-0">
<div className="flex-1">
<div className="text-xs font-medium text-slate-200">{e.asset}</div>
<div className="text-[10px] text-slate-500 font-mono mt-0.5">
Front: {e.front_price?.toFixed(2) ?? '—'}
{e.far_price && ` | +3M: ${e.far_price.toFixed(2)}`}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className={clsx('flex items-center gap-1 text-xs font-bold',
isContango ? 'text-sky-400' : isBackward ? 'text-orange-400' : 'text-slate-400'
)}>
{isContango ? <TrendingUp className="w-3 h-3" />
: isBackward ? <TrendingDown className="w-3 h-3" />
: <Minus className="w-3 h-3" />}
{e.structure.toUpperCase()}
</div>
{slope !== null && (
<div className={clsx('text-[10px] font-mono mt-0.5',
slope > 0 ? 'text-sky-500' : slope < 0 ? 'text-orange-500' : 'text-slate-500'
)}>
{slope >= 0 ? '+' : ''}{slope.toFixed(2)}%
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="text-[10px] text-slate-500 space-y-1">
<div><span className="text-sky-400 font-bold">CONTANGO</span> = futures prix &gt; spot marché normal (coût de stockage) <em>pénalise les positions longues au rollover</em></div>
<div><span className="text-orange-400 font-bold">BACKWARDATION</span> = futures prix &lt; spot pénurie physique immédiate <em>profite aux positions longues au rollover</em></div>
</div>
</div>
</div>
)}
</div>
)}
{activeTab === 'all-reports' ? (
/* ══ ALL REPORTS VIEW ════════════════════════════════════════════ */
<div className="p-6 space-y-4">
@@ -560,6 +863,87 @@ export default function SpecialistDesks() {
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none"
/>
</div>
{/* ── Hawk/Dove Text Scorer (Forex & Bonds desks only) ── */}
{(activeDeskAc === 'forex' || activeDeskAc === 'bonds') && (
<div className="mt-6 border-t border-slate-700/40 pt-5">
<div className="flex items-center gap-2 mb-3">
<FlaskConical className="w-4 h-4 text-violet-400" />
<span className="text-sm font-semibold text-slate-200">Hawk/Dove Text Scorer</span>
<span className="text-[10px] text-slate-500"> Paste a central bank statement to get an IA sentiment score</span>
</div>
<div className="space-y-3">
<input
value={scoreReportName}
onChange={e => setScoreReportName(e.target.value)}
placeholder="Report name (e.g. FOMC Minutes June 2026)"
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500"
/>
<textarea
value={scoreTextInput}
onChange={e => setScoreTextInput(e.target.value)}
rows={5}
placeholder="Paste the FOMC minutes, ECB statement, BoJ press release… (up to 3000 chars used)"
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none font-mono"
/>
<div className="flex items-center gap-2">
<button
onClick={async () => {
if (!scoreTextInput.trim()) return
const result = await scoreText({ text: scoreTextInput, report_name: scoreReportName, desk: activeDeskAc })
setScoreTextResult(result)
}}
disabled={scoringText || !scoreTextInput.trim()}
className="flex items-center gap-1.5 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 text-white text-xs font-semibold rounded transition-colors">
<FlaskConical className="w-3.5 h-3.5" />
{scoringText ? 'Analyzing…' : 'Score Text'}
</button>
{scoreTextResult && (
<button onClick={() => { setScoreTextResult(null); setScoreTextInput(''); setScoreReportName('') }}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">Clear</button>
)}
</div>
{scoreTextResult && (
<div className={clsx('rounded-lg border p-4 space-y-3',
scoreTextResult.score > 0.3 ? 'border-red-700/40 bg-red-900/10' :
scoreTextResult.score < -0.3 ? 'border-sky-700/40 bg-sky-900/10' :
'border-slate-700/40 bg-dark-700/30'
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={clsx('text-sm font-bold px-2 py-0.5 rounded',
scoreTextResult.score > 0.3 ? 'bg-red-800/50 text-red-200' :
scoreTextResult.score < -0.3 ? 'bg-sky-800/50 text-sky-200' :
'bg-slate-700/50 text-slate-300'
)}>
{scoreTextResult.label.replace(/_/g, ' ').toUpperCase()}
</span>
<span className="text-[10px] text-slate-500">confidence: {scoreTextResult.confidence}</span>
</div>
<div className="text-right">
<div className="text-lg font-mono font-bold" style={{
color: scoreTextResult.score > 0 ? '#f87171' : scoreTextResult.score < 0 ? '#38bdf8' : '#94a3b8'
}}>
{scoreTextResult.score >= 0 ? '+' : ''}{scoreTextResult.score.toFixed(2)}
</div>
<div className="text-[10px] text-slate-500">score (1=dovish, +1=hawkish)</div>
</div>
</div>
<p className="text-xs text-slate-300 leading-relaxed">{scoreTextResult.summary}</p>
{scoreTextResult.key_phrases?.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{scoreTextResult.key_phrases.map((phrase, i) => (
<span key={i} className="px-2 py-0.5 bg-slate-700/50 border border-slate-600/40 rounded text-[10px] text-slate-400 italic">
"{phrase}"
</span>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
)}
@@ -607,6 +991,10 @@ export default function SpecialistDesks() {
)}
</div>
{r.notes && <p className="text-[10px] text-slate-600 mt-0.5 italic">{r.notes}</p>}
<SurpriseInput
report={r}
onSave={(data) => updateReportResult({ id: r.id, body: data })}
/>
</div>
<Stars n={r.importance} />
<div className="flex items-center gap-1.5">