Files
OpenFin/frontend/src/pages/SaxoHistory.tsx
2026-07-19 08:49:57 +02:00

215 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react'
import { History, RefreshCw, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { useSaxoSymbols, useSaxoHistory, useDedupeSaxoHistory, useDeleteSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi'
function fmt(v: number | null | undefined, digits = 2) {
return v == null ? '—' : v.toFixed(digits)
}
// FX option premiums are a small fraction of the underlying (e.g. 0.0050 on a 1.15
// EURUSD) — 2 decimals rounds every real value to "0.00", indistinguishable from an
// actually-empty quote. Scale decimals to the premium's own magnitude instead.
function fmtPrice(v: number | null | undefined) {
if (v == null) return '—'
const digits = v !== 0 && Math.abs(v) < 1 ? 5 : 2
return v.toFixed(digits)
}
// <input type="datetime-local"> has no timezone suffix, so the browser parses it as local
// time — reformat to the same "YYYY-MM-DD HH:MM:SS" UTC shape SQLite's datetime('now')
// writes into created_at, so a plain string comparison on the backend lines up correctly.
function toSqliteUtc(datetimeLocal: string): string {
return new Date(datetimeLocal).toISOString().slice(0, 19).replace('T', ' ')
}
export default function SaxoHistory() {
const { data: symbolSummaries = [] } = useSaxoSymbols()
const [symbol, setSymbol] = useState<string>('')
const [asOfLocal, setAsOfLocal] = useState<string>('')
const asOf = asOfLocal ? toSqliteUtc(asOfLocal) : undefined
const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, asOf)
const dedupe = useDedupeSaxoHistory()
const [dedupeMsg, setDedupeMsg] = useState<string | null>(null)
const handleDedupe = () => {
dedupe.mutate(undefined, {
onSuccess: (data) => setDedupeMsg(`${data.rows_deleted} ligne(s) redondante(s) supprimée(s).`),
})
}
const deleteHistory = useDeleteSaxoHistory()
const handleDelete = () => {
if (!symbol) return
if (!window.confirm(`Effacer tout l'historique de snapshots de "${symbol}" ? Cette action est irréversible (les données ne seront plus disponibles pour les backtests).`)) return
deleteHistory.mutate(symbol, {
onSuccess: (data) => {
setDedupeMsg(`${data.rows_deleted} ligne(s) supprimée(s) pour ${data.symbol}.`)
setSymbol('')
},
})
}
const grouped = useMemo(() => {
const groups: { key: string; symbol: string; expiry: string; rows: SaxoSnapshotRow[] }[] = []
for (const r of rows) {
const key = `${r.symbol}||${r.expiry_date}`
const last = groups[groups.length - 1]
if (last && last.key === key) last.rows.push(r)
else groups.push({ key, symbol: r.symbol, expiry: r.expiry_date, rows: [r] })
}
return groups
}, [rows])
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">
<History className="w-5 h-5 text-blue-400" /> Saxo Historique des snapshots
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Une ligne par contrat (ticker/maturité/strike/type) la capture la plus récente, ou celle en vigueur à la date/heure choisie ci-dessous.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDedupe}
disabled={dedupe.isPending}
title="Supprime les lignes dont bid/ask/mid/IV n'ont pas bougé depuis la capture précédente"
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
{dedupe.isPending ? 'Nettoyage...' : 'Nettoyer les doublons'}
</button>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
{isFetching ? 'Chargement...' : 'Rafraîchir'}
</button>
</div>
</div>
{dedupeMsg && <div className="text-xs text-emerald-400">{dedupeMsg}</div>}
<div className="card flex flex-wrap items-end gap-4">
<div>
<label className="stat-label block mb-1">Symbole</label>
<select
value={symbol}
onChange={e => setSymbol(e.target.value)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white w-40"
>
<option value="">Tous</option>
{symbolSummaries.map(s => <option key={s.symbol} value={s.symbol}>{s.symbol}</option>)}
</select>
</div>
<div>
<label className="stat-label block mb-1">Voir au (vide = actuel)</label>
<div className="flex items-center gap-1.5">
<input type="datetime-local" value={asOfLocal} onChange={e => setAsOfLocal(e.target.value)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
{asOfLocal && (
<button onClick={() => setAsOfLocal('')} title="Revenir à l'actuel" className="text-slate-500 hover:text-slate-300">
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
<button
onClick={handleDelete}
disabled={!symbol || deleteHistory.isPending}
title={symbol ? `Effacer tout l'historique de ${symbol}` : 'Choisissez un symbole précis pour effacer son historique'}
className="flex items-center gap-1.5 text-xs border border-red-700/50 text-red-400 hover:text-red-300 hover:border-red-500 px-3 py-1.5 rounded transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trash2 className="w-3.5 h-3.5" />
{deleteHistory.isPending ? 'Suppression...' : `Effacer l'historique${symbol ? ` de ${symbol}` : ''}`}
</button>
<div className="text-xs text-slate-500 ml-auto">{rows.length} contrat(s)</div>
</div>
{symbolSummaries.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{symbolSummaries.map(s => (
<button
key={s.symbol}
onClick={() => setSymbol(s.symbol === symbol ? '' : s.symbol)}
className={clsx('card-sm text-left transition-colors', s.symbol === symbol && 'border-blue-500/60')}
>
<div className="text-sm font-bold text-white">{s.symbol}</div>
<div className="text-xs text-slate-500">{s.rows_count} lignes · {s.first_date} {s.last_date}</div>
</button>
))}
</div>
)}
{isLoading ? (
<div className="card-sm text-xs text-slate-500">Chargement</div>
) : rows.length === 0 ? (
<div className="card-sm text-xs text-slate-500">Aucun snapshot enregistré pour ces filtres.</div>
) : (
<div className="card overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 text-left sticky top-0 bg-dark-800">
<th className="py-1 pr-3">Symbole</th>
<th className="py-1 pr-3">Expiry</th>
<th className="py-1 pr-3 text-right">Strike</th>
<th className="py-1 pr-3">Type</th>
<th className="py-1 pr-3 text-right">Spot</th>
<th className="py-1 pr-3 text-right">Bid</th>
<th className="py-1 pr-3 text-right">Ask</th>
<th className="py-1 pr-3 text-right">Mid</th>
<th className="py-1 pr-3 text-right">IV%</th>
<th className="py-1 pr-3 text-right">Delta</th>
<th className="py-1 pr-3 text-right">Gamma</th>
<th className="py-1 pr-3 text-right">Theta</th>
<th className="py-1 pr-3 text-right">Vega</th>
<th className="py-1 pr-3">Capturé le</th>
</tr>
</thead>
<tbody>
{grouped.map(group => (
<>
<tr key={`h-${group.key}`} className="border-t border-slate-700/40">
<td colSpan={14} className="py-1.5 pr-3 text-slate-300 font-semibold bg-dark-700/30">{group.symbol} {group.expiry}</td>
</tr>
{group.rows.map(r => (
<tr key={r.id} className="border-t border-slate-800/60 hover:bg-dark-700/30">
<td className="py-1 pr-3 text-slate-300">{r.symbol}</td>
<td className="py-1 pr-3 text-slate-400">{r.expiry_date}</td>
<td className="py-1 pr-3 text-right text-slate-300">{r.strike}</td>
<td className="py-1 pr-3">
<span className={clsx('badge', r.option_type === 'call' ? 'badge-green' : 'badge-red')}>{r.option_type}</span>
</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.spot)}</td>
<td className="py-1 pr-3 text-right text-slate-300">{fmtPrice(r.bid)}</td>
<td className="py-1 pr-3 text-right text-slate-300">{fmtPrice(r.ask)}</td>
<td className="py-1 pr-3 text-right text-white font-semibold">
{fmtPrice(r.mid)}
{!!r.is_synthetic && (
<span className="ml-1 text-amber-400" title="Bid/Ask non fournis par Saxo (marché fermé/illiquide) — reconstruit théoriquement (Black-Scholes) à partir de l'IV">*</span>
)}
</td>
{/* Saxo's field is already named "...Pct" (MidVolatilityPct) — assumed pre-scaled, not a 0-1 fraction */}
<td className="py-1 pr-3 text-right text-amber-400">{r.volatility_pct != null ? `${r.volatility_pct.toFixed(1)}%` : '—'}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.delta, 4)}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.gamma, 4)}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.theta, 4)}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.vega, 4)}</td>
<td className="py-1 pr-3 text-slate-500">{r.created_at}</td>
</tr>
))}
</>
))}
</tbody>
</table>
</div>
)}
</div>
)
}