diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index f58b8d7..4270f7f 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -86,6 +86,19 @@ def snapshot_now(symbol: str): return {"symbol": symbol.upper(), "rows_saved": len(rows)} +@router.get("/quote/{symbol}") +def quote(symbol: str, asset_type: str = Query("FxSpot")): + """Basic (non-options) price test — isolates whether NoDataAccess is options-specific + or a broader account restriction, independently of the options-chain subscription.""" + from services.saxo_client import get_price_quote, SaxoNotConnected, SaxoApiError + try: + return get_price_quote(symbol, asset_type=asset_type) + except SaxoNotConnected as e: + raise HTTPException(status_code=401, detail=str(e)) + except (ValueError, SaxoApiError) as e: + raise HTTPException(status_code=502, detail=str(e)) + + @router.get("/history") def history( symbol: Optional[str] = Query(None), diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 2508be6..0474409 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -114,16 +114,17 @@ def _first(d: Dict[str, Any], *keys: str) -> Any: return None -def resolve_instrument(symbol: str) -> Dict[str, Any]: +def resolve_instrument(symbol: str, asset_types: str = _OPTION_ASSET_TYPES) -> Dict[str, Any]: """Full matched instrument (Uic + Symbol/Description/AssetType) — surfaced by /validate so a wrong-ticker guess is visibly distinguishable from an account-entitlement error.""" - if symbol in _root_uic_cache: - return _root_uic_cache[symbol] + cache_key = f"{symbol}|{asset_types}" + if cache_key in _root_uic_cache: + return _root_uic_cache[cache_key] - data = _get("/ref/v1/instruments", {"Keywords": symbol, "AssetTypes": _OPTION_ASSET_TYPES}) + data = _get("/ref/v1/instruments", {"Keywords": symbol, "AssetTypes": asset_types}) items = data.get("Data") or data.get("data") or [] if not items: - raise ValueError(f"Aucun option root Saxo trouvé pour '{symbol}' (réponse: {list(data.keys())})") + raise ValueError(f"Aucun instrument Saxo trouvé pour '{symbol}' (AssetTypes={asset_types})") item = items[0] uic = _first(item, "Identifier", "Uic", "uic") @@ -136,10 +137,43 @@ def resolve_instrument(symbol: str) -> Dict[str, Any]: "description": _first(item, "Description", "description"), "asset_type": _first(item, "AssetType", "assetType"), } - _root_uic_cache[symbol] = result + _root_uic_cache[cache_key] = result return result +def get_price_quote(symbol: str, asset_type: str = "FxSpot", amount: int = 100000) -> Dict[str, Any]: + """ + Basic (non-options) price lookup via /trade/v1/infoprices/list — confirmed working shape + (AccountKey, Uics, AssetType, Amount, FieldGroups) straight from a real Saxo Explorer + response. Used to test plain market-data access independently of the options chain + (which returns NoDataAccess even though basic FxSpot/Stock prices work fine). + """ + instrument = resolve_instrument(symbol, asset_types=asset_type) + data = _get("/trade/v1/infoprices/list", { + "AccountKey": get_default_account_key(), + "Uics": instrument["uic"], + "AssetType": asset_type, + "Amount": amount, + "FieldGroups": "DisplayAndFormat,Quote", + }) + items = data.get("Data") or [] + if not items: + raise ValueError(f"Aucune cotation renvoyée pour '{symbol}' ({asset_type})") + row = items[0] + quote = row.get("Quote", {}) + display = row.get("DisplayAndFormat", {}) + return { + "symbol": instrument["symbol"], + "description": display.get("Description"), + "asset_type": asset_type, + "uic": instrument["uic"], + "bid": quote.get("Bid"), + "ask": quote.get("Ask"), + "last_updated": row.get("LastUpdated"), + "price_source": row.get("PriceSource"), + } + + def resolve_option_root_uic(symbol: str) -> int: return resolve_instrument(symbol)["uic"] diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index b84a82c..f564e07 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1768,3 +1768,14 @@ export const useRefreshSaxoCatalog = () => { }, }) } + +export type SaxoQuote = { + symbol: string; description: string | null; asset_type: string; uic: number + bid: number | null; ask: number | null; last_updated: string | null; price_source: string | null +} + +export const useTestSaxoQuote = () => + useMutation({ + mutationFn: ({ symbol, assetType }: { symbol: string; assetType?: string }) => + api.get(`/saxo/quote/${encodeURIComponent(symbol)}`, { params: assetType ? { asset_type: assetType } : {} }).then(r => r.data), + }) diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 6c32d79..6403f3c 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, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, 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, useTestSaxoQuote, 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' @@ -433,6 +433,9 @@ function SaxoConnectionCard() { const [input, setInput] = useState('') const [snapMsg, setSnapMsg] = useState('') const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined) + const testQuote = useTestSaxoQuote() + const [quoteSymbol, setQuoteSymbol] = useState('EURUSD') + const [quoteAssetType, setQuoteAssetType] = useState('FxSpot') useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -584,6 +587,48 @@ function SaxoConnectionCard() { })} {snapMsg &&
{snapMsg}
} + +
+
+ Tester un prix hors options (isole si un blocage est spécifique aux options ou plus large) : +
+
+ setQuoteSymbol(e.target.value.toUpperCase())} + placeholder="EURUSD" + className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-32 focus:outline-none focus:border-blue-500/50" + /> + + +
+ {testQuote.isSuccess && ( +
+ ✓ {testQuote.data.symbol} ({testQuote.data.description}) — Bid {testQuote.data.bid} / Ask {testQuote.data.ask} + · {testQuote.data.price_source} +
+ )} + {testQuote.isError && ( +
+ ✗ {(testQuote.error as any)?.response?.data?.detail ?? 'échec'} +
+ )} +
) }