Initial commit — GeoOptions Intelligence Cockpit v2.0
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>
This commit is contained in:
733
frontend/src/pages/Config.tsx
Normal file
733
frontend/src/pages/Config.tsx
Normal file
@@ -0,0 +1,733 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile } from '../hooks/useApi'
|
||||
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const SOURCE_CATEGORIES = {
|
||||
'Flux RSS actifs': ['reuters_world', 'reuters_business', 'reuters_energy', 'ap_top', 'aljazeera', 'ft', 'bloomberg'],
|
||||
'Données économiques': ['newsapi', 'gdelt', 'eia', 'fred', 'usda'],
|
||||
'Santé & Catastrophes': ['who', 'emdat'],
|
||||
'Réseaux sociaux': ['twitter_trump'],
|
||||
}
|
||||
|
||||
const SOURCE_DOCS: Record<string, { description: string; link?: string; cost: string }> = {
|
||||
reuters_world: { description: 'Actualités mondiales Reuters', cost: 'Gratuit' },
|
||||
reuters_business: { description: 'Business et marchés Reuters', cost: 'Gratuit' },
|
||||
reuters_energy: { description: 'Énergie et commodités Reuters', cost: 'Gratuit' },
|
||||
ap_top: { description: 'Associated Press — Top Stories', cost: 'Gratuit' },
|
||||
aljazeera: { description: 'Al Jazeera — couverture Moyen-Orient/Monde', cost: 'Gratuit' },
|
||||
ft: { description: 'Financial Times — finance internationale', cost: 'Gratuit' },
|
||||
bloomberg: { description: 'Bloomberg Markets RSS', cost: 'Gratuit' },
|
||||
newsapi: { description: '100 req/jour gratuit — actualités mondiales multi-sources', link: 'https://newsapi.org', cost: '100 req/j gratuit' },
|
||||
gdelt: { description: 'Base de données géopolitique mondiale — 300K events/jour', link: 'https://gdeltproject.org', cost: 'Gratuit total' },
|
||||
eia: { description: 'US Energy Information Administration — données pétrole/gaz hebdo', link: 'https://www.eia.gov/opendata', cost: 'Gratuit avec clé' },
|
||||
fred: { description: 'Federal Reserve St. Louis — macro US (PIB, inflation, emploi)', link: 'https://fred.stlouisfed.org/docs/api/fred/', cost: 'Gratuit avec clé' },
|
||||
usda: { description: 'USDA — rapports agricoles officiels US', cost: 'Gratuit' },
|
||||
who: { description: 'OMS — alertes sanitaires mondiales RSS', cost: 'Gratuit' },
|
||||
emdat: { description: 'EM-DAT — base de données catastrophes naturelles', link: 'https://www.emdat.be', cost: 'Inscription gratuite' },
|
||||
twitter_trump: { description: 'Flux X/Twitter Trump — discours, annonces tarifs', cost: 'API payante ($100/mois+)' },
|
||||
}
|
||||
|
||||
// ── Risk Profiles Component ───────────────────────────────────────────────────
|
||||
|
||||
const PROFILE_COLORS = [
|
||||
{ value: '#22c55e', label: 'Vert' },
|
||||
{ value: '#3b82f6', label: 'Bleu' },
|
||||
{ value: '#f97316', label: 'Orange' },
|
||||
{ value: '#ef4444', label: 'Rouge' },
|
||||
{ value: '#8b5cf6', label: 'Violet' },
|
||||
{ value: '#eab308', label: 'Jaune' },
|
||||
]
|
||||
|
||||
function evNetLabel(evNet: number): { text: string; cls: string } {
|
||||
if (evNet > 0.05) return { text: `EV nette +${(evNet * 100).toFixed(0)}%`, cls: 'text-emerald-400' }
|
||||
if (evNet >= -0.01) return { text: 'EV nette ≈ 0', cls: 'text-yellow-400' }
|
||||
return { text: `EV nette ${(evNet * 100).toFixed(0)}%`, cls: 'text-slate-600' }
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
profile,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: any
|
||||
onSave: (p: any) => void
|
||||
onDelete: (id: number) => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(profile.name)
|
||||
const [score, setScore] = useState(profile.min_score)
|
||||
const [gain, setGain] = useState(profile.min_gain_pct)
|
||||
const [color, setColor] = useState(profile.color ?? '#3b82f6')
|
||||
const [enabled, setEnabled] = useState(profile.enabled !== 0)
|
||||
|
||||
// Live EV computation
|
||||
const p = score / 100
|
||||
const G = gain / 100
|
||||
const denom = p * G + (1 - p)
|
||||
const ev_net = p * G - (1 - p)
|
||||
const trade_score = denom > 0 ? (p * G / denom * 100) : 0
|
||||
const { text: evText, cls: evCls } = evNetLabel(ev_net)
|
||||
|
||||
const save = () => {
|
||||
onSave({ id: profile.id, name, min_score: score, min_gain_pct: gain, color, enabled, sort_order: profile.sort_order ?? 0 })
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
const { text: fev, cls: fevc } = evNetLabel(profile.ev_net_at_frontier ?? 0)
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 px-3 rounded hover:bg-dark-700/40 group">
|
||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: profile.color ?? '#3b82f6' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={clsx('text-sm font-semibold', enabled ? 'text-slate-200' : 'text-slate-600')}>{profile.name}</span>
|
||||
{!enabled && <span className="text-[10px] text-slate-700 italic">désactivé</span>}
|
||||
<span className="text-xs text-slate-500">Score ≥ <span className="text-slate-300 font-mono">{profile.min_score}</span></span>
|
||||
<span className="text-xs text-slate-500">Gain ≥ <span className="text-slate-300 font-mono">{profile.min_gain_pct}%</span></span>
|
||||
<span className={clsx('text-[11px] font-mono', fevc)}>{fev}</span>
|
||||
<span className="text-[11px] text-slate-600">Trade Score ≥ {(profile.trade_score_at_frontier ?? 0).toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button onClick={() => setEditing(true)}
|
||||
className="text-slate-600 hover:text-slate-300 p-1 rounded">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(profile.id)}
|
||||
className="text-slate-600 hover:text-red-400 p-1 rounded">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-700/60 rounded-lg p-3 border border-slate-600/40 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">Nom du profil</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">Score min = <span className="text-blue-400 font-mono">{score}</span>/100</label>
|
||||
<input type="range" min="0" max="90" step="5" value={score}
|
||||
onChange={e => setScore(parseInt(e.target.value))}
|
||||
className="w-full accent-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">Gain min = <span className="text-blue-400 font-mono">{gain}%</span></label>
|
||||
<input type="range" min="10" max="2000" step="10" value={gain}
|
||||
onChange={e => setGain(parseFloat(e.target.value))}
|
||||
className="w-full accent-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live EV preview */}
|
||||
<div className="bg-dark-800 rounded px-3 py-2 text-xs flex items-center gap-4 flex-wrap">
|
||||
<span className="text-slate-500">À la frontière (score={score}, gain={gain}%)</span>
|
||||
<span className={clsx('font-mono font-bold', evCls)}>{evText}</span>
|
||||
<span className="text-slate-600">Trade Score = <span className="text-slate-400">{trade_score.toFixed(1)}</span>/100</span>
|
||||
<span className="text-slate-600">
|
||||
Score min EV=0 : <span className="text-slate-400 font-mono">{Math.ceil(100 / (G + 1))}</span>/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
{PROFILE_COLORS.map(c => (
|
||||
<button key={c.value} onClick={() => setColor(c.value)}
|
||||
title={c.label}
|
||||
className={clsx('w-5 h-5 rounded-full border-2 transition-all',
|
||||
color === c.value ? 'border-white scale-110' : 'border-transparent opacity-60 hover:opacity-100')}
|
||||
style={{ background: c.value }} />
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setEnabled(!enabled)}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-all',
|
||||
enabled ? 'border-emerald-600/40 text-emerald-400 bg-emerald-900/20' : 'border-slate-700 text-slate-600')}>
|
||||
{enabled ? '✓ Activé' : '○ Désactivé'}
|
||||
</button>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={() => setEditing(false)}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 px-2 py-1">Annuler</button>
|
||||
<button onClick={save}
|
||||
className="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded font-semibold">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskProfilesCard() {
|
||||
const { data: profileData, isLoading } = useRiskProfiles()
|
||||
const { mutate: upsert } = useUpsertProfile()
|
||||
const { mutate: del } = useDeleteProfile()
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newScore, setNewScore] = useState(30)
|
||||
const [newGain, setNewGain] = useState(200)
|
||||
const [newColor, setNewColor] = useState('#3b82f6')
|
||||
|
||||
const profiles: any[] = (profileData as any)?.profiles ?? []
|
||||
|
||||
// Live preview for new profile
|
||||
const np = newScore / 100
|
||||
const nG = newGain / 100
|
||||
const nDenom = np * nG + (1 - np)
|
||||
const nEvNet = np * nG - (1 - np)
|
||||
const nTradeScore = nDenom > 0 ? (np * nG / nDenom * 100) : 0
|
||||
const nMinScore = Math.ceil(100 / (nG + 1))
|
||||
|
||||
const saveNew = () => {
|
||||
if (!newName.trim()) return
|
||||
upsert({ name: newName, min_score: newScore, min_gain_pct: newGain, color: newColor, enabled: true, sort_order: profiles.length })
|
||||
setShowNew(false)
|
||||
setNewName('')
|
||||
setNewScore(30)
|
||||
setNewGain(200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-dark-700/20 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-300">Profils de risque</div>
|
||||
<div className="text-[10px] text-slate-600 mt-0.5">
|
||||
Un trade est loggé dans le journal s'il passe <span className="text-slate-500">au moins un</span> profil activé
|
||||
· Formule : EV nette = (score/100 × gain/100) − (1 − score/100)
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowNew(true)}
|
||||
className="flex items-center gap-1 text-xs border border-blue-500/40 text-blue-400 hover:bg-blue-900/20 px-2.5 py-1 rounded">
|
||||
<Plus className="w-3 h-3" /> Nouveau profil
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-16 animate-pulse bg-dark-700 rounded" />
|
||||
) : profiles.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-600 text-sm">Aucun profil — tous les trades seront ignorés</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{profiles.map((p: any) => (
|
||||
<ProfileRow key={p.id} profile={p}
|
||||
onSave={data => upsert(data)}
|
||||
onDelete={id => del(id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New profile form */}
|
||||
{showNew && (
|
||||
<div className="bg-dark-700/60 rounded-lg p-3 border border-blue-700/30 space-y-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-blue-400">Nouveau profil</span>
|
||||
<button onClick={() => setShowNew(false)} className="text-slate-600 hover:text-slate-400"><X className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">Nom</label>
|
||||
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="ex: Ultra-risqué"
|
||||
className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white placeholder:text-slate-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">Score min = <span className="text-blue-400 font-mono">{newScore}</span></label>
|
||||
<input type="range" min="0" max="90" step="5" value={newScore}
|
||||
onChange={e => setNewScore(parseInt(e.target.value))} className="w-full accent-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">Gain min = <span className="text-blue-400 font-mono">{newGain}%</span></label>
|
||||
<input type="range" min="10" max="2000" step="10" value={newGain}
|
||||
onChange={e => setNewGain(parseFloat(e.target.value))} className="w-full accent-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live math */}
|
||||
<div className="bg-dark-800 rounded px-3 py-2 text-xs flex items-center gap-4 flex-wrap">
|
||||
<span className={clsx('font-mono font-bold', evNetLabel(nEvNet).cls)}>{evNetLabel(nEvNet).text}</span>
|
||||
<span className="text-slate-600">Trade Score = <span className="text-slate-400">{nTradeScore.toFixed(1)}</span>/100</span>
|
||||
<span className="text-slate-700">Score min pour EV=0 avec gain {newGain}% : <span className="text-slate-500 font-mono">{nMinScore}</span>/100</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
{PROFILE_COLORS.map(c => (
|
||||
<button key={c.value} onClick={() => setNewColor(c.value)} title={c.label}
|
||||
className={clsx('w-5 h-5 rounded-full border-2 transition-all',
|
||||
newColor === c.value ? 'border-white scale-110' : 'border-transparent opacity-60 hover:opacity-100')}
|
||||
style={{ background: c.value }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={() => setShowNew(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2 py-1">Annuler</button>
|
||||
<button onClick={saveNew} disabled={!newName.trim()}
|
||||
className="text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 py-1 rounded font-semibold">
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frontier visualization */}
|
||||
{profiles.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
||||
<div className="text-[10px] text-slate-600 mb-2">Frontière d'acceptation — chaque point représente le minimum requis par profil</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profiles.filter((p: any) => p.enabled).map((p: any) => {
|
||||
const { text: ev, cls } = evNetLabel(p.ev_net_at_frontier ?? 0)
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-2 bg-dark-700 rounded px-2.5 py-1.5 text-xs">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: p.color }} />
|
||||
<span className="text-slate-400 font-semibold">{p.name}</span>
|
||||
<span className="font-mono text-slate-500">{p.min_score}pts</span>
|
||||
<span className="text-slate-600">×</span>
|
||||
<span className="font-mono text-slate-500">{p.min_gain_pct}%</span>
|
||||
<span className={clsx('font-mono text-[10px]', cls)}>→ {ev}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Config() {
|
||||
const { data: sources, isLoading } = useSources()
|
||||
const { data: config } = useConfig()
|
||||
const { data: aiStatus } = useAiStatus()
|
||||
const { data: analysisCfg } = useAnalysisConfig()
|
||||
const { mutate: updateSources, isPending: savingSources } = useUpdateSources()
|
||||
const { mutate: updateApiKeys, isPending: savingKeys } = useUpdateApiKeys()
|
||||
const { mutate: saveAnalysis, isPending: savingAnalysis } = useSaveAnalysisConfig()
|
||||
const { data: cycleStatus, refetch: refetchCycle } = useCycleStatus()
|
||||
const { mutate: updateCycleConfig, isPending: savingCycle } = useUpdateCycleConfig()
|
||||
const { mutate: triggerCycle, isPending: triggeringCycle } = useTriggerCycle()
|
||||
|
||||
const [localSources, setLocalSources] = useState<Record<string, any> | null>(null)
|
||||
const [openaiKey, setOpenaiKey] = useState('')
|
||||
const [newsapiKey, setNewsapiKey] = useState('')
|
||||
const [eiaKey, setEiaKey] = useState('')
|
||||
const [fredKey, setFredKey] = useState('')
|
||||
const [showKeys, setShowKeys] = useState(false)
|
||||
const [savedMsg, setSavedMsg] = useState('')
|
||||
|
||||
// Analysis config local state
|
||||
const [analysisTopN, setAnalysisTopN] = useState(10)
|
||||
const [analysisCategoryDefault, setAnalysisCategoryDefault] = useState('all')
|
||||
const [analysisTemplate, setAnalysisTemplate] = useState('')
|
||||
|
||||
// Auto-cycle local state
|
||||
const cs = cycleStatus as any
|
||||
const [cycleEnabled, setCycleEnabled] = useState(false)
|
||||
const [cycleHours, setCycleHours] = useState(3)
|
||||
const [cycleSimilarity, setCycleSimilarity] = useState(0.30)
|
||||
const [minEv, setMinEv] = useState(0.0)
|
||||
const [minScore, setMinScore] = useState(0)
|
||||
useEffect(() => {
|
||||
if (cs) {
|
||||
setCycleEnabled(cs.enabled ?? false)
|
||||
setCycleHours(cs.interval_hours ?? 3)
|
||||
setCycleSimilarity(cs.similarity_threshold ?? 0.30)
|
||||
setMinEv(cs.min_ev_threshold ?? 0.0)
|
||||
setMinScore(cs.min_score_threshold ?? 0)
|
||||
}
|
||||
}, [cs])
|
||||
|
||||
useEffect(() => {
|
||||
if (analysisCfg) {
|
||||
setAnalysisTopN(analysisCfg.top_n ?? 10)
|
||||
setAnalysisCategoryDefault(analysisCfg.category_filter ?? 'all')
|
||||
setAnalysisTemplate(analysisCfg.template ?? '')
|
||||
}
|
||||
}, [analysisCfg])
|
||||
|
||||
const displaySources = localSources ?? sources ?? {}
|
||||
|
||||
const toggleSource = (key: string) => {
|
||||
setLocalSources(prev => {
|
||||
const base = prev ?? sources ?? {}
|
||||
return { ...base, [key]: { ...base[key], enabled: !base[key]?.enabled } }
|
||||
})
|
||||
}
|
||||
|
||||
const saveSources = () => {
|
||||
updateSources(displaySources, {
|
||||
onSuccess: () => { setSavedMsg('Sources sauvegardées'); setTimeout(() => setSavedMsg(''), 2000) }
|
||||
})
|
||||
}
|
||||
|
||||
const saveKeys = () => {
|
||||
const keys: Record<string, string> = {}
|
||||
if (openaiKey) keys.openai_api_key = openaiKey
|
||||
if (newsapiKey) keys.newsapi_key = newsapiKey
|
||||
if (eiaKey) keys.eia_api_key = eiaKey
|
||||
if (fredKey) keys.fred_api_key = fredKey
|
||||
updateApiKeys(keys, {
|
||||
onSuccess: () => {
|
||||
setSavedMsg('Clés API sauvegardées')
|
||||
setTimeout(() => setSavedMsg(''), 2000)
|
||||
setOpenaiKey(''); setNewsapiKey(''); setEiaKey(''); setFredKey('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-blue-400" /> Configuration
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Clés API, sources d'information, paramètres IA</p>
|
||||
</div>
|
||||
|
||||
{savedMsg && (
|
||||
<div className="card border-emerald-700/40 bg-emerald-900/10 text-emerald-400 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> {savedMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Left col: API Keys + AI status */}
|
||||
<div className="col-span-1 space-y-4">
|
||||
{/* AI Status */}
|
||||
<div className={clsx('card', aiStatus?.enabled ? 'border-emerald-700/40' : 'border-slate-700/40')}>
|
||||
<div className="section-title flex items-center gap-1"><Key className="w-3 h-3" /> Statut IA</div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{aiStatus?.enabled ? (
|
||||
<><CheckCircle className="w-5 h-5 text-emerald-400" />
|
||||
<div><div className="text-sm text-emerald-400 font-semibold">OpenAI Connecté</div>
|
||||
<div className="text-xs text-slate-500">GPT-4o + GPT-4o-mini</div></div></>
|
||||
) : (
|
||||
<><XCircle className="w-5 h-5 text-red-400" />
|
||||
<div><div className="text-sm text-red-400 font-semibold">OpenAI Non configuré</div>
|
||||
<div className="text-xs text-slate-500">Entrer la clé ci-dessous</div></div></>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 space-y-1">
|
||||
<div>• Analyse de discours (Trump, Powell...)</div>
|
||||
<div>• Classification IA des actualités</div>
|
||||
<div>• Évaluation de patterns</div>
|
||||
<div>• Top 10 idées contextualisées</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-title mb-0 flex items-center gap-1"><Key className="w-3 h-3" /> Clés API</div>
|
||||
<button onClick={() => setShowKeys(!showKeys)} className="text-slate-500 hover:text-slate-300">
|
||||
{showKeys ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'OpenAI API Key', value: openaiKey, setter: setOpenaiKey, placeholder: 'sk-proj-...', status: aiStatus?.enabled },
|
||||
{ label: 'NewsAPI Key', value: newsapiKey, setter: setNewsapiKey, placeholder: 'Obtenir sur newsapi.org', status: false },
|
||||
{ label: 'EIA API Key', value: eiaKey, setter: setEiaKey, placeholder: 'Obtenir sur eia.gov', status: false },
|
||||
{ label: 'FRED API Key', value: fredKey, setter: setFredKey, placeholder: 'Obtenir sur fred.stlouisfed.org', status: false },
|
||||
].map(({ label, value, setter, placeholder, status }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-slate-500">{label}</label>
|
||||
{status !== undefined && (
|
||||
<span className={clsx('text-xs', status ? 'text-emerald-400' : 'text-slate-600')}>
|
||||
{status ? '✓ Actif' : '○ Inactif'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={e => setter(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={saveKeys}
|
||||
disabled={savingKeys || (!openaiKey && !newsapiKey && !eiaKey && !fredKey)}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded py-1.5 text-xs font-semibold flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingKeys ? 'Sauvegarde...' : 'Sauvegarder les clés'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right col: Sources */}
|
||||
<div className="col-span-2 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="card animate-pulse h-40 bg-dark-700" />
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(SOURCE_CATEGORIES).map(([catName, keys]) => (
|
||||
<div key={catName} className="card">
|
||||
<div className="section-title flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" /> {catName}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{keys.map(key => {
|
||||
const src = displaySources[key] ?? {}
|
||||
const enabled = src.enabled ?? false
|
||||
const requiresKey = src.requires_key
|
||||
const doc = SOURCE_DOCS[key]
|
||||
const keySet = requiresKey ? !!config?.[requiresKey + '_set'] : true
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex items-start justify-between p-3 rounded border transition-all',
|
||||
enabled
|
||||
? 'border-blue-500/40 bg-blue-900/5'
|
||||
: 'border-slate-700/30 bg-dark-700/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white font-semibold">{src.name || key}</span>
|
||||
<span className={clsx('badge text-xs', {
|
||||
'badge-green': doc?.cost === 'Gratuit' || doc?.cost === 'Gratuit total',
|
||||
'badge-blue': doc?.cost?.includes('gratuit') || doc?.cost?.includes('Inscription'),
|
||||
'badge-yellow': doc?.cost?.includes('clé'),
|
||||
'badge-red': doc?.cost?.includes('payante'),
|
||||
})}>
|
||||
{doc?.cost}
|
||||
</span>
|
||||
{requiresKey && !keySet && (
|
||||
<span className="badge badge-orange text-xs">Clé requise</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{doc?.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleSource(key)}
|
||||
disabled={requiresKey && !keySet}
|
||||
className={clsx(
|
||||
'shrink-0 w-10 h-5 rounded-full border transition-all relative',
|
||||
enabled
|
||||
? 'bg-blue-600 border-blue-500'
|
||||
: 'bg-dark-600 border-slate-600',
|
||||
requiresKey && !keySet && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all',
|
||||
enabled ? 'left-5' : 'left-0.5'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={saveSources}
|
||||
disabled={savingSources || !localSources}
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{savingSources ? 'Sauvegarde...' : 'Appliquer les changements'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Paramètres d'analyse IA ── */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
|
||||
<SlidersHorizontal className="w-4 h-4 text-blue-400" /> Paramètres d'analyse IA
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Nombre de résultats par défaut (Top N)</label>
|
||||
<div className="flex gap-1">
|
||||
{[5, 10, 15, 20].map(n => (
|
||||
<button key={n} onClick={() => setAnalysisTopN(n)}
|
||||
className={clsx('flex-1 py-1.5 rounded text-sm transition-colors', {
|
||||
'bg-blue-600 text-white': analysisTopN === n,
|
||||
'bg-dark-700 text-slate-400 hover:text-slate-200': analysisTopN !== n,
|
||||
})}>
|
||||
Top {n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Catégorie par défaut</label>
|
||||
<select value={analysisCategoryDefault} onChange={e => setAnalysisCategoryDefault(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
|
||||
<option value="all">Toutes les catégories</option>
|
||||
<option value="energy">Énergie</option>
|
||||
<option value="metals">Métaux</option>
|
||||
<option value="agriculture">Agriculture</option>
|
||||
<option value="indices">Indices</option>
|
||||
<option value="equities">Actions</option>
|
||||
<option value="forex">Forex</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-slate-500">Template d'analyse (prompt envoyé à GPT-4o)</label>
|
||||
<button onClick={() => setAnalysisTemplate('')}
|
||||
className="text-xs text-slate-600 hover:text-slate-400">
|
||||
Remettre par défaut
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={analysisTemplate}
|
||||
onChange={e => setAnalysisTemplate(e.target.value)}
|
||||
rows={10}
|
||||
placeholder="Laisser vide pour utiliser le template par défaut..."
|
||||
className="w-full bg-dark-700 border border-slate-700 rounded px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-blue-500 resize-y"
|
||||
/>
|
||||
<div className="text-xs text-slate-600 mt-1">
|
||||
Variables disponibles dans le template : le contexte marché (prix, IV, variation 1j) et les news filtrées par keywords sont toujours injectées automatiquement.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => saveAnalysis(
|
||||
{ top_n: analysisTopN, category_filter: analysisCategoryDefault, template: analysisTemplate || undefined },
|
||||
{ onSuccess: () => { setSavedMsg('Paramètres d\'analyse sauvegardés'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||||
)}
|
||||
disabled={savingAnalysis}
|
||||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||||
<Save className="w-4 h-4" />
|
||||
{savingAnalysis ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</button>
|
||||
{savedMsg && <span className="text-xs text-emerald-400">{savedMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Auto-Cycle ── */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-blue-400" /> Auto-Cycle Intelligence
|
||||
</h2>
|
||||
{cs && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{cs.running ? (
|
||||
<span className="flex items-center gap-1 text-blue-400 bg-blue-900/30 border border-blue-700/30 px-2 py-0.5 rounded animate-pulse">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" /> En cours...
|
||||
</span>
|
||||
) : cs.scheduler_alive ? (
|
||||
<span className="text-emerald-400 bg-emerald-900/30 border border-emerald-700/30 px-2 py-0.5 rounded">
|
||||
● Scheduler actif
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600 bg-dark-700 border border-slate-700/30 px-2 py-0.5 rounded">
|
||||
○ Scheduler inactif
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Toutes les N heures : suggère de nouveaux patterns, filtre les doublons, score tout, log les prix et génère un commentaire IA sur les performances.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-2 block">Activer l'auto-cycle</label>
|
||||
<button
|
||||
onClick={() => setCycleEnabled(!cycleEnabled)}
|
||||
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
|
||||
'bg-blue-600 border-blue-500 text-white': cycleEnabled,
|
||||
'bg-dark-700 border-slate-700 text-slate-400 hover:border-slate-500': !cycleEnabled,
|
||||
})}>
|
||||
{cycleEnabled ? '✓ Activé' : '○ Désactivé'}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-2 block">Intervalle (heures)</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 3, 6, 12].map(h => (
|
||||
<button key={h} onClick={() => setCycleHours(h)}
|
||||
className={clsx('flex-1 py-2 rounded text-sm transition-colors', {
|
||||
'bg-blue-600 text-white': cycleHours === h,
|
||||
'bg-dark-700 text-slate-400 hover:text-slate-200': cycleHours !== h,
|
||||
})}>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-2 block">
|
||||
Similarité max ({Math.round(cycleSimilarity * 100)}% — nouveaux patterns en-dessous)
|
||||
</label>
|
||||
<input type="range" min="0.1" max="0.8" step="0.05"
|
||||
value={cycleSimilarity}
|
||||
onChange={e => setCycleSimilarity(parseFloat(e.target.value))}
|
||||
className="w-full accent-blue-500" />
|
||||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||||
<span>10% (strict)</span><span>80% (permissif)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Profiles */}
|
||||
<RiskProfilesCard />
|
||||
|
||||
{cs?.last_cycle && (
|
||||
<div className="card bg-dark-700/50 mb-4 text-xs">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-slate-500">Dernier cycle :</span>
|
||||
<span className="text-slate-300">{cs.last_cycle.started_at?.slice(0, 16)} UTC</span>
|
||||
<span className={clsx('badge', cs.last_cycle.status === 'completed' ? 'badge-green' : 'badge-red')}>
|
||||
{cs.last_cycle.status}
|
||||
</span>
|
||||
<span className="text-slate-500">+{cs.last_cycle.patterns_added} patterns</span>
|
||||
<span className="text-slate-500">{cs.last_cycle.patterns_scored} scorés</span>
|
||||
<span className="text-slate-500">Géo: {cs.last_cycle.geo_score}</span>
|
||||
<span className="text-slate-500 capitalize">{cs.last_cycle.dominant_regime}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => updateCycleConfig(
|
||||
{ enabled: cycleEnabled, interval_hours: cycleHours, similarity_threshold: cycleSimilarity, min_ev_threshold: minEv, min_score_threshold: minScore },
|
||||
{ onSuccess: () => { refetchCycle(); setSavedMsg('Auto-cycle configuré'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||||
)}
|
||||
disabled={savingCycle}
|
||||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||||
<Save className="w-4 h-4" />
|
||||
{savingCycle ? 'Sauvegarde...' : 'Appliquer'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerCycle(undefined, { onSuccess: () => { refetchCycle(); setSavedMsg('Cycle lancé !'); setTimeout(() => setSavedMsg(''), 3000) } })}
|
||||
disabled={triggeringCycle || !aiStatus?.enabled}
|
||||
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
|
||||
<RefreshCw className={clsx('w-4 h-4', triggeringCycle && 'animate-spin')} />
|
||||
Lancer maintenant
|
||||
</button>
|
||||
{savedMsg && <span className="text-xs text-emerald-400">{savedMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user