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:
OpenSquared
2026-06-16 20:29:59 +02:00
commit d256b65d30
69 changed files with 18301 additions and 0 deletions

49
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Sidebar from './components/layout/Sidebar'
import Dashboard from './pages/Dashboard'
import GeoRadar from './pages/GeoRadar'
import Markets from './pages/Markets'
import MacroRegime from './pages/MacroRegime'
import OptionsLab from './pages/OptionsLab'
import Backtest from './pages/Backtest'
import CalendarPage from './pages/CalendarPage'
import Portfolio from './pages/Portfolio'
import PatternEditor from './pages/PatternEditor'
import JournalDeBord from './pages/JournalDeBord'
import RapportIA from './pages/RapportIA'
import SuperContexte from './pages/SuperContexte'
import Config from './pages/Config'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
useCycleWatcher()
return null
}
export default function App() {
return (
<BrowserRouter>
<div className="flex min-h-screen">
<GlobalWatcher />
<Sidebar />
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/geo" element={<GeoRadar />} />
<Route path="/markets" element={<Markets />} />
<Route path="/macro" element={<MacroRegime />} />
<Route path="/options" element={<OptionsLab />} />
<Route path="/patterns" element={<PatternEditor />} />
<Route path="/portfolio" element={<Portfolio />} />
<Route path="/backtest" element={<Backtest />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/journal" element={<JournalDeBord />} />
<Route path="/rapport" element={<RapportIA />} />
<Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,105 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Cockpit' },
{ to: '/geo', icon: Globe, label: 'Radar Géopolitique' },
{ to: '/markets', icon: BarChart2, label: 'Marchés & Prix' },
{ to: '/macro', icon: Activity, label: 'Régime Macro' },
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
{ to: '/patterns', icon: Zap, label: 'Patterns' },
{ to: '/portfolio', icon: DollarSign, label: 'Portefeuille' },
{ to: '/journal', icon: BookOpen, label: 'Journal de Bord' },
{ to: '/rapport', icon: FileBarChart, label: 'Rapport IA' },
{ to: '/super-contexte', icon: Brain, label: 'Super Contexte' },
{ to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },
{ to: '/config', icon: Settings, label: 'Configuration' },
]
const riskColors: Record<string, string> = {
low: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40',
medium: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/40',
high: 'text-orange-400 bg-orange-900/30 border-orange-700/40',
extreme: 'text-red-400 bg-red-900/30 border-red-700/40 animate-pulse',
}
export default function Sidebar() {
const { data: riskScore } = useGeoRiskScore()
const { data: aiStatus } = useAiStatus()
const { data: summary } = usePortfolioSummary()
return (
<aside className="w-56 bg-dark-800 border-r border-slate-700/40 flex flex-col h-screen sticky top-0">
{/* Logo */}
<div className="p-4 border-b border-slate-700/40">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-400" />
<div>
<div className="text-sm font-bold text-white tracking-wide">GeoOptions</div>
<div className="text-xs text-slate-500">Intelligence v2.0</div>
</div>
</div>
</div>
{/* Geo risk indicator */}
{riskScore && (
<div className={clsx('mx-3 mt-3 px-3 py-2 rounded border text-xs', riskColors[riskScore.level])}>
<div className="font-semibold uppercase tracking-wider">Risque Géo</div>
<div className="text-lg font-bold">{riskScore.score}/100</div>
<div className="capitalize">{riskScore.level}</div>
</div>
)}
{/* Portfolio mini summary */}
{summary && (summary.open_positions > 0 || summary.unrealized_pnl !== 0) && (
<div className="mx-3 mt-2 px-3 py-2 rounded border border-slate-700/30 bg-dark-700/50 text-xs">
<div className="text-slate-500 uppercase tracking-wider text-xs mb-1">Portefeuille</div>
<div className="flex justify-between">
<span className="text-slate-400">{summary.open_positions} positions</span>
<span className={clsx('font-bold', summary.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{summary.unrealized_pnl >= 0 ? '+' : ''}{summary.unrealized_pnl?.toFixed(0)}
</span>
</div>
</div>
)}
{/* Navigation */}
<nav className="flex-1 p-2 mt-2 space-y-0.5 overflow-y-auto">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) => clsx('nav-link', isActive && 'active')}
>
<Icon className="w-4 h-4 shrink-0" />
<span>{label}</span>
</NavLink>
))}
</nav>
{/* AI status badge */}
<div className="px-3 py-2 border-t border-slate-700/40">
<div className={clsx('flex items-center gap-2 text-xs px-2 py-1.5 rounded', {
'bg-blue-900/30 text-blue-400': aiStatus?.enabled,
'bg-dark-700 text-slate-600': !aiStatus?.enabled,
})}>
<BrainCircuit className="w-3.5 h-3.5" />
<span>{aiStatus?.enabled ? 'IA GPT-4o active' : 'IA non configurée'}</span>
</div>
</div>
{/* Footer */}
<div className="p-3 border-t border-slate-700/40 text-xs text-slate-600">
<div>© 2026 GeoOptions</div>
<div className="text-slate-700 mt-0.5">Local · IBKR ready</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,545 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import axios from 'axios'
import type {
Quote, GeoNews, GeoRiskScore, PatternMatch, TradeIdea,
EconomicEvent, HistoricalCandle, BacktestResult
} from '../types'
export const api = axios.create({ baseURL: '/api' })
// ── Market ────────────────────────────────────────────────────────────────────
export const useAllQuotes = () =>
useQuery<Record<string, Quote[]>>({
queryKey: ['quotes'],
queryFn: () => api.get('/market/quotes').then(r => r.data),
refetchInterval: 60_000,
})
export const useHistory = (symbol: string, period = '1y', interval = '1d') =>
useQuery<HistoricalCandle[]>({
queryKey: ['history', symbol, period, interval],
queryFn: () =>
api.get(`/market/history/${encodeURIComponent(symbol)}`, { params: { period, interval } }).then(r => r.data),
enabled: !!symbol,
})
// ── Geo ───────────────────────────────────────────────────────────────────────
export const useGeoNews = () =>
useQuery<GeoNews[]>({
queryKey: ['geo-news'],
queryFn: () => api.get('/geo/news').then(r => r.data),
refetchInterval: 3_600_000,
})
export const useGeoRiskScore = () =>
useQuery<GeoRiskScore>({
queryKey: ['geo-risk-score'],
queryFn: () => api.get('/geo/risk-score').then(r => r.data),
refetchInterval: 3_600_000,
})
export const usePatternMatches = () =>
useQuery<PatternMatch[]>({
queryKey: ['pattern-matches'],
queryFn: () => api.get('/geo/pattern-matches').then(r => r.data),
})
export const usePatternRelevance = (days: number) =>
useQuery({
queryKey: ['pattern-relevance', days],
queryFn: () => api.get('/geo/pattern-relevance', { params: { days } }).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useTradeIdeas = () =>
useQuery<TradeIdea[]>({
queryKey: ['trade-ideas'],
queryFn: () => api.get('/geo/trade-ideas').then(r => r.data),
})
export const useCalendar = () =>
useQuery<EconomicEvent[]>({
queryKey: ['calendar'],
queryFn: () => api.get('/geo/calendar').then(r => r.data),
})
// ── Options ───────────────────────────────────────────────────────────────────
export const useIvSurface = (symbol: string) =>
useQuery({
queryKey: ['iv-surface', symbol],
queryFn: () => api.get('/options/iv-surface', { params: { symbol } }).then(r => r.data),
enabled: !!symbol,
})
export const usePnlCurve = (params: {
symbol: string; strike: number; expiry_days: number
option_type: string; quantity: number; premium_paid: number
}) =>
useQuery({
queryKey: ['pnl-curve', params],
queryFn: () => api.get('/options/pnl-curve', { params }).then(r => r.data),
enabled: !!params.symbol && !!params.strike && !!params.premium_paid,
})
// ── Backtest ──────────────────────────────────────────────────────────────────
export const useBacktest = () =>
useMutation<BacktestResult, Error, Record<string, unknown>>({
mutationFn: (data) => api.post('/backtest/run', data).then(r => r.data),
})
// ── AI ────────────────────────────────────────────────────────────────────────
export const useAiStatus = () =>
useQuery({
queryKey: ['ai-status'],
queryFn: () => api.get('/ai/status').then(r => r.data),
refetchInterval: 30_000,
})
export const useAiTopIdeas = () =>
useQuery({
queryKey: ['ai-top-ideas'],
queryFn: () => api.get('/ai/top-ideas').then(r => r.data),
enabled: false,
retry: false,
})
export const useAnalyzeSpeech = () =>
useMutation({
mutationFn: (data: { text: string; speaker?: string }) =>
api.post('/ai/analyze-speech', data).then(r => r.data),
})
export const useEvaluatePattern = () =>
useMutation({
mutationFn: (pattern: Record<string, unknown>) =>
api.post('/ai/evaluate-pattern', { pattern }).then(r => r.data),
})
export const useSuggestPattern = () =>
useMutation({
mutationFn: (context: string) =>
api.post('/ai/suggest-pattern', { context }).then(r => r.data),
})
export const useAiTopIdeasRefetch = () =>
useMutation({
mutationFn: () => api.get('/ai/top-ideas').then(r => r.data),
})
// ── Portfolio ─────────────────────────────────────────────────────────────────
export const usePortfolioPositions = (status = 'open') =>
useQuery({
queryKey: ['portfolio', status],
queryFn: () => api.get('/portfolio/positions', { params: { status } }).then(r => r.data),
refetchInterval: status === 'open' ? 60_000 : false,
})
export const usePortfolioSummary = () =>
useQuery({
queryKey: ['portfolio-summary'],
queryFn: () => api.get('/portfolio/summary').then(r => r.data),
refetchInterval: 60_000,
})
export const usePnlHistory = () =>
useQuery({
queryKey: ['pnl-history'],
queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data),
})
export const useAddPosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.post('/portfolio/add', data).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
},
})
}
export const useClosePosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, close_value }: { id: string; close_value: number }) =>
api.post(`/portfolio/close/${id}`, { close_value }).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
qc.invalidateQueries({ queryKey: ['pnl-history'] })
},
})
}
// ── Config ────────────────────────────────────────────────────────────────────
export const useConfig = () =>
useQuery({
queryKey: ['config'],
queryFn: () => api.get('/config/').then(r => r.data),
})
export const useSources = () =>
useQuery({
queryKey: ['sources'],
queryFn: () => api.get('/config/sources').then(r => r.data),
})
export const useUpdateSources = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (sources: Record<string, unknown>) =>
api.put('/config/sources', { sources }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['sources'] }),
})
}
export const useUpdateApiKeys = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (keys: Record<string, string>) =>
api.put('/config/api-keys', keys).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config'] })
qc.invalidateQueries({ queryKey: ['ai-status'] })
},
})
}
// ── AI Pattern Scoring ────────────────────────────────────────────────────────
export const useScorePatterns = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (params: { top_n?: number; category_filter?: string; template?: string }) =>
api.post('/ai/score-patterns', params).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['last-scores'] }),
})
}
export const useLastScores = () =>
useQuery({
queryKey: ['last-scores'],
queryFn: () => api.get('/ai/last-scores').then(r => r.data),
staleTime: Infinity,
})
export const useSuggestNewPatterns = () =>
useMutation({
mutationFn: () => api.post('/ai/suggest-new-patterns').then(r => r.data),
})
export const useAnalysisConfig = () =>
useQuery({
queryKey: ['analysis-config'],
queryFn: () => api.get('/config/analysis').then(r => r.data),
})
export const useSaveAnalysisConfig = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (cfg: { top_n?: number; category_filter?: string; template?: string }) =>
api.put('/config/analysis', cfg).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['analysis-config'] }),
})
}
// ── Patterns ──────────────────────────────────────────────────────────────────
export const useAllPatterns = () =>
useQuery({
queryKey: ['all-patterns'],
queryFn: () => api.get('/patterns/all').then(r => r.data),
})
export const usePatternSimilarity = () =>
useQuery({
queryKey: ['pattern-similarity'],
queryFn: () => api.get('/ai/pattern-similarity').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useMacroRegime = () => {
const qc = useQueryClient()
const query = useQuery({
queryKey: ['macro-regime'],
queryFn: () => api.get('/market/macro-regime').then(r => r.data),
staleTime: 10 * 60 * 1000,
refetchInterval: 15 * 60 * 1000,
})
const forceRefetch = async () => {
const fresh = await api.get('/market/macro-regime?force=true').then(r => r.data)
qc.setQueryData(['macro-regime'], fresh)
return fresh
}
return { ...query, forceRefetch }
}
export const useSavePattern = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (pattern: Record<string, unknown>) =>
api.post('/patterns/custom', pattern).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }),
})
}
export const useDeletePattern = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
api.delete(`/patterns/custom/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }),
})
}
export const useTogglePattern = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
api.put(`/patterns/toggle/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }),
})
}
// ── Auto-Cycle ───────────────────────────────────────────────────────────────
export const useCycleStatus = () =>
useQuery({
queryKey: ['cycle-status'],
queryFn: () => api.get('/cycle/status').then(r => r.data),
staleTime: 5_000,
// Poll every 5s while a cycle is running, every 30s otherwise
refetchInterval: (query) => ((query.state.data as any)?.running ? 5_000 : 30_000),
})
export const useCycleHistory = (limit = 20) =>
useQuery({
queryKey: ['cycle-history', limit],
queryFn: () => api.get(`/cycle/history?limit=${limit}`).then(r => r.data),
staleTime: 60_000,
})
export const useUpdateCycleConfig = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number }) =>
api.post('/cycle/config', cfg).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cycle-status'] }),
})
}
/** Keys to refresh once a cycle finishes — covers cockpit, macro, journal */
export const CYCLE_REFRESH_KEYS = [
['macro-regime'],
['last-scores'],
['all-patterns'],
['cycle-history'],
['cycle-status'],
['journal-summary'],
['journal-mtm'],
['journal-geo'],
['journal-macro'],
['geo-risk-score'],
['pattern-matches'],
]
export const useTriggerCycle = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/cycle/trigger').then(r => r.data),
onSuccess: () => {
// Immediate status refresh so the spinner shows
qc.invalidateQueries({ queryKey: ['cycle-status'] })
},
})
}
/**
* Mount this hook once at a high level (e.g. CyclesSection or App).
* It watches cycle-status and, when a running cycle finishes, refreshes
* all cockpit + journal queries so the UI reflects the new scores/regime.
*/
export const useCycleWatcher = () => {
const qc = useQueryClient()
const { data: statusData } = useCycleStatus()
const wasRunning = useRef(false)
useEffect(() => {
const running = (statusData as any)?.running ?? false
if (running) {
wasRunning.current = true
} else if (wasRunning.current) {
// Transition: was running → now done → refresh everything
wasRunning.current = false
CYCLE_REFRESH_KEYS.forEach(key => qc.invalidateQueries({ queryKey: key }))
}
}, [(statusData as any)?.running])
}
// ── Journal de Bord ──────────────────────────────────────────────────────────
export const useJournalSummary = () =>
useQuery({
queryKey: ['journal-summary'],
queryFn: () => api.get('/journal/summary').then(r => r.data),
staleTime: 2 * 60_000,
})
export const useMacroHistory = (days = 15) =>
useQuery({
queryKey: ['journal-macro', days],
queryFn: () => api.get(`/journal/macro-history?days=${days}`).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useGeoHistory = (days = 30) =>
useQuery({
queryKey: ['journal-geo', days],
queryFn: () => api.get(`/journal/geo-history?days=${days}`).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useTradeMtm = (days = 30) =>
useQuery({
queryKey: ['journal-mtm', days],
queryFn: () => api.get(`/journal/trade-mtm?days=${days}`).then(r => r.data),
staleTime: 0,
refetchInterval: 5 * 60_000, // re-fetch live prices every 5 minutes
refetchIntervalInBackground: false,
})
// ── Risk Profiles ─────────────────────────────────────────────────────────────
export const useRiskProfiles = () =>
useQuery({
queryKey: ['risk-profiles'],
queryFn: () => api.get('/profiles').then(r => r.data),
staleTime: 30_000,
})
export const useUpsertProfile = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (profile: {
id?: number; name: string; min_score: number; min_gain_pct: number;
color?: string; enabled?: boolean; sort_order?: number
}) => {
if (profile.id) {
return api.put(`/profiles/${profile.id}`, profile).then(r => r.data)
}
return api.post('/profiles', profile).then(r => r.data)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['risk-profiles'] }),
})
}
export const useDeleteProfile = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/profiles/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['risk-profiles'] }),
})
}
export const usePreviewTradeScore = (score: number, gainPct: number, enabled = true) =>
useQuery({
queryKey: ['profile-preview', score, gainPct],
queryFn: () => api.get(`/profiles/preview?score=${score}&gain_pct=${gainPct}`).then(r => r.data),
enabled,
staleTime: 0,
})
// ── Reasoning Traces / Post-mortem ────────────────────────────────────────────
export const useTradePostmortem = (tradeId: number | null) =>
useQuery({
queryKey: ['postmortem', tradeId],
queryFn: () => api.get(`/reasoning/postmortem/${tradeId}`).then(r => r.data),
enabled: tradeId !== null,
staleTime: 60_000,
})
export const useAnalyzePostmortem = () =>
useMutation({
mutationFn: (tradeId: number) =>
api.post(`/reasoning/postmortem/${tradeId}/analyze`).then(r => r.data),
})
export const usePortfolioReportData = (days: number) =>
useQuery({
queryKey: ['portfolio-report-data', days],
queryFn: () => api.get(`/reasoning/portfolio-report?days=${days}`).then(r => r.data),
staleTime: 120_000,
})
export const useGeneratePortfolioReport = () =>
useMutation({
mutationFn: (days: number) =>
api.post(`/reasoning/portfolio-report/generate?days=${days}`).then(r => r.data),
})
export const useAiReportsList = () =>
useQuery({
queryKey: ['ai-reports-list'],
queryFn: () => api.get('/reasoning/reports').then(r => r.data),
staleTime: 30_000,
})
export const useAiReport = (reportId: number | null) =>
useQuery({
queryKey: ['ai-report', reportId],
queryFn: () => api.get(`/reasoning/reports/${reportId}`).then(r => r.data),
enabled: reportId !== null,
staleTime: Infinity,
})
// ── Super Contexte / Knowledge Base ──────────────────────────────────────────
export const useKnowledgeState = () =>
useQuery({
queryKey: ['knowledge-state'],
queryFn: () => api.get('/knowledge/state').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useKnowledgeHistory = () =>
useQuery({
queryKey: ['knowledge-history'],
queryFn: () => api.get('/knowledge/history').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useKnowledgeStateVersion = (stateId: number | null) =>
useQuery({
queryKey: ['knowledge-state-version', stateId],
queryFn: () => api.get(`/knowledge/history/${stateId}`).then(r => r.data),
enabled: stateId !== null,
staleTime: Infinity,
})
export const useKnowledgeEntries = () =>
useQuery({
queryKey: ['knowledge-entries'],
queryFn: () => api.get('/knowledge/entries').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useSynthesizeKnowledge = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/knowledge/synthesize').then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['knowledge-state'] })
qc.invalidateQueries({ queryKey: ['knowledge-history'] })
qc.invalidateQueries({ queryKey: ['knowledge-entries'] })
},
})
}
export const usePatchKbEntryStatus = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) =>
api.patch(`/knowledge/entries/${id}/status`, { status }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge-entries'] }),
})
}

52
frontend/src/index.css Normal file
View File

@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-dark-900 text-slate-200 font-mono;
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { @apply bg-dark-800; }
::-webkit-scrollbar-thumb { @apply bg-dark-500 rounded; }
::-webkit-scrollbar-thumb:hover { @apply bg-slate-600; }
}
@layer components {
.card {
@apply bg-dark-800 border border-slate-700/40 rounded-lg p-4;
}
.card-sm {
@apply bg-dark-700 border border-slate-700/30 rounded p-3;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.badge-green { @apply badge bg-emerald-900/50 text-emerald-400 border border-emerald-700/30; }
.badge-red { @apply badge bg-red-900/50 text-red-400 border border-red-700/30; }
.badge-yellow { @apply badge bg-yellow-900/50 text-yellow-400 border border-yellow-700/30; }
.badge-blue { @apply badge bg-blue-900/50 text-blue-400 border border-blue-700/30; }
.badge-orange { @apply badge bg-orange-900/50 text-orange-400 border border-orange-700/30; }
.badge-purple { @apply badge bg-purple-900/50 text-purple-400 border border-purple-700/30; }
.stat-value { @apply text-2xl font-bold text-white; }
.stat-label { @apply text-xs text-slate-500 uppercase tracking-wider; }
.nav-link {
@apply flex items-center gap-2 px-3 py-2 rounded text-sm text-slate-400
hover:bg-dark-600 hover:text-slate-200 transition-colors;
}
.nav-link.active {
@apply bg-dark-600 text-blue-400 border-l-2 border-blue-400;
}
.positive { @apply text-emerald-400; }
.negative { @apply text-red-400; }
.neutral { @apply text-slate-400; }
.section-title {
@apply text-xs font-semibold text-slate-500 uppercase tracking-widest mb-3;
}
.risk-low { @apply text-emerald-400; }
.risk-medium { @apply text-yellow-400; }
.risk-high { @apply text-orange-400; }
.risk-extreme { @apply text-red-400; }
}

19
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, retry: 1 },
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)

View File

@@ -0,0 +1,310 @@
import { useState } from 'react'
import { useBacktest } from '../hooks/useApi'
import clsx from 'clsx'
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine,
} from 'recharts'
import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react'
import type { BacktestResult } from '../types'
const STRATEGIES = [
{ key: 'long_call', label: 'Long Call' },
{ key: 'long_put', label: 'Long Put' },
{ key: 'bull_call_spread', label: 'Bull Call Spread' },
{ key: 'bear_put_spread', label: 'Bear Put Spread' },
]
const SYMBOLS = [
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
'XLE', 'FXE', 'XOM', 'LMT', 'BA', 'RTX',
]
function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) {
return (
<div className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-xl font-bold mt-1', {
'text-emerald-400': positive === true,
'text-red-400': positive === false,
'text-white': positive === undefined,
})}>
{value}
</div>
{sub && <div className="text-xs text-slate-600 mt-0.5">{sub}</div>}
</div>
)
}
export default function Backtest() {
const { mutate: runBacktest, data: result, isPending } = useBacktest()
const [form, setForm] = useState({
symbol: 'GLD',
start_date: '2022-01-01',
end_date: '2024-12-31',
strategy: 'long_call',
strike_offset_pct: 0.05,
expiry_days: 90,
capital: 1000,
})
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const run = () => runBacktest(form as Record<string, unknown>)
const typed = result as BacktestResult | undefined
const hasResult = typed && !typed.error
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-blue-400" /> Backtest & Simulation
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Testez vos stratégies options sur des données historiques réelles
</p>
</div>
<div className="grid grid-cols-4 gap-5">
{/* Config panel */}
<div className="col-span-1 space-y-4">
<div className="card">
<div className="section-title">Configuration</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Sous-jacent</label>
<div className="flex flex-wrap gap-1 mb-1">
{SYMBOLS.slice(0, 7).map(s => (
<button
key={s}
onClick={() => set('symbol', s)}
className={clsx('px-1.5 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': form.symbol === s,
'border-slate-700 text-slate-500': form.symbol !== s,
})}
>
{s}
</button>
))}
</div>
<input
type="text"
value={form.symbol}
onChange={e => set('symbol', e.target.value.toUpperCase())}
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"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Stratégie</label>
{STRATEGIES.map(s => (
<button
key={s.key}
onClick={() => set('strategy', s.key)}
className={clsx('w-full text-left px-2 py-1.5 rounded mb-1 text-xs border transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': form.strategy === s.key,
'border-slate-700/40 text-slate-400': form.strategy !== s.key,
})}
>
{s.label}
</button>
))}
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Période</label>
<input
type="date"
value={form.start_date}
onChange={e => set('start_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white mb-1 focus:outline-none focus:border-blue-500"
/>
<input
type="date"
value={form.end_date}
onChange={e => set('end_date', 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"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Strike OTM: {(form.strike_offset_pct * 100).toFixed(0)}%
</label>
<input
type="range" min={0} max={0.20} step={0.01}
value={form.strike_offset_pct}
onChange={e => set('strike_offset_pct', Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Expiration: {form.expiry_days}j</label>
<div className="flex gap-1 mb-1">
{[30, 60, 90, 180].map(d => (
<button
key={d}
onClick={() => set('expiry_days', d)}
className={clsx('flex-1 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': form.expiry_days === d,
'border-slate-700 text-slate-500': form.expiry_days !== d,
})}
>
{d}j
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Capital initial ()</label>
<input
type="number"
value={form.capital}
onChange={e => set('capital', Number(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"
/>
</div>
<button
onClick={run}
disabled={isPending}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold flex items-center justify-center gap-2 transition-colors"
>
<Play className="w-4 h-4" />
{isPending ? 'Calcul...' : 'Lancer le backtest'}
</button>
</div>
</div>
</div>
{/* Results panel */}
<div className="col-span-3 space-y-4">
{typed?.error && (
<div className="card border-red-700/40 bg-red-900/10">
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4" /> {typed.error}
</div>
</div>
)}
{hasResult && (
<>
{/* KPIs */}
<div className="grid grid-cols-4 gap-3">
<StatCard
label="Retour total"
value={`${typed.total_return_pct >= 0 ? '+' : ''}${typed.total_return_pct.toFixed(2)}%`}
positive={typed.total_return_pct >= 0}
/>
<StatCard
label="Taux de succès"
value={`${typed.win_rate.toFixed(1)}%`}
sub={`${typed.wins}W / ${typed.losses}L`}
positive={typed.win_rate >= 50}
/>
<StatCard
label="Max Drawdown"
value={`${typed.max_drawdown_pct.toFixed(2)}%`}
positive={false}
/>
<StatCard
label="Profit Factor"
value={typed.profit_factor.toFixed(2)}
sub={`${typed.total_trades} trades`}
positive={typed.profit_factor >= 1}
/>
</div>
{/* Equity curve */}
<div className="card">
<div className="section-title">Courbe d'équité — {form.symbol} {STRATEGIES.find(s => s.key === form.strategy)?.label}</div>
<div className="text-xs text-slate-500 mb-3">
Capital final: <span className="text-white font-bold">{typed.final_capital.toFixed(2)}€</span>
{' '}(initial: {form.capital}€ · P&L: {typed.total_pnl >= 0 ? '+' : ''}{typed.total_pnl.toFixed(2)}€)
</div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={typed.equity_curve}>
<defs>
<linearGradient id="equity-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
<stop offset="95%" stopColor={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="index" tick={{ fill: '#475569', fontSize: 9 }} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `${v.toFixed(0)}€`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`${v.toFixed(2)}€`, 'Capital']}
/>
<ReferenceLine y={form.capital} stroke="#475569" strokeDasharray="4 4" label={{ value: 'Initial', fill: '#475569', fontSize: 9 }} />
<Area
type="monotone" dataKey="capital"
stroke={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'}
fill="url(#equity-grad)" strokeWidth={2} dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Last trades */}
<div className="card">
<div className="section-title">Derniers trades exécutés</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Entrée</th>
<th className="text-left pb-2">Sortie</th>
<th className="text-right pb-2">Prix entrée</th>
<th className="text-right pb-2">Strike</th>
<th className="text-right pb-2">Prime</th>
<th className="text-right pb-2">Prix sortie</th>
<th className="text-right pb-2">P&L</th>
<th className="text-right pb-2">Capital</th>
</tr>
</thead>
<tbody>
{typed.trades.map((t, i) => {
const pnl = t.pnl as number
return (
<tr key={i} className="border-b border-slate-700/20 hover:bg-dark-700/50">
<td className="py-1">{t.entry_date as string}</td>
<td className="py-1">{t.exit_date as string}</td>
<td className="py-1 text-right font-mono">${(t.S_entry as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.K as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.premium as number).toFixed(4)}</td>
<td className="py-1 text-right font-mono">${(t.S_expiry as number).toFixed(2)}</td>
<td className={clsx('py-1 text-right font-mono font-bold', pnl >= 0 ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}
</td>
<td className="py-1 text-right font-mono text-slate-300">{(t.capital as number).toFixed(2)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</>
)}
{!hasResult && !isPending && !typed?.error && (
<div className="card h-80 flex items-center justify-center text-slate-600">
<div className="text-center">
<History className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div className="text-sm">Configurer et lancer un backtest</div>
<div className="text-xs mt-1">Données yfinance historique complet disponible</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
import { useCalendar, useGeoNews } from '../hooks/useApi'
import clsx from 'clsx'
import { Calendar, Clock, Globe, AlertTriangle } from 'lucide-react'
import type { EconomicEvent, AssetClass } from '../types'
import { format, parseISO, isAfter, isBefore, addDays } from 'date-fns'
import { fr } from 'date-fns/locale'
const ASSET_EMOJIS: Record<string, string> = {
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
indices: '📊', forex: '💱', rates: '🏦',
}
const COUNTRY_FLAGS: Record<string, string> = {
US: '🇺🇸', EU: '🇪🇺', CN: '🇨🇳', JP: '🇯🇵', GB: '🇬🇧',
DE: '🇩🇪', FR: '🇫🇷', Global: '🌍',
}
const IMPORTANCE_CONFIG: Record<string, { color: string; label: string; dots: number }> = {
high: { color: 'text-red-400 border-red-700/40', label: 'Majeur', dots: 3 },
medium: { color: 'text-yellow-400 border-yellow-700/40', label: 'Modéré', dots: 2 },
low: { color: 'text-slate-400 border-slate-700/40', label: 'Mineur', dots: 1 },
}
function EventCard({ ev }: { ev: EconomicEvent }) {
const cfg = IMPORTANCE_CONFIG[ev.importance]
const isPast = ev.actual !== undefined && ev.actual !== null
const today = new Date()
const evDate = parseISO(ev.date)
const isToday = format(evDate, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')
const isSoon = !isToday && isAfter(evDate, today) && isBefore(evDate, addDays(today, 3))
return (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-yellow-500/40 bg-yellow-900/5': isToday,
'border-blue-500/30': isSoon && !isToday,
'opacity-60': isPast,
})}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-base">{COUNTRY_FLAGS[ev.country] ?? '🌍'}</span>
<span className={clsx('text-xs font-bold uppercase tracking-wider', cfg.color.split(' ')[0])}>
{'●'.repeat(cfg.dots)}
</span>
{isToday && <span className="badge badge-yellow text-xs">AUJOURD'HUI</span>}
{isSoon && !isToday && <span className="badge badge-blue text-xs">BIENTÔT</span>}
</div>
<div className="text-sm text-white font-semibold">{ev.title}</div>
<div className="text-xs text-slate-500 mt-0.5">
{format(evDate, "EEEE d MMM yyyy", { locale: fr })} · {ev.country}
</div>
</div>
<div className="text-right shrink-0">
<div className={clsx('text-xs font-semibold', cfg.color.split(' ')[0])}>{cfg.label}</div>
{ev.previous && <div className="text-xs text-slate-600 mt-0.5">Préc: {ev.previous}</div>}
{ev.forecast && <div className="text-xs text-slate-500">Prév: {ev.forecast}</div>}
{ev.actual && <div className="text-xs text-emerald-400 font-bold">Réel: {ev.actual}</div>}
</div>
</div>
{ev.asset_impact && ev.asset_impact.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{ev.asset_impact.map(cls => (
<span key={cls} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded border border-slate-700/40">
{ASSET_EMOJIS[cls] ?? ''} {cls}
</span>
))}
</div>
)}
</div>
)
}
export default function CalendarPage() {
const { data: calendar, isLoading } = useCalendar()
const { data: news } = useGeoNews()
const today = new Date()
const upcoming = calendar?.filter(ev => isAfter(parseISO(ev.date), today)) ?? []
const past = calendar?.filter(ev => isBefore(parseISO(ev.date), today)) ?? []
const highImpactNews = news?.filter(n => n.impact_score > 0.4).slice(0, 5) ?? []
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-400" /> Calendrier Économique & Géopolitique
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Événements macro, catalyseurs géopolitiques, dates clés
</p>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<div className="flex items-center gap-1"><span className="text-red-400">●●●</span> Majeur (forte volatilité attendue)</div>
<div className="flex items-center gap-1"><span className="text-yellow-400">●●</span> Modéré</div>
<div className="flex items-center gap-1"><span className="text-slate-400">●</span> Mineur</div>
</div>
<div className="grid grid-cols-3 gap-5">
{/* Economic calendar */}
<div className="col-span-2 space-y-3">
<div className="section-title flex items-center gap-1">
<Clock className="w-3 h-3" /> Événements à venir ({upcoming.length})
</div>
{isLoading ? (
[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700"></div>)
) : upcoming.length > 0 ? (
upcoming.map((ev, i) => <EventCard key={i} ev={ev} />)
) : (
<div className="card text-center py-8 text-slate-500 text-sm">
Démarrer le backend pour charger le calendrier
</div>
)}
{past.length > 0 && (
<>
<div className="section-title mt-6 flex items-center gap-1 opacity-60">
Événements passés ({past.length})
</div>
{past.map((ev, i) => <EventCard key={i} ev={ev} />)}
</>
)}
</div>
{/* Right: geo alerts + timeline */}
<div className="col-span-1 space-y-4">
<div className="card">
<div className="section-title flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400" /> Alertes géopolitiques
</div>
{highImpactNews.length > 0 ? (
<div className="space-y-2">
{highImpactNews.map((n, i) => (
<div key={i} className="card-sm">
<div className="flex items-start justify-between gap-1">
<div className="text-xs text-white line-clamp-2">{n.title}</div>
<span className="text-xs text-orange-400 font-bold shrink-0 ml-1">
{Math.round(n.impact_score * 100)}
</span>
</div>
<div className="text-xs text-slate-600 mt-1">{n.source}</div>
</div>
))}
</div>
) : (
<div className="text-xs text-slate-600 text-center py-4">Charger les actualités géopolitiques</div>
)}
</div>
{/* Trade opportunity windows */}
<div className="card">
<div className="section-title">Fenêtres d'opportunité</div>
<div className="space-y-2 text-xs">
{[
{ window: 'Pré-FOMC (-3j)', strategy: 'Straddle sur SPY', rationale: 'IV monte avant décision' },
{ window: 'Pré-NFP (-2j)', strategy: 'Straddle sur indices', rationale: 'Directional uncertainty' },
{ window: 'Post-OPEC', strategy: 'Bull Call Spread USO', rationale: 'Cut → oil spike probable' },
{ window: 'Pré-USDA Crop', strategy: 'Long Call WEAT', rationale: 'Supply news catalyst' },
{ window: 'Élections US approche', strategy: 'Long VIX Call', rationale: 'Vol expansion garantie' },
].map((op, i) => (
<div key={i} className="card-sm">
<div className="font-semibold text-blue-400">{op.window}</div>
<div className="text-white mt-0.5">{op.strategy}</div>
<div className="text-slate-500">{op.rationale}</div>
</div>
))}
</div>
</div>
{/* Geo-event impact guide */}
<div className="card">
<div className="section-title flex items-center gap-1">
<Globe className="w-3 h-3" /> Guide d'impact
</div>
<div className="space-y-1.5 text-xs">
{[
{ event: 'Conflit Moyen-Orient', impact: 'Oil +10-20%', cls: 'energy' },
{ event: 'Sanctions Russie', impact: 'Gaz +15-40%', cls: 'energy' },
{ event: 'Tarifs US-Chine', impact: 'Soja -8%', cls: 'agriculture' },
{ event: 'Crise sanitaire', impact: 'Or +7-12%', cls: 'metals' },
{ event: 'Hausses Fed', impact: 'USD +2-4%', cls: 'forex' },
{ event: 'Guerre Ukraine', impact: 'Blé +15-50%', cls: 'agriculture' },
].map((g, i) => (
<div key={i} className="flex justify-between items-center py-1 border-b border-slate-700/20 last:border-0">
<span className="text-slate-400">{g.event}</span>
<span className={clsx('font-bold', g.impact.includes('+') ? 'positive' : 'negative')}>
{g.impact}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,867 @@
import { useState, useMemo, useEffect } from 'react'
import {
useGeoRiskScore, useAllQuotes,
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
usePortfolioPositions, useTradeMtm, useRiskProfiles,
} from '../hooks/useApi'
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2 } from 'lucide-react'
import clsx from 'clsx'
import type { Quote } from '../types'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer } from 'recharts'
const riskGauge = (score: number) => {
if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'FAIBLE' }
if (score < 50) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'MODÉRÉ' }
if (score < 75) return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'ÉLEVÉ' }
return { color: 'text-red-400', bg: 'bg-red-500', label: 'EXTRÊME' }
}
const assetEmoji: Record<string, string> = {
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
indices: '📊', forex: '💱', crypto: '₿', rates: '📉',
}
const scoreColor = (s: number) => {
if (s >= 70) return 'text-emerald-400'
if (s >= 50) return 'text-yellow-400'
return 'text-slate-400'
}
const scoreBg = (s: number) => {
if (s >= 70) return 'bg-emerald-500'
if (s >= 50) return 'bg-yellow-500'
return 'bg-slate-600'
}
const BUCKET_ICONS: Record<string, string> = {
actualites: '📰', calendrier: '📅', prix: '📈', rr: '⚖️',
geo: '🌍', eco: '🌐', flux: '📡',
banques: '🏦', macro_cal: '📋',
taux: '📉', energie: '⛽', forex_sig: '💱', actions: '📊', vix: '⚡',
asymetrie: '⚖️', timing_rr: '⏱️',
}
function BucketBar({ score, max }: { score: number; max: number }) {
const pct = max > 0 ? (score / max) * 100 : 0
const color = pct >= 75 ? 'bg-emerald-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500/70'
return (
<div className="w-12 bg-dark-600 rounded-full h-1 shrink-0">
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
)
}
function BucketBreakdown({ buckets }: { buckets: any[] }) {
const [openBucket, setOpenBucket] = useState<string | null>(null)
return (
<div className="space-y-1 text-xs">
{buckets.map((b: any) => {
const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0
const isOpen = openBucket === b.id
return (
<div key={b.id} className="bg-dark-700/60 rounded overflow-hidden">
<button
className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-dark-600/60 transition-colors text-left"
onClick={() => setOpenBucket(isOpen ? null : b.id)}
>
<span>{BUCKET_ICONS[b.id] ?? '•'}</span>
<span className="text-slate-400 flex-1 truncate">{b.label}</span>
<BucketBar score={b.score} max={b.max} />
<span className={clsx('font-mono w-9 text-right shrink-0', pct >= 75 ? 'text-emerald-400' : pct >= 50 ? 'text-yellow-400' : 'text-red-400')}>
{b.score}/{b.max}
</span>
{isOpen ? <ChevronUp className="w-2.5 h-2.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-2.5 h-2.5 text-slate-600 shrink-0" />}
</button>
{isOpen && (
<div className="px-2 pb-2 border-t border-slate-700/30 space-y-2 pt-1.5">
{b.comment && (
<p className="text-slate-500 italic">{b.comment}</p>
)}
{b.subs?.map((sub: any) => {
const subPct = sub.max > 0 ? Math.round((sub.score / sub.max) * 100) : 0
return (
<div key={sub.id} className="pl-1 space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="text-xs">{BUCKET_ICONS[sub.id] ?? ''}</span>
<span className="text-slate-500 flex-1 truncate">{sub.label}</span>
<BucketBar score={sub.score} max={sub.max} />
<span className={clsx('font-mono w-7 text-right shrink-0', subPct >= 75 ? 'text-emerald-400' : subPct >= 50 ? 'text-yellow-400' : 'text-slate-600')}>
{sub.score}/{sub.max}
</span>
</div>
{sub.comment && (
<p className="text-slate-600 italic ml-4">{sub.comment}</p>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}
const CATEGORIES = [
{ key: 'all', label: 'Tous' },
{ key: 'energy', label: '⛽ Énergie' },
{ key: 'metals', label: '🥇 Métaux' },
{ key: 'agriculture', label: '🌾 Agri' },
{ key: 'indices', label: '📊 Indices' },
{ key: 'equities', label: '📈 Actions' },
{ key: 'forex', label: '💱 Forex' },
]
interface TradeItem {
trade: any
patternName: string
patternId: string
assetClass: string
score: number | null
scoreInfo: any | null
scoreDelta: number | null
rankRationale: string | null
expectedMovePct: number // from original pattern definition
}
const BIAS_DISPLAY: Record<string, { label: string; color: string }> = {
'bullish+': { label: '★★ Compatible', color: '#10b981' },
'bullish': { label: '★ Compatible', color: '#34d399' },
'neutral': { label: '→ Neutre', color: '#64748b' },
'bearish': { label: '✗ Défavorable', color: '#f97316' },
'bearish+': { label: '✗✗ Contra', color: '#ef4444' },
'defensive':{ label: '⚠ Défensif', color: '#f59e0b' },
}
function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
item: TradeItem
onAdd: (item: TradeItem) => void
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | null
addedInfo?: { entry_date: string } | null
profiles?: any[]
}) {
const [expanded, setExpanded] = useState(false)
const { trade, patternName, assetClass, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item
const effectiveScore = score !== null
? Math.max(0, Math.min(100, score + (scoreDelta ?? 0)))
: null
// EV calculation: ev_net = p×G - (1-p) where p=score/100, G=gain/100
const gainPct = expectedMovePct ?? 0
const evNet = effectiveScore !== null && gainPct > 0
? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100)
: null
// Which profile matches this trade (if any)
const matchedProfile = useMemo(() => {
if (effectiveScore === null || !profiles || profiles.length === 0) return undefined
return profiles.find(prof =>
prof.enabled && effectiveScore >= prof.min_score && gainPct >= prof.min_gain_pct
) ?? null
}, [effectiveScore, gainPct, profiles])
const breakdown = scoreInfo?.score_breakdown ?? {}
const rationale = scoreInfo?.summary ?? trade.rationale ?? scoreInfo?.key_catalyst ?? ''
const maxLoss = trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur
// Cible dérivée de la formule (cohérent avec Gain%) — fallback sur estimation GPT-4o
const target = maxLoss != null && gainPct > 0
? Math.round(Math.abs(maxLoss) * gainPct / 100)
: (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur)
const timing = trade.timing_note ?? scoreInfo?.recommended_trade?.timing_note
return (
<div className={clsx('card transition-all', {
'border-emerald-700/50': effectiveScore !== null && effectiveScore >= 70,
'border-yellow-700/30': effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70,
'border-slate-700/20': effectiveScore === null,
})}>
{/* Pattern name (tiny, above) */}
<div className="text-xs text-slate-600 line-clamp-1 mb-1 font-mono">{patternName}</div>
{/* Trade + score */}
<div className="flex items-start justify-between mb-1.5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 flex-wrap">
<span className="badge badge-blue text-xs">{assetClass}</span>
{trade.underlying && (
<span className="text-sm text-white font-semibold font-mono">{trade.underlying}</span>
)}
{trade.isRecommended && effectiveScore !== null && (
<span className="text-xs text-yellow-400 bg-yellow-400/10 border border-yellow-400/30 rounded px-1"> IA</span>
)}
</div>
{trade.strategy && (
<span className="badge badge-green text-xs mt-0.5">{trade.strategy}</span>
)}
</div>
{effectiveScore !== null ? (
<div className="ml-2 shrink-0 text-center min-w-[48px]">
<div className={clsx('text-2xl font-bold leading-none', scoreColor(effectiveScore))}>{effectiveScore}</div>
<div className="text-xs text-slate-600 flex items-center justify-center gap-0.5">
<span>/100</span>
{scoreDelta !== null && scoreDelta !== 0 && (
<span className={clsx('text-[10px] font-mono font-bold', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
</span>
)}
</div>
{scoreInfo?.score_trend != null && (
<div className={clsx('text-[10px] font-mono font-bold mt-0.5', scoreInfo.score_trend > 0 ? 'text-emerald-400' : scoreInfo.score_trend < 0 ? 'text-red-400' : 'text-slate-600')}>
{scoreInfo.score_trend > 0 ? '↑+' : scoreInfo.score_trend < 0 ? '↓' : '→'}{scoreInfo.score_trend !== 0 ? Math.abs(scoreInfo.score_trend) : ''}
</div>
)}
</div>
) : (
<span className="ml-2 shrink-0 text-xs text-slate-500 bg-dark-600 border border-slate-700/40 rounded px-1.5 py-0.5 whitespace-nowrap">
à scorer
</span>
)}
</div>
{/* Score bar */}
{effectiveScore !== null && (
<div className="bg-dark-600 rounded-full h-1.5 mb-2">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
</div>
)}
{/* EV / Profile match row — always shown when scored, helps understand why trade is/isn't logged */}
{effectiveScore !== null && (
<div className="flex items-center gap-1.5 mb-2 text-[10px] flex-wrap">
<span className="text-slate-600">
Gain:{' '}
<span className={gainPct > 0 ? 'text-slate-300' : 'text-orange-400/80'}>
{gainPct > 0 ? `${gainPct}%` : '?'}
</span>
</span>
{evNet !== null ? (
<>
<span className="text-slate-700">·</span>
<span className={clsx('font-mono font-semibold', evNet >= 0 ? 'text-emerald-400' : 'text-orange-400')}>
EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%
</span>
</>
) : gainPct === 0 ? (
<>
<span className="text-slate-700">·</span>
<span className="text-orange-400/70">EV ?</span>
</>
) : null}
{matchedProfile !== undefined && (
<>
<span className="text-slate-700">·</span>
{matchedProfile ? (
<span className="font-semibold" style={{ color: matchedProfile.color }}>
{matchedProfile.name}
</span>
) : (
<span className="text-red-400/80">
{gainPct === 0 ? 'Gain non défini' : 'Aucun profil'}
</span>
)}
</>
)}
</div>
)}
{/* Rank rationale (why this trade differs from pattern average) */}
{rankRationale && (
<div className="text-[10px] text-slate-600 italic mb-1.5 line-clamp-1">{rankRationale}</div>
)}
{/* Macro scenario compatibility */}
{macroInfo && macroInfo.dominant !== 'incertain' && (() => {
const bias = macroInfo.assetBias[assetClass] ?? 'neutral'
const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral']
return (
<div className="flex items-center gap-1.5 mb-2 text-[10px]">
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
<span className="text-slate-700">·</span>
<span style={{ color: bd.color }}>{bd.label}</span>
</div>
)
})()}
{/* Rationale */}
{rationale && <p className="text-xs text-slate-400 line-clamp-2 mb-2">{rationale}</p>}
{/* timing */}
{timing && <div className="text-xs text-yellow-400/80 mb-2"> {timing}</div>}
{/* max/target */}
{(maxLoss != null || target != null) && (
<div className="flex gap-2 text-xs mb-2">
{maxLoss != null && <span className="text-red-400">Max -{Math.abs(maxLoss)}</span>}
{target != null && <span className="text-emerald-400">Cible +{target}</span>}
{scoreInfo?.confidence && <span className="text-slate-600 ml-auto">conf. {scoreInfo.confidence}%</span>}
</div>
)}
{/* Expandable score breakdown */}
{effectiveScore !== null && (scoreInfo?.buckets?.length > 0 || Object.keys(breakdown).length > 0) && (
<>
<button onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-1.5">
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Détail du score par pilier
</button>
{expanded && (
<div className="mb-2">
{scoreInfo?.buckets?.length > 0 ? (
<BucketBreakdown buckets={scoreInfo.buckets} />
) : (
<div className="space-y-1 bg-dark-700/50 rounded p-2">
{Object.entries(breakdown).map(([k, v]: [string, any]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-slate-500 capitalize">{k.replace(/_/g, ' ')}</span>
<div className="flex items-center gap-1">
<div className="w-14 bg-dark-600 rounded-full h-1">
<div className="bg-blue-500 h-1 rounded-full" style={{ width: `${(v / 25) * 100}%` }} />
</div>
<span className="text-slate-300 w-5 text-right text-xs">{v}/25</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
{addedInfo && (
<div className="flex items-center gap-1.5 mb-2 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
<CheckCircle2 className="w-3 h-3 shrink-0" />
<span>Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}</span>
</div>
)}
<button onClick={() => onAdd(item)}
className={clsx(
'w-full flex items-center justify-center gap-1 text-xs rounded py-1 transition-all border',
addedInfo
? 'text-slate-500 border-slate-700/30 hover:text-slate-300 hover:border-slate-600'
: 'text-blue-400 hover:text-white hover:bg-blue-600 border-blue-500/30 hover:border-blue-500'
)}>
<Plus className="w-3 h-3" />
{addedInfo ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
</button>
</div>
)
}
function QuoteRow({ q }: { q: Quote }) {
if (!q.price) return null
const pos = q.change_pct >= 0
return (
<div className="flex items-center justify-between py-1.5 border-b border-slate-700/20 last:border-0">
<span className="text-xs text-white truncate max-w-[140px]">{q.name || q.symbol}</span>
<div className="text-right ml-2">
<div className="text-xs text-white font-mono">{q.price.toFixed(2)}</div>
<div className={clsx('text-xs font-mono', pos ? 'positive' : 'negative')}>
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
</div>
</div>
</div>
)
}
export default function Dashboard() {
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
const { data: allQuotes } = useAllQuotes()
const { data: calendar } = useCalendar()
const { data: aiStatus } = useAiStatus()
const { data: portfolio } = usePortfolioSummary()
const { data: lastScoresData } = useLastScores()
const { data: allPatternsData } = useAllPatterns()
const { data: macroData } = useMacroRegime()
const { data: positions, refetch: refetchPositions } = usePortfolioPositions('open')
const { data: tradeMtmData } = useTradeMtm(30)
const { data: riskProfilesData } = useRiskProfiles()
const { mutate: scorePatterns, isPending: scoring } = useScorePatterns()
const { mutate: addPos } = useAddPosition()
const riskProfiles: any[] = (riskProfilesData as any)?.profiles ?? []
const [categoryFilter, setCategoryFilter] = useState('all')
const [topN, setTopN] = useState(10)
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null)
useEffect(() => {
if (!toast) return
const t = setTimeout(() => setToast(null), 3500)
return () => clearTimeout(t)
}, [toast])
// Build two keys per position so we match regardless of ticker normalization:
// key1 = geo_trigger (pattern name) + strategy → survives underlying normalization
// key2 = underlying + strategy → direct ticker match fallback
const addedMap = useMemo(() => {
const map: Record<string, { entry_date: string }> = {}
const upsert = (key: string, entry_date: string) => {
if (!map[key] || entry_date > map[key].entry_date) map[key] = { entry_date }
}
for (const pos of (positions as any[] ?? [])) {
const strategy = (pos.strategy ?? '').toLowerCase()
const trigger = (pos.geo_trigger ?? '').toLowerCase()
const underly = (pos.underlying ?? '').toLowerCase()
if (trigger) upsert(`trigger:${trigger}:${strategy}`, pos.entry_date ?? '')
if (underly) upsert(`ticker:${underly}:${strategy}`, pos.entry_date ?? '')
}
return map
}, [positions])
const macroInfo = useMemo(() => {
if (!macroData?.scenarios) return null
const sc = macroData.scenarios
const dom = sc.dominant ?? 'incertain'
const m = sc.meta?.[dom] ?? { label: dom, color: '#94a3b8', emoji: '?' }
const assetBias: Record<string, string> = sc.asset_bias?.[dom] ?? {}
return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias }
}, [macroData])
const gauge = riskScore ? riskGauge(riskScore.score) : null
const radarData = riskScore?.breakdown
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
: []
// Map of last AI scores by pattern_id
const scoreMap = useMemo(() => {
const map: Record<string, any> = {}
for (const sp of (lastScoresData?.scored_patterns ?? [])) {
if (sp.pattern_id) map[sp.pattern_id] = sp
}
return map
}, [lastScoresData])
const allPatterns: any[] = allPatternsData ?? []
const scoredAt: string | null = lastScoresData?.scored_at ?? null
// Build flat list of TradeItems
const { topScored, allUnscored } = useMemo(() => {
const filtered = allPatterns.filter(p =>
categoryFilter === 'all' || p.asset_class === categoryFilter
)
const scored: TradeItem[] = []
const unscored: TradeItem[] = []
for (const p of filtered) {
const sp = scoreMap[p.id]
if (sp) {
// Scored: show ALL suggested_trades from pattern, annotated with score
const recUnderlying = sp.recommended_trade?.underlying
const trades: any[] = p.suggested_trades ?? []
const tradesOrFallback = trades.length > 0 ? trades : [sp.recommended_trade ?? {}]
const rankings: any[] = sp.trade_rankings ?? []
for (const t of tradesOrFallback) {
const isRecommended = recUnderlying && t.underlying === recUnderlying
// Match this trade in rankings by underlying (+ strategy if available)
const ranking = rankings.find(r =>
r.underlying === t.underlying &&
(!r.strategy || !t.strategy || r.strategy === t.strategy)
)
scored.push({
trade: { ...t, isRecommended },
patternName: p.name,
patternId: p.id,
assetClass: t.asset_class ?? p.asset_class,
score: sp.score,
scoreInfo: sp,
scoreDelta: ranking?.score_delta ?? null,
rankRationale: ranking?.rationale ?? null,
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
})
}
} else {
// Unscored: one card per suggested trade
const trades: any[] = p.suggested_trades ?? []
if (trades.length === 0) {
unscored.push({ trade: {}, patternName: p.name, patternId: p.id, assetClass: p.asset_class, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: p.expected_move_pct ?? 0 })
} else {
for (const t of trades) {
unscored.push({
trade: t,
patternName: p.name,
patternId: p.id,
assetClass: t.asset_class ?? p.asset_class,
score: null,
scoreInfo: null,
scoreDelta: null,
rankRationale: null,
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
})
}
}
}
}
const effScore = (item: TradeItem) =>
Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
scored.sort((a, b) => effScore(b) - effScore(a))
return { topScored: scored.slice(0, topN), allUnscored: unscored }
}, [allPatterns, scoreMap, categoryFilter, topN])
const handleAdd = (item: TradeItem) => {
const t = item.trade
const sp = item.scoreInfo
addPos({
title: `${t.strategy ?? ''} ${t.underlying ?? ''}${item.patternName}`.trim(),
underlying: t.underlying ?? item.patternName,
strategy: t.strategy ?? '',
asset_class: item.assetClass,
expiry_days: t.expiry_days ?? sp?.recommended_trade?.expiry_days ?? 90,
capital_invested: Math.abs(t.max_loss_eur ?? sp?.recommended_trade?.max_loss_eur ?? 1000),
geo_trigger: item.patternName,
rationale: t.rationale ?? sp?.key_catalyst ?? '',
legs: [{
option_type: (t.strategy ?? '').toLowerCase().includes('put') ? 'put' : 'call',
quantity: 1,
position: 'long',
}],
}, {
onSuccess: () => {
refetchPositions()
setToast({
title: 'Ajouté au portefeuille',
sub: `${t.strategy ?? ''} ${t.underlying ?? ''} · ${item.patternName}`.trim(),
})
},
})
}
const getAddedInfo = (item: TradeItem) => {
const strategy = (item.trade.strategy ?? '').toLowerCase()
const trigger = (item.patternName ?? '').toLowerCase()
const underly = (item.trade.underlying ?? '').toLowerCase()
return (
addedMap[`trigger:${trigger}:${strategy}`] ??
addedMap[`ticker:${underly}:${strategy}`] ??
null
)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">Cockpit GeoOptions</h1>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(), "EEEE d MMMM yyyy · HH:mm", { locale: fr })}
</p>
</div>
<div className="flex items-center gap-3">
{portfolio && portfolio.open_positions > 0 && (
<div className={clsx('text-sm font-bold', portfolio.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
Portfolio: {portfolio.unrealized_pnl >= 0 ? '+' : ''}{portfolio.unrealized_pnl?.toFixed(0)}
</div>
)}
<div className="flex items-center gap-2 text-xs text-slate-500">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
Live
</div>
</div>
</div>
{/* Top row */}
<div className="grid grid-cols-4 gap-4">
{/* Geo Risk */}
<div className="card col-span-1">
<div className="section-title flex items-center gap-1"><Globe className="w-3 h-3" /> Risque Géopolitique</div>
{riskLoading ? (
<div className="animate-pulse h-16 bg-dark-600 rounded"></div>
) : riskScore && gauge ? (
<>
<div className={clsx('text-5xl font-bold', gauge.color)}>{riskScore.score}</div>
<div className={clsx('text-sm font-semibold mt-1', gauge.color)}>{gauge.label}</div>
<div className="mt-3 bg-dark-700 rounded-full h-2">
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
</div>
<div className="mt-2 space-y-1">
{riskScore.top_risks?.map(([cat, val]) => (
<div key={cat} className="flex justify-between text-xs">
<span className="text-slate-500 capitalize">{(cat as string).replace('_', ' ')}</span>
<span className="text-slate-300">{Math.round((val as number) * 100)}%</span>
</div>
))}
</div>
</>
) : <div className="text-slate-500 text-xs">Backend requis</div>}
</div>
{/* Radar */}
<div className="card col-span-1">
<div className="section-title">Cartographie risques</div>
{radarData.length > 0 ? (
<ResponsiveContainer width="100%" height={160}>
<RadarChart data={radarData}>
<PolarGrid stroke="#1e2d4d" />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
<Radar dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
</RadarChart>
</ResponsiveContainer>
) : <div className="h-40 flex items-center justify-center text-slate-600 text-xs">Chargement...</div>}
</div>
{/* Patterns — AI scores */}
<div className="card col-span-1 overflow-y-auto max-h-64">
<div className="section-title flex items-center gap-1">
<Brain className="w-3 h-3" /> Scores patterns
{scoredAt && <span className="text-slate-600 text-xs ml-auto font-normal">{allPatterns.length} patterns</span>}
</div>
<div className="space-y-1.5 mt-1">
{allPatterns.length === 0 ? (
<div className="text-slate-600 text-xs">Backend requis</div>
) : (
[...allPatterns]
.sort((a, b) => {
const spA = scoreMap[a.id], spB = scoreMap[b.id]
const effA = spA ? Math.max(...[0, ...(spA.trade_rankings ?? []).map((r: any) => (spA.score ?? 0) + (r.score_delta ?? 0))]) : -1
const effB = spB ? Math.max(...[0, ...(spB.trade_rankings ?? []).map((r: any) => (spB.score ?? 0) + (r.score_delta ?? 0))]) : -1
return effB - effA
})
.map(p => {
const sp = scoreMap[p.id]
const rawScore = sp?.score ?? null
// Show best effective score across all trades (consistent with card display)
const bestEff = sp
? Math.max(rawScore ?? 0, ...(sp.trade_rankings ?? []).map((r: any) =>
Math.max(0, Math.min(100, (rawScore ?? 0) + (r.score_delta ?? 0)))))
: null
return (
<div key={p.id} className="flex items-center justify-between gap-2">
<span className="text-xs text-slate-300 truncate flex-1 min-w-0">{p.name}</span>
{bestEff !== null ? (
<span className={clsx('text-xs font-bold shrink-0', scoreColor(bestEff))}>{bestEff}</span>
) : (
<span className="text-xs text-slate-600 shrink-0"></span>
)}
</div>
)
})
)}
</div>
</div>
{/* Calendrier */}
<div className="card col-span-1 space-y-3">
<div className="section-title flex items-center gap-1"><Clock className="w-3 h-3" /> Prochains catalyseurs</div>
<div className="space-y-1.5">
{calendar?.slice(0, 4).map((ev, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={clsx('badge', { 'badge-red': ev.importance === 'high', 'badge-yellow': ev.importance === 'medium', 'badge-blue': ev.importance === 'low' })}>
{'!'.repeat(ev.importance === 'high' ? 3 : ev.importance === 'medium' ? 2 : 1)}
</span>
<div className="min-w-0">
<div className="text-white line-clamp-1">{ev.title}</div>
<div className="text-slate-600">{ev.date?.slice(0, 10)}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* ── Trade ideas scorées par IA ── */}
<div>
{/* Toolbar */}
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="section-title flex items-center gap-1 mb-0">
<Target className="w-3 h-3" /> Idées de trade
</h2>
{scoredAt && (
<span className="text-xs text-slate-600">
scoré le {format(new Date(scoredAt), "d MMM à HH:mm", { locale: fr })}
</span>
)}
{topScored.length > 0 && (
<span className="text-xs text-emerald-500">
{topScored.length} scoré{topScored.length > 1 ? 's' : ''} · {allUnscored.length} à scorer
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Category filter */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{CATEGORIES.map(c => (
<button key={c.key} onClick={() => setCategoryFilter(c.key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-blue-600 text-white': categoryFilter === c.key,
'text-slate-400 hover:text-slate-200': categoryFilter !== c.key,
})}>
{c.label}
</button>
))}
</div>
{/* Top N */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{[5, 10, 20].map(n => (
<button key={n} onClick={() => setTopN(n)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': topN === n,
'text-slate-400 hover:text-slate-200': topN !== n,
})}>
Top {n}
</button>
))}
</div>
{/* Score button */}
{aiStatus?.enabled ? (
<button onClick={() => scorePatterns({})}
disabled={scoring}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-all">
{scoring
? <><RefreshCw className="w-3 h-3 animate-spin" /> Scoring GPT-4o...</>
: <><Brain className="w-3 h-3" /> Scorer les patterns</>}
</button>
) : (
<span className="text-xs text-slate-600 border border-slate-700/30 rounded px-2 py-1">Clé OpenAI requise</span>
)}
</div>
</div>
{/* Scored trade cards */}
{topScored.length > 0 && (
<div className="grid grid-cols-5 gap-3 mb-5">
{topScored.map((item, i) => (
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
))}
</div>
)}
{/* Unscored trade cards */}
{allUnscored.length > 0 && (
<>
{topScored.length > 0 && (
<div className="flex items-center gap-2 text-xs text-slate-600 mb-3">
<span className="border-b border-slate-700/30 flex-1"></span>
{allUnscored.length} trade{allUnscored.length > 1 ? 's' : ''} à scorer
<span className="border-b border-slate-700/30 flex-1"></span>
</div>
)}
<div className="grid grid-cols-5 gap-3">
{allUnscored.map((item, i) => (
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
))}
</div>
</>
)}
{allPatterns.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Démarrer le backend patterns en cours de chargement
</div>
)}
</div>
{/* ── Suivi M2M des trades logués par le système ── */}
{(() => {
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
const withPnl = mtmTrades.filter(t => t.pnl_pct != null)
if (mtmTrades.length === 0) return null
const winners = withPnl.filter(t => t.pnl_pct > 0).length
const losers = withPnl.filter(t => t.pnl_pct < 0).length
const avgPnl = withPnl.length ? withPnl.reduce((s, t) => s + t.pnl_pct, 0) / withPnl.length : null
return (
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="section-title flex items-center gap-1 mb-0">
<Brain className="w-3 h-3 text-blue-400" /> Suivi M2M système ({mtmTrades.length} trades)
</div>
<div className="flex items-center gap-3 text-xs">
{avgPnl != null && (
<span className={clsx('font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
moy {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
</span>
)}
<span className="text-emerald-400">{winners}</span>
<span className="text-red-400">{losers}</span>
<span className="text-slate-600">{withPnl.length - winners - losers} flat</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-800">
<th className="text-left py-1.5 pr-3">Pattern</th>
<th className="text-left py-1.5 pr-3">Stratégie</th>
<th className="text-left py-1.5 pr-3 font-mono">Ticker</th>
<th className="text-right py-1.5 pr-3">Score</th>
<th className="text-right py-1.5 pr-3">Entrée</th>
<th className="text-right py-1.5 pr-3">Actuel</th>
<th className="text-right py-1.5 pr-3">J</th>
<th className="text-right py-1.5">P&L th.</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/40">
{mtmTrades.slice(0, 15).map((t: any) => {
const pnl: number | null = t.pnl_pct
return (
<tr key={t.id} className="hover:bg-dark-700/30">
<td className="py-1.5 pr-3 text-slate-300 max-w-[140px] truncate">{t.pattern_name || '—'}</td>
<td className="py-1.5 pr-3">
<span className={clsx('badge text-[10px]', t.direction === 'bearish' ? 'badge-red' : 'badge-green')}>
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
</span>
</td>
<td className="py-1.5 pr-3 font-mono text-slate-400">{t.underlying}</td>
<td className={clsx('py-1.5 pr-3 text-right font-bold font-mono',
t.score_at_entry >= 50 ? 'text-emerald-400' : t.score_at_entry >= 25 ? 'text-yellow-400' : 'text-slate-600')}>
{t.score_at_entry}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-slate-500">
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-slate-300">
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
</td>
<td className="py-1.5 pr-3 text-right text-slate-600">{t.days_held ?? '—'}</td>
<td className="py-1.5 text-right">
{pnl != null ? (
<span className={clsx('font-bold font-mono', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}%
</span>
) : <span className="text-slate-600"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
})()}
{/* Markets mini overview */}
<div className="grid grid-cols-4 gap-3">
{['energy', 'metals', 'indices', 'forex'].map(cls => (
<div key={cls} className="card">
<div className="section-title">{assetEmoji[cls]} {cls}</div>
{allQuotes?.[cls]?.map(q => <QuoteRow key={q.symbol} q={q} />) ?? (
<div className="text-xs text-slate-600">Chargement...</div>
)}
</div>
))}
</div>
{/* Toast notification */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-start gap-3 bg-emerald-950 border border-emerald-600/50 text-emerald-200 rounded-xl px-4 py-3 shadow-2xl animate-fade-in min-w-[260px]">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-semibold text-emerald-300">{toast.title}</div>
<div className="text-xs text-emerald-500 mt-0.5 line-clamp-2">{toast.sub}</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,345 @@
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>
)
}

View File

@@ -0,0 +1,846 @@
import { useState, useEffect, useRef } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' },
desinflation: { label: 'Désinflation', color: '#06b6d4', emoji: '❄️' },
soft_landing: { label: 'Soft Landing', color: '#3b82f6', emoji: '🛬' },
reflation: { label: 'Reflation', color: '#f97316', emoji: '🔥' },
stagflation: { label: 'Stagflation', color: '#eab308', emoji: '⚡' },
inflation_shock: { label: 'Inflation Shock', color: '#ef4444', emoji: '💥' },
recession: { label: 'Récession', color: '#8b5cf6', emoji: '📉' },
crise_liquidite: { label: 'Crise Liquidité', color: '#ec4899', emoji: '🚨' },
incertain: { label: 'Incertain', color: '#64748b', emoji: '❓' },
}
function ScenarioBadge({ dominant, size = 'sm' }: { dominant: string; size?: 'sm' | 'lg' }) {
const m = SCENARIO_META[dominant] ?? SCENARIO_META.incertain
return (
<span
className={clsx('inline-flex items-center gap-1 rounded font-semibold',
size === 'lg' ? 'px-2.5 py-1 text-sm' : 'px-1.5 py-0.5 text-[11px]')}
style={{ background: `${m.color}22`, color: m.color, border: `1px solid ${m.color}44` }}
>
{m.emoji} {m.label}
</span>
)
}
function PnlBadge({ pnl }: { pnl: number | null | undefined }) {
if (pnl == null) return <span className="text-slate-600 text-xs"></span>
const pos = pnl >= 0
return (
<span className={clsx('inline-flex items-center gap-0.5 text-xs font-bold font-mono',
pos ? 'text-emerald-400' : 'text-red-400')}>
{pos ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{pos ? '+' : ''}{pnl.toFixed(2)}%
</span>
)
}
function ScoreDelta({ entry, latest }: { entry: number | null; latest: number | null }) {
if (entry == null || latest == null) return <span className="text-slate-600 text-[10px]"></span>
const delta = latest - entry
return (
<span className="flex flex-col items-end gap-0.5">
<span className={clsx('font-bold font-mono text-xs',
latest >= 50 ? 'text-emerald-400' : latest >= 25 ? 'text-yellow-400' : 'text-slate-500')}>
{latest}
</span>
{delta !== 0 && (
<span className={clsx('text-[10px] font-mono', delta > 0 ? 'text-emerald-600' : 'text-red-600')}>
{delta > 0 ? '+' : ''}{delta}
</span>
)}
</span>
)
}
// ── Section 1 : Historique des régimes macro ──────────────────────────────────
function MacroHistorySection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
const history: any[] = (data as any)?.history ?? []
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{history.length} snapshots · {days} derniers jours
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
{isLoading ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="card h-14 animate-pulse bg-dark-700" />)}</div>
) : history.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<Activity className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucun snapshot cliquer "Ré-analyser" dans Régime Macro pour commencer à logger
</div>
) : (
<div className="space-y-2">
{history.map((entry: any, i: number) => {
const scores: Record<string, number> = entry.scores ?? {}
const maxScore = Math.max(...Object.values(scores), 1)
const ts = entry.timestamp?.slice(0, 16).replace('T', ' ') ?? ''
const reasons: string[] = entry.reasons?.[entry.dominant] ?? []
const isTransition = i > 0 && history[i - 1].dominant !== entry.dominant
return (
<div key={entry.id} className={clsx('card', isTransition && 'border-amber-700/30')}>
<div className="flex items-center gap-3 mb-2">
{isTransition && (
<span className="text-[10px] bg-amber-900/30 text-amber-400 border border-amber-700/30 rounded px-1.5 py-0.5 shrink-0">
Transition
</span>
)}
<ScenarioBadge dominant={entry.dominant} size="lg" />
<span className="text-xs text-slate-600 ml-auto shrink-0">{ts} UTC</span>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mb-2">
{Object.entries(scores)
.sort(([, a], [, b]) => (b as number) - (a as number))
.slice(0, 8)
.map(([key, score]) => {
const m = SCENARIO_META[key] ?? SCENARIO_META.incertain
return (
<div key={key} className="flex items-center gap-1">
<div className="w-16 text-[10px] text-slate-600 truncate">{m.label}</div>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all"
style={{ width: `${((score as number) / maxScore) * 100}%`, background: m.color }} />
</div>
<span className="text-[10px] font-mono text-slate-500 w-6 text-right">{score}</span>
</div>
)
})}
</div>
{reasons.length > 0 && (
<div className="flex flex-wrap gap-1">
{reasons.slice(0, 4).map((r: string, ri: number) => (
<span key={ri} className="text-[10px] text-slate-500 bg-dark-700 px-1.5 py-0.5 rounded">{r}</span>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ── Post-mortem panel ─────────────────────────────────────────────────────────
function PostmortemPanel({ tradeId, onClose }: { tradeId: number; onClose: () => void }) {
const { data, isLoading } = useTradePostmortem(tradeId)
const { mutate: analyze, isPending: analyzing, data: analysisData } = useAnalyzePostmortem()
const [showScoring, setShowScoring] = useState(true)
const [showSuggestion, setShowSuggestion] = useState(false)
if (isLoading) {
return (
<div className="card mt-3 p-4 border border-blue-700/30 animate-pulse">
<div className="h-4 bg-dark-700 rounded w-48 mb-3" />
<div className="h-3 bg-dark-700 rounded w-full mb-2" />
<div className="h-3 bg-dark-700 rounded w-3/4" />
</div>
)
}
if (!data) return null
const { trade, scoring_context: sc, suggestion_context: sg, score_history } = data as any
const scOut = sc?.output ?? {}
const sgOut = sg?.output ?? {}
const scCtx = sc?.input_context ?? {}
const buckets: any[] = scOut.buckets ?? []
const analysis: any = analysisData?.analysis ?? null
return (
<div className="mt-3 rounded-lg border border-blue-700/30 bg-dark-900/80 p-4 space-y-4 text-xs">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-blue-400" />
<span className="font-semibold text-blue-300">Post-mortem {trade?.pattern_name}</span>
<span className="text-slate-500">|</span>
<span className="font-mono text-slate-400">{trade?.underlying} {trade?.strategy}</span>
</div>
<button onClick={onClose} className="text-slate-600 hover:text-slate-400">
<X className="w-4 h-4" />
</button>
</div>
{/* Score history sparkline */}
{score_history?.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-slate-600 shrink-0">Historique :</span>
<div className="flex items-center gap-1.5 flex-wrap">
{[...score_history].reverse().map((h: any, i: number) => (
<div key={i} className="flex flex-col items-center gap-0.5">
<div
className="w-6 rounded-sm"
style={{
height: `${Math.max(4, (h.score ?? 0) / 2)}px`,
background: (h.score ?? 0) >= 60 ? '#22c55e' : (h.score ?? 0) >= 40 ? '#eab308' : '#64748b',
}}
/>
<span className="text-[9px] font-mono text-slate-600">{h.score ?? '?'}</span>
</div>
))}
</div>
{score_history.length > 0 && (
<span className="text-slate-600 ml-auto shrink-0">
{score_history[0]?.macro_dominant ?? ''} géo {score_history[0]?.geo_score ?? '?'}
</span>
)}
</div>
)}
{/* Scoring context */}
<div>
<button
className="flex items-center gap-1.5 text-slate-400 hover:text-slate-200 font-semibold mb-2"
onClick={() => setShowScoring(v => !v)}
>
{showScoring ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Pourquoi noté {scOut.score ?? '?'}/100
{scOut.key_catalyst && <span className="text-slate-600 font-normal ml-1">· {scOut.key_catalyst}</span>}
</button>
{showScoring && (
<div className="pl-5 space-y-2">
{/* Regime / bias */}
<div className="flex flex-wrap gap-3 text-[11px] text-slate-500">
{sc?.macro_dominant && (
<span>Régime : <ScenarioBadge dominant={sc.macro_dominant} /></span>
)}
{scCtx.asset_bias && (
<span>Biais asset : <span className="text-slate-300">{scCtx.asset_bias}</span></span>
)}
{sc?.geo_score != null && (
<span>Géo : <span className="font-mono text-slate-300">{sc.geo_score}/100</span></span>
)}
</div>
{/* Buckets */}
{buckets.length > 0 && (
<div className="grid grid-cols-2 gap-1.5">
{buckets.map((b: any, i: number) => {
const pct = b.max ? Math.round((b.score / b.max) * 100) : 0
const color = pct >= 66 ? '#22c55e' : pct >= 33 ? '#eab308' : '#ef4444'
return (
<div key={i} className="flex flex-col gap-0.5 bg-dark-800 rounded p-2">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-medium truncate mr-2">{b.label ?? b.id}</span>
<span className="font-mono shrink-0" style={{ color }}>{b.score}/{b.max}</span>
</div>
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
</div>
{b.comment && (
<span className="text-[10px] text-slate-600 line-clamp-2">{b.comment}</span>
)}
</div>
)
})}
</div>
)}
{scOut.summary && (
<p className="text-slate-500 italic">{scOut.summary}</p>
)}
</div>
)}
</div>
{/* Suggestion context */}
{sg && (
<div>
<button
className="flex items-center gap-1.5 text-slate-400 hover:text-slate-200 font-semibold mb-2"
onClick={() => setShowSuggestion(v => !v)}
>
{showSuggestion ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Pourquoi ce pattern a é créé
</button>
{showSuggestion && (
<div className="pl-5 space-y-1.5 text-[11px] text-slate-400">
{sgOut.macro_fit && <p className="italic">{sgOut.macro_fit}</p>}
{sgOut.description && <p className="text-slate-500">{sgOut.description}</p>}
{sg.macro_dominant && (
<div className="flex gap-3 mt-1">
<span>Créé sous : <ScenarioBadge dominant={sg.macro_dominant} /></span>
{sg.geo_score != null && <span>Géo : <span className="font-mono text-slate-300">{sg.geo_score}/100</span></span>}
</div>
)}
</div>
)}
</div>
)}
{/* GPT-4o analysis */}
<div className="border-t border-slate-800 pt-3">
{!analysis ? (
<button
onClick={() => analyze(tradeId)}
disabled={analyzing}
className="flex items-center gap-2 px-3 py-1.5 rounded bg-blue-900/30 border border-blue-700/40 text-blue-300 hover:bg-blue-900/50 disabled:opacity-50 text-xs font-medium"
>
<Brain className={clsx('w-3.5 h-3.5', analyzing && 'animate-pulse')} />
{analyzing ? 'Analyse GPT-4o en cours…' : 'Analyser avec GPT-4o'}
</button>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 text-blue-400 font-semibold">
<Brain className="w-3.5 h-3.5" />
Analyse GPT-4o
</div>
{[
{ key: 'diagnostic', label: 'Diagnostic', color: 'text-slate-200' },
{ key: 'what_worked', label: 'Ce qui a marché', color: 'text-emerald-400' },
{ key: 'what_missed', label: 'Ce qui a manqué', color: 'text-red-400' },
{ key: 'regime_alignment', label: 'Alignement régime', color: 'text-yellow-400' },
{ key: 'contra_assessment', label: 'Contra-signals', color: 'text-orange-400' },
{ key: 'lesson', label: 'Leçon', color: 'text-blue-300' },
{ key: 'next_cycle', label: 'Prochain cycle', color: 'text-purple-400' },
].map(({ key, label, color }) =>
analysis[key] ? (
<div key={key}>
<span className={clsx('font-semibold', color)}>{label} : </span>
<span className="text-slate-400">{analysis[key]}</span>
</div>
) : null
)}
</div>
)}
</div>
</div>
)
}
// ── Section 2 : Mark-to-Market des trades ─────────────────────────────────────
function TradeMtmSection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useTradeMtm(days)
const [minScoreFilter, setMinScoreFilter] = useState(0)
const [selectedTradeId, setSelectedTradeId] = useState<number | null>(null)
const allTrades: any[] = (data as any)?.trades ?? []
const trades = allTrades.filter((t: any) => (t.latest_score ?? t.score_at_entry ?? 0) >= minScoreFilter)
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
const avgPnl = withPnl.length
? withPnl.reduce((s: number, t: any) => s + t.pnl_pct, 0) / withPnl.length
: null
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{trades.length}/{allTrades.length} trades · {withPnl.length} pricés
{avgPnl != null && (
<span className={clsx('ml-2 font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
· moy {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
</span>
)}
</div>
<div className="flex items-center gap-3">
{/* Score filter */}
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="shrink-0">Score </span>
<input type="range" min="0" max="80" step="5"
value={minScoreFilter}
onChange={e => setMinScoreFilter(parseInt(e.target.value))}
className="w-24 accent-blue-500" />
<span className="w-5 font-mono text-blue-400">{minScoreFilter}</span>
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
</div>
{isLoading ? (
<div className="card h-32 animate-pulse bg-dark-700" />
) : trades.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<TrendingUp className="w-8 h-8 mx-auto mb-2 opacity-20" />
{allTrades.length === 0
? 'Aucun trade logué — scorer des patterns pour commencer le suivi'
: `Aucun trade avec score ≥ ${minScoreFilter}`}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left px-3 py-2 font-medium">Pattern</th>
<th className="text-left px-3 py-2 font-medium">Profil</th>
<th className="text-left px-3 py-2 font-medium">Stratégie</th>
<th className="text-left px-3 py-2 font-medium">Ticker</th>
<th className="text-right px-3 py-2 font-medium">Score</th>
<th className="text-right px-3 py-2 font-medium">Trade Score</th>
<th className="text-right px-3 py-2 font-medium">EV nette</th>
<th className="text-right px-3 py-2 font-medium">Gain prévu</th>
<th className="text-right px-3 py-2 font-medium">Date</th>
<th className="text-right px-3 py-2 font-medium">Prix entrée</th>
<th className="text-right px-3 py-2 font-medium">Prix actuel</th>
<th className="text-right px-3 py-2 font-medium">J</th>
<th className="text-right px-3 py-2 font-medium">P&L th.</th>
<th className="px-3 py-2 font-medium w-8"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{trades.map((t: any) => {
const evNet = t.ev_net ?? null
const tradeScore = t.trade_score ?? null
return (
<tr key={t.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2 text-slate-300 max-w-[110px] truncate">{t.pattern_name || t.pattern_id}</td>
<td className="px-3 py-2">
{t.matched_profile ? (
<span className="text-[10px] font-semibold text-slate-400 bg-dark-700 px-1.5 py-0.5 rounded border border-slate-700/50">
{t.matched_profile}
</span>
) : <span className="text-slate-700 text-[10px]"></span>}
</td>
<td className="px-3 py-2">
<span className={clsx('badge text-[10px]',
t.direction === 'bearish' ? 'badge-red' : 'badge-green')}>
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
</span>
</td>
<td className="px-3 py-2 font-mono text-slate-300">{t.underlying}</td>
<td className="px-3 py-2 text-right">
<ScoreDelta entry={t.score_at_entry} latest={t.latest_score ?? t.score_at_entry} />
</td>
<td className="px-3 py-2 text-right">
{tradeScore != null ? (
<span className={clsx('font-bold font-mono text-xs',
tradeScore >= 55 ? 'text-emerald-400' : tradeScore >= 45 ? 'text-yellow-400' : 'text-slate-500')}>
{tradeScore.toFixed(1)}
</span>
) : <span className="text-slate-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-right">
{evNet != null ? (
<span className={clsx('font-mono text-[11px]',
evNet > 0.05 ? 'text-emerald-400' : evNet >= -0.01 ? 'text-yellow-400' : 'text-slate-600')}>
{evNet > 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%
</span>
) : <span className="text-slate-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-right">
{t.expected_move_pct != null ? (
<span className="font-mono text-[11px] text-slate-500">{t.expected_move_pct.toFixed(0)}%</span>
) : <span className="text-slate-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-right text-slate-600 whitespace-nowrap text-[11px]">{t.entry_date}</td>
<td className="px-3 py-2 text-right font-mono text-slate-400 text-[11px]">
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right font-mono text-slate-300 text-[11px]">
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right text-slate-600 text-[11px]">
{t.days_held != null ? t.days_held : '—'}
</td>
<td className="px-3 py-2 text-right">
<PnlBadge pnl={t.pnl_pct} />
</td>
<td className="px-2 py-2">
<button
onClick={() => setSelectedTradeId(selectedTradeId === t.id ? null : t.id)}
className={clsx(
'p-1 rounded transition-colors',
selectedTradeId === t.id
? 'text-blue-400 bg-blue-900/30'
: 'text-slate-600 hover:text-blue-400 hover:bg-blue-900/20'
)}
title="Post-mortem IA"
>
<Search className="w-3.5 h-3.5" />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{selectedTradeId !== null && (
<PostmortemPanel
tradeId={selectedTradeId}
onClose={() => setSelectedTradeId(null)}
/>
)}
</div>
)
}
// ── Section 3 : Historique du score géopolitique ──────────────────────────────
function GeoHistorySection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useGeoHistory(days)
const history: any[] = (data as any)?.history ?? []
const maxScore = Math.max(...history.map((h: any) => h.geo_score), 1)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{history.length} alertes · {days} derniers jours
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
{isLoading ? (
<div className="space-y-2">{[1,2].map(i => <div key={i} className="card h-12 animate-pulse bg-dark-700" />)}</div>
) : history.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucune alerte scorer des patterns pour commencer le suivi
</div>
) : (
<div className="space-y-2">
{history.map((entry: any) => {
const score = entry.geo_score ?? 0
const level = score >= 70 ? 'extreme' : score >= 50 ? 'high' : score >= 30 ? 'medium' : 'low'
const barColor = { low: '#22c55e', medium: '#eab308', high: '#f97316', extreme: '#ef4444' }[level]
const ts = entry.timestamp?.slice(0, 16).replace('T', ' ') ?? ''
const patterns: any[] = entry.top_patterns ?? []
return (
<div key={entry.id} className="card">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-2 shrink-0">
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold"
style={{ background: `${barColor}22`, color: barColor, border: `1px solid ${barColor}44` }}>
{score}
</div>
<div>
<div className="text-[10px] text-slate-600 uppercase">{level}</div>
<div className="text-[10px] text-slate-700">{ts}</div>
</div>
</div>
<div className="flex-1">
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${(score / 100) * 100}%`, background: barColor }} />
</div>
</div>
<span className="text-[10px] text-slate-700 shrink-0">{entry.news_count} news</span>
</div>
{patterns.length > 0 && (
<div className="flex flex-wrap gap-1">
{patterns.slice(0, 5).map((p: any, i: number) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-dark-700 text-slate-400 rounded px-1.5 py-0.5">
<span className="font-mono text-slate-500">{p.score}</span>
<span className="max-w-[120px] truncate">{p.name || p.pattern_id}</span>
</span>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ── Section 4 : Cycles d'intelligence ────────────────────────────────────────
function CyclesSection() {
const { data: histData, isLoading, refetch, isFetching } = useCycleHistory(20)
const { data: statusData } = useCycleStatus()
const { mutate: triggerCycle, isPending: triggering } = useTriggerCycle()
const runs: any[] = (histData as any)?.runs ?? []
const cs = statusData as any
// Auto-refetch history list when the running cycle finishes
const wasRunning = useRef(false)
useEffect(() => {
if (cs?.running) { wasRunning.current = true }
else if (wasRunning.current) { wasRunning.current = false; refetch() }
}, [cs?.running])
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{runs.length} cycles · {cs?.enabled ? `auto ${cs.interval_hours}h` : 'manuel uniquement'}
</div>
<div className="flex gap-2">
<button
onClick={() => triggerCycle(undefined, { onSuccess: () => setTimeout(() => refetch(), 3000) })}
disabled={triggering}
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 disabled:opacity-40">
<Zap className={clsx('w-3 h-3', triggering && 'animate-pulse')} />
{triggering ? 'Lancement...' : 'Lancer un cycle'}
</button>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
</div>
{isLoading ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="card h-20 animate-pulse bg-dark-700" />)}</div>
) : runs.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucun cycle activer l'auto-cycle dans Configuration ou lancer manuellement
</div>
) : (
<div className="space-y-3">
{runs.map((run: any) => {
const commentary = run.commentary_parsed
const ok = run.status === 'completed'
const ts = run.started_at?.slice(0, 16).replace('T', ' ') ?? ''
const duration = run.completed_at
? Math.round((new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000)
: null
return (
<div key={run.id} className={clsx('card', ok ? 'border-slate-700/40' : 'border-red-800/30')}>
<div className="flex items-start gap-3 mb-2">
{ok
? <CheckCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-300 font-semibold">{ts} UTC</span>
<span className={clsx('badge text-[10px]', run.trigger === 'manual' ? 'badge-blue' : 'badge-purple')}>
{run.trigger === 'manual' ? 'Manuel' : `Auto ${cs?.interval_hours ?? ''}h`}
</span>
{run.dominant_regime && <ScenarioBadge dominant={run.dominant_regime} />}
{duration != null && <span className="text-[10px] text-slate-600">{duration}s</span>}
</div>
<div className="flex gap-3 mt-1 text-[11px] text-slate-500 flex-wrap">
<span>💡 {run.patterns_suggested} suggérés</span>
<span>✚ {run.patterns_added} ajoutés</span>
<span>🎯 {run.patterns_scored} scorés</span>
{run.geo_score != null && <span>🌐 Géo {run.geo_score}/100</span>}
</div>
</div>
</div>
{commentary && (
<div className="bg-blue-900/10 border border-blue-700/20 rounded p-3 space-y-2">
<div className="flex items-center gap-1.5 text-[10px] text-blue-400 font-semibold uppercase tracking-wide">
<Brain className="w-3 h-3" /> Commentaire IA
</div>
<p className="text-xs text-slate-300 leading-relaxed">{commentary.commentary}</p>
{commentary.key_risk && (
<div className="flex items-start gap-1.5 text-[11px] text-orange-400/80 bg-orange-900/10 border border-orange-700/20 rounded px-2 py-1">
<span className="shrink-0">⚠</span> {commentary.key_risk}
</div>
)}
{commentary.top_pattern && (
<div className="text-[11px] text-emerald-400/80">
🎯 Pattern clé : <span className="font-semibold">{commentary.top_pattern}</span>
</div>
)}
{commentary.lessons_from_report ? (
<div className="flex items-center gap-1.5 text-[10px] text-purple-400/80 bg-purple-900/10 border border-purple-700/20 rounded px-2 py-1">
<BookOpen className="w-3 h-3 shrink-0" />
Leçons du rapport du {commentary.lessons_from_report} intégrées dans ce cycle
{commentary.lessons_headline && (
<span className="text-slate-600 ml-1">· {commentary.lessons_headline}</span>
)}
</div>
) : (
<div className="flex items-center gap-1.5 text-[10px] text-slate-700 italic">
<BookOpen className="w-3 h-3 shrink-0" />
Aucun rapport de performance disponible — générer un rapport dans "Rapport IA"
</div>
)}
</div>
)}
{!commentary && ok && (
<div className="text-[11px] text-slate-700 italic">Aucun commentaire IA généré pour ce cycle</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ── Page principale ────────────────────────────────────────────────────────────
const TABS = [
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
{ key: 'mtm', label: 'Mark-to-Market', icon: TrendingUp },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
] as const
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'geo'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [resetMsg, setResetMsg] = useState('')
const { data: summary, refetch: refetchSummary } = useJournalSummary()
const qc = useQueryClient()
const s = summary as any
const handleReset = async () => {
if (!confirmReset) {
setConfirmReset(true)
return
}
setResetting(true)
try {
await api.delete('/journal/reset')
setResetMsg('Journal réinitialisé')
setConfirmReset(false)
// Invalidate all journal queries
await qc.invalidateQueries({ queryKey: ['journal-summary'] })
await qc.invalidateQueries({ queryKey: ['macro-history'] })
await qc.invalidateQueries({ queryKey: ['geo-history'] })
await qc.invalidateQueries({ queryKey: ['trade-mtm'] })
await qc.invalidateQueries({ queryKey: ['cycle-history'] })
setTimeout(() => setResetMsg(''), 3000)
} catch {
setResetMsg('Erreur lors du reset')
setTimeout(() => setResetMsg(''), 3000)
} finally {
setResetting(false)
}
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" /> Journal de Bord
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Historique des régimes macro · Mark-to-market des trades · Évolution du risque géopolitique
</p>
</div>
<div className="flex items-center gap-3">
{/* Reset button */}
<div className="flex items-center gap-2">
{resetMsg && (
<span className={clsx('text-xs', resetMsg.includes('Erreur') ? 'text-red-400' : 'text-emerald-400')}>
{resetMsg}
</span>
)}
{confirmReset && !resetting && (
<span className="text-xs text-amber-400">Confirmer ?</span>
)}
<button
onClick={confirmReset ? handleReset : () => setConfirmReset(true)}
disabled={resetting}
onBlur={() => setTimeout(() => setConfirmReset(false), 300)}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold border transition-all disabled:opacity-40',
confirmReset
? 'bg-red-900/30 border-red-600/50 text-red-400 hover:bg-red-900/50'
: 'border-slate-700/40 text-slate-600 hover:text-slate-400 hover:border-slate-600'
)}>
<Trash2 className="w-3.5 h-3.5" />
{resetting ? 'Réinit...' : confirmReset ? 'Effacer tout' : 'Reset journal'}
</button>
</div>
{/* Period selector */}
<div className="flex gap-1 bg-dark-700 p-1 rounded text-xs">
{[7, 15, 30].map(d => (
<button key={d} onClick={() => setDays(d)}
className={clsx('px-2.5 py-1 rounded transition-colors', {
'bg-blue-600 text-white': days === d,
'text-slate-400 hover:text-slate-200': days !== d,
})}>
{d}j
</button>
))}
</div>
</div>
</div>
{/* Summary cards */}
{s && (
<div className="grid grid-cols-4 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-white">{s.macro_snapshots ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">snapshots macro</div>
{s.current_dominant && (
<div className="mt-1"><ScenarioBadge dominant={s.current_dominant} /></div>
)}
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-white">{s.regime_transitions?.length ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">transitions de régime</div>
{s.regime_transitions?.length > 0 && (
<div className="text-[10px] text-amber-400 mt-1">
↕ {s.regime_transitions[0].from} → {s.regime_transitions[0].to}
</div>
)}
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold',
(s.avg_geo_score ?? 0) >= 70 ? 'text-red-400' : (s.avg_geo_score ?? 0) >= 40 ? 'text-orange-400' : 'text-emerald-400')}>
{s.avg_geo_score != null ? s.avg_geo_score.toFixed(0) : ''}
</div>
<div className="text-xs text-slate-600 mt-0.5">score géo moyen</div>
{s.max_geo_score != null && (
<div className="text-[10px] text-slate-600 mt-1">max {s.max_geo_score}</div>
)}
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-white">{s.trade_entries_logged ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">trades logués</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{TABS.map(({ key, label, icon: Icon }) => (
<button key={key} onClick={() => setTab(key)}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
{/* Content */}
{tab === 'cycles' && <CyclesSection />}
{tab === 'macro' && <MacroHistorySection days={days} />}
{tab === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}
</div>
)
}

View File

@@ -0,0 +1,353 @@
import { useState, useEffect } from 'react'
import { useMacroRegime, useAiStatus } from '../hooks/useApi'
import { RefreshCw, Activity, ChevronDown, ChevronUp, Brain } from 'lucide-react'
import clsx from 'clsx'
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
interface MacroGauge {
id: string; label: string; ticker?: string | null
value: number | null; change_pct: number | null
unit: string; bloc: string; note?: string | null
}
const SCENARIO_ORDER = [
'goldilocks', 'desinflation', 'soft_landing', 'reflation',
'stagflation', 'inflation_shock', 'recession', 'crise_liquidite',
] as const
const BLOC_INFO: Record<string, { label: string; emoji: string; description: string }> = {
liquidite: { label: 'Liquidité mondiale', emoji: '💧', description: 'Carburant des marchés — DXY↓ = conditions mondiales détendues ; pente = signal récession/reprise' },
credit: { label: 'Crédit & Stress', emoji: '⚡', description: 'Thermomètre systémique — HYG (HY, obligataires détectent avant les actions), LQD (IG), VIX' },
energie: { label: 'Énergie', emoji: '⛽', description: 'Premier moteur d\'inflation — Brent↑↑ = stagflation/choc ; Brent↓ = désinflationniste' },
metaux: { label: 'Métaux stratégiques', emoji: '🥇', description: 'Cuivre = "Dr Copper" (croissance mondiale) ; Or = refuge/taux réels ; ratio Or/Cu = biais craint vs expansion' },
croissance: { label: 'Croissance mondiale', emoji: '📊', description: 'Cycle réel — S&P, Russell 2000 (breadth risk-on), XLI (proxy ISM mfg), Baltic Dry (secondaire)' },
derive: { label: 'Indicateurs dérivés', emoji: '🔗', description: 'Pente 10Y3M (récession) · Or/Cu (peur vs expansion) · S&P vs 200j · Russell vs S&P (breadth)' },
}
const ASSET_BIAS_COLORS: Record<string, string> = {
'bullish+': '#10b981', 'bullish': '#34d399', 'neutral': '#64748b',
'bearish': '#f97316', 'bearish+': '#ef4444', 'defensive': '#f59e0b',
}
const ASSET_BIAS_LABELS: Record<string, string> = {
'bullish+': '★★ Très favorable', 'bullish': '★ Favorable', 'neutral': '→ Neutre',
'bearish': '✗ Défavorable', 'bearish+': '✗✗ Contre', 'defensive': '⚠ Défensif',
}
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: 'Énergie ⛽', metals: 'Métaux 🥇', agriculture: 'Agriculture 🌾',
indices: 'Indices 📊', equities: 'Actions 📈', forex: 'Forex 💱',
}
function GaugeRow({ g }: { g: MacroGauge }) {
const val = g.value
const chg = g.change_pct
const up = chg != null && chg > 0
const dn = chg != null && chg < 0
const fmt = (v: number) => {
if (v > 10000) return `${(v / 1000).toFixed(1)}k`
if (v > 1000) return v.toFixed(0)
if (v > 100) return v.toFixed(2)
return v.toFixed(3)
}
return (
<div className="flex items-center justify-between py-1 border-b border-slate-800/50 last:border-0 group">
<div className="flex-1 min-w-0 mr-3">
<span className="text-xs text-slate-300 group-hover:text-white transition-colors">{g.label}</span>
{g.ticker && <span className="text-[10px] text-slate-700 ml-1.5">{g.ticker}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
{val != null ? (
<span className="text-sm font-mono font-semibold text-white">
{fmt(val)}<span className="text-slate-600 text-xs ml-0.5">{g.unit}</span>
</span>
) : (
<span className="text-xs text-slate-700">N/A</span>
)}
{chg != null && (
<span className={clsx('text-xs font-mono w-14 text-right', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-600')}>
{up ? '↑' : dn ? '↓' : '→'} {Math.abs(chg).toFixed(2)}%
</span>
)}
{g.note && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-dark-600 text-slate-500 w-28 text-center truncate">
{g.note}
</span>
)}
</div>
</div>
)
}
function BlocCard({ id, gauges }: { id: string; gauges: MacroGauge[] }) {
const [open, setOpen] = useState(true)
const info = BLOC_INFO[id] ?? { label: id, emoji: '📌', description: '' }
if (!gauges.length) return null
return (
<div className="card p-0 overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-dark-700/50 transition-colors"
>
<div>
<div className="text-sm font-semibold text-white text-left">
{info.emoji} {info.label}
</div>
<div className="text-[10px] text-slate-600 text-left mt-0.5">{info.description}</div>
</div>
{open ? <ChevronUp className="w-3.5 h-3.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-slate-600 shrink-0" />}
</button>
{open && (
<div className="px-3 pb-2 border-t border-slate-800/50">
{gauges.map(g => <GaugeRow key={g.id} g={g} />)}
</div>
)}
</div>
)
}
export default function MacroRegime() {
const { data, isLoading, refetch, isFetching, forceRefetch } = useMacroRegime()
const { data: aiStatus } = useAiStatus()
const [savedNarration, setSavedNarration] = useState<{ text: string; at: string } | null>(null)
const [loadingNarration, setLoadingNarration] = useState(false)
useEffect(() => {
try {
const raw = localStorage.getItem('geo-options:macro-narration')
if (raw) setSavedNarration(JSON.parse(raw))
} catch {}
}, [])
const gauges: Record<string, MacroGauge> = data?.gauges ?? {}
const scenarios = data?.scenarios ?? {}
const dominant: string = scenarios.dominant ?? 'incertain'
const scores: Record<string, number> = scenarios.scores ?? {}
const meta: Record<string, { label: string; color: string; emoji: string }> = scenarios.meta ?? {}
const ranked: [string, number][] = scenarios.ranked ?? []
const reasons: Record<string, string[]> = scenarios.reasons ?? {}
const assetBias: Record<string, Record<string, string>> = scenarios.asset_bias ?? {}
const dominantMeta = meta[dominant] ?? { label: dominant, color: '#94a3b8', emoji: '?' }
const dominantBias: Record<string, string> = assetBias[dominant] ?? {}
// Group gauges by bloc
const byBloc: Record<string, MacroGauge[]> = {}
for (const g of Object.values(gauges)) {
if (!byBloc[g.bloc]) byBloc[g.bloc] = []
byBloc[g.bloc].push(g)
}
// Add derived gauges into their own bloc
const derivedGauges = ([gauges.slope_10y3m, gauges.gold_copper_ratio, gauges.spx_vs_200d] as (MacroGauge | undefined)[])
.filter((g): g is MacroGauge => !!g)
if (derivedGauges.length) byBloc['derive'] = derivedGauges
const handleAiNarration = async () => {
setLoadingNarration(true)
try {
const freshData = await forceRefetch()
const res = await api.post('/ai/macro-narration', { macro_regime: freshData })
const text = res.data.narration ?? res.data.text ?? JSON.stringify(res.data)
const entry = { text, at: new Date().toISOString() }
setSavedNarration(entry)
localStorage.setItem('geo-options:macro-narration', JSON.stringify(entry))
} catch {
const entry = { text: 'Erreur lors de la génération — vérifier la clé OpenAI.', at: new Date().toISOString() }
setSavedNarration(entry)
} finally {
setLoadingNarration(false)
}
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-400" /> Régime Macro
</h1>
<p className="text-xs text-slate-500 mt-0.5">
30 compteurs institutionnels · Détection de régime · Compatibilité scénarios
</p>
</div>
<div className="flex items-center gap-3">
{data?.fetched_at && (
<span className="text-xs text-slate-600">
{data.cached ? `cache ${data.cache_age_sec}s` : 'live'} · {data.fetched_at.slice(11, 16)} UTC
</span>
)}
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white border border-slate-700 rounded px-2.5 py-1.5 transition-colors hover:border-slate-500 disabled:opacity-40"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
Actualiser
</button>
</div>
</div>
{isLoading ? (
<div className="card flex items-center justify-center py-12 text-slate-600 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Chargement des compteurs macro...
</div>
) : (
<>
{/* Dominant scenario banner */}
{dominant !== 'incertain' && (
<div className="rounded-xl border p-4 flex items-center gap-4"
style={{ borderColor: `${dominantMeta.color}44`, background: `${dominantMeta.color}0d` }}>
<div className="text-4xl">{dominantMeta.emoji}</div>
<div className="flex-1">
<div className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Scénario dominant</div>
<div className="text-xl font-bold" style={{ color: dominantMeta.color }}>{dominantMeta.label}</div>
<div className="text-xs text-slate-500 mt-1 flex flex-wrap gap-1">
{(reasons[dominant] ?? []).map((r, i) => (
<span key={i} className="px-1.5 py-0.5 rounded text-[10px]"
style={{ background: `${dominantMeta.color}22`, color: dominantMeta.color }}>
{r}
</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-3xl font-bold font-mono" style={{ color: dominantMeta.color }}>
{scores[dominant]}%
</div>
<div className="text-xs text-slate-600">score de régime</div>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-5">
{/* Left: Gauge blocs */}
<div className="col-span-2 space-y-3">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
Compteurs par bloc ({Object.values(gauges).length} indicateurs)
</div>
{['liquidite', 'credit', 'energie', 'metaux', 'croissance', 'derive'].map(bloc => (
<BlocCard key={bloc} id={bloc} gauges={byBloc[bloc] ?? []} />
))}
</div>
{/* Right: Scenarios + asset compatibility */}
<div className="space-y-4">
{/* Scenario ranking */}
<div className="card">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">
Classement des scénarios
</div>
<div className="space-y-3">
{ranked.map(([key, score]) => {
const m = meta[key] ?? { label: key, color: '#94a3b8', emoji: '?' }
const isDom = key === dominant
const rl = reasons[key] ?? []
return (
<div key={key}>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm w-5 text-center">{m.emoji}</span>
<span className="text-xs flex-1 font-medium"
style={{ color: isDom ? m.color : '#94a3b8', fontWeight: isDom ? 700 : 400 }}>
{m.label}
</span>
<span className="text-xs font-mono font-bold w-8 text-right"
style={{ color: isDom ? m.color : '#475569' }}>
{score}%
</span>
</div>
<div className="bg-slate-800 rounded-full h-1.5 overflow-hidden">
<div className="h-full rounded-full transition-all duration-700"
style={{ width: `${score}%`, background: m.color, opacity: isDom ? 1 : 0.45 }} />
</div>
{isDom && rl.length > 0 && (
<div className="mt-1 space-y-0.5">
{rl.slice(0, 3).map((r, i) => (
<div key={i} className="text-[10px] text-slate-600 flex items-center gap-1">
<span style={{ color: m.color }}></span> {r}
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Asset class compatibility for dominant scenario */}
{dominant !== 'incertain' && Object.keys(dominantBias).length > 0 && (
<div className="card">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">
Classes d'actifs · {dominantMeta.emoji} {dominantMeta.label}
</div>
<div className="space-y-1.5">
{Object.entries(dominantBias).map(([cls, bias]) => {
const col = ASSET_BIAS_COLORS[bias] ?? '#64748b'
const lbl = ASSET_BIAS_LABELS[bias] ?? bias
return (
<div key={cls} className="flex items-center justify-between">
<span className="text-xs text-slate-400">{ASSET_CLASS_LABELS[cls] ?? cls}</span>
<span className="text-xs font-semibold" style={{ color: col }}>{lbl}</span>
</div>
)
})}
</div>
</div>
)}
{/* AI narration block */}
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5 text-blue-400" /> Analyse IA
</div>
{savedNarration?.at && (
<span className="text-[10px] text-slate-700">
{new Date(savedNarration.at).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
})}
</span>
)}
</div>
{savedNarration ? (
<p className="text-xs text-slate-300 leading-relaxed mb-3">{savedNarration.text}</p>
) : (
<p className="text-xs text-slate-600 leading-relaxed mb-3">
GPT-4o peut interpréter le régime actuel, identifier les incohérences entre compteurs et proposer des biais de trading.
</p>
)}
{aiStatus?.enabled ? (
<button
onClick={handleAiNarration}
disabled={loadingNarration}
className="w-full text-xs bg-blue-600/20 hover:bg-blue-600/40 border border-blue-500/30 hover:border-blue-500/60 text-blue-300 rounded py-1.5 transition-all disabled:opacity-40 flex items-center justify-center gap-1.5"
>
{loadingNarration ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> Analyse en cours...</>
) : (
<><Brain className="w-3 h-3" /> {savedNarration ? '-analyser' : 'Demander à l\'IA'}</>
)}
</button>
) : (
<div className="text-xs text-slate-700 text-center">OpenAI non configuré</div>
)}
</div>
{/* Reading order reminder */}
<div className="card bg-dark-700/30">
<div className="text-xs font-semibold text-slate-600 mb-2">Ordre de lecture (5 min)</div>
<div className="space-y-0.5 text-[10px] text-slate-600">
<div>1. <span className="text-slate-500">Liquidité</span> Bilan Fed, DXY, taux réels</div>
<div>2. <span className="text-slate-500">Crédit</span> HY spreads, VIX, MOVE</div>
<div>3. <span className="text-slate-500">Énergie</span> Brent, gaz naturel</div>
<div>4. <span className="text-slate-500">Métaux</span> Or/Cuivre ratio</div>
<div>5. <span className="text-slate-500">Synthèse</span> Scénario dominant</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,353 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useAllQuotes, useHistory } from '../hooks/useApi'
import clsx from 'clsx'
import type { Quote, AssetClass } from '../types'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { TrendingUp, TrendingDown, BarChart2, RefreshCw, Search } from 'lucide-react'
const ASSET_CLASSES: { key: AssetClass; label: string; emoji: string }[] = [
{ key: 'energy', label: 'Énergie', emoji: '⛽' },
{ key: 'metals', label: 'Métaux', emoji: '🥇' },
{ key: 'agriculture', label: 'Agriculture', emoji: '🌾' },
{ key: 'indices', label: 'Indices', emoji: '📊' },
{ key: 'equities', label: 'Actions', emoji: '📈' },
{ key: 'forex', label: 'Forex', emoji: '💱' },
]
function QuoteCard({ q, selected, onClick }: { q: Quote; selected: boolean; onClick: () => void }) {
if (!q.price) return null
const pos = q.change_pct >= 0
return (
<div
onClick={onClick}
className={clsx(
'card-sm cursor-pointer hover:border-slate-500/60 transition-all',
selected && 'border-blue-500/60 bg-dark-600'
)}
>
<div className="flex justify-between items-start">
<div className="min-w-0">
<div className="text-xs text-white font-semibold truncate">{q.name || q.symbol}</div>
<div className="text-xs text-slate-600">{q.symbol}</div>
</div>
{pos ? (
<TrendingUp className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
) : (
<TrendingDown className="w-3.5 h-3.5 text-red-400 shrink-0" />
)}
</div>
<div className="mt-2 flex justify-between items-end">
<span className="text-sm font-bold text-white font-mono">{q.price.toFixed(q.price > 100 ? 2 : 4)}</span>
<span className={clsx('text-xs font-mono font-bold', pos ? 'positive' : 'negative')}>
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
</span>
</div>
{q.iv !== undefined && (
<div className="text-xs text-slate-600 mt-1">IV: {(q.iv * 100).toFixed(1)}%</div>
)}
</div>
)
}
const PERIOD_OPTIONS = [
{ label: '5D', value: '5d' },
{ label: '1M', value: '1mo' },
{ label: '3M', value: '3mo' },
{ label: '6M', value: '6mo' },
{ label: '1A', value: '1y' },
{ label: '2A', value: '2y' },
]
function PriceChart({ symbol, name }: { symbol: string; name: string }) {
const [period, setPeriod] = useState('3mo')
const { data: hist, isLoading } = useHistory(symbol, period)
const chartData = hist?.map(h => ({
date: h.date.slice(0, 10),
close: h.close,
open: h.open,
high: h.high,
low: h.low,
})) ?? []
const first = chartData[0]?.close ?? 0
const last = chartData[chartData.length - 1]?.close ?? 0
const isUp = last >= first
const chg = first > 0 ? ((last - first) / first * 100).toFixed(2) : '0'
return (
<div className="card h-full">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm font-bold text-white">{name}</div>
<div className="text-xs text-slate-500">{symbol}</div>
</div>
<div className="flex items-center gap-3">
<span className={clsx('text-sm font-bold', isUp ? 'positive' : 'negative')}>
{isUp ? '+' : ''}{chg}%
</span>
<div className="flex gap-1">
{PERIOD_OPTIONS.map(p => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={clsx('px-2 py-0.5 rounded text-xs transition-colors', {
'bg-blue-600 text-white': period === p.value,
'text-slate-500 hover:text-slate-300': period !== p.value,
})}
>
{p.label}
</button>
))}
</div>
</div>
</div>
{isLoading ? (
<div className="h-48 flex items-center justify-center">
<RefreshCw className="w-4 h-4 animate-spin text-slate-600" />
</div>
) : chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={`grad-${symbol}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={isUp ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
<stop offset="95%" stopColor={isUp ? '#10b981' : '#ef4444'} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 9 }} tickLine={false}
tickFormatter={v => v.slice(5)} interval="preserveStartEnd" />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
domain={['auto', 'auto']} width={55}
tickFormatter={v => v > 1000 ? `${(v/1000).toFixed(1)}k` : v.toFixed(2)} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
labelStyle={{ color: '#94a3b8' }}
formatter={(v: number) => [v.toFixed(4), 'Prix']}
/>
<Area
type="monotone" dataKey="close"
stroke={isUp ? '#10b981' : '#ef4444'}
fill={`url(#grad-${symbol})`}
strokeWidth={1.5} dot={false}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-slate-600 text-xs">
Aucune donnée disponible
</div>
)}
</div>
)
}
export default function Markets() {
const { data: allQuotes, isLoading, refetch } = useAllQuotes()
const [searchParams, setSearchParams] = useSearchParams()
const [activeClass, setActiveClass] = useState<AssetClass>('energy')
const [selectedSymbol, setSelectedSymbol] = useState<{ symbol: string; name: string } | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const initialSymbol = useRef(searchParams.get('symbol'))
// Auto-select symbol coming from another page (e.g. Portfolio ticker link)
useEffect(() => {
if (!initialSymbol.current || !allQuotes) return
const sym = initialSymbol.current
initialSymbol.current = null
for (const [cls, quotes] of Object.entries(allQuotes)) {
const match = (quotes as Quote[]).find(q => q.symbol.toUpperCase() === sym.toUpperCase())
if (match) {
setActiveClass(cls as AssetClass)
setSelectedSymbol({ symbol: match.symbol, name: match.name || match.symbol })
break
}
}
setSearchParams({}, { replace: true })
}, [allQuotes, setSearchParams])
const allQuotesFlat = useMemo(() => {
if (!allQuotes) return []
return Object.entries(allQuotes).flatMap(([cls, quotes]) =>
(quotes as Quote[]).filter(q => q.price).map(q => ({ ...q, assetClass: cls as AssetClass }))
)
}, [allQuotes])
const searchResults = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return []
return allQuotesFlat
.filter(quote => quote.symbol.toLowerCase().includes(q) || (quote.name ?? '').toLowerCase().includes(q))
.slice(0, 8)
}, [searchQuery, allQuotesFlat])
const currentQuotes = allQuotes?.[activeClass] ?? []
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">
<BarChart2 className="w-5 h-5 text-blue-400" /> Marchés & Prix
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Données en temps réel · Volatilité implicite · Opportunités options
</p>
</div>
<button
onClick={() => refetch()}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Actualiser
</button>
</div>
{/* Asset class tabs + ticker search */}
<div className="flex flex-wrap items-center gap-2">
{ASSET_CLASSES.map(({ key, label, emoji }) => (
<button
key={key}
onClick={() => { setActiveClass(key); setSelectedSymbol(null); setSearchQuery('') }}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm transition-all', {
'bg-blue-600 border-blue-500 text-white': activeClass === key,
'border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200': activeClass !== key,
})}
>
<span>{emoji}</span> {label}
</button>
))}
<div className="relative ml-auto">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setSearchQuery('') }}
placeholder="Rechercher un ticker..."
className="bg-dark-700 border border-slate-700 rounded pl-8 pr-3 py-1.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 w-52"
/>
{searchResults.length > 0 && (
<div className="absolute top-full right-0 w-72 bg-dark-700 border border-slate-600 rounded shadow-xl z-20 mt-1 overflow-hidden">
{searchResults.map(q => (
<button
key={q.symbol}
onMouseDown={e => e.preventDefault()}
onClick={() => {
setActiveClass(q.assetClass)
setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })
setSearchQuery('')
}}
className="w-full text-left px-3 py-2 hover:bg-dark-600 border-b border-slate-700/30 last:border-0 flex items-center justify-between transition-colors"
>
<div className="min-w-0 flex-1">
<span className="text-white font-mono text-sm">{q.symbol}</span>
{q.name && <span className="text-slate-500 ml-2 text-xs truncate">{q.name}</span>}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{q.price != null && (
<span className="text-slate-300 font-mono text-xs">{q.price.toFixed(q.price > 100 ? 2 : 4)}</span>
)}
<span className={clsx('text-xs font-mono', q.change_pct >= 0 ? 'positive' : 'negative')}>
{q.change_pct >= 0 ? '+' : ''}{q.change_pct.toFixed(2)}%
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{/* Quote grid */}
<div className="col-span-1 space-y-2">
<div className="section-title">
{ASSET_CLASSES.find(c => c.key === activeClass)?.emoji}{' '}
{ASSET_CLASSES.find(c => c.key === activeClass)?.label}
</div>
{isLoading ? (
[1,2,3,4].map(i => <div key={i} className="card-sm animate-pulse h-16 bg-dark-700"></div>)
) : currentQuotes.length > 0 ? (
currentQuotes.map(q => (
<QuoteCard
key={q.symbol}
q={q}
selected={selectedSymbol?.symbol === q.symbol}
onClick={() => setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })}
/>
))
) : (
<div className="card text-slate-500 text-xs text-center py-6">
Démarrer le backend pour charger les prix
</div>
)}
</div>
{/* Chart */}
<div className="col-span-2">
{selectedSymbol ? (
<PriceChart symbol={selectedSymbol.symbol} name={selectedSymbol.name} />
) : (
<div className="card h-full flex items-center justify-center text-slate-600 text-sm">
<div className="text-center">
<BarChart2 className="w-8 h-8 mx-auto mb-2 opacity-30" />
<div>Sélectionner un instrument</div>
<div className="text-xs mt-1">pour voir le graphique de prix</div>
</div>
</div>
)}
</div>
</div>
{/* Market overview table */}
<div className="card">
<div className="section-title">Vue d'ensemble — Tous marchés</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Instrument</th>
<th className="text-left pb-2">Classe</th>
<th className="text-right pb-2">Prix</th>
<th className="text-right pb-2">Var. 1j</th>
<th className="text-right pb-2">Vol. IV (est.)</th>
<th className="text-right pb-2">Signal</th>
</tr>
</thead>
<tbody>
{allQuotes && Object.entries(allQuotes).flatMap(([cls, quotes]) =>
quotes.map(q => q.price && (
<tr key={q.symbol} className="border-b border-slate-700/20 hover:bg-dark-700/50 transition-colors">
<td className="py-1.5">
<div className="text-white">{q.name || q.symbol}</div>
<div className="text-slate-600">{q.symbol}</div>
</td>
<td className="py-1.5 text-slate-500 capitalize">{cls}</td>
<td className="py-1.5 text-right font-mono text-white">
{q.price.toFixed(q.price > 100 ? 2 : 4)}
</td>
<td className={clsx('py-1.5 text-right font-mono font-bold', q.change_pct >= 0 ? 'positive' : 'negative')}>
{q.change_pct >= 0 ? '+' : ''}{q.change_pct.toFixed(2)}%
</td>
<td className="py-1.5 text-right text-slate-400">
{q.iv ? `${(q.iv * 100).toFixed(1)}%` : ''}
</td>
<td className="py-1.5 text-right">
{q.change_pct > 1.5 ? <span className="badge-green badge"> Haussier</span>
: q.change_pct < -1.5 ? <span className="badge-red badge"> Baissier</span>
: <span className="badge badge-blue"> Neutre</span>}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,341 @@
import { useState } from 'react'
import { usePnlCurve } from '../hooks/useApi'
import axios from 'axios'
import clsx from 'clsx'
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, Legend,
} from 'recharts'
import { TrendingUp, FlaskConical, Calculator } from 'lucide-react'
import type { PnLPoint } from '../types'
const STRATEGIES = [
{ key: 'long_call', label: 'Long Call', desc: 'Pari haussier, gain illimité, perte limitée à la prime' },
{ key: 'long_put', label: 'Long Put', desc: 'Pari baissier, gain important, perte limitée à la prime' },
{ key: 'bull_call_spread', label: 'Bull Call Spread', desc: 'Haussier modéré, coût réduit, gain plafonné' },
{ key: 'bear_put_spread', label: 'Bear Put Spread', desc: 'Baissier modéré, coût réduit, gain plafonné' },
{ key: 'straddle', label: 'Long Straddle', desc: 'Pari sur la volatilité, direction neutre' },
]
const WATCHLIST_QUICK = [
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
'XLE', 'FXE', 'UUP', 'XOM', 'LMT',
]
interface Greeks {
price: number; delta: number; gamma: number; theta: number; vega: number; rho: number
underlying_price: number; sigma: number
}
interface SpreadResult {
strategy: string; net_debit: number; max_loss: number; max_gain: number | null
breakeven?: number; breakevens?: number[]; legs: Array<{type: string; strike: number; premium: number}>
underlying_price: number; sigma: number
}
export default function OptionsLab() {
const [strategy, setStrategy] = useState('long_call')
const [symbol, setSymbol] = useState('GLD')
const [strike, setStrike] = useState(200)
const [strikeHigh, setStrikeHigh] = useState(210)
const [expiry, setExpiry] = useState(90)
const [optionType, setOptionType] = useState('call')
const [quantity, setQuantity] = useState(1)
const [result, setResult] = useState<Greeks | SpreadResult | null>(null)
const [pnlData, setPnlData] = useState<PnLPoint[]>([])
const [loading, setLoading] = useState(false)
const compute = async () => {
setLoading(true)
try {
let res: Greeks | SpreadResult
if (strategy === 'long_call' || strategy === 'long_put') {
const type = strategy === 'long_call' ? 'call' : 'put'
const r = await axios.get('/api/options/price', {
params: { symbol, strike, expiry_days: expiry, option_type: type }
})
res = r.data as Greeks
const pnl = await axios.get('/api/options/pnl-curve', {
params: { symbol, strike, expiry_days: expiry, option_type: type, quantity, premium_paid: res.price }
})
setPnlData(pnl.data as PnLPoint[])
} else if (strategy === 'bull_call_spread') {
const r = await axios.get('/api/options/strategy/bull-call-spread', {
params: { symbol, strike_low: strike, strike_high: strikeHigh, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
} else if (strategy === 'bear_put_spread') {
const r = await axios.get('/api/options/strategy/bear-put-spread', {
params: { symbol, strike_high: strikeHigh, strike_low: strike, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
} else {
const r = await axios.get('/api/options/strategy/straddle', {
params: { symbol, strike, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
}
setResult(res)
} catch (e) {
console.error(e)
}
setLoading(false)
}
const isGreeks = result && 'delta' in result
const isSpread = result && 'net_debit' in result
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<FlaskConical className="w-5 h-5 text-blue-400" /> Options Lab
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Pricer Black-Scholes · Greeks · Stratégies · Courbe P&L
</p>
</div>
<div className="grid grid-cols-3 gap-5">
{/* Left: strategy builder */}
<div className="col-span-1 space-y-4">
{/* Strategy select */}
<div className="card">
<div className="section-title">Stratégie</div>
<div className="space-y-1.5">
{STRATEGIES.map(s => (
<button
key={s.key}
onClick={() => setStrategy(s.key)}
className={clsx('w-full text-left px-3 py-2 rounded border text-xs transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': strategy === s.key,
'border-slate-700/40 text-slate-400 hover:border-slate-600': strategy !== s.key,
})}
>
<div className="font-semibold">{s.label}</div>
<div className="text-slate-500 mt-0.5">{s.desc}</div>
</button>
))}
</div>
</div>
{/* Parameters */}
<div className="card">
<div className="section-title flex items-center gap-1"><Calculator className="w-3 h-3" /> Paramètres</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Sous-jacent</label>
<div className="flex gap-1 flex-wrap mb-1">
{WATCHLIST_QUICK.map(s => (
<button
key={s}
onClick={() => setSymbol(s)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': symbol === s,
'border-slate-700 text-slate-500 hover:border-slate-500': symbol !== s,
})}
>
{s}
</button>
))}
</div>
<input
type="text"
value={symbol}
onChange={e => setSymbol(e.target.value.toUpperCase())}
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"
placeholder="Ex: GLD, USO, SPY"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Strike {strategy === 'bull_call_spread' || strategy === 'bear_put_spread' ? 'bas' : ''}
</label>
<input
type="number"
value={strike}
onChange={e => setStrike(Number(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"
/>
</div>
{(strategy === 'bull_call_spread' || strategy === 'bear_put_spread') && (
<div>
<label className="text-xs text-slate-500 mb-1 block">Strike haut</label>
<input
type="number"
value={strikeHigh}
onChange={e => setStrikeHigh(Number(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"
/>
</div>
)}
<div>
<label className="text-xs text-slate-500 mb-1 block">Expiration (jours)</label>
<div className="flex gap-1 mb-1">
{[30, 60, 90, 180].map(d => (
<button
key={d}
onClick={() => setExpiry(d)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': expiry === d,
'border-slate-700 text-slate-500 hover:border-slate-500': expiry !== d,
})}
>
{d}j
</button>
))}
</div>
<input
type="number"
value={expiry}
onChange={e => setExpiry(Number(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"
/>
</div>
{(strategy === 'long_call' || strategy === 'long_put') && (
<div>
<label className="text-xs text-slate-500 mb-1 block">Nb contrats</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
min={1}
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"
/>
</div>
)}
<button
onClick={compute}
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold transition-colors"
>
{loading ? 'Calcul...' : '⚡ Calculer'}
</button>
</div>
</div>
</div>
{/* Right: results */}
<div className="col-span-2 space-y-4">
{/* Greeks / Spread summary */}
{isGreeks && (
<div className="card">
<div className="section-title">Prix & Greeks {symbol} {strike} {strategy === 'long_call' ? 'Call' : 'Put'} {expiry}j</div>
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Prime', value: `$${result.price.toFixed(4)}`, highlight: true },
{ label: 'Coût (1 contrat)', value: `$${(result.price * 100).toFixed(2)}` },
{ label: 'Spot sous-jacent', value: `$${result.underlying_price?.toFixed(2)}` },
{ label: 'Vol. Réalisée', value: `${((result.sigma ?? 0) * 100).toFixed(1)}%` },
].map(({ label, value, highlight }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('stat-value text-lg mt-1', highlight && 'text-blue-400')}>{value}</div>
</div>
))}
</div>
<div className="grid grid-cols-5 gap-2">
{[
{ label: 'Delta (Δ)', value: result.delta, desc: 'Sensibilité au prix' },
{ label: 'Gamma (Γ)', value: result.gamma, desc: 'Variation du delta' },
{ label: 'Theta (Θ)', value: result.theta, desc: 'Déclin temporel/j' },
{ label: 'Vega (ν)', value: result.vega, desc: 'Sensibilité à IV' },
{ label: 'Rho (ρ)', value: result.rho, desc: 'Sensibilité aux taux' },
].map(({ label, value, desc }) => (
<div key={label} className="card-sm text-center">
<div className="text-xs text-slate-500">{label}</div>
<div className={clsx('text-base font-bold mt-0.5', {
'text-emerald-400': value > 0,
'text-red-400': value < 0,
'text-slate-400': value === 0,
})}>
{value?.toFixed(4)}
</div>
<div className="text-xs text-slate-600 mt-0.5">{desc}</div>
</div>
))}
</div>
</div>
)}
{isSpread && (
<div className="card">
<div className="section-title">Résumé stratégie {result.strategy}</div>
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Débit net', value: `$${result.net_debit.toFixed(4)}`, color: 'text-red-400' },
{ label: 'Perte max', value: `$${result.max_loss.toFixed(2)}`, color: 'text-red-400' },
{ label: 'Gain max', value: result.max_gain != null ? `$${result.max_gain.toFixed(2)}` : '∞', color: 'text-emerald-400' },
{ label: 'Seuil renta.', value: `$${(result.breakeven ?? (result.breakevens?.[0]) ?? 0).toFixed(2)}`, color: 'text-yellow-400' },
].map(({ label, value, color }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-lg font-bold mt-1', color)}>{value}</div>
</div>
))}
</div>
<div>
<div className="text-xs text-slate-500 mb-2">Jambes de la stratégie</div>
{result.legs.map((leg, i) => (
<div key={i} className="flex items-center gap-3 text-xs py-1.5 border-b border-slate-700/30 last:border-0">
<span className={clsx('badge', leg.type.includes('long') ? 'badge-green' : 'badge-red')}>
{leg.type.toUpperCase()}
</span>
<span className="text-white">Strike: ${leg.strike}</span>
<span className="text-slate-400">Prime: ${leg.premium.toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
{/* P&L Chart */}
{pnlData.length > 0 && (
<div className="card">
<div className="section-title">Courbe P&L à l'expiration</div>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={pnlData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="underlying" tick={{ fill: '#475569', fontSize: 9 }}
tickFormatter={v => `$${v.toFixed(0)}`} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `$${v.toFixed(0)}`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`$${v.toFixed(2)}`, 'P&L']}
labelFormatter={v => `Prix sous-jacent: $${Number(v).toFixed(2)}`}
/>
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<ReferenceLine x={strike} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: 'Strike', fill: '#f59e0b', fontSize: 9 }} />
<Line
type="monotone" dataKey="pnl"
stroke="#3b82f6" strokeWidth={2} dot={false}
activeDot={{ r: 4, fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{!result && !loading && (
<div className="card h-80 flex items-center justify-center text-slate-600 text-sm">
<div className="text-center">
<FlaskConical className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div>Configurer et calculer une stratégie</div>
<div className="text-xs mt-1">Les résultats apparaîtront ici</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,690 @@
import { useState, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores } from '../hooks/useApi'
import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff } from 'lucide-react'
function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0
const setA = new Set(a.map(x => x.toLowerCase()))
const setB = new Set(b.map(x => x.toLowerCase()))
let intersection = 0
for (const x of setA) if (setB.has(x)) intersection++
const union = setA.size + setB.size - intersection
return union === 0 ? 0 : intersection / union
}
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
const TRIGGER_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',
}
const EMPTY_PATTERN = {
name: '', description: '', triggers: [] as string[], keywords: [] as string[],
historical_instances: [] as any[], suggested_trades: [] as any[],
asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30,
}
function QualityBadge({ score }: { score: number }) {
const color = score >= 75 ? 'badge-green' : score >= 50 ? 'badge-yellow' : 'badge-red'
const label = score >= 75 ? 'Excellent' : score >= 50 ? 'Bon' : 'Faible'
return <span className={clsx('badge', color)}>{score}/100 {label}</span>
}
function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void
similarTo?: Array<{ name: string; similarity: number }>
aiScore?: number | null
}) {
const [expanded, setExpanded] = useState(false)
const isCustom = p.source === 'custom'
const isActive = p.is_active !== 0
return (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-blue-500/30': isCustom,
'opacity-50': !isActive,
})}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 mr-2">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<div className="text-sm font-semibold text-white">{p.name}</div>
{isCustom && <span className="badge badge-purple text-xs">Custom</span>}
{!isActive && <span className="badge badge-red text-xs">Désactivé</span>}
{p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />}
</div>
<div className="text-xs text-slate-500">{p.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<div className={clsx('text-sm font-bold', p.expected_move_pct > 0 ? 'positive' : 'negative')}>
{p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%
</div>
<div className="text-xs text-slate-600">{p.horizon_days}j</div>
</div>
<button onClick={onEdit} title="Modifier" className="text-slate-500 hover:text-blue-400 transition-colors">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={onToggle} title={isActive ? 'Désactiver' : 'Activer'} className={clsx('transition-colors', isActive ? 'text-slate-500 hover:text-yellow-400' : 'text-yellow-500 hover:text-yellow-300')}>
{isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button onClick={onDelete} title="Supprimer" className="text-slate-500 hover:text-red-400 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{similarTo && similarTo.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similarTo.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mb-2">
{(p.triggers || []).map((t: string) => (
<span key={t} className="badge badge-orange text-xs">{TRIGGER_LABELS[t] ?? t}</span>
))}
<span className="badge badge-blue text-xs">{p.asset_class}</span>
{aiScore != null ? (
<span className={clsx('badge text-xs font-bold', aiScore >= 50 ? 'badge-green' : aiScore >= 25 ? 'badge-yellow' : 'badge-red')}>
{aiScore}/100 IA
</span>
) : (
<span className="badge badge-purple text-xs">{Math.round(p.probability * 100)}% prob.</span>
)}
</div>
<button onClick={() => setExpanded(!expanded)} className="text-xs text-slate-600 hover:text-slate-400">
{expanded ? '▲ Réduire' : '▼ Voir détails'}
</button>
{expanded && (
<div className="mt-3 space-y-2">
{p.keywords?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Mots-clés de détection</div>
<div className="flex flex-wrap gap-1">
{p.keywords.map((kw: string) => (
<span key={kw} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded">#{kw}</span>
))}
</div>
</div>
)}
{p.historical_instances?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Instances historiques</div>
{p.historical_instances.map((h: any, i: number) => (
<div key={i} className="card-sm mb-1">
<span className="text-xs text-slate-500">{h.date}</span>
<span className="text-xs text-slate-300 ml-2">{h.event || h.outcome}</span>
</div>
))}
</div>
)}
{p.suggested_trades?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Trades suggérés</div>
{p.suggested_trades.map((t: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs mb-1 flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
{t.asset_class && <span className="badge badge-blue">{t.asset_class}</span>}
<span className="text-slate-500"> {t.rationale}</span>
</div>
))}
</div>
)}
{p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && (
<div className="card-sm border-blue-700/30">
<div className="text-xs text-blue-400 font-semibold mb-1">Évaluation IA</div>
{p.ai_evaluation.strengths?.length > 0 && (
<div className="text-xs text-emerald-400 mb-1"> {p.ai_evaluation.strengths[0]}</div>
)}
{p.ai_evaluation.weaknesses?.length > 0 && (
<div className="text-xs text-red-400"> {p.ai_evaluation.weaknesses[0]}</div>
)}
{p.ai_evaluation.overall_recommendation && (
<div className="text-xs text-slate-400 mt-1">{p.ai_evaluation.overall_recommendation}</div>
)}
</div>
)}
</div>
)}
</div>
)
}
function AiSuggestModal({ onClose, onSaveAll, allPatterns }: { onClose: () => void; onSaveAll: (patterns: any[]) => void; allPatterns: any[] }) {
const { mutate: suggest, isPending, data } = useSuggestNewPatterns()
const [saved, setSaved] = useState<Set<number>>(new Set())
const { mutate: savePattern } = useSavePattern()
const suggestions: any[] = (data as any)?.suggested_patterns ?? []
const handleSave = (p: any, idx: number) => {
savePattern(p, { onSuccess: () => setSaved(prev => new Set(prev).add(idx)) })
}
// For each suggestion, find existing patterns with keyword overlap >= 25%
const suggestionSimilarities = useMemo(() =>
suggestions.map(s => {
const kws = s.keywords ?? []
return allPatterns
.map(ep => ({ name: ep.name, similarity: jaccard(kws, ep.keywords ?? []) }))
.filter(x => x.similarity >= 0.25)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
}),
[suggestions, allPatterns]
)
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/30">
<div>
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-400" /> Suggérer des patterns par l'IA
</h2>
<p className="text-xs text-slate-500 mt-0.5">
GPT-4o analyse l'actualité géo, les prix, le calendrier et le <span className="text-blue-400">régime macro actuel</span> pour proposer des patterns cohérents avec le scénario dominant
</p>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-5 h-5" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{!data && !isPending && (
<div className="text-center py-12">
<Sparkles className="w-10 h-10 text-blue-400/40 mx-auto mb-3" />
<p className="text-sm text-slate-400 mb-4">
L'IA va analyser les news géopolitiques actuelles, les mouvements de prix et le calendrier économique pour proposer des patterns pertinents <em>aujourd'hui</em>.
</p>
<button onClick={() => suggest()}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded text-sm font-semibold">
Lancer l'analyse (GPT-4o)
</button>
</div>
)}
{isPending && (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-slate-400">GPT-4o analyse le contexte du moment…</p>
</div>
)}
{suggestions.map((p: any, idx: number) => {
const score = Math.round((p.probability ?? 0.5) * 100)
const similars = suggestionSimilarities[idx] ?? []
return (
<div key={idx} className={clsx('card', saved.has(idx) ? 'border-emerald-700/40 opacity-60' : similars.length > 0 ? 'border-amber-700/30' : 'border-blue-700/20')}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{p.name}</div>
<div className="flex gap-1 mt-0.5 flex-wrap">
<span className="badge badge-blue text-xs">{p.asset_class}</span>
<span className={clsx('badge text-xs font-bold', score >= 50 ? 'badge-green' : score >= 25 ? 'badge-yellow' : 'badge-red')}>
{score}/100 IA
</span>
<span className="text-xs text-slate-500">{p.horizon_days}j · {p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%</span>
</div>
</div>
<button
onClick={() => handleSave(p, idx)}
disabled={saved.has(idx)}
className={clsx('shrink-0 ml-3 flex items-center gap-1 px-3 py-1.5 rounded text-xs font-semibold transition-all', {
'bg-emerald-700/30 border border-emerald-700/40 text-emerald-400 cursor-default': saved.has(idx),
'bg-blue-600 hover:bg-blue-500 text-white': !saved.has(idx),
})}>
{saved.has(idx) ? <><Check className="w-3 h-3" /> Sauvegardé</> : <><Save className="w-3 h-3" /> Sauvegarder</>}
</button>
</div>
{similars.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similars.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
⚠ Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<p className="text-xs text-slate-400 mb-2">{p.description}</p>
{p.macro_fit && (
<div className="flex items-start gap-1.5 mb-2 text-[11px] text-blue-300/80 bg-blue-900/20 border border-blue-700/20 rounded px-2 py-1.5">
<span className="shrink-0 mt-px">🌐</span>
<span>{p.macro_fit}</span>
</div>
)}
{p.suggested_trades?.length > 0 && (
<div className="space-y-1">
{p.suggested_trades.map((t: any, ti: number) => (
<div key={ti} className="flex items-center gap-2 text-xs flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
<span className="text-slate-500">— {t.rationale}</span>
</div>
))}
</div>
)}
</div>
)
})}
{data && suggestions.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Aucun pattern suggéré. Réessayer.
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-700/30 flex justify-between items-center">
{suggestions.length > 0 && (
<span className="text-xs text-slate-500">
{saved.size}/{suggestions.length} sauvegardé{saved.size > 1 ? 's' : ''}
</span>
)}
<div className="flex gap-2 ml-auto">
{data && suggestions.length > 0 && (
<button onClick={() => suggest()} disabled={isPending}
className="flex items-center gap-1 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-xs">
<RotateCcw className="w-3 h-3" /> Régénérer
</button>
)}
<button onClick={onClose} className="border border-slate-700 text-slate-400 hover:text-slate-200 px-4 py-1.5 rounded text-xs">
Fermer
</button>
</div>
</div>
</div>
</div>
)
}
function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p: any) => void; onCancel: () => void }) {
const [form, setForm] = useState(initial ? {
...initial,
triggers: Array.isArray(initial.triggers) ? initial.triggers : [],
keywords: Array.isArray(initial.keywords) ? initial.keywords : [],
} : { ...EMPTY_PATTERN })
const [kwInput, setKwInput] = useState('')
const [aiResult, setAiResult] = useState<any>(null)
const [aiLoading, setAiLoading] = useState(false)
const [suggestInput, setSuggestInput] = useState('')
const [suggestLoading, setSuggestLoading] = useState(false)
const { mutateAsync: evaluate } = useEvaluatePattern()
const { mutateAsync: suggest } = useSuggestPattern()
const { data: aiStatus } = useAiStatus()
const set = (k: string, v: unknown) => setForm((f: any) => ({ ...f, [k]: v }))
const toggleTrigger = (t: string) => {
set('triggers', form.triggers.includes(t) ? form.triggers.filter((x: string) => x !== t) : [...form.triggers, t])
}
const addKeyword = () => {
if (kwInput.trim() && !form.keywords.includes(kwInput.trim())) {
set('keywords', [...form.keywords, kwInput.trim()])
setKwInput('')
}
}
const evaluateWithAI = async () => {
setAiLoading(true)
try {
const result = await evaluate(form)
setAiResult(result)
if (result.quality_score) set('ai_quality_score', result.quality_score)
set('ai_evaluation', result)
} catch (e) { console.error(e) }
setAiLoading(false)
}
const suggestFromContext = async () => {
setSuggestLoading(true)
try {
const result = await suggest(suggestInput)
if (result && !result.error) {
setForm((f: any) => ({
...f,
name: result.name || f.name,
description: result.description || f.description,
triggers: result.triggers || f.triggers,
keywords: result.keywords || f.keywords,
historical_instances: result.historical_instances || f.historical_instances,
suggested_trades: result.suggested_trades || f.suggested_trades,
asset_class: result.asset_class || f.asset_class,
expected_move_pct: result.expected_move_pct ?? f.expected_move_pct,
probability: result.probability ?? f.probability,
horizon_days: result.horizon_days ?? f.horizon_days,
}))
}
} catch (e) { console.error(e) }
setSuggestLoading(false)
}
return (
<div className="space-y-4">
{/* AI Suggest from context */}
{aiStatus?.enabled && (
<div className="card border-blue-500/30">
<div className="section-title flex items-center gap-1"><Brain className="w-3 h-3 text-blue-400" /> Générer depuis un contexte (IA)</div>
<div className="flex gap-2">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Décris le contexte géopolitique... ex: 'Quand la Chine impose des restrictions sur les terres rares, que se passe-t-il sur le marché des semi-conducteurs et des métaux ?'"
rows={2}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none"
/>
<button onClick={suggestFromContext} disabled={suggestLoading || !suggestInput}
className="shrink-0 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 rounded text-xs">
{suggestLoading ? '...' : ' Générer'}
</button>
</div>
</div>
)}
{/* Form fields */}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Nom du pattern</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="Ex: Chine tensions Taiwan → Semi-conducteurs spike"
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" />
</div>
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
rows={2} placeholder="Mécanisme géopolitique → impact marché..."
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 resize-none" />
</div>
</div>
{/* Triggers */}
<div>
<label className="text-xs text-slate-500 mb-2 block">Déclencheurs</label>
<div className="flex flex-wrap gap-1.5">
{TRIGGERS.map(t => (
<button key={t} onClick={() => toggleTrigger(t)}
className={clsx('px-2 py-1 rounded border text-xs transition-all', {
'bg-orange-900/30 border-orange-700/50 text-orange-300': form.triggers.includes(t),
'border-slate-700/40 text-slate-500 hover:border-slate-500': !form.triggers.includes(t),
})}>
{TRIGGER_LABELS[t]}
</button>
))}
</div>
</div>
{/* Keywords */}
<div>
<label className="text-xs text-slate-500 mb-1 block">Mots-clés de détection (anglais)</label>
<div className="flex gap-2 mb-2">
<input value={kwInput} onChange={e => setKwInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addKeyword()}
placeholder="Ajouter un mot-clé..."
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
<button onClick={addKeyword} className="bg-dark-600 border border-slate-700 hover:border-slate-500 text-slate-300 px-3 rounded text-xs">+</button>
</div>
<div className="flex flex-wrap gap-1">
{form.keywords.map((kw: string) => (
<span key={kw} className="flex items-center gap-1 text-xs bg-dark-700 text-slate-300 px-2 py-0.5 rounded border border-slate-700/40">
#{kw}
<button onClick={() => set('keywords', form.keywords.filter((k: string) => k !== kw))}
className="text-slate-600 hover:text-red-400 ml-0.5">×</button>
</span>
))}
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Classe d'actif</label>
<select value={form.asset_class} onChange={e => set('asset_class', 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">
{ASSET_CLASSES.map(a => <option key={a}>{a}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Move attendu (%)</label>
<input type="number" value={form.expected_move_pct} onChange={e => set('expected_move_pct', Number(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" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Probabilité (0-1)</label>
<input type="number" step="0.05" min="0" max="1" value={form.probability} onChange={e => set('probability', Number(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" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Horizon (jours)</label>
<input type="number" value={form.horizon_days} onChange={e => set('horizon_days', Number(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" />
</div>
</div>
{/* AI Evaluation result */}
{aiResult && (
<div className="card border-blue-700/40">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-blue-400 flex items-center gap-1">
<Brain className="w-4 h-4" /> Évaluation IA
</div>
<QualityBadge score={aiResult.quality_score || 0} />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-slate-500 mb-1 font-semibold"> Points forts</div>
{aiResult.strengths?.map((s: string, i: number) => (
<div key={i} className="text-emerald-400 mb-0.5"> {s}</div>
))}
</div>
<div>
<div className="text-slate-500 mb-1 font-semibold"> Faiblesses</div>
{aiResult.weaknesses?.map((w: string, i: number) => (
<div key={i} className="text-orange-400 mb-0.5"> {w}</div>
))}
</div>
</div>
{aiResult.suggested_improvements?.additional_keywords?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Mots-clés suggérés par l'IA</div>
<div className="flex flex-wrap gap-1">
{aiResult.suggested_improvements.additional_keywords.map((kw: string) => (
<button key={kw} onClick={() => set('keywords', [...form.keywords, kw])}
className="text-xs bg-blue-900/30 border border-blue-700/40 text-blue-300 px-1.5 py-0.5 rounded hover:bg-blue-800/40">
+{kw}
</button>
))}
</div>
</div>
)}
{aiResult.overall_recommendation && (
<div className="mt-2 text-xs text-slate-400 border-t border-slate-700/40 pt-2">
{aiResult.overall_recommendation}
</div>
)}
{aiResult.counter_scenarios?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Scénarios d'invalidation</div>
{aiResult.counter_scenarios.map((s: string, i: number) => (
<div key={i} className="text-xs text-red-400 mb-0.5"> {s}</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={evaluateWithAI} disabled={aiLoading || !form.name}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm transition-colors disabled:opacity-40">
<Brain className="w-3.5 h-3.5" />
{aiLoading ? 'Évaluation IA...' : 'Évaluer avec IA'}
</button>
)}
<button onClick={onCancel} className="flex items-center gap-1.5 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-sm">
<RotateCcw className="w-3.5 h-3.5" /> Annuler
</button>
<button onClick={() => onSave(form)} disabled={!form.name || form.triggers.length === 0}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-1.5 rounded text-sm font-semibold ml-auto">
<Save className="w-3.5 h-3.5" /> Sauvegarder le pattern
</button>
</div>
</div>
)
}
export default function PatternEditor() {
const { data: patterns, isLoading } = useAllPatterns()
const { mutate: savePattern } = useSavePattern()
const { mutate: deletePattern } = useDeletePattern()
const { mutate: togglePattern } = useTogglePattern()
const { data: aiStatus } = useAiStatus()
const { data: simData } = usePatternSimilarity()
const { data: lastScoresData } = useLastScores()
const [editing, setEditing] = useState<any>(null)
const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false)
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
const aiScoreMap = useMemo(() => {
const map: Record<string, number> = {}
for (const sp of (lastScoresData as any)?.scored_patterns ?? []) {
const pid = sp.pattern_id
if (!pid) continue
const base = sp.score ?? 0
const best = Math.max(base, ...((sp.trade_rankings ?? []) as any[]).map((r: any) =>
Math.max(0, Math.min(100, base + (r.score_delta ?? 0)))))
map[pid] = best
}
return map
}, [lastScoresData])
// Build a map: patternId → [{name, similarity}] for each side of a similar pair
const similarityMap = useMemo(() => {
const pairs: any[] = (simData as any)?.pairs ?? []
const map: Record<string, Array<{ name: string; similarity: number }>> = {}
for (const pair of pairs) {
if (!map[pair.id_a]) map[pair.id_a] = []
map[pair.id_a].push({ name: pair.name_b, similarity: pair.similarity })
if (!map[pair.id_b]) map[pair.id_b] = []
map[pair.id_b].push({ name: pair.name_a, similarity: pair.similarity })
}
return map
}, [simData])
const displayPatterns = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
const handleSave = (form: any) => {
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
}
return (
<div className="p-6 space-y-5">
{showAiSuggest && (
<AiSuggestModal
onClose={() => setShowAiSuggest(false)}
onSaveAll={() => setShowAiSuggest(false)}
allPatterns={patterns ?? []}
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns actifs ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
</p>
</div>
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={() => setShowAiSuggest(true)}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm font-semibold transition-colors">
<Sparkles className="w-4 h-4" /> Suggérer par l'IA
</button>
)}
<button onClick={() => { setCreating(true); setEditing(null) }}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> Créer un pattern
</button>
</div>
</div>
{(creating || editing) && (
<div className="card border-blue-500/30">
<div className="text-sm font-semibold text-white mb-4">
{editing ? `Modifier: ${editing.name}` : 'Nouveau pattern géopolitique'}
</div>
<PatternForm
initial={editing}
onSave={handleSave}
onCancel={() => { setEditing(null); setCreating(false) }}
/>
</div>
)}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
{isLoading ? (
<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">
{displayPatterns.map((p: any) => (
<PatternCard key={p.id}
p={p}
onEdit={() => { setEditing(p); setCreating(false) }}
onDelete={() => deletePattern(p.id)}
onToggle={() => togglePattern(p.id)}
similarTo={similarityMap[p.id]}
aiScore={aiScoreMap[p.id] ?? null}
/>
))}
{displayPatterns.length === 0 && (
<div className="card text-center py-10 text-slate-500">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Aucun pattern personnalisé</div>
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,627 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
useAddPosition, useClosePosition
} from '../hooks/useApi'
import { useQueryClient, useMutation } from '@tanstack/react-query'
import axios from 'axios'
import clsx from 'clsx'
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, BarChart, Bar
} from 'recharts'
import { TrendingUp, TrendingDown, Plus, X, DollarSign, BarChart2, RefreshCw, Trash2, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react'
import type { TradeIdea } from '../types'
const useDeletePosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => axios.delete(`/api/portfolio/${id}`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
},
})
}
const STRATEGIES = ['Long Call', 'Long Put', 'Bull Call Spread', 'Bear Put Spread', 'Long Straddle', 'Long Strangle', 'Covered Call']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex']
interface AddModalProps {
prefill?: Partial<TradeIdea>
onClose: () => void
}
const STRADDLE_STRATEGIES = ['Long Straddle', 'Short Straddle', 'Long Strangle', 'Short Strangle']
function AddPositionModal({ prefill, onClose }: AddModalProps) {
const { mutate: addPos, isPending } = useAddPosition()
const [addError, setAddError] = useState<string | null>(null)
const [form, setForm] = useState({
title: prefill?.title || '',
underlying: prefill?.underlying || '',
strategy: prefill?.strategy || 'Long Call',
asset_class: prefill?.asset_class || 'indices',
expiry_days: prefill?.horizon_days || 90,
capital_invested: prefill?.capital_required || 1000,
geo_trigger: prefill?.geo_trigger || '',
rationale: prefill?.rationale || '',
strike: '',
option_type: 'call',
quantity: 1,
premium: '',
})
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const isStraddle = STRADDLE_STRATEGIES.includes(form.strategy)
const isShort = form.strategy.startsWith('Short')
const submit = () => {
setAddError(null)
const strikeVal = parseFloat(form.strike) || undefined
const premiumVal = parseFloat(form.premium) || undefined
const legs = isStraddle
? [
{ strike: strikeVal, option_type: 'call', quantity: form.quantity, position: isShort ? 'short' : 'long', premium_paid: premiumVal },
{ strike: strikeVal, option_type: 'put', quantity: form.quantity, position: isShort ? 'short' : 'long', premium_paid: premiumVal },
]
: [{
strike: strikeVal,
option_type: form.option_type,
quantity: form.quantity,
position: form.strategy.startsWith('Short') ? 'short' : 'long',
premium_paid: premiumVal,
}]
addPos({
title: form.title || `${form.strategy} ${form.underlying}`,
underlying: form.underlying,
strategy: form.strategy,
asset_class: form.asset_class,
expiry_days: form.expiry_days,
capital_invested: form.capital_invested,
geo_trigger: form.geo_trigger,
rationale: form.rationale,
legs,
}, {
onSuccess: onClose,
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setAddError(detail || 'Erreur lors de l\'ajout de la position')
}
})
}
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="card w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-blue-400" /> Ajouter au portefeuille
</h2>
<button onClick={onClose} className="text-slate-500 hover:text-slate-200"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Titre</label>
<input value={form.title} onChange={e => set('title', e.target.value)}
placeholder="Ex: Long Call GLD Q3 2026"
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" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Sous-jacent <span className="text-slate-600">(ticker Yahoo Finance)</span>
</label>
<input value={form.underlying} onChange={e => { set('underlying', e.target.value.toUpperCase()); setAddError(null) }}
placeholder="^GSPC, GLD, CL=F..."
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" />
<div className="text-xs text-slate-600 mt-0.5">S&amp;P 500: ^GSPC · Or: GC=F · WTI: CL=F · GLD · USO</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Stratégie</label>
<select value={form.strategy} onChange={e => set('strategy', 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">
{STRATEGIES.map(s => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Classe d'actif</label>
<select value={form.asset_class} onChange={e => set('asset_class', 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">
{ASSET_CLASSES.map(s => <option key={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Strike</label>
<input type="number" value={form.strike} onChange={e => set('strike', e.target.value)}
placeholder="Prix d'exercice"
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" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Type</label>
<select value={form.option_type} onChange={e => set('option_type', 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="call">Call</option>
<option value="put">Put</option>
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Prime payée</label>
<input type="number" value={form.premium} onChange={e => set('premium', e.target.value)}
placeholder="Prix option"
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" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Capital investi (€)</label>
<input type="number" value={form.capital_invested} onChange={e => set('capital_invested', Number(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" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Durée (jours)</label>
<input type="number" value={form.expiry_days} onChange={e => set('expiry_days', Number(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" />
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Déclencheur géopolitique</label>
<input value={form.geo_trigger} onChange={e => set('geo_trigger', e.target.value)}
placeholder="Ex: Middle East escalation oil spike"
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" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Raisonnement</label>
<textarea value={form.rationale} onChange={e => set('rationale', e.target.value)}
rows={2} placeholder="Pourquoi ce trade..."
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 resize-none" />
</div>
{isStraddle && (
<div className="bg-blue-900/30 border border-blue-700/30 rounded p-2 text-xs text-blue-300">
Straddle : 2 legs seront créés automatiquement (CALL + PUT au même strike)
</div>
)}
<div className="bg-dark-700 rounded p-2 text-xs text-slate-500">
<div className="font-semibold text-slate-400 mb-1">Frais IB simulés</div>
<div>Options: $0.65/contrat (min $1.00) à l'entrée + sortie</div>
<div>1 contrat = <span className="text-white">$0.65</span> · 5 contrats = <span className="text-white">$3.25</span></div>
</div>
{addError && (
<div className="bg-red-900/40 border border-red-700/40 rounded p-2 text-xs text-red-300">
{addError}
</div>
)}
<button onClick={submit} disabled={isPending || !form.underlying}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded py-2 text-sm font-semibold">
{isPending ? 'Ajout...' : '+ Ajouter au portefeuille'}
</button>
</div>
</div>
</div>
)
}
function PositionCard({ pos }: { pos: Record<string, any> }) {
const navigate = useNavigate()
const [showClose, setShowClose] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [closeVal, setCloseVal] = useState('')
const { mutate: closePos, isPending: closing } = useClosePosition()
const { mutate: deletePos, isPending: deleting } = useDeletePosition()
const pnl = pos.pnl ?? 0
const pnlPct = pos.pnl_pct ?? 0
const isProfit = pnl >= 0
const entryRef = pos.entry_ref ?? pos.capital_invested
const hasPremium = pos.entry_ref != null && pos.entry_ref !== pos.capital_invested
return (
<div className={clsx('card transition-all', {
'border-emerald-700/40': isProfit && pnl !== 0,
'border-red-700/40': !isProfit && pnl !== 0,
})}>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{pos.title}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="badge badge-blue">{pos.strategy}</span>
<button
onClick={() => navigate(`/markets?symbol=${pos.underlying}`)}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-0.5 transition-colors"
title="Voir dans les marchés"
>
{pos.underlying} <ExternalLink className="w-2.5 h-2.5" />
</button>
<span className="text-xs text-slate-600">{pos.entry_date?.slice(0, 10)}</span>
{pos.sigma_used && (
<span className="text-xs text-slate-600">IV {(pos.sigma_used * 100).toFixed(1)}%</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
<div className="text-right">
<div className={clsx('text-lg font-bold', isProfit ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}€
</div>
<div className={clsx('text-xs font-mono', isProfit ? 'positive' : 'negative')}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
</div>
</div>
<button onClick={() => deletePos(pos.id)} disabled={deleting}
className="text-slate-600 hover:text-red-400 transition-colors" title="Supprimer">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* KPI grid */}
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="card-sm text-center">
<div className="text-xs text-slate-600">{hasPremium ? 'Prime payée' : 'Capital investi'}</div>
<div className="text-xs text-white font-mono mt-0.5">{entryRef.toFixed(2)}€</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Valeur BS actuelle</div>
<div className="text-xs text-white font-mono mt-0.5">
{pos.current_value != null ? `${pos.current_value.toFixed(2)}€` : '—'}
</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Spot sous-jacent</div>
<div className="text-xs text-white font-mono mt-0.5">
{pos.current_underlying != null ? `$${pos.current_underlying}` : '—'}
</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Jours restants</div>
<div className={clsx('text-xs font-mono mt-0.5', {
'text-red-400': (pos.days_remaining ?? 99) < 14,
'text-yellow-400': (pos.days_remaining ?? 99) < 30,
'text-white': (pos.days_remaining ?? 99) >= 30,
})}>
{pos.days_remaining != null ? `${pos.days_remaining}j` : '—'}
</div>
</div>
</div>
{/* Greeks + fees */}
{pos.greeks && (
<div className="flex items-center gap-4 text-xs mb-2">
<span className="text-slate-600">Greeks:</span>
<span>Δ <span className="text-slate-300 font-mono">{pos.greeks.net_delta?.toFixed(3)}</span></span>
<span>Θ <span className="text-red-400 font-mono">{pos.greeks.net_theta?.toFixed(3)}</span>/j</span>
<span>ν <span className="text-blue-400 font-mono">{pos.greeks.net_vega?.toFixed(3)}</span></span>
<span className="ml-auto text-slate-600">Frais IB: <span className="text-orange-400">{(pos.ib_fees_entry || 0).toFixed(2)}€</span></span>
</div>
)}
{/* Contract detail toggle */}
{pos.legs && pos.legs.length > 0 && (
<button
onClick={() => setShowDetails(s => !s)}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-2 transition-colors"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Détail de la simulation Black-Scholes
</button>
)}
{/* Contract details panel */}
{showDetails && pos.legs && pos.legs.length > 0 && (
<div className="mb-3 bg-dark-700/40 rounded p-2.5 text-xs border border-slate-700/30">
<div className="flex flex-wrap items-center gap-3 mb-2 text-slate-500 border-b border-slate-700/30 pb-1.5">
{pos.entry_underlying_price != null && (
<span>Spot entrée: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
)}
{pos.sigma_used != null && (
<span>σ (IV histo): <span className="text-slate-300 font-mono">{(pos.sigma_used * 100).toFixed(1)}%</span></span>
)}
<span>r: <span className="text-slate-300">5%</span></span>
{pos.expiry_days != null && (
<span>Durée initiale: <span className="text-slate-300">{pos.expiry_days}j</span></span>
)}
</div>
<table className="w-full mb-2">
<thead>
<tr className="text-slate-600">
<th className="text-left pb-1 font-normal">Leg</th>
<th className="text-left pb-1 font-normal">Strike</th>
<th className="text-right pb-1 font-normal">Qté</th>
<th className="text-right pb-1 font-normal">Prime/contrat</th>
<th className="text-right pb-1 font-normal">Coût total</th>
</tr>
</thead>
<tbody>
{pos.legs.map((leg: any, i: number) => {
const legTotal = leg.premium_paid != null
? leg.premium_paid * (leg.quantity ?? 1) * 100
: null
return (
<tr key={i} className="border-t border-slate-700/20">
<td className="py-1">
<span className={clsx(
'badge text-xs',
(leg.option_type ?? 'call') === 'call' ? 'badge-green' : 'badge-red'
)}>
{(leg.option_type ?? 'call').toUpperCase()}
</span>
<span className="ml-1 text-slate-500">
{leg.position === 'long' ? '▲' : '▼'} {leg.position ?? 'long'}
</span>
</td>
<td className="py-1 font-mono text-slate-300">
{leg.strike != null ? `$${Number(leg.strike).toFixed(2)}` : 'ATM'}
</td>
<td className="py-1 text-right text-slate-300">{leg.quantity ?? 1}</td>
<td className="py-1 text-right font-mono text-slate-300">
{leg.premium_paid != null ? `$${Number(leg.premium_paid).toFixed(4)}` : '—'}
</td>
<td className="py-1 text-right font-mono text-white">
{legTotal != null ? `$${legTotal.toFixed(2)}` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="text-slate-600 italic">
Black-Scholes · 1 contrat = 100 actions · Prix calculé au moment de l'ajout (spot, IV historique, r=5%)
</div>
</div>
)}
{/* Geo trigger */}
{pos.geo_trigger && (
<div className="text-xs text-slate-500 mb-2">
<span className="text-slate-600">⚡ Trigger: </span>{pos.geo_trigger}
</div>
)}
{/* P&L bar */}
<div className="mb-3">
<div className="bg-dark-600 rounded-full h-1.5 relative overflow-hidden">
<div className="absolute inset-y-0 left-1/2 w-px bg-slate-500 z-10"></div>
<div
className={clsx('h-1.5 rounded-full transition-all absolute top-0', isProfit ? 'bg-emerald-500' : 'bg-red-500')}
style={{
width: `${Math.min(50, Math.abs(pnlPct) / 4)}%`,
left: isProfit ? '50%' : `${50 - Math.min(50, Math.abs(pnlPct) / 4)}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-slate-700 mt-0.5">
<span>-100%</span><span>0</span><span>+100%</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button onClick={() => setShowClose(!showClose)}
className="flex-1 text-xs border border-slate-700 hover:border-emerald-600 text-slate-400 hover:text-emerald-400 rounded py-1.5 transition-colors">
{showClose ? 'Annuler' : '💰 Clôturer la position'}
</button>
</div>
{showClose && (
<div className="mt-2 space-y-1">
<div className="text-xs text-slate-500">Valeur de revente (€) — ex: {pos.current_value?.toFixed(2)}</div>
<div className="flex gap-2">
<input type="number" value={closeVal} onChange={e => setCloseVal(e.target.value)}
placeholder={`Valeur actuelle: ~${pos.current_value?.toFixed(2)}€`}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-blue-500" />
<button
onClick={() => closePos({ id: pos.id, close_value: parseFloat(closeVal) || 0 },
{ onSuccess: () => setShowClose(false) })}
disabled={closing || !closeVal}
className="bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 text-white px-3 rounded text-xs font-semibold">
{closing ? '...' : 'Confirmer'}
</button>
</div>
{closeVal && (
<div className={clsx('text-xs', parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1 >= 0 ? 'positive' : 'negative')}>
P&L net IB: {(parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1).toFixed(2)}€
(frais sortie: ~$1.00)
</div>
)}
</div>
)}
</div>
)
}
function ClosedRow({ pos, fees, pnl }: { pos: Record<string, any>; fees: number; pnl: number | null }) {
const { mutate: deletePos, isPending } = useDeletePosition()
return (
<tr className="border-b border-slate-700/20 group">
<td className="py-1.5 text-white">{pos.title}</td>
<td className="py-1.5 text-slate-400">{pos.strategy}</td>
<td className="py-1.5 text-right font-mono">{pos.capital_invested}€</td>
<td className="py-1.5 text-right font-mono">{pos.close_value?.toFixed(2) ?? '—'}€</td>
<td className="py-1.5 text-right font-mono text-orange-400">{fees.toFixed(2)}€</td>
<td className={clsx('py-1.5 text-right font-mono font-bold', pnl != null ? (pnl >= 0 ? 'positive' : 'negative') : 'text-slate-500')}>
{pnl != null ? `${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}€` : '—'}
</td>
<td className="py-1.5 text-slate-500">{pos.close_date?.slice(0, 10)}</td>
<td className="py-1.5 pl-2">
<button onClick={() => deletePos(pos.id)} disabled={isPending}
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all" title="Supprimer">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
)
}
export default function Portfolio() {
const { data: positions, isLoading, refetch } = usePortfolioPositions('open')
const { data: closed } = usePortfolioPositions('closed')
const { data: summary } = usePortfolioSummary()
const { data: pnlHistory } = usePnlHistory()
const [showModal, setShowModal] = useState(false)
const [tab, setTab] = useState<'open' | 'closed' | 'history'>('open')
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">
<DollarSign className="w-5 h-5 text-blue-400" /> Portefeuille
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Suivi temps réel · Mark-to-market Black-Scholes · Frais IB simulés
</p>
</div>
<div className="flex gap-2">
<button onClick={() => refetch()} className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200">
<RefreshCw className="w-3.5 h-3.5" /> Actualiser
</button>
<button onClick={() => setShowModal(true)}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> Nouvelle position
</button>
</div>
</div>
{/* Summary KPIs */}
{summary && (
<div className="grid grid-cols-5 gap-3">
{[
{ label: 'Positions ouvertes', value: summary.open_positions, color: 'text-white' },
{ label: 'Capital engagé', value: `${summary.total_invested}€`, color: 'text-white' },
{ label: 'P&L latent', value: `${summary.unrealized_pnl >= 0 ? '+' : ''}${summary.unrealized_pnl}€`, color: summary.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'P&L réalisé', value: `${summary.realized_pnl >= 0 ? '+' : ''}${summary.realized_pnl}€`, color: summary.realized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Frais IB total', value: `${summary.total_fees_paid}€`, color: 'text-orange-400' },
].map(({ label, value, color }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-lg font-bold mt-1', color)}>{value}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'open', label: `Positions ouvertes (${positions?.length ?? 0})` },
{ key: 'closed', label: `Clôturées (${closed?.length ?? 0})` },
{ key: 'history', label: 'Courbe P&L' },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
{/* Open positions */}
{tab === 'open' && (
<>
{isLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => <div key={i} className="card animate-pulse h-32 bg-dark-700" />)}
</div>
) : positions && positions.length > 0 ? (
<div className="space-y-3">
{(positions as Record<string, any>[]).map(pos => (
<PositionCard key={pos.id} pos={pos} />
))}
</div>
) : (
<div className="card text-center py-12 text-slate-500">
<DollarSign className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div>Aucune position ouverte</div>
<div className="text-xs mt-1">Ajouter un trade depuis les idées ou manuellement</div>
</div>
)}
</>
)}
{/* Closed positions */}
{tab === 'closed' && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Position</th>
<th className="text-left pb-2">Stratégie</th>
<th className="text-right pb-2">Investi</th>
<th className="text-right pb-2">Clôture</th>
<th className="text-right pb-2">Frais IB</th>
<th className="text-right pb-2">P&L net</th>
<th className="text-left pb-2">Date clôture</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody>
{(closed as Record<string, any>[] || []).map(pos => {
const fees = (pos.ib_fees_entry || 0) + (pos.ib_fees_exit || 0)
const pnl = pos.close_value != null ? pos.close_value - pos.capital_invested - fees : null
return (
<ClosedRow key={pos.id} pos={pos} fees={fees} pnl={pnl} />
)
})}
{(!closed || closed.length === 0) && (
<tr><td colSpan={8} className="py-8 text-center text-slate-600">Aucun trade clôturé</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* P&L history */}
{tab === 'history' && (
<div className="card">
<div className="section-title">Courbe de P&L cumulé (trades réalisés)</div>
{pnlHistory && pnlHistory.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={pnlHistory}>
<defs>
<linearGradient id="pnl-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `${v.toFixed(0)}€`} />
<Tooltip contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`${v.toFixed(2)}€`, 'P&L cumulé']} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<Area type="monotone" dataKey="cumulative" stroke="#10b981" fill="url(#pnl-grad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={120} className="mt-2">
<BarChart data={pnlHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 8 }} />
<YAxis tick={{ fill: '#475569', fontSize: 8 }} tickFormatter={v => `${v}€`} />
<Tooltip contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 10 }}
formatter={(v: number) => [`${v >= 0 ? '+' : ''}${v.toFixed(2)}€`, 'P&L']} />
<Bar dataKey="pnl" fill="#3b82f6" radius={[2,2,0,0]}
label={false} />
</BarChart>
</ResponsiveContainer>
</>
) : (
<div className="h-40 flex items-center justify-center text-slate-600 text-sm">
Clôturer des positions pour voir la courbe P&L
</div>
)}
</div>
)}
{showModal && <AddPositionModal onClose={() => setShowModal(false)} />}
</div>
)
}

View File

@@ -0,0 +1,509 @@
import { useState } from 'react'
import { Brain, TrendingUp, TrendingDown, RefreshCw, Zap, AlertTriangle, BookOpen, Target, Eye, Clock, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import { usePortfolioReportData, useGeneratePortfolioReport, useAiReportsList, useAiReport } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' },
desinflation: { label: 'Désinflation', color: '#06b6d4', emoji: '❄️' },
soft_landing: { label: 'Soft Landing', color: '#3b82f6', emoji: '🛬' },
reflation: { label: 'Reflation', color: '#f97316', emoji: '🔥' },
stagflation: { label: 'Stagflation', color: '#eab308', emoji: '⚡' },
inflation_shock: { label: 'Inflation Shock', color: '#ef4444', emoji: '💥' },
recession: { label: 'Récession', color: '#8b5cf6', emoji: '📉' },
crise_liquidite: { label: 'Crise Liquidité', color: '#ec4899', emoji: '🚨' },
incertain: { label: 'Incertain', color: '#64748b', emoji: '❓' },
}
function ScenarioBadge({ dominant }: { dominant: string }) {
const m = SCENARIO_META[dominant] ?? SCENARIO_META.incertain
return (
<span
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-semibold"
style={{ background: `${m.color}22`, color: m.color, border: `1px solid ${m.color}44` }}
>
{m.emoji} {m.label}
</span>
)
}
function PnlBar({ pnl }: { pnl: number }) {
const pos = pnl >= 0
const width = Math.min(Math.abs(pnl), 200) / 2 // cap at 100% width
return (
<div className="flex items-center gap-2">
<span className={clsx('font-bold font-mono text-sm w-16 text-right', pos ? 'text-emerald-400' : 'text-red-400')}>
{pos ? '+' : ''}{pnl.toFixed(1)}%
</span>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${width}%`, background: pos ? '#22c55e' : '#ef4444' }}
/>
</div>
</div>
)
}
function ScoreTrend({ trend }: { trend: number[] }) {
if (!trend || trend.length === 0) return <span className="text-slate-700 text-xs"></span>
return (
<div className="flex items-end gap-0.5 h-6">
{trend.map((s, i) => (
<div
key={i}
className="w-2 rounded-sm"
style={{
height: `${Math.max(3, (s ?? 0) / 100 * 24)}px`,
background: (s ?? 0) >= 60 ? '#22c55e' : (s ?? 0) >= 40 ? '#eab308' : '#64748b',
}}
title={`${s}/100`}
/>
))}
</div>
)
}
function MoverCard({ trade, rank, type }: { trade: any; rank: number; type: 'winner' | 'loser' }) {
const [expanded, setExpanded] = useState(false)
const pnl = trade.pnl_pct ?? 0
const sc = trade.scoring_context ?? {}
const scOut = sc.output ?? {}
const sg = trade.suggestion_context ?? {}
const sgOut = sg.output ?? {}
const buckets: any[] = scOut.buckets ?? []
const isWinner = type === 'winner'
return (
<div className={clsx(
'card border',
isWinner ? 'border-emerald-700/20' : 'border-red-700/20'
)}>
<div className="flex items-start gap-3">
{/* Rank */}
<div className={clsx(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0',
isWinner ? 'bg-emerald-900/40 text-emerald-400' : 'bg-red-900/40 text-red-400'
)}>
{rank}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-slate-200 truncate">{trade.pattern_name || trade.pattern_id}</span>
<span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded',
trade.direction === 'bearish' ? 'bg-red-900/30 text-red-400' : 'bg-emerald-900/30 text-emerald-400')}>
{trade.direction === 'bearish' ? '🐻' : '🐂'} {trade.strategy}
</span>
<span className="font-mono text-slate-400 text-xs">{trade.underlying}</span>
</div>
<button
onClick={() => setExpanded(v => !v)}
className="text-slate-600 hover:text-slate-400 shrink-0"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
<PnlBar pnl={pnl} />
<div className="flex flex-wrap items-center gap-3 mt-2 text-[11px] text-slate-500">
<span>Score entrée : <span className="font-mono text-slate-300">{trade.score_at_entry ?? '?'}/100</span></span>
<span>EV : <span className={clsx('font-mono', (trade.ev_net ?? 0) > 0 ? 'text-emerald-400' : 'text-red-400')}>
{trade.ev_net != null ? `${(trade.ev_net * 100).toFixed(0)}%` : '—'}
</span></span>
{sc.macro_dominant && <ScenarioBadge dominant={sc.macro_dominant} />}
{sc.geo_score != null && <span>Géo <span className="font-mono text-slate-300">{sc.geo_score}/100</span></span>}
<span className="ml-auto text-slate-700">{trade.entry_date}</span>
</div>
{scOut.key_catalyst && (
<p className="mt-1.5 text-[11px] text-slate-500 italic">
Catalyseur : {scOut.key_catalyst}
</p>
)}
{trade.score_trend?.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] text-slate-600">Score trend :</span>
<ScoreTrend trend={trade.score_trend} />
</div>
)}
{expanded && (
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs">
{/* Thèse d'origine */}
{(sgOut.macro_fit || sgOut.description) && (
<div>
<span className="text-slate-600 font-medium">Thèse initiale : </span>
<span className="text-slate-400">{sgOut.macro_fit || sgOut.description}</span>
</div>
)}
{/* Piliers */}
{buckets.length > 0 && (
<div className="grid grid-cols-2 gap-1.5 mt-2">
{buckets.map((b: any, i: number) => {
const pct = b.max ? Math.round((b.score / b.max) * 100) : 0
const color = pct >= 66 ? '#22c55e' : pct >= 33 ? '#eab308' : '#ef4444'
return (
<div key={i} className="flex items-center gap-2 bg-dark-800 rounded px-2 py-1">
<div className="w-10 h-1 bg-dark-700 rounded-full overflow-hidden shrink-0">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="text-slate-500 truncate">{b.label ?? b.id}</span>
<span className="font-mono ml-auto shrink-0" style={{ color }}>{b.score}/{b.max}</span>
</div>
)
})}
</div>
)}
{scOut.summary && (
<p className="text-slate-600 italic mt-1">{scOut.summary}</p>
)}
</div>
)}
</div>
</div>
</div>
)
}
function ReportSection({ icon: Icon, title, content, color = 'text-slate-300' }: {
icon: any; title: string; content: string | string[]; color?: string
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-400">
<Icon className="w-4 h-4" />
{title}
</div>
{Array.isArray(content) ? (
<ul className="space-y-1 pl-4">
{content.map((item, i) => (
<li key={i} className={clsx('text-sm', color)}> {item}</li>
))}
</ul>
) : (
<p className={clsx('text-sm leading-relaxed', color)}>{content}</p>
)}
</div>
)
}
export default function RapportIA() {
const [days, setDays] = useState(30)
const [selectedHistoryId, setSelectedHistoryId] = useState<number | null>(null)
const [showHistory, setShowHistory] = useState(false)
const queryClient = useQueryClient()
const { data: rawData, isLoading: loadingRaw, refetch } = usePortfolioReportData(days)
const { mutate: generate, isPending: generating, data: freshReportData, reset: resetFresh } = useGeneratePortfolioReport()
const { data: historyList } = useAiReportsList()
const { data: historicReport } = useAiReport(selectedHistoryId)
const history: any[] = (historyList as any)?.reports ?? []
// Active report: either a loaded historic one, or the freshly generated one
const activeHistoric = historicReport as any
const fresh = freshReportData as any
const raw = rawData as any
const rep = selectedHistoryId && activeHistoric ? activeHistoric : fresh
const report = rep?.report ?? null
const stats = rep?.stats ?? raw
const winners = rep?.winners ?? raw?.winners ?? []
const losers = rep?.losers ?? raw?.losers ?? []
const avgPnl = stats?.avg_pnl_pct ?? null
function handleGenerate() {
setSelectedHistoryId(null)
resetFresh()
generate(days, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ai-reports-list'] })
},
})
}
return (
<div className="flex h-full">
{/* History sidebar */}
{showHistory && (
<aside className="w-64 shrink-0 border-r border-slate-700/40 bg-dark-800 flex flex-col h-screen sticky top-0 overflow-y-auto">
<div className="p-3 border-b border-slate-700/40 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Rapports archivés</span>
<button onClick={() => setShowHistory(false)} className="text-slate-600 hover:text-slate-400 text-xs"></button>
</div>
{history.length === 0 ? (
<div className="p-4 text-xs text-slate-600 text-center mt-4">
Aucun rapport archivé.<br />Générez votre premier rapport.
</div>
) : (
<div className="flex flex-col divide-y divide-slate-800">
{history.map((r: any) => {
const avg = r.stats?.avg_pnl_pct
const date = r.created_at?.slice(0, 16).replace('T', ' ') ?? ''
const isActive = selectedHistoryId === r.id
return (
<button
key={r.id}
onClick={() => { setSelectedHistoryId(r.id) }}
className={clsx(
'text-left px-3 py-2.5 hover:bg-dark-700/50 transition-colors',
isActive && 'bg-blue-900/20 border-l-2 border-blue-500'
)}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[11px] font-mono text-slate-500">{date}</span>
<span className="text-[10px] text-slate-600">{r.days}j</span>
</div>
{r.report?.headline && (
<p className="text-xs text-slate-400 line-clamp-2 leading-snug">{r.report.headline}</p>
)}
{avg != null && (
<span className={clsx('text-[11px] font-mono font-bold mt-1 block',
avg >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avg >= 0 ? '+' : ''}{avg.toFixed(1)}% moy.
</span>
)}
{isActive && <ChevronRight className="w-3 h-3 text-blue-400 mt-1" />}
</button>
)
})}
</div>
)}
</aside>
)}
<div className="flex-1 p-6 space-y-6 max-w-5xl overflow-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{!showHistory && (
<button
onClick={() => setShowHistory(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-slate-700/40 text-slate-500 hover:text-slate-300 text-xs"
title={`${history.length} rapport${history.length !== 1 ? 's' : ''} archivé${history.length !== 1 ? 's' : ''}`}
>
<Clock className="w-3.5 h-3.5" />
{history.length > 0 && <span className="font-mono">{history.length}</span>}
</button>
)}
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Brain className="w-6 h-6 text-blue-400" />
Rapport IA Performance & Analyse
{selectedHistoryId && activeHistoric && (
<span className="text-xs font-normal text-slate-500 ml-2">
archivé · {activeHistoric.created_at?.slice(0, 10)}
</span>
)}
</h1>
<p className="text-sm text-slate-500 mt-0.5">
Synthèse des top mouvements + explication GPT-4o basée sur les traces de raisonnement
</p>
</div>
</div>
<div className="flex items-center gap-3">
{selectedHistoryId && (
<button
onClick={() => setSelectedHistoryId(null)}
className="text-xs text-slate-500 hover:text-slate-300 px-2.5 py-1.5 rounded border border-slate-700/40"
>
Nouveau
</button>
)}
{/* Période — only relevant for new generation */}
{!selectedHistoryId && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Période :</span>
{[7, 14, 30, 90].map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={clsx(
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
days === d
? 'bg-blue-900/40 text-blue-300 border border-blue-700/40'
: 'bg-dark-700 text-slate-500 hover:text-slate-300'
)}
>
{d}j
</button>
))}
</div>
)}
{!selectedHistoryId && (
<button
onClick={() => refetch()}
disabled={loadingRaw}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40"
>
<RefreshCw className={clsx('w-4 h-4', loadingRaw && 'animate-spin')} />
</button>
)}
<button
onClick={handleGenerate}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
<Zap className={clsx('w-4 h-4', generating && 'animate-pulse')} />
{generating ? 'Génération GPT-4o…' : 'Générer rapport IA'}
</button>
</div>
</div>
{/* Stats bar */}
{(loadingRaw || stats) && (
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Trades total', value: stats?.total_trades ?? '…' },
{ label: 'Trades pricés', value: stats?.priced_count ?? '…' },
{
label: 'P&L moyen',
value: avgPnl != null
? <span className={clsx('font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
</span>
: '—'
},
].map(({ label, value }) => (
<div key={label} className="card text-center">
<div className="text-2xl font-bold text-white">{value}</div>
<div className="text-xs text-slate-500 mt-0.5">{label}</div>
</div>
))}
</div>
)}
{/* GPT-4o report */}
{report && (
<div className="card border border-blue-700/30 space-y-5">
<div className="flex items-center gap-2 text-blue-400 font-semibold">
<Brain className="w-4 h-4" />
Analyse GPT-4o
</div>
{report.headline && (
<p className="text-base font-medium text-white border-l-2 border-blue-500 pl-3">
{report.headline}
</p>
)}
<div className="grid grid-cols-2 gap-5">
{report.winners_analysis && (
<ReportSection
icon={TrendingUp}
title="Pourquoi les gains"
content={report.winners_analysis}
color="text-emerald-300"
/>
)}
{report.losers_analysis && (
<ReportSection
icon={TrendingDown}
title="Pourquoi les pertes"
content={report.losers_analysis}
color="text-red-300"
/>
)}
</div>
{report.regime_assessment && (
<ReportSection
icon={Eye}
title="Alignement régime macro"
content={report.regime_assessment}
/>
)}
{report.key_lessons?.length > 0 && (
<ReportSection
icon={BookOpen}
title="Leçons clés"
content={report.key_lessons}
color="text-blue-300"
/>
)}
{report.blind_spots && (
<ReportSection
icon={AlertTriangle}
title="Angles morts du scoring"
content={report.blind_spots}
color="text-yellow-300"
/>
)}
{report.next_cycle_priorities && (
<ReportSection
icon={Target}
title="Priorités prochain cycle"
content={report.next_cycle_priorities}
color="text-purple-300"
/>
)}
{report.risk_watch && (
<ReportSection
icon={AlertTriangle}
title="Risques à surveiller"
content={report.risk_watch}
color="text-orange-300"
/>
)}
</div>
)}
{/* Top movers */}
{(winners.length > 0 || losers.length > 0) && (
<div className="grid grid-cols-2 gap-6">
{/* Winners */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-emerald-400 uppercase tracking-wide flex items-center gap-2">
<TrendingUp className="w-4 h-4" /> Top Gains
</h2>
{winners.length === 0 ? (
<div className="card text-center py-8 text-slate-600 text-sm">Aucun trade pricé</div>
) : (
winners.map((t: any, i: number) => (
<MoverCard key={t.id ?? i} trade={t} rank={i + 1} type="winner" />
))
)}
</div>
{/* Losers */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-red-400 uppercase tracking-wide flex items-center gap-2">
<TrendingDown className="w-4 h-4" /> Top Pertes
</h2>
{losers.length === 0 ? (
<div className="card text-center py-8 text-slate-600 text-sm">Aucun trade pricé</div>
) : (
losers.map((t: any, i: number) => (
<MoverCard key={t.id ?? i} trade={t} rank={i + 1} type="loser" />
))
)}
</div>
</div>
)}
{!loadingRaw && !raw && !report && (
<div className="card text-center py-16 text-slate-600">
<Brain className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">Cliquer "Générer rapport IA" pour lancer l'analyse GPT-4o</p>
<p className="text-xs mt-1 text-slate-700">
Le rapport compare les trades gagnants et perdants, identifie les patterns récurrents
et génère des recommandations pour le prochain cycle.
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,451 @@
import { useState } from 'react'
import {
Brain, RefreshCw, Clock, ChevronDown, ChevronUp,
AlertTriangle, Target, TrendingUp, TrendingDown,
Shield, Zap, BookOpen, CheckCircle, XCircle, HelpCircle,
BarChart2, Activity,
} from 'lucide-react'
import {
useKnowledgeState, useKnowledgeHistory, useKnowledgeStateVersion,
useKnowledgeEntries, useSynthesizeKnowledge, usePatchKbEntryStatus,
} from '../hooks/useApi'
// ─── Status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
active: { icon: <CheckCircle className="w-3 h-3" />, color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', label: 'Validé' },
tentative: { icon: <HelpCircle className="w-3 h-3" />, color: 'text-amber-400 bg-amber-400/10 border-amber-400/20', label: 'Tentative' },
invalidated: { icon: <XCircle className="w-3 h-3" />, color: 'text-rose-400 bg-rose-400/10 border-rose-400/20', label: 'Invalidé' },
}
const s = map[status] || map['tentative']
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-medium ${s.color}`}>
{s.icon}{s.label}
</span>
)
}
// ─── Confidence bar ───────────────────────────────────────────────────────────
function ConfidenceBar({ value }: { value: number }) {
const color = value >= 70 ? 'bg-emerald-500' : value >= 40 ? 'bg-amber-500' : 'bg-rose-500'
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-slate-700">
<div className={`h-full rounded-full ${color}`} style={{ width: `${value}%` }} />
</div>
<span className="text-[10px] text-slate-400 w-7 text-right">{value}%</span>
</div>
)
}
// ─── KB Entry card ────────────────────────────────────────────────────────────
function KbEntry({ entry, onStatusChange }: { entry: any; onStatusChange: (id: number, s: string) => void }) {
const [open, setOpen] = useState(false)
return (
<div className="bg-slate-800/60 border border-slate-700/50 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-slate-200">{entry.title}</span>
<StatusBadge status={entry.status} />
</div>
<ConfidenceBar value={entry.confidence} />
</div>
<button onClick={() => setOpen(o => !o)} className="text-slate-500 hover:text-slate-300 mt-0.5">
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
{open && (
<div className="space-y-2 pt-1">
<p className="text-xs text-slate-300 leading-relaxed">{entry.content}</p>
{entry.tags && (
<div className="flex gap-1 flex-wrap">
{entry.tags.split(',').filter(Boolean).map((t: string) => (
<span key={t} className="px-1.5 py-0.5 rounded bg-slate-700 text-[10px] text-slate-400">{t.trim()}</span>
))}
</div>
)}
<div className="flex gap-2 pt-1">
{['active', 'tentative', 'invalidated'].map(s => (
<button
key={s}
onClick={() => onStatusChange(entry.id, s)}
className={`text-[10px] px-2 py-1 rounded border transition-colors ${
entry.status === s
? 'bg-slate-600 border-slate-500 text-slate-200'
: 'border-slate-700 text-slate-500 hover:border-slate-500 hover:text-slate-300'
}`}
>
{s === 'active' ? 'Valider' : s === 'tentative' ? 'Tentative' : 'Invalider'}
</button>
))}
</div>
<p className="text-[10px] text-slate-600">
Vu le {entry.first_seen_at?.slice(0, 10)} · Confirmé {entry.confirmation_count}×
</p>
</div>
)}
</div>
)
}
// ─── Category section ─────────────────────────────────────────────────────────
const CAT_META: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
régimes: { icon: <Activity className="w-4 h-4" />, color: 'text-blue-400', label: 'Régimes macro' },
patterns: { icon: <BarChart2 className="w-4 h-4" />, color: 'text-purple-400', label: 'Patterns' },
erreurs: { icon: <AlertTriangle className="w-4 h-4" />, color: 'text-rose-400', label: 'Erreurs récurrentes' },
corrélations: { icon: <TrendingUp className="w-4 h-4" />, color: 'text-emerald-400', label: 'Corrélations' },
général: { icon: <BookOpen className="w-4 h-4" />, color: 'text-slate-400', label: 'Général' },
}
function CategorySection({
category, entries, onStatusChange,
}: { category: string; entries: any[]; onStatusChange: (id: number, s: string) => void }) {
const [open, setOpen] = useState(true)
const meta = CAT_META[category] || CAT_META['général']
const active = entries.filter(e => e.status !== 'invalidated').length
return (
<div className="border border-slate-700/50 rounded-xl overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 bg-slate-800/80 hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2">
<span className={meta.color}>{meta.icon}</span>
<span className="text-sm font-medium text-slate-200">{meta.label}</span>
<span className="text-xs text-slate-500">{active}/{entries.length} actifs</span>
</div>
{open ? <ChevronUp className="w-4 h-4 text-slate-500" /> : <ChevronDown className="w-4 h-4 text-slate-500" />}
</button>
{open && (
<div className="p-3 grid gap-2 bg-slate-900/40">
{entries.map(e => (
<KbEntry key={e.id} entry={e} onStatusChange={onStatusChange} />
))}
</div>
)}
</div>
)
}
// ─── Synthesis insight list ───────────────────────────────────────────────────
function InsightList({ title, icon, items, color }: {
title: string; icon: React.ReactNode; items: string[]; color: string
}) {
if (!items?.length) return null
return (
<div className="space-y-2">
<div className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${color}`}>
{icon}{title}
</div>
<ul className="space-y-1">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-300">
<span className={`mt-0.5 w-1.5 h-1.5 rounded-full flex-shrink-0 ${color.replace('text-', 'bg-')}`} />
{typeof item === 'string' ? item : (item as any).mistake || JSON.stringify(item)}
</li>
))}
</ul>
</div>
)
}
// ─── Macro regime card ────────────────────────────────────────────────────────
function RegimeCard({ r }: { r: any }) {
const conf = r.confidence ?? 50
const color = conf >= 70 ? 'border-emerald-500/30 bg-emerald-500/5' : conf >= 40 ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-700/50 bg-slate-800/30'
return (
<div className={`border rounded-lg p-3 space-y-1 ${color}`}>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-200">{r.regime}</span>
<span className="text-[10px] text-slate-400">{conf}% conf · {r.trade_count ?? '?'} trades</span>
</div>
<p className="text-xs text-slate-400">{r.observation}</p>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SuperContexte() {
const [selectedHistoryId, setSelectedHistoryId] = useState<number | null>(null)
const [showHistory, setShowHistory] = useState(false)
const { data: stateData, isLoading: stateLoading } = useKnowledgeState()
const { data: histData } = useKnowledgeHistory()
const { data: entriesData } = useKnowledgeEntries()
const { data: versionData } = useKnowledgeStateVersion(selectedHistoryId)
const synthesize = useSynthesizeKnowledge()
const patchStatus = usePatchKbEntryStatus()
const currentState = selectedHistoryId
? versionData?.state
: stateData?.state
const synthesis = currentState?.synthesis || {}
const narrative = currentState?.narrative || ''
const history = histData?.history || []
const byCategory = entriesData?.by_category || {}
const totalEntries = entriesData?.total || 0
const handleSynthesize = () => {
synthesize.mutate(undefined, {
onSuccess: () => setSelectedHistoryId(null),
})
}
const handleStatusChange = (id: number, status: string) => {
patchStatus.mutate({ id, status })
}
return (
<div className="min-h-screen bg-slate-950 text-white">
{/* Header */}
<div className="border-b border-slate-800 bg-slate-900/60 backdrop-blur px-6 py-4">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Brain className="w-6 h-6 text-violet-400" />
<div>
<h1 className="text-xl font-bold text-slate-100">Super Contexte</h1>
<p className="text-xs text-slate-500">Base de raisonnement évolutive cerveau du système</p>
</div>
</div>
<div className="flex items-center gap-3">
{currentState && (
<div className="text-right text-xs text-slate-500">
<div>v{currentState.version} · {currentState.created_at?.slice(0, 16)}</div>
<div>{currentState.reports_used} rapports · {currentState.trades_analyzed} trades analysés</div>
</div>
)}
<button
onClick={() => setShowHistory(h => !h)}
className={`p-2 rounded-lg border transition-colors ${showHistory ? 'border-violet-500/50 text-violet-400 bg-violet-500/10' : 'border-slate-700 text-slate-400 hover:border-slate-500'}`}
>
<Clock className="w-4 h-4" />
</button>
<button
onClick={handleSynthesize}
disabled={synthesize.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{synthesize.isPending ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Brain className="w-4 h-4" />
)}
{synthesize.isPending ? 'Synthèse en cours…' : 'Lancer synthèse GPT-4o'}
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-6 flex gap-6">
{/* History sidebar */}
{showHistory && (
<div className="w-56 flex-shrink-0 space-y-2">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Versions</p>
<button
onClick={() => setSelectedHistoryId(null)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors ${!selectedHistoryId ? 'bg-violet-600/20 border border-violet-500/30 text-violet-300' : 'border border-slate-700/50 text-slate-400 hover:border-slate-600'}`}
>
Dernière version
</button>
{history.map((h: any) => (
<button
key={h.id}
onClick={() => setSelectedHistoryId(h.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors ${selectedHistoryId === h.id ? 'bg-violet-600/20 border border-violet-500/30 text-violet-300' : 'border border-slate-700/50 text-slate-400 hover:border-slate-600'}`}
>
<div className="font-medium">v{h.version}</div>
<div className="text-slate-500">{h.created_at?.slice(0, 16)}</div>
<div className="text-slate-600">{h.reports_used} rapports · {h.trades_analyzed} trades</div>
</button>
))}
</div>
)}
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
{stateLoading && (
<div className="flex items-center justify-center h-40 text-slate-500">
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Chargement
</div>
)}
{!currentState && !stateLoading && (
<div className="border border-violet-500/20 rounded-xl p-8 text-center bg-violet-500/5">
<Brain className="w-12 h-12 text-violet-400/50 mx-auto mb-3" />
<p className="text-slate-300 font-medium mb-1">Aucune synthèse disponible</p>
<p className="text-slate-500 text-sm mb-4">
Lance la première synthèse GPT-4o pour initialiser la base de raisonnement.
Le système analysera tous les rapports et trades disponibles.
</p>
<button
onClick={handleSynthesize}
disabled={synthesize.isPending}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-sm font-medium transition-colors"
>
{synthesize.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Brain className="w-4 h-4" />}
{synthesize.isPending ? 'Synthèse en cours…' : 'Initialiser le Super Contexte'}
</button>
</div>
)}
{synthesize.isSuccess && (
<div className="border border-emerald-500/30 bg-emerald-500/5 rounded-xl p-4 flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-emerald-400 flex-shrink-0" />
<div className="text-sm">
<span className="text-emerald-300 font-medium">Synthèse complète. </span>
<span className="text-slate-400">
{(synthesize.data as any)?.kb_entries_added ?? 0} entrées KB ajoutées ·{' '}
{(synthesize.data as any)?.sources?.reports ?? 0} rapports ·{' '}
{(synthesize.data as any)?.sources?.trades ?? 0} trades analysés.
</span>
</div>
</div>
)}
{currentState && (
<>
{/* Narrative */}
<div className="border border-violet-500/20 bg-violet-500/5 rounded-xl p-5 space-y-3">
<div className="flex items-center gap-2 text-violet-300">
<Brain className="w-5 h-5" />
<span className="text-sm font-semibold">Narrative de raisonnement</span>
{selectedHistoryId && (
<span className="text-xs text-slate-500 ml-auto">v{currentState.version} (archivé)</span>
)}
</div>
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">{narrative}</p>
</div>
{/* Synthesis grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Régimes */}
{synthesis.regime_insights?.length > 0 && (
<div className="border border-slate-700/50 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-blue-400 text-xs font-semibold uppercase tracking-wide">
<Activity className="w-4 h-4" />Régimes macro identifiés
</div>
<div className="space-y-2">
{synthesis.regime_insights.map((r: any, i: number) => (
<RegimeCard key={i} r={r} />
))}
</div>
</div>
)}
{/* Patterns */}
{synthesis.pattern_insights?.length > 0 && (
<div className="border border-slate-700/50 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-purple-400 text-xs font-semibold uppercase tracking-wide">
<BarChart2 className="w-4 h-4" />Patterns documentés
</div>
<div className="space-y-2">
{synthesis.pattern_insights.map((p: any, i: number) => (
<div key={i} className="border border-slate-700/40 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200">{p.pattern}</span>
<span className="text-[10px] text-slate-500">conf {p.confidence}% · win {p.win_rate_pct}%</span>
</div>
<p className="text-xs text-slate-400">{p.observation}</p>
</div>
))}
</div>
</div>
)}
{/* Macro correlations */}
{synthesis.macro_correlations?.length > 0 && (
<div className="border border-slate-700/50 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold uppercase tracking-wide">
<TrendingUp className="w-4 h-4" />Corrélations macro/géo
</div>
<div className="space-y-2">
{synthesis.macro_correlations.map((c: any, i: number) => (
<div key={i} className="border border-slate-700/40 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200">{c.trigger}</span>
<span className={`text-[10px] ${c.reliability === 'haute' ? 'text-emerald-400' : c.reliability === 'faible' ? 'text-rose-400' : 'text-amber-400'}`}>
{c.reliability}
</span>
</div>
<p className="text-xs text-slate-400">{c.market_reaction}</p>
</div>
))}
</div>
</div>
)}
{/* Risk params + insights */}
<div className="border border-slate-700/50 rounded-xl p-4 space-y-4">
<InsightList
title="Priorités stratégiques"
icon={<Target className="w-4 h-4" />}
items={synthesis.strategic_priorities || []}
color="text-violet-400"
/>
<InsightList
title="Forces identifiées"
icon={<Zap className="w-4 h-4" />}
items={synthesis.strengths || []}
color="text-emerald-400"
/>
<InsightList
title="Angles morts"
icon={<AlertTriangle className="w-4 h-4" />}
items={synthesis.blind_spots || []}
color="text-amber-400"
/>
<InsightList
title="Erreurs récurrentes"
icon={<TrendingDown className="w-4 h-4" />}
items={(synthesis.recurring_mistakes || []).map((m: any) =>
typeof m === 'string' ? m : `${m.mistake}${m.mitigation}`
)}
color="text-rose-400"
/>
{synthesis.risk_parameters && (
<>
<InsightList
title="Préférer quand"
icon={<Shield className="w-4 h-4" />}
items={synthesis.risk_parameters.prefer_when || []}
color="text-blue-400"
/>
<InsightList
title="Éviter quand"
icon={<XCircle className="w-4 h-4" />}
items={synthesis.risk_parameters.avoid_when || []}
color="text-rose-400"
/>
</>
)}
</div>
</div>
</>
)}
{/* Knowledge Base entries */}
{totalEntries > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-slate-400" />
<h2 className="text-sm font-semibold text-slate-300">Base de connaissances ({totalEntries} entrées)</h2>
</div>
<div className="space-y-3">
{Object.entries(byCategory).map(([cat, entries]: [string, any]) => (
<CategorySection
key={cat}
category={cat}
entries={entries}
onStatusChange={handleStatusChange}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

136
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,136 @@
export type AssetClass = 'energy' | 'metals' | 'agriculture' | 'equities' | 'indices' | 'forex' | 'crypto' | 'rates'
export type RiskLevel = 'low' | 'medium' | 'high' | 'extreme'
export type GeoCategory =
| 'military' | 'sanctions' | 'elections' | 'natural_disaster'
| 'health_crisis' | 'resource_scarcity' | 'trade_war'
| 'energy' | 'political_speech' | 'financial_crisis' | 'general'
export interface Quote {
symbol: string
name: string
price: number
change: number
change_pct: number
volume: number
iv?: number
asset_class: AssetClass
timestamp: string
error?: string
}
export interface GeoNews {
id: string
title: string
summary: string
source: string
category: GeoCategory
impact_score: number
asset_impacts: Partial<Record<AssetClass, number>>
date: string
tags: string[]
url: string
}
export interface GeoRiskScore {
score: number
level: RiskLevel
breakdown: Record<string, number>
top_risks: [string, number][]
}
export interface GeoPattern {
id: string
name: string
description: string
triggers: string[]
keywords: string[]
historical_instances: Array<{date: string; event: string; [key: string]: unknown}>
suggested_trades: Array<{strategy: string; underlying: string; rationale: string}>
asset_class: AssetClass
expected_move_pct: number
probability: number
horizon_days: number
}
export interface PatternMatch extends GeoPattern {
pattern_id: string
similarity: number
}
export interface TradeIdea {
id: string
title: string
rationale: string
pattern: string
asset_class: AssetClass
underlying: string
strategy: string
expected_move_pct: number
confidence: number
horizon_days: number
capital_required: number
risk_level: RiskLevel
pattern_similarity: number
geo_trigger?: string
expiry_days?: number
max_loss_eur?: number
target_gain_eur?: number
strike_guidance?: string
timing?: string
invalidation?: string
rank?: number
}
export interface OptionPrice {
price: number
delta: number
gamma: number
theta: number
vega: number
rho: number
underlying_price: number
sigma: number
}
export interface PnLPoint {
underlying: number
pnl: number
}
export interface BacktestResult {
symbol: string
strategy: string
period: string
total_trades: number
wins: number
losses: number
win_rate: number
total_pnl: number
total_return_pct: number
max_drawdown_pct: number
profit_factor: number
final_capital: number
equity_curve: Array<{index: number; capital: number}>
trades: Array<Record<string, unknown>>
error?: string
}
export interface EconomicEvent {
title: string
country: string
importance: 'low' | 'medium' | 'high'
date: string
asset_impact: AssetClass[]
previous?: string
forecast?: string
actual?: string
}
export interface HistoricalCandle {
date: string
open: number
high: number
low: number
close: number
volume: number
}