feat: saxo connector
This commit is contained in:
@@ -86,6 +86,19 @@ def snapshot_now(symbol: str):
|
|||||||
return {"symbol": symbol.upper(), "rows_saved": len(rows)}
|
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")
|
@router.get("/history")
|
||||||
def history(
|
def history(
|
||||||
symbol: Optional[str] = Query(None),
|
symbol: Optional[str] = Query(None),
|
||||||
|
|||||||
@@ -114,16 +114,17 @@ def _first(d: Dict[str, Any], *keys: str) -> Any:
|
|||||||
return None
|
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
|
"""Full matched instrument (Uic + Symbol/Description/AssetType) — surfaced by /validate so
|
||||||
a wrong-ticker guess is visibly distinguishable from an account-entitlement error."""
|
a wrong-ticker guess is visibly distinguishable from an account-entitlement error."""
|
||||||
if symbol in _root_uic_cache:
|
cache_key = f"{symbol}|{asset_types}"
|
||||||
return _root_uic_cache[symbol]
|
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 []
|
items = data.get("Data") or data.get("data") or []
|
||||||
if not items:
|
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]
|
item = items[0]
|
||||||
uic = _first(item, "Identifier", "Uic", "uic")
|
uic = _first(item, "Identifier", "Uic", "uic")
|
||||||
@@ -136,10 +137,43 @@ def resolve_instrument(symbol: str) -> Dict[str, Any]:
|
|||||||
"description": _first(item, "Description", "description"),
|
"description": _first(item, "Description", "description"),
|
||||||
"asset_type": _first(item, "AssetType", "assetType"),
|
"asset_type": _first(item, "AssetType", "assetType"),
|
||||||
}
|
}
|
||||||
_root_uic_cache[symbol] = result
|
_root_uic_cache[cache_key] = result
|
||||||
return 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:
|
def resolve_option_root_uic(symbol: str) -> int:
|
||||||
return resolve_instrument(symbol)["uic"]
|
return resolve_instrument(symbol)["uic"]
|
||||||
|
|
||||||
|
|||||||
@@ -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<SaxoQuote>(`/saxo/quote/${encodeURIComponent(symbol)}`, { params: assetType ? { asset_type: assetType } : {} }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|||||||
@@ -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, 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 { 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'
|
||||||
|
|
||||||
@@ -433,6 +433,9 @@ function SaxoConnectionCard() {
|
|||||||
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)
|
const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined)
|
||||||
|
const testQuote = useTestSaxoQuote()
|
||||||
|
const [quoteSymbol, setQuoteSymbol] = useState('EURUSD')
|
||||||
|
const [quoteAssetType, setQuoteAssetType] = useState('FxSpot')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
@@ -584,6 +587,48 @@ function SaxoConnectionCard() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
|
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-700/30">
|
||||||
|
<div className="text-xs text-slate-500 mb-2">
|
||||||
|
Tester un prix hors options (isole si un blocage est spécifique aux options ou plus large) :
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={quoteSymbol}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={quoteAssetType}
|
||||||
|
onChange={e => setQuoteAssetType(e.target.value)}
|
||||||
|
className="bg-dark-800 border border-slate-700/40 rounded px-2 py-1.5 text-xs text-white"
|
||||||
|
>
|
||||||
|
<option value="FxSpot">FxSpot</option>
|
||||||
|
<option value="Stock">Stock</option>
|
||||||
|
<option value="StockIndex">StockIndex</option>
|
||||||
|
<option value="ContractFutures">ContractFutures</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => testQuote.mutate({ symbol: quoteSymbol, assetType: quoteAssetType })}
|
||||||
|
disabled={!status?.connected || !quoteSymbol.trim() || testQuote.isPending}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('w-3.5 h-3.5', testQuote.isPending && 'animate-spin')} /> Tester
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{testQuote.isSuccess && (
|
||||||
|
<div className="text-xs text-emerald-400 mt-2">
|
||||||
|
✓ {testQuote.data.symbol} ({testQuote.data.description}) — Bid {testQuote.data.bid} / Ask {testQuote.data.ask}
|
||||||
|
<span className="text-slate-600"> · {testQuote.data.price_source}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testQuote.isError && (
|
||||||
|
<div className="text-xs text-red-400 mt-2">
|
||||||
|
✗ {(testQuote.error as any)?.response?.data?.detail ?? 'échec'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user