feat: saxo

This commit is contained in:
OpenSquared
2026-07-19 00:55:46 +02:00
parent 38d9ddfd53
commit d4fb15ca9a
8 changed files with 205 additions and 29 deletions

View File

@@ -1738,7 +1738,18 @@ export type SaxoSnapshotRow = {
expiry_date: string; strike: number; option_type: 'call' | 'put'
bid: number | null; ask: number | null; mid: number | null; volatility_pct: number | null
delta: number | null; gamma: number | null; theta: number | null; vega: number | null
created_at: string
is_synthetic?: number; created_at: string
}
export const useDedupeSaxoHistory = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/saxo/history/dedupe').then(r => r.data as { rows_deleted: number }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['saxo-history'] })
qc.invalidateQueries({ queryKey: ['saxo-symbols'] })
},
})
}
export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) =>

View File

@@ -1,12 +1,21 @@
import { useMemo, useState } from 'react'
import { History, RefreshCw } from 'lucide-react'
import { History, RefreshCw, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { useSaxoSymbols, useSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi'
import { useSaxoSymbols, useSaxoHistory, useDedupeSaxoHistory, 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)
}
function daysAgo(n: number) {
const d = new Date()
d.setDate(d.getDate() - n)
@@ -20,6 +29,14 @@ export default function SaxoHistory() {
const [dateTo, setDateTo] = useState('')
const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, dateFrom || undefined, dateTo || undefined)
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 grouped = useMemo(() => {
const groups: { date: string; rows: SaxoSnapshotRow[] }[] = []
@@ -42,15 +59,27 @@ export default function SaxoHistory() {
Accumulé depuis la connexion Saxo pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes.
</p>
</div>
<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 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>
@@ -131,9 +160,14 @@ export default function SaxoHistory() {
<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">{fmt(r.bid)}</td>
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.ask)}</td>
<td className="py-1 pr-3 text-right text-white font-semibold">{fmt(r.mid)}</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>

View File

@@ -478,8 +478,8 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS
<th className="py-1 pr-3">Jambes</th>
<th className="py-1 pr-3 text-right">Score</th>
<th className="py-1 pr-3 text-right">P&amp;L net</th>
<th className="py-1 pr-3 text-right">Max gain</th>
<th className="py-1 pr-3 text-right">Max perte</th>
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario">Max gain</th>
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario">Max perte</th>
<th className="py-1 pr-3 text-right">Δ net</th>
<th className="py-1 pr-1"></th>
</tr>
@@ -777,8 +777,8 @@ export default function StrategyBuilder() {
<div className="stat-label">Coût spread broker</div>
<div className="text-lg font-bold text-amber-400">{fmtMoney(priced.broker_spread_cost)}</div>
</div>
<div className="card-sm">
<div className="stat-label">Max gain / Max perte</div>
<div className="card-sm" title="Borne théorique à l'échéance de la jambe la plus proche, sous la même vue de volatilité que votre scénario. Pour les structures mono-échéance (condor, butterfly...), c'est un calcul purement intrinsèque, indépendant de la vol. Le P&L net scénario est une valorisation avant échéance (mark-to-market) : sur une structure vendeuse de vega, un choc de vol important dans le scénario peut transitoirement le faire sortir de cette fourchette — risque vega réel, pas un bug.">
<div className="stat-label">Max gain / Max perte (à échéance)</div>
<div className="text-sm font-semibold">
<span className="text-emerald-400">{priced.max_gain != null ? fmtMoney(priced.max_gain) : ''}</span>
<span className="text-slate-500"> / </span>