diff --git a/backend/data/geooptions.db-journal b/backend/data/geooptions.db-journal new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index 4270f7f..6e6c5a4 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -4,7 +4,7 @@ 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.saxo_scheduler import get_watchlist, set_watchlist, get_snapshot_minutes, set_snapshot_minutes, run_snapshot_pass from services.database import ( get_saxo_snapshots, get_saxo_snapshot_symbols, get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows, @@ -72,6 +72,29 @@ def symbols_with_history(): return get_saxo_snapshot_symbols() +class SettingsRequest(BaseModel): + snapshot_minutes: float + + +@router.get("/settings") +def get_settings(): + return {"snapshot_minutes": get_snapshot_minutes()} + + +@router.put("/settings") +def update_settings(req: SettingsRequest): + set_snapshot_minutes(req.snapshot_minutes) + return {"snapshot_minutes": get_snapshot_minutes()} + + +@router.post("/snapshot-now") +def snapshot_now_all(): + """Manual immediate refresh of the whole watchlist (doesn't wait for the periodic poll).""" + if not saxo_auth.get_status().get("connected"): + raise HTTPException(status_code=401, detail="Saxo n'est pas connecté") + return {"results": run_snapshot_pass()} + + @router.post("/snapshot-now/{symbol}") def snapshot_now(symbol: str): from services.saxo_client import snapshot_options_chain, SaxoNotConnected, SaxoApiError diff --git a/backend/routers/saxo_oauth.py b/backend/routers/saxo_oauth.py index eb353bf..85af769 100644 --- a/backend/routers/saxo_oauth.py +++ b/backend/routers/saxo_oauth.py @@ -30,6 +30,13 @@ def saxo_callback(code: str = Query(...), state: str = Query(...)): except Exception as e: logger.error(f"[Saxo OAuth] Token exchange failed: {e}") return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_error=1") + + # Snapshot the whole watchlist right away — don't make the user wait for the next + # scheduled poll after connecting. Runs in the background so the redirect isn't delayed. + import threading + from services.saxo_scheduler import run_snapshot_pass + threading.Thread(target=run_snapshot_pass, daemon=True, name="saxo-connect-snapshot").start() + return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_connected=1") diff --git a/backend/services/option_chain.py b/backend/services/option_chain.py index c550517..964e53c 100644 --- a/backend/services/option_chain.py +++ b/backend/services/option_chain.py @@ -41,6 +41,32 @@ def _rows_from_df(df) -> List[Dict[str, Any]]: def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]: + """ + Fetch the real option chain for `symbol` — yfinance by default, with an automatic + Saxo fallback for instruments yfinance can't handle (FX/futures options). + + Dispatch: a Saxo-formatted symbol (exchange suffix, e.g. "OG:xcme") goes straight to + Saxo; otherwise yfinance is tried first (unchanged, proven path for stocks/ETFs), and + only falls back to Saxo if yfinance fails AND a Saxo connection is available. + """ + if ":" in symbol: + from services.saxo_client import get_chain_slice_saxo + return get_chain_slice_saxo(symbol, target_days, n_expiries) + + try: + return _get_chain_slice_yfinance(symbol, target_days, n_expiries) + except ValueError: + from services import saxo_auth + from services.saxo_client import get_chain_slice_saxo + if saxo_auth.get_status().get("connected"): + try: + return get_chain_slice_saxo(symbol, target_days, n_expiries) + except Exception as e: + logger.debug(f"[OptionChain] Saxo fallback failed for {symbol}: {e}") + raise + + +def _get_chain_slice_yfinance(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]: """ Fetch the real option chain for `symbol` around a target horizon (days). Returns the `n_expiries` expirations closest to target_days, each with diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index d66278d..c001860 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -291,3 +291,65 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, if not rows: raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})") return rows + + +def get_chain_slice_saxo(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]: + """ + Saxo-backed equivalent of services/option_chain.get_chain_slice — same output shape + ({symbol, proxy, spot, expiries: [{expiry_date, days_to_expiry, calls, puts}]}, each row + {strike, bid, ask, mid, last, iv, open_interest, volume}) so vol_surface.py/strategy_engine.py + work unchanged regardless of data source. Reuses snapshot_options_chain (already flat, + already bid/ask/mid/greeks per contract) and reshapes/filters it down to n_expiries. + """ + instrument = resolve_instrument(symbol) + flat_rows = snapshot_options_chain(symbol) + spot = flat_rows[0]["spot"] if flat_rows else None + today = date.today() + + by_expiry: Dict[str, List[Dict[str, Any]]] = {} + for r in flat_rows: + if r.get("expiry_date"): + by_expiry.setdefault(r["expiry_date"], []).append(r) + + def _days_to(expiry_date: str) -> int: + return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days + + selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)] + + def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]: + bid = r.get("bid") or 0.0 + ask = r.get("ask") or 0.0 + mid = r.get("mid") or (round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else 0.0) + vol_pct = r.get("volatility_pct") + return { + "strike": float(r["strike"]), + "bid": float(bid), + "ask": float(ask), + "mid": float(mid), + "last": float(mid), # Saxo's chain snapshot has no separate last-traded field + "iv": float(vol_pct) / 100.0 if vol_pct is not None else 0.0, + "open_interest": 0, + "volume": 0, + } + + expiries_out = [] + for expiry_date in sorted(selected, key=_days_to): + rows = by_expiry[expiry_date] + calls = sorted([_row_shape(r) for r in rows if r["option_type"] == "call"], key=lambda x: x["strike"]) + puts = sorted([_row_shape(r) for r in rows if r["option_type"] == "put"], key=lambda x: x["strike"]) + expiries_out.append({ + "expiry_date": expiry_date, + "days_to_expiry": _days_to(expiry_date), + "calls": calls, + "puts": puts, + }) + + if not expiries_out: + raise ValueError(f"Aucune chaîne exploitable pour '{symbol}' via Saxo") + + return { + "symbol": symbol.upper(), + "proxy": instrument["symbol"] or symbol.upper(), + "spot": round(float(spot), 4) if spot is not None else None, + "expiries": expiries_out, + } diff --git a/backend/services/saxo_scheduler.py b/backend/services/saxo_scheduler.py index 850b9ff..285d824 100644 --- a/backend/services/saxo_scheduler.py +++ b/backend/services/saxo_scheduler.py @@ -61,28 +61,47 @@ def _refresh_loop(stop: threading.Event): stop.wait(timeout=REFRESH_INTERVAL_SECONDS) +def get_snapshot_minutes() -> float: + from .database import get_config + return float(get_config("saxo_snapshot_minutes") or "5") + + +def set_snapshot_minutes(minutes: float): + from .database import set_config + set_config("saxo_snapshot_minutes", str(minutes)) + + +def run_snapshot_pass() -> dict[str, int]: + """Snapshot every watchlist symbol once — shared by the periodic loop, the + 'connect now' trigger (saxo_oauth.py callback), and the manual refresh button.""" + from .saxo_client import snapshot_options_chain + from .database import save_saxo_snapshot_rows + results: dict[str, int] = {} + for symbol in get_watchlist(): + try: + rows = snapshot_options_chain(symbol) + save_saxo_snapshot_rows(rows) + results[symbol] = len(rows) + logger.info(f"[Saxo Scheduler] Snapshot saved: {symbol} ({len(rows)} rows)") + except Exception as e: + results[symbol] = 0 + logger.warning(f"[Saxo Scheduler] Snapshot failed for {symbol}: {e}") + return results + + def _snapshot_loop(stop: threading.Event): while not stop.wait(0): try: - from .database import get_config from .saxo_auth import get_status - minutes = float(get_config("saxo_snapshot_minutes") or "20") + minutes = get_snapshot_minutes() if get_status().get("connected"): - from .saxo_client import snapshot_options_chain - from .database import save_saxo_snapshot_rows - for symbol in get_watchlist(): - try: - rows = snapshot_options_chain(symbol) - save_saxo_snapshot_rows(rows) - logger.info(f"[Saxo Scheduler] Snapshot saved: {symbol} ({len(rows)} rows)") - except Exception as e: - logger.warning(f"[Saxo Scheduler] Snapshot failed for {symbol}: {e}") + run_snapshot_pass() else: minutes = max(minutes, 5) # don't busy-loop while disconnected except Exception as e: logger.error(f"[Saxo Scheduler] Snapshot loop error: {e}") - minutes = 20 + minutes = 5 stop.wait(timeout=minutes * 60) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index f564e07..e9b5dd6 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1706,6 +1706,25 @@ export const useSnapshotSaxoNow = () => mutationFn: (symbol: string) => api.post(`/saxo/snapshot-now/${encodeURIComponent(symbol)}`).then(r => r.data), }) +export const useSnapshotAllSaxoNow = () => + useMutation({ + mutationFn: () => api.post('/saxo/snapshot-now').then(r => r.data as { results: Record }), + }) + +export const useSaxoSettings = () => + useQuery<{ snapshot_minutes: number }>({ + queryKey: ['saxo-settings'], + queryFn: () => api.get('/saxo/settings').then(r => r.data), + }) + +export const useUpdateSaxoSettings = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (snapshotMinutes: number) => api.put('/saxo/settings', { snapshot_minutes: snapshotMinutes }).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['saxo-settings'] }), + }) +} + export type SaxoSnapshotRow = { id: string; symbol: string; snapshot_date: string; spot: number | null expiry_date: string; strike: number; option_type: 'call' | 'put' @@ -1745,11 +1764,13 @@ export const useValidateSaxoWatchlist = () => 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) => +export const useSaxoCatalog = (assetType?: string, q?: string, opts?: { enabled?: boolean; limit?: number }) => 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), + queryKey: ['saxo-catalog', assetType, q, opts?.limit], + queryFn: () => api.get('/saxo/catalog', { + params: { ...(assetType ? { asset_type: assetType } : {}), ...(q ? { q } : {}), ...(opts?.limit ? { limit: opts.limit } : {}) }, + }).then(r => r.data), + enabled: opts?.enabled ?? !!(assetType || q), }) export const useSaxoCatalogSummary = () => diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index cddb242..c32051d 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, useTestSaxoQuote, 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, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, 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' @@ -430,6 +430,9 @@ function SaxoConnectionCard() { const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist() const { data: catalogSummary } = useSaxoCatalogSummary() const refreshCatalog = useRefreshSaxoCatalog() + const { data: settings } = useSaxoSettings() + const updateSettings = useUpdateSaxoSettings() + const snapshotAllNow = useSnapshotAllSaxoNow() const [input, setInput] = useState('') const [snapMsg, setSnapMsg] = useState('') const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined) @@ -470,6 +473,17 @@ function SaxoConnectionCard() { } } + const handleSnapshotAllNow = async () => { + setSnapMsg('') + try { + const r = await snapshotAllNow.mutateAsync() + const total = Object.values(r.results).reduce((a, b) => a + b, 0) + setSnapMsg(`✓ Watchlist entière — ${total} lignes enregistrées (${Object.keys(r.results).length} symboles)`) + } catch (e: any) { + setSnapMsg(`✗ ${e?.response?.data?.detail ?? 'échec'}`) + } + } + return (
@@ -517,10 +531,25 @@ function SaxoConnectionCard() {
-
- Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) : +
+ Watchlist snapshotée toutes les + updateSettings.mutate(parseFloat(e.target.value) || 5)} + className="w-14 bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-xs text-white text-center" + /> + min (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
+