diff --git a/backend/data/geooptions.db-journal b/backend/data/geooptions.db-journal deleted file mode 100644 index feff101..0000000 Binary files a/backend/data/geooptions.db-journal and /dev/null differ 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 => ( + + ))} +