Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
import { useState } from 'react'
|
|
import { useGeoNews, usePatternRelevance, useGeoRiskScore } from '../hooks/useApi'
|
|
import clsx from 'clsx'
|
|
import type { GeoNews } from '../types'
|
|
import { Globe, ExternalLink, Search } from 'lucide-react'
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
military: 'badge-red', sanctions: 'badge-orange', elections: 'badge-purple',
|
|
natural_disaster: 'badge-yellow', health_crisis: 'badge-orange',
|
|
resource_scarcity: 'badge-yellow', trade_war: 'badge-blue',
|
|
energy: 'badge-orange', political_speech: 'badge-blue',
|
|
financial_crisis: 'badge-red', general: 'badge-blue',
|
|
}
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
|
|
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
|
|
resource_scarcity: '⚠️ Ressources', trade_war: '🤝 Commerce',
|
|
energy: '⚡ Énergie', political_speech: '🎙️ Discours',
|
|
financial_crisis: '💸 Finance', general: '📰 Général',
|
|
}
|
|
|
|
const ASSET_LABELS: Record<string, string> = {
|
|
energy: '⛽ Énergie', metals: '🥇 Métaux', agriculture: '🌾 Agri',
|
|
equities: '📈 Actions', indices: '📊 Indices', forex: '💱 Forex',
|
|
}
|
|
|
|
function ImpactBar({ value, label }: { value: number; label: string }) {
|
|
const abs = Math.abs(value)
|
|
const positive = value > 0
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-slate-500 w-16 shrink-0">{label}</span>
|
|
<div className="flex-1 flex items-center gap-1">
|
|
{positive ? (
|
|
<><div className="w-1/2"></div><div className="flex-1 bg-dark-600 rounded-full h-1.5"><div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${abs * 100}%` }}></div></div></>
|
|
) : (
|
|
<><div className="flex-1 bg-dark-600 rounded-full h-1.5 flex justify-end"><div className="bg-red-500 h-1.5 rounded-full" style={{ width: `${abs * 100}%` }}></div></div><div className="w-1/2"></div></>
|
|
)}
|
|
</div>
|
|
<span className={clsx('w-10 text-right', positive ? 'positive' : 'negative')}>
|
|
{positive ? '+' : ''}{(value * 100).toFixed(0)}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NewsCard({ news }: { news: GeoNews }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
return (
|
|
<div className="card hover:border-slate-600/50 transition-colors cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm text-white font-medium line-clamp-2 leading-tight">{news.title}</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className={clsx('badge', CATEGORY_COLORS[news.category] ?? 'badge-blue')}>
|
|
{CATEGORY_LABELS[news.category] ?? news.category}
|
|
</span>
|
|
<span className="text-xs text-slate-600">{news.source}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="text-xs font-bold text-orange-400">{Math.round(news.impact_score * 100)}</div>
|
|
<div className="text-xs text-slate-600">impact</div>
|
|
</div>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<>
|
|
<p className="text-xs text-slate-400 leading-relaxed mb-3">{news.summary}</p>
|
|
{Object.keys(news.asset_impacts).length > 0 && (
|
|
<div className="mb-3">
|
|
<div className="text-xs text-slate-600 mb-1">Impact par classe d'actif</div>
|
|
{Object.entries(news.asset_impacts).map(([cls, val]) => (
|
|
<ImpactBar key={cls} label={ASSET_LABELS[cls] ?? cls} value={val} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{news.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
{news.tags.map(tag => (
|
|
<span key={tag} className="text-xs text-slate-600 bg-dark-700 px-1.5 py-0.5 rounded">#{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{news.url && (
|
|
<a href={news.url} target="_blank" rel="noopener noreferrer"
|
|
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
|
|
onClick={e => e.stopPropagation()}>
|
|
<ExternalLink className="w-3 h-3" /> Lire l'article
|
|
</a>
|
|
)}
|
|
</>
|
|
)}
|
|
<div className="flex items-center justify-between mt-2 text-xs text-slate-600">
|
|
<span>{news.date?.slice(0, 16)}</span>
|
|
<span>{expanded ? '▲ Réduire' : '▼ Détail'}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RelevanceBar({ pct }: { pct: number }) {
|
|
const color = pct >= 60 ? 'bg-emerald-500' : pct >= 30 ? 'bg-yellow-500' : 'bg-slate-600'
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-24 bg-dark-600 rounded-full h-1.5">
|
|
<div className={clsx('h-1.5 rounded-full', color)} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className={clsx('text-sm font-bold', pct >= 60 ? 'text-emerald-400' : pct >= 30 ? 'text-yellow-400' : 'text-slate-500')}>
|
|
{pct}%
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PatternRelevanceCard({ p }: { p: any }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const hasNews = p.matching_news?.length > 0
|
|
|
|
return (
|
|
<div className={clsx('card transition-colors', {
|
|
'border-emerald-700/40': p.relevance >= 60,
|
|
'border-yellow-700/30': p.relevance >= 30 && p.relevance < 60,
|
|
'border-slate-700/20 opacity-60': p.relevance === 0,
|
|
})}>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex-1 min-w-0 mr-3">
|
|
<div className="text-sm font-semibold text-white line-clamp-1">{p.name}</div>
|
|
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
|
<span className="badge badge-blue text-xs">{p.asset_class}</span>
|
|
<span className="text-xs text-slate-500">{p.keyword_hits}/{p.keyword_total} mots-clés</span>
|
|
{hasNews && (
|
|
<span className="text-xs text-emerald-400 flex items-center gap-0.5">
|
|
<Search className="w-2.5 h-2.5" />{p.matching_news.length} news
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<RelevanceBar pct={p.relevance} />
|
|
</div>
|
|
|
|
{/* Matched keywords */}
|
|
{p.matched_keywords?.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
{p.matched_keywords.map((kw: string) => (
|
|
<span key={kw} className="text-xs bg-emerald-900/30 border border-emerald-700/30 text-emerald-400 px-1.5 py-0.5 rounded">
|
|
#{kw}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Toggle matching news */}
|
|
{hasNews && (
|
|
<>
|
|
<button onClick={() => setExpanded(!expanded)}
|
|
className="text-xs text-slate-600 hover:text-slate-400 mb-2">
|
|
{expanded ? '▲ Masquer les news' : `▼ Voir les ${p.matching_news.length} news correspondantes`}
|
|
</button>
|
|
{expanded && (
|
|
<div className="space-y-1.5">
|
|
{p.matching_news.map((n: any, i: number) => (
|
|
<div key={i} className="card-sm border-slate-700/20">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs text-white line-clamp-2">{n.title}</div>
|
|
<div className="text-xs text-slate-600 mt-0.5">{n.source} · {n.date}</div>
|
|
</div>
|
|
<div className="shrink-0 text-right">
|
|
<div className="text-xs text-orange-400 font-mono">{Math.round(n.impact * 100)}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{n.matched_keywords.map((kw: string) => (
|
|
<span key={kw} className="text-xs bg-blue-900/20 text-blue-400 px-1 rounded">#{kw}</span>
|
|
))}
|
|
</div>
|
|
{n.url && (
|
|
<a href={n.url} target="_blank" rel="noopener noreferrer"
|
|
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 mt-1"
|
|
onClick={e => e.stopPropagation()}>
|
|
<ExternalLink className="w-2.5 h-2.5" /> Lire
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!hasNews && p.relevance === 0 && (
|
|
<div className="text-xs text-slate-600">Aucune news correspondante sur la période</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function GeoRadar() {
|
|
const { data: news, isLoading } = useGeoNews()
|
|
const { data: riskScore } = useGeoRiskScore()
|
|
const [filterCat, setFilterCat] = useState<string>('all')
|
|
const [tab, setTab] = useState<'news' | 'patterns'>('news')
|
|
const [days, setDays] = useState(2)
|
|
|
|
const { data: patternRelevance, isLoading: relLoading } = usePatternRelevance(days)
|
|
|
|
const categories = ['all', ...Array.from(new Set(news?.map(n => n.category) ?? []))]
|
|
const filtered = filterCat === 'all' ? news : news?.filter(n => n.category === filterCat)
|
|
|
|
const patternsWithNews = (patternRelevance ?? []).filter((p: any) => p.matching_news?.length > 0).length
|
|
const totalPatterns = patternRelevance?.length ?? 0
|
|
|
|
return (
|
|
<div className="p-6 space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
|
<Globe className="w-5 h-5 text-blue-400" /> Radar Géopolitique
|
|
</h1>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
Flux d'actualités · Correspondance avec les patterns de trading
|
|
</p>
|
|
</div>
|
|
{riskScore && (
|
|
<div className={clsx('card-sm text-center', {
|
|
'border-emerald-700/40': riskScore.level === 'low',
|
|
'border-yellow-700/40': riskScore.level === 'medium',
|
|
'border-orange-700/40': riskScore.level === 'high',
|
|
'border-red-700/40 animate-pulse': riskScore.level === 'extreme',
|
|
})}>
|
|
<div className={clsx('text-2xl font-bold', {
|
|
'text-emerald-400': riskScore.level === 'low',
|
|
'text-yellow-400': riskScore.level === 'medium',
|
|
'text-orange-400': riskScore.level === 'high',
|
|
'text-red-400': riskScore.level === 'extreme',
|
|
})}>{riskScore.score}/100</div>
|
|
<div className="text-xs text-slate-500 uppercase">{riskScore.level}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex gap-1 bg-dark-700 p-1 rounded">
|
|
{(['news', 'patterns'] as const).map(t => (
|
|
<button key={t} onClick={() => setTab(t)}
|
|
className={clsx('px-4 py-1.5 rounded text-sm transition-colors', {
|
|
'bg-blue-600 text-white': tab === t,
|
|
'text-slate-400 hover:text-slate-200': tab !== t,
|
|
})}>
|
|
{t === 'news' ? `📰 Actualités (${news?.length ?? 0})` : `🧩 Patterns (${patternsWithNews}/${totalPatterns} avec news)`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Day selector — only shown on patterns tab */}
|
|
{tab === 'patterns' && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-500">Fenêtre :</span>
|
|
<div className="flex gap-0.5 bg-dark-700 p-0.5 rounded">
|
|
{[1, 2, 7, 30].map(d => (
|
|
<button key={d} onClick={() => setDays(d)}
|
|
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
|
'bg-slate-600 text-white': days === d,
|
|
'text-slate-400 hover:text-slate-200': days !== d,
|
|
})}>
|
|
{d}j
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-slate-600">derniers jours</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* News tab */}
|
|
{tab === 'news' && (
|
|
<>
|
|
<div className="flex flex-wrap gap-2">
|
|
{categories.slice(0, 8).map(cat => (
|
|
<button key={cat} onClick={() => setFilterCat(cat)}
|
|
className={clsx('px-3 py-1 rounded text-xs border transition-colors', {
|
|
'bg-blue-600 border-blue-500 text-white': filterCat === cat,
|
|
'border-slate-700 text-slate-400 hover:border-slate-500': filterCat !== cat,
|
|
})}>
|
|
{cat === 'all' ? 'Tous' : CATEGORY_LABELS[cat] ?? cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{isLoading ? (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{[1,2,3,4].map(i => <div key={i} className="card animate-pulse h-32 bg-dark-700"></div>)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{filtered?.map((n, i) => <NewsCard key={n.id || i} news={n} />)}
|
|
{(!filtered || filtered.length === 0) && (
|
|
<div className="card col-span-2 text-center py-8 text-slate-500">
|
|
Démarrer le backend pour charger les actualités
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Patterns relevance tab */}
|
|
{tab === 'patterns' && (
|
|
<>
|
|
<div className="flex items-center gap-3 text-xs text-slate-500 bg-dark-700/40 rounded px-3 py-2">
|
|
<Search className="w-3.5 h-3.5 shrink-0 text-blue-400" />
|
|
<span>
|
|
Correspondance entre les mots-clés de chaque pattern et les news des {days} derniers jours.
|
|
Ce signal sert à enrichir le contexte donné à l'IA lors du scoring.
|
|
{patternsWithNews > 0 && (
|
|
<span className="text-emerald-400 ml-1">
|
|
{patternsWithNews} patterns ont des news correspondantes.
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{relLoading ? (
|
|
<div className="space-y-3">
|
|
{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{(patternRelevance ?? []).map((p: any) => (
|
|
<PatternRelevanceCard key={p.pattern_id} p={p} />
|
|
))}
|
|
{(!patternRelevance || patternRelevance.length === 0) && (
|
|
<div className="card text-center py-8 text-slate-500">
|
|
Démarrer le backend pour calculer la correspondance news-patterns
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|