215 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|