Saxo connector
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user