feat: saxo
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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&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>
|
||||
|
||||
Reference in New Issue
Block a user