Files
OpenFin/frontend/src/pages/Markets.tsx
OpenSquared d256b65d30 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>
2026-06-16 20:29:59 +02:00

354 lines
15 KiB
TypeScript

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