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:
@@ -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>,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 > 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 < 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">
|
||||
|
||||
Reference in New Issue
Block a user