feat: saxo connector

This commit is contained in:
OpenSquared
2026-07-18 19:44:32 +02:00
parent 3cd453906e
commit dec5da37b1
6 changed files with 174 additions and 7 deletions

Binary file not shown.

View File

@@ -1,11 +1,14 @@
from typing import List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from services import saxo_auth from services import saxo_auth
from services.saxo_scheduler import get_watchlist, set_watchlist from services.saxo_scheduler import get_watchlist, set_watchlist
from services.database import get_saxo_snapshots, get_saxo_snapshot_symbols from services.database import (
get_saxo_snapshots, get_saxo_snapshot_symbols,
get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows,
)
router = APIRouter(prefix="/api/saxo", tags=["saxo"]) router = APIRouter(prefix="/api/saxo", tags=["saxo"])
@@ -90,3 +93,44 @@ def history(
date_to: Optional[str] = Query(None), date_to: Optional[str] = Query(None),
): ):
return get_saxo_snapshots(symbol, date_from, date_to) return get_saxo_snapshots(symbol, date_from, date_to)
class CatalogRefreshRequest(BaseModel):
asset_types: Optional[List[str]] = None # default: CATALOG_ASSET_TYPES (FuturesOption, FxVanillaOption)
@router.post("/catalog/refresh")
def refresh_catalog(req: CatalogRefreshRequest):
from services.saxo_client import list_instruments, CATALOG_ASSET_TYPES, SaxoNotConnected, SaxoApiError
asset_types = req.asset_types or CATALOG_ASSET_TYPES
counts: Dict[str, int] = {}
try:
for asset_type in asset_types:
items = list_instruments(asset_type)
rows = [{
"uic": item.get("Identifier") or item.get("Uic"),
"symbol": item.get("Symbol"),
"asset_type": asset_type,
"description": item.get("Description"),
} for item in items if item.get("Identifier") or item.get("Uic")]
upsert_saxo_catalog_rows(rows)
counts[asset_type] = len(rows)
except SaxoNotConnected as e:
raise HTTPException(status_code=401, detail=str(e))
except SaxoApiError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"refreshed": counts}
@router.get("/catalog")
def catalog(
asset_type: Optional[str] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(200),
):
return get_saxo_catalog(asset_type, q, limit)
@router.get("/catalog/summary")
def catalog_summary():
return get_saxo_catalog_summary()

View File

@@ -74,6 +74,16 @@ def init_db():
)""") )""")
c.execute("CREATE INDEX IF NOT EXISTS idx_saxo_snap_symbol_date ON saxo_option_snapshots(symbol, snapshot_date)") c.execute("CREATE INDEX IF NOT EXISTS idx_saxo_snap_symbol_date ON saxo_option_snapshots(symbol, snapshot_date)")
c.execute("""CREATE TABLE IF NOT EXISTS saxo_instrument_catalog (
uic INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
asset_type TEXT NOT NULL,
description TEXT,
updated_at TEXT DEFAULT (datetime('now'))
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_saxo_catalog_type ON saxo_instrument_catalog(asset_type)")
c.execute("CREATE INDEX IF NOT EXISTS idx_saxo_catalog_symbol ON saxo_instrument_catalog(symbol)")
c.execute("""CREATE TABLE IF NOT EXISTS strategy_scenarios ( c.execute("""CREATE TABLE IF NOT EXISTS strategy_scenarios (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
@@ -6126,3 +6136,44 @@ def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]:
""").fetchall() """).fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def upsert_saxo_catalog_rows(rows: List[Dict[str, Any]]):
if not rows:
return
conn = get_conn()
conn.executemany("""INSERT INTO saxo_instrument_catalog (uic, symbol, asset_type, description, updated_at)
VALUES (?,?,?,?, datetime('now'))
ON CONFLICT(uic) DO UPDATE SET
symbol=excluded.symbol, asset_type=excluded.asset_type,
description=excluded.description, updated_at=datetime('now')""",
[(r["uic"], r["symbol"], r["asset_type"], r.get("description")) for r in rows])
conn.commit()
conn.close()
def get_saxo_catalog(asset_type: Optional[str] = None, q: Optional[str] = None, limit: int = 200) -> List[Dict[str, Any]]:
conn = get_conn()
query = "SELECT * FROM saxo_instrument_catalog WHERE 1=1"
params: List[Any] = []
if asset_type:
query += " AND asset_type=?"
params.append(asset_type)
if q:
query += " AND (symbol LIKE ? OR description LIKE ?)"
params.extend([f"%{q}%", f"%{q}%"])
query += " ORDER BY symbol LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_saxo_catalog_summary() -> List[Dict[str, Any]]:
conn = get_conn()
rows = conn.execute("""
SELECT asset_type, COUNT(*) AS count, MAX(updated_at) AS last_refreshed
FROM saxo_instrument_catalog GROUP BY asset_type ORDER BY asset_type
""").fetchall()
conn.close()
return [dict(r) for r in rows]

View File

@@ -24,7 +24,11 @@ from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption" _OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOption"
# Bounded, stable catalogs worth fully caching in our own DB (StockOption/StockIndexOption
# are far too large to bulk-fetch — those stay resolved on demand via Keywords search).
CATALOG_ASSET_TYPES = ["FuturesOption", "FxVanillaOption"]
# symbol -> resolved instrument details, cheap in-process cache (roots don't change within a session) # symbol -> resolved instrument details, cheap in-process cache (roots don't change within a session)
_root_uic_cache: Dict[str, Dict[str, Any]] = {} _root_uic_cache: Dict[str, Dict[str, Any]] = {}
@@ -67,6 +71,25 @@ def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return resp.json() return resp.json()
def list_instruments(asset_types: str, keywords: Optional[str] = None, max_pages: int = 20) -> List[Dict[str, Any]]:
"""Paginated dump of /ref/v1/instruments for a given AssetTypes filter — used to build
our own local catalog for bounded, stable asset classes (FuturesOption/FxVanillaOption)."""
all_items: List[Dict[str, Any]] = []
top = 1000
skip = 0
for _ in range(max_pages):
params: Dict[str, Any] = {"AssetTypes": asset_types, "$top": top, "$skip": skip}
if keywords:
params["Keywords"] = keywords
data = _get("/ref/v1/instruments", params)
items = data.get("Data") or data.get("data") or []
all_items.extend(items)
if len(items) < top:
break
skip += top
return all_items
def get_default_account_key() -> str: def get_default_account_key() -> str:
"""Most /trade/v1 subscriptions (including the options chain) require AccountKey in """Most /trade/v1 subscriptions (including the options chain) require AccountKey in
Arguments — /port/v1/accounts/me is the standard way to get the user's default account.""" Arguments — /port/v1/accounts/me is the standard way to get the user's default account."""
@@ -103,7 +126,7 @@ def resolve_instrument(symbol: str) -> Dict[str, Any]:
raise ValueError(f"Aucun option root Saxo trouvé pour '{symbol}' (réponse: {list(data.keys())})") raise ValueError(f"Aucun option root Saxo trouvé pour '{symbol}' (réponse: {list(data.keys())})")
item = items[0] item = items[0]
uic = _first(item, "Uic", "uic", "Identifier") uic = _first(item, "Identifier", "Uic", "uic")
if uic is None: if uic is None:
raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {item}") raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {item}")

View File

@@ -1741,3 +1741,30 @@ export const useValidateSaxoWatchlist = () =>
queryFn: () => api.get('/saxo/validate').then(r => r.data), queryFn: () => api.get('/saxo/validate').then(r => r.data),
enabled: false, enabled: false,
}) })
export type SaxoCatalogEntry = { uic: number; symbol: string; asset_type: string; description: string; updated_at: string }
export type SaxoCatalogSummary = { asset_type: string; count: number; last_refreshed: string }
export const useSaxoCatalog = (assetType?: string, q?: string) =>
useQuery<SaxoCatalogEntry[]>({
queryKey: ['saxo-catalog', assetType, q],
queryFn: () => api.get('/saxo/catalog', { params: { ...(assetType ? { asset_type: assetType } : {}), ...(q ? { q } : {}) } }).then(r => r.data),
enabled: !!(assetType || q),
})
export const useSaxoCatalogSummary = () =>
useQuery<SaxoCatalogSummary[]>({
queryKey: ['saxo-catalog-summary'],
queryFn: () => api.get('/saxo/catalog/summary').then(r => r.data),
})
export const useRefreshSaxoCatalog = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (assetTypes?: string[]) => api.post('/saxo/catalog/refresh', { asset_types: assetTypes ?? null }).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['saxo-catalog'] })
qc.invalidateQueries({ queryKey: ['saxo-catalog-summary'] })
},
})
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, type CycleStepDef } from '../hooks/useApi' 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, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, 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, ShieldCheck, ExternalLink } from 'lucide-react' 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, ShieldCheck, ExternalLink } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -428,8 +428,11 @@ function SaxoConnectionCard() {
const updateWatchlist = useUpdateSaxoWatchlist() const updateWatchlist = useUpdateSaxoWatchlist()
const snapshotNow = useSnapshotSaxoNow() const snapshotNow = useSnapshotSaxoNow()
const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist() const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist()
const { data: catalogSummary } = useSaxoCatalogSummary()
const refreshCatalog = useRefreshSaxoCatalog()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [snapMsg, setSnapMsg] = useState('') const [snapMsg, setSnapMsg] = useState('')
const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined)
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -528,14 +531,33 @@ function SaxoConnectionCard() {
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] text-slate-600">
Catalogue local (Futures/FX — tickers Saxo non-évidents, ex. Or = <code>OG:xcme</code>) :{' '}
{(catalogSummary ?? []).map(s => `${s.asset_type} (${s.count})`).join(' · ') || 'vide'}
</div>
<button
onClick={() => refreshCatalog.mutate(undefined)}
disabled={!status?.connected || refreshCatalog.isPending}
className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-slate-200 border border-slate-700/50 px-2 py-1 rounded disabled:opacity-40"
>
<RefreshCw className={clsx('w-3 h-3', refreshCatalog.isPending && 'animate-spin')} /> Rafraîchir le catalogue
</button>
</div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<input <input
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addSymbol()} onKeyDown={e => e.key === 'Enter' && addSymbol()}
placeholder="Symbole (ex. SPY)" placeholder="Symbole (ex. SPY, ou tape pour chercher dans le catalogue)"
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" list="saxo-catalog-datalist"
className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-72 focus:outline-none focus:border-blue-500/50"
/> />
<datalist id="saxo-catalog-datalist">
{(catalogMatches ?? []).map(m => (
<option key={m.uic} value={m.symbol}>{m.description} ({m.asset_type})</option>
))}
</datalist>
<button onClick={addSymbol} disabled={!input.trim()} <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"> 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 <Plus className="w-3.5 h-3.5" /> Ajouter