Saxo connector

This commit is contained in:
OpenSquared
2026-07-18 17:14:13 +02:00
parent 91054979ec
commit bff2f70781
10 changed files with 826 additions and 2 deletions

View File

@@ -1664,3 +1664,59 @@ export const useDeleteSavedStrategy = () => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-strategies'] }),
})
}
// ── Saxo connection ───────────────────────────────────────────────────────────
export type SaxoStatus = {
connected: boolean; configured: boolean; environment: string
expires_at?: string; refresh_expires_at?: string
}
export const useSaxoStatus = () =>
useQuery<SaxoStatus>({
queryKey: ['saxo-status'],
queryFn: () => api.get('/saxo/status').then(r => r.data),
refetchInterval: 60_000,
})
export const useDisconnectSaxo = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/saxo/disconnect').then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saxo-status'] }),
})
}
export const useSaxoWatchlist = () =>
useQuery<{ symbols: string[] }>({
queryKey: ['saxo-watchlist'],
queryFn: () => api.get('/saxo/watchlist').then(r => r.data),
})
export const useUpdateSaxoWatchlist = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.put('/saxo/watchlist', { symbols }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saxo-watchlist'] }),
})
}
export const useSnapshotSaxoNow = () =>
useMutation({
mutationFn: (symbol: string) => api.post(`/saxo/snapshot-now/${encodeURIComponent(symbol)}`).then(r => r.data),
})
export type SaxoSnapshotRow = {
id: string; symbol: string; snapshot_date: string; spot: number | null
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
}
export const useSaxoHistory = (symbol?: string) =>
useQuery<SaxoSnapshotRow[]>({
queryKey: ['saxo-history', symbol],
queryFn: () => api.get('/saxo/history', { params: symbol ? { symbol } : {} }).then(r => r.data),
enabled: !!symbol,
})

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, type CycleStepDef } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar } from 'lucide-react'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, type CycleStepDef } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera } from 'lucide-react'
import clsx from 'clsx'
const API = ''
@@ -409,6 +409,137 @@ function WatchlistCard() {
)
}
// ── Saxo OpenAPI connection ────────────────────────────────────────────────────
function timeUntil(iso?: string): string {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (ms <= 0) return 'expiré'
const min = Math.round(ms / 60000)
return min < 60 ? `${min} min` : `${(min / 60).toFixed(1)} h`
}
function SaxoConnectionCard() {
const qc = useQueryClient()
const { data: status } = useSaxoStatus()
const { data: watchlistData } = useSaxoWatchlist()
const disconnect = useDisconnectSaxo()
const updateWatchlist = useUpdateSaxoWatchlist()
const snapshotNow = useSnapshotSaxoNow()
const [input, setInput] = useState('')
const [snapMsg, setSnapMsg] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('saxo_connected') || params.has('saxo_error')) {
qc.invalidateQueries({ queryKey: ['saxo-status'] })
setSnapMsg(params.has('saxo_connected') ? '✓ Connecté à Saxo' : "✗ Échec de la connexion Saxo — vérifiez l'App Secret dans backend/.env")
params.delete('saxo_connected'); params.delete('saxo_error')
const qs = params.toString()
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const symbols = watchlistData?.symbols ?? []
const addSymbol = () => {
const sym = input.trim().toUpperCase()
if (!sym || symbols.includes(sym)) return
updateWatchlist.mutate([...symbols, sym])
setInput('')
}
const removeSymbol = (sym: string) => updateWatchlist.mutate(symbols.filter(s => s !== sym))
const handleSnapshotNow = async (sym: string) => {
setSnapMsg('')
try {
const r = await snapshotNow.mutateAsync(sym)
setSnapMsg(`${sym}${r.rows_saved} lignes enregistrées`)
} catch (e: any) {
setSnapMsg(`${sym}${e?.response?.data?.detail ?? 'échec'}`)
}
}
return (
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="section-title mb-0 flex items-center gap-1">
<Link2 className="w-3 h-3" /> Saxo OpenAPI (SIM)
</div>
<span className={clsx('badge', status?.connected ? 'badge-green' : 'badge-red')}>
{status?.connected ? 'Connecté' : 'Déconnecté'}
</span>
</div>
{!status?.configured && (
<div className="text-xs text-amber-400 bg-amber-900/10 border border-amber-700/30 rounded px-3 py-2 mb-3">
SAXO_APP_KEY / SAXO_APP_SECRET manquants dans backend/.env ajoutez-les puis redémarrez le backend.
</div>
)}
{status?.connected && (
<div className="text-xs text-slate-500 mb-3 space-y-0.5">
<div>Token valide encore <span className="text-slate-300">{timeUntil(status.expires_at)}</span></div>
<div>Rafraîchi automatiquement en tâche de fond pas besoin de te reconnecter.</div>
</div>
)}
<div className="flex items-center gap-2 mb-4">
{status?.connected ? (
<button
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
className="flex items-center gap-1.5 bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-3 py-1.5 rounded text-xs font-semibold disabled:opacity-40"
>
<Unlink className="w-3.5 h-3.5" /> Déconnecter
</button>
) : (
<a
href="/oauth/saxo/login"
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold text-white',
status?.configured ? 'bg-blue-600 hover:bg-blue-500' : 'bg-dark-700 opacity-40 pointer-events-none',
)}
>
<Link2 className="w-3.5 h-3.5" /> Se connecter à Saxo
</a>
)}
</div>
<div className="text-xs text-slate-500 mb-2">
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
</div>
<div className="flex items-center gap-2 mb-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addSymbol()}
placeholder="Symbole (ex. SPY)"
className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-40 focus:outline-none focus:border-blue-500/50"
/>
<button onClick={addSymbol} disabled={!input.trim()}
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-40">
<Plus className="w-3.5 h-3.5" /> Ajouter
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{symbols.map(sym => (
<span key={sym} className="badge-blue flex items-center gap-1.5">
{sym}
<button onClick={() => handleSnapshotNow(sym)} title="Snapshot maintenant" className="hover:text-white">
<Camera className="w-3 h-3" />
</button>
<button onClick={() => removeSymbol(sym)}><X className="w-3 h-3" /></button>
</span>
))}
</div>
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
</div>
)
}
// ── Cycle step config — generic renderer driven by GET /api/cycle/step-catalog ─
// Same declarative pattern as AI Desks' SIGNAL_CATALOG/SignalToggle: adding a new
// knob to CYCLE_STEP_CATALOG (backend) needs zero new frontend code here.
@@ -1355,6 +1486,8 @@ export default function Config() {
</button>
</div>
<SaxoConnectionCard />
{/* RSS / data sources */}
{isLoading ? (
<div className="card animate-pulse h-40 bg-dark-700" />