From dec5da37b10336b3711e4d12ad651872c6e1fd8d Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sat, 18 Jul 2026 19:44:32 +0200 Subject: [PATCH] feat: saxo connector --- backend/data/geooptions.db-journal | Bin 33344 -> 0 bytes backend/routers/saxo.py | 48 +++++++++++++++++++++++++-- backend/services/database.py | 51 +++++++++++++++++++++++++++++ backend/services/saxo_client.py | 27 +++++++++++++-- frontend/src/hooks/useApi.ts | 27 +++++++++++++++ frontend/src/pages/Config.tsx | 28 ++++++++++++++-- 6 files changed, 174 insertions(+), 7 deletions(-) delete mode 100644 backend/data/geooptions.db-journal diff --git a/backend/data/geooptions.db-journal b/backend/data/geooptions.db-journal deleted file mode 100644 index feff101913cce8b634479764e9f0995deda55d7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33344 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 diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index e477535..f58b8d7 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -1,11 +1,14 @@ -from typing import List, Optional +from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from services import saxo_auth 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"]) @@ -90,3 +93,44 @@ def history( date_to: Optional[str] = Query(None), ): 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() diff --git a/backend/services/database.py b/backend/services/database.py index 4b07539..bf02305 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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 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 ( id TEXT PRIMARY KEY, symbol TEXT NOT NULL, @@ -6126,3 +6136,44 @@ def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]: """).fetchall() conn.close() 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] diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 68390e7..2508be6 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -24,7 +24,11 @@ from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token 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) _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() +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: """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.""" @@ -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())})") item = items[0] - uic = _first(item, "Uic", "uic", "Identifier") + uic = _first(item, "Identifier", "Uic", "uic") if uic is None: raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {item}") diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 9dc32a4..b84a82c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1741,3 +1741,30 @@ export const useValidateSaxoWatchlist = () => queryFn: () => api.get('/saxo/validate').then(r => r.data), 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({ + 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({ + 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'] }) + }, + }) +} diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 6746116..6c32d79 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' 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 clsx from 'clsx' @@ -428,8 +428,11 @@ function SaxoConnectionCard() { const updateWatchlist = useUpdateSaxoWatchlist() const snapshotNow = useSnapshotSaxoNow() const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist() + const { data: catalogSummary } = useSaxoCatalogSummary() + const refreshCatalog = useRefreshSaxoCatalog() const [input, setInput] = useState('') const [snapMsg, setSnapMsg] = useState('') + const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined) useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -528,14 +531,33 @@ function SaxoConnectionCard() { +
+
+ Catalogue local (Futures/FX — tickers Saxo non-évidents, ex. Or = OG:xcme) :{' '} + {(catalogSummary ?? []).map(s => `${s.asset_type} (${s.count})`).join(' · ') || 'vide'} +
+ +
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" + placeholder="Symbole (ex. SPY, ou tape pour chercher dans le catalogue)" + 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" /> + + {(catalogMatches ?? []).map(m => ( + + ))} +