feat: saxo connector
This commit is contained in:
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user