Saxo connector

This commit is contained in:
OpenSquared
2026-07-18 17:14:13 +02:00
parent 91054979ec
commit bff2f70781
10 changed files with 826 additions and 2 deletions

1
appsecret.txt Normal file
View File

@@ -0,0 +1 @@
d1ea611b126140dda0f3a4c541346d7a

View File

@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router
@@ -21,6 +24,8 @@ from routers import instruments_watchlist as instruments_watchlist_router
from routers import wavelet as wavelet_router
from routers import ai_chat as ai_chat_router
from routers import strategy_builder as strategy_builder_router
from routers import saxo_oauth as saxo_oauth_router
from routers import saxo as saxo_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -134,6 +139,9 @@ def startup():
# Start institutional reports weekly scheduler (COT Fridays, EIA Wednesdays)
from services.institutional_scheduler import start_institutional_scheduler
start_institutional_scheduler()
# Start Saxo OAuth token refresh + options-chain snapshot poller
from services.saxo_scheduler import start_saxo_scheduler
start_saxo_scheduler()
# One-time migration: remove FXStreet-sourced rows (decimal format) — FF sync below will refill from FF
try:
@@ -211,6 +219,8 @@ def shutdown():
stop_pnl_scheduler()
from services.institutional_scheduler import stop_institutional_scheduler
stop_institutional_scheduler()
from services.saxo_scheduler import stop_saxo_scheduler
stop_saxo_scheduler()
app.include_router(market_data.router)
@@ -249,6 +259,8 @@ app.include_router(instruments_watchlist_router.router)
app.include_router(wavelet_router.router)
app.include_router(ai_chat_router.router)
app.include_router(strategy_builder_router.router)
app.include_router(saxo_oauth_router.router)
app.include_router(saxo_router.router)
@app.get("/")

59
backend/routers/saxo.py Normal file
View File

@@ -0,0 +1,59 @@
from typing import 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
router = APIRouter(prefix="/api/saxo", tags=["saxo"])
class WatchlistRequest(BaseModel):
symbols: List[str]
@router.get("/status")
def status():
return saxo_auth.get_status()
@router.post("/disconnect")
def disconnect():
saxo_auth.disconnect()
return {"disconnected": True}
@router.get("/watchlist")
def watchlist():
return {"symbols": get_watchlist()}
@router.put("/watchlist")
def update_watchlist(req: WatchlistRequest):
set_watchlist(req.symbols)
return {"symbols": get_watchlist()}
@router.post("/snapshot-now/{symbol}")
def snapshot_now(symbol: str):
from services.saxo_client import snapshot_options_chain, SaxoNotConnected
from services.database import save_saxo_snapshot_rows
try:
rows = snapshot_options_chain(symbol)
except SaxoNotConnected as e:
raise HTTPException(status_code=401, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=502, detail=str(e))
save_saxo_snapshot_rows(rows)
return {"symbol": symbol.upper(), "rows_saved": len(rows)}
@router.get("/history")
def history(
symbol: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
):
return get_saxo_snapshots(symbol, date_from, date_to)

View File

@@ -0,0 +1,41 @@
"""OAuth2 login/callback for Saxo — paths outside /api, matching the callback URL
registered on the Saxo app dashboard (https://openfin.open-squared.tech/oauth/saxo/callback)."""
import logging
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import RedirectResponse
from services import saxo_auth
logger = logging.getLogger(__name__)
router = APIRouter(tags=["saxo-oauth"])
FRONTEND_CONFIG_URL = "/config"
@router.get("/oauth/saxo/login")
def saxo_login():
if not saxo_auth.is_configured():
raise HTTPException(status_code=400, detail="SAXO_APP_KEY / SAXO_APP_SECRET manquants dans backend/.env")
state = saxo_auth.new_login_state()
return RedirectResponse(saxo_auth.build_authorize_url(state))
@router.get("/oauth/saxo/callback")
def saxo_callback(code: str = Query(...), state: str = Query(...)):
if not saxo_auth.consume_and_verify_state(state):
raise HTTPException(status_code=400, detail="État OAuth invalide (CSRF) — relancez la connexion depuis Config.")
try:
saxo_auth.exchange_code_for_tokens(code)
except Exception as e:
logger.error(f"[Saxo OAuth] Token exchange failed: {e}")
return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_error=1")
return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_connected=1")
@router.post("/oauth/saxo/refresh")
def saxo_manual_refresh():
result = saxo_auth.refresh_tokens()
if not result:
raise HTTPException(status_code=400, detail="Saxo n'est pas connecté")
return {"refreshed": True}

View File

@@ -44,6 +44,36 @@ def init_db():
created_at TEXT DEFAULT (datetime('now'))
)""")
c.execute("""CREATE TABLE IF NOT EXISTS saxo_auth (
id INTEGER PRIMARY KEY CHECK (id = 1),
access_token TEXT,
refresh_token TEXT,
expires_at TEXT,
refresh_expires_at TEXT,
environment TEXT DEFAULT 'sim',
updated_at TEXT DEFAULT (datetime('now'))
)""")
c.execute("""CREATE TABLE IF NOT EXISTS saxo_option_snapshots (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
snapshot_date TEXT NOT NULL,
spot REAL,
expiry_date TEXT NOT NULL,
strike REAL NOT NULL,
option_type TEXT NOT NULL,
bid REAL,
ask REAL,
mid REAL,
volatility_pct REAL,
delta REAL,
gamma REAL,
theta REAL,
vega REAL,
created_at TEXT DEFAULT (datetime('now'))
)""")
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 strategy_scenarios (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
@@ -6017,3 +6047,72 @@ def delete_saved_strategy(strategy_id: str) -> bool:
deleted = cur.rowcount > 0
conn.close()
return deleted
# ── Saxo OpenAPI: OAuth tokens & option snapshots ─────────────────────────────
def save_saxo_tokens(access_token: str, refresh_token: str, expires_at: str, refresh_expires_at: str, environment: str = "sim"):
conn = get_conn()
conn.execute("""INSERT INTO saxo_auth (id, access_token, refresh_token, expires_at, refresh_expires_at, environment, updated_at)
VALUES (1, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
access_token=excluded.access_token, refresh_token=excluded.refresh_token,
expires_at=excluded.expires_at, refresh_expires_at=excluded.refresh_expires_at,
environment=excluded.environment, updated_at=datetime('now')""",
(access_token, refresh_token, expires_at, refresh_expires_at, environment))
conn.commit()
conn.close()
def get_saxo_tokens() -> Optional[Dict[str, Any]]:
conn = get_conn()
row = conn.execute("SELECT * FROM saxo_auth WHERE id=1").fetchone()
conn.close()
return dict(row) if row else None
def clear_saxo_tokens():
conn = get_conn()
conn.execute("DELETE FROM saxo_auth WHERE id=1")
conn.commit()
conn.close()
def save_saxo_snapshot_rows(rows: List[Dict[str, Any]]):
if not rows:
return
import uuid
conn = get_conn()
conn.executemany("""INSERT INTO saxo_option_snapshots (
id, symbol, snapshot_date, spot, expiry_date, strike, option_type,
bid, ask, mid, volatility_pct, delta, gamma, theta, vega
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", [
(
f"SNAP-{uuid.uuid4().hex[:12]}",
r["symbol"], r["snapshot_date"], r.get("spot"), r["expiry_date"], r["strike"], r["option_type"],
r.get("bid"), r.get("ask"), r.get("mid"), r.get("volatility_pct"),
r.get("delta"), r.get("gamma"), r.get("theta"), r.get("vega"),
)
for r in rows
])
conn.commit()
conn.close()
def get_saxo_snapshots(symbol: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None) -> List[Dict[str, Any]]:
conn = get_conn()
query = "SELECT * FROM saxo_option_snapshots WHERE 1=1"
params: List[Any] = []
if symbol:
query += " AND symbol=?"
params.append(symbol)
if date_from:
query += " AND snapshot_date>=?"
params.append(date_from)
if date_to:
query += " AND snapshot_date<=?"
params.append(date_to)
query += " ORDER BY snapshot_date DESC, expiry_date, strike LIMIT 5000"
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]

View File

@@ -0,0 +1,144 @@
"""
Saxo OpenAPI OAuth2 Authorization Code Grant — SIM environment only.
Access tokens last ~20 min, refresh tokens ~40 min (SIM), so a background scheduler
(services/saxo_scheduler.py) must proactively refresh well before either expires —
this module only exposes the primitives (build the login URL, exchange code, refresh,
read a currently-valid token).
"""
import os
import secrets
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger(__name__)
SAXO_APP_KEY = os.environ.get("SAXO_APP_KEY", "")
SAXO_APP_SECRET = os.environ.get("SAXO_APP_SECRET", "")
SAXO_REDIRECT_URI = os.environ.get("SAXO_REDIRECT_URI", "https://openfin.open-squared.tech/oauth/saxo/callback")
SAXO_AUTH_BASE_URL = os.environ.get("SAXO_AUTH_BASE_URL", "https://sim.logonvalidation.net")
SAXO_API_BASE_URL = os.environ.get("SAXO_API_BASE_URL", "https://gateway.saxobank.com/sim/openapi")
# In-memory CSRF state for the OAuth login->callback round trip (single-user desktop app).
_pending_state: Optional[str] = None
def is_configured() -> bool:
return bool(SAXO_APP_KEY and SAXO_APP_SECRET)
def new_login_state() -> str:
global _pending_state
_pending_state = secrets.token_urlsafe(24)
return _pending_state
def consume_and_verify_state(state: str) -> bool:
global _pending_state
ok = bool(_pending_state) and secrets.compare_digest(_pending_state, state or "")
_pending_state = None
return ok
def build_authorize_url(state: str) -> str:
from urllib.parse import urlencode
params = {
"response_type": "code",
"client_id": SAXO_APP_KEY,
"redirect_uri": SAXO_REDIRECT_URI,
"state": state,
}
return f"{SAXO_AUTH_BASE_URL}/authorize?{urlencode(params)}"
def _store_token_response(token_resp: Dict[str, Any]):
from services.database import save_saxo_tokens
now = datetime.now(timezone.utc)
expires_at = (now + timedelta(seconds=int(token_resp["expires_in"]))).isoformat()
refresh_expires_at = (now + timedelta(seconds=int(token_resp["refresh_token_expires_in"]))).isoformat()
save_saxo_tokens(
access_token=token_resp["access_token"],
refresh_token=token_resp["refresh_token"],
expires_at=expires_at,
refresh_expires_at=refresh_expires_at,
environment="sim",
)
def exchange_code_for_tokens(code: str) -> Dict[str, Any]:
resp = httpx.post(
f"{SAXO_AUTH_BASE_URL}/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": SAXO_REDIRECT_URI,
},
auth=(SAXO_APP_KEY, SAXO_APP_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15,
)
resp.raise_for_status()
token_resp = resp.json()
_store_token_response(token_resp)
logger.info("[Saxo] Connected — tokens stored")
return token_resp
def refresh_tokens() -> Optional[Dict[str, Any]]:
from services.database import get_saxo_tokens
stored = get_saxo_tokens()
if not stored or not stored.get("refresh_token"):
return None
resp = httpx.post(
f"{SAXO_AUTH_BASE_URL}/token",
data={
"grant_type": "refresh_token",
"refresh_token": stored["refresh_token"],
"redirect_uri": SAXO_REDIRECT_URI,
},
auth=(SAXO_APP_KEY, SAXO_APP_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15,
)
resp.raise_for_status()
token_resp = resp.json()
_store_token_response(token_resp)
logger.info("[Saxo] Token refreshed")
return token_resp
def get_valid_access_token() -> Optional[str]:
from services.database import get_saxo_tokens
stored = get_saxo_tokens()
if not stored or not stored.get("access_token"):
return None
expires_at = datetime.fromisoformat(stored["expires_at"])
if datetime.now(timezone.utc) < expires_at - timedelta(seconds=60):
return stored["access_token"]
refreshed = refresh_tokens()
return refreshed["access_token"] if refreshed else None
def get_status() -> Dict[str, Any]:
from services.database import get_saxo_tokens
stored = get_saxo_tokens()
if not stored or not stored.get("access_token"):
return {"connected": False, "configured": is_configured(), "environment": "sim"}
return {
"connected": True,
"configured": is_configured(),
"environment": stored.get("environment", "sim"),
"expires_at": stored.get("expires_at"),
"refresh_expires_at": stored.get("refresh_expires_at"),
}
def disconnect():
from services.database import clear_saxo_tokens
clear_saxo_tokens()

View File

@@ -0,0 +1,182 @@
"""
Thin Saxo OpenAPI REST client for options chain snapshots.
Two parts have high confidence (directly verified against Saxo's docs): the OAuth
flow (services/saxo_auth.py) and the general shape of a streaming *subscription*
call (ContextId/ReferenceId/Arguments, POST-then-DELETE for a one-off snapshot).
One part has LOWER confidence and is written defensively on purpose: the exact
field names returned by /ref/v1/instruments and /ref/v1/instruments/contractoptionspaces
(Saxo's Swagger UI didn't expose the full response schema to static fetching). Every
extraction below tries a couple of documented field-name variants and raises a
clear error showing the raw keys received if none match — this is the one piece
worth a quick calibration pass against a real response once SIM is connected.
"""
import logging
import time
import uuid
from datetime import date
from typing import Any, Dict, List, Optional
import httpx
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
logger = logging.getLogger(__name__)
_OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,ContractFutures"
# symbol -> option root Uic, cheap in-process cache (roots don't change within a session)
_root_uic_cache: Dict[str, int] = {}
class SaxoNotConnected(Exception):
pass
def _headers() -> Dict[str, str]:
token = get_valid_access_token()
if not token:
raise SaxoNotConnected("Saxo n'est pas connecté — complétez l'OAuth login d'abord.")
return {"Authorization": f"Bearer {token}"}
def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
resp = httpx.get(f"{SAXO_API_BASE_URL}{path}", headers=_headers(), params=params or {}, timeout=15)
resp.raise_for_status()
return resp.json()
def _first(d: Dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in d:
return d[k]
return None
def resolve_option_root_uic(symbol: str) -> int:
if symbol in _root_uic_cache:
return _root_uic_cache[symbol]
data = _get("/ref/v1/instruments", {"Keywords": symbol, "AssetTypes": _OPTION_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())})")
uic = _first(items[0], "Uic", "uic", "Identifier")
if uic is None:
raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {items[0]}")
_root_uic_cache[symbol] = int(uic)
return int(uic)
def get_option_space(root_uic: int) -> Dict[str, Any]:
return _get(f"/ref/v1/instruments/contractoptionspaces/{root_uic}")
def _extract_option_legs(space: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Flatten the option space response into [{uic, expiry_date, strike, option_type}, ...]."""
expiries = _first(space, "OptionSpace", "SpecificOptions", "Expiries") or []
legs: List[Dict[str, Any]] = []
# Some Saxo response shapes nest strikes/sides under each expiry; others return a flat
# list of contracts directly. Handle both defensively.
for entry in expiries:
expiry_date = _first(entry, "Expiry", "ExpiryDate", "Date")
strikes = _first(entry, "SpecificOptions", "Strikes", "Options") or []
for opt in strikes:
uic = _first(opt, "Uic", "uic")
strike = _first(opt, "StrikePrice", "Strike")
side = _first(opt, "PutCall", "OptionType", "Side")
if uic is None or strike is None or side is None:
continue
legs.append({
"uic": int(uic),
"expiry_date": expiry_date,
"strike": float(strike),
"option_type": "put" if str(side).lower().startswith("p") else "call",
})
if not legs:
raise ValueError(f"Impossible d'extraire des contrats depuis contractoptionspaces (clés reçues: {list(space.keys())})")
return legs
def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dict[str, Any]:
"""POST creates a subscription and returns an initial snapshot in the same response;
DELETE immediately after — no websocket needs to be kept alive for a one-off read."""
context_id = f"of-{uuid.uuid4().hex[:12]}"
reference_id = "chain"
token_headers = _headers()
body = {
"ContextId": context_id,
"ReferenceId": reference_id,
"Arguments": {"Uic": uic, "AssetType": asset_type},
"RefreshRate": 5000,
}
resp = httpx.post(
f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions",
headers={**token_headers, "Content-Type": "application/json"},
json=body, timeout=15,
)
resp.raise_for_status()
snapshot = resp.json()
try:
httpx.delete(
f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions/{context_id}/{reference_id}",
headers=token_headers, timeout=10,
)
except Exception as e:
logger.warning(f"[Saxo] Failed to clean up options-chain subscription {context_id}: {e}")
return snapshot
def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, Any]]:
"""
Returns normalized rows ready for services/database.save_saxo_snapshot_rows:
{symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid,
volatility_pct, delta, gamma, theta, vega}
"""
root_uic = resolve_option_root_uic(symbol)
space = get_option_space(root_uic)
legs = _extract_option_legs(space)
snapshot = _snapshot_via_subscription(root_uic)
spot = _first(snapshot, "UnderlyingSpotPrice", "Spot", "UnderlyingPrice")
snapshot_date = date.today().isoformat()
by_uic = {leg["uic"]: leg for leg in legs}
rows: List[Dict[str, Any]] = []
for expiry_block in (_first(snapshot, "Expiries", "OptionsChain") or []):
for strike_block in (_first(expiry_block, "Strikes") or []):
for side_key in ("Call", "Put", "call", "put"):
side = strike_block.get(side_key)
if not side:
continue
uic = _first(side, "Uic", "ContractId")
leg = by_uic.get(int(uic)) if uic is not None else None
rows.append({
"symbol": symbol.upper(),
"snapshot_date": snapshot_date,
"spot": float(spot) if spot is not None else None,
"expiry_date": _first(expiry_block, "Expiry", "ExpiryDate") or (leg["expiry_date"] if leg else None),
"strike": float(_first(strike_block, "Strike", "StrikePrice") or (leg["strike"] if leg else 0)),
"option_type": "put" if side_key.lower() == "put" else "call",
"bid": _first(side, "Bid"),
"ask": _first(side, "Ask"),
"mid": _first(side, "Mid"),
"volatility_pct": _first(strike_block, "MidVolatilityPct", "VolatilityPct"),
"delta": _first(side, "DeltaPct", "Delta"),
"gamma": _first(side, "Gamma"),
"theta": _first(side, "Theta"),
"vega": _first(side, "Vega"),
})
if not rows:
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
return rows

View File

@@ -0,0 +1,97 @@
"""
Background scheduler for the Saxo connection — mirrors services/var_scheduler.py.
Two independent loops:
- Token refresh: proactively renews the access/refresh token well before the 40-min
refresh-token expiry (SIM), so the user never has to re-login as long as the
server keeps running.
- Snapshot poller: periodically captures an options-chain snapshot for each symbol
in the configured watchlist and appends it to saxo_option_snapshots.
"""
from __future__ import annotations
import json
import logging
import threading
logger = logging.getLogger(__name__)
_refresh_thread: threading.Thread | None = None
_snapshot_thread: threading.Thread | None = None
_refresh_stop = threading.Event()
_snapshot_stop = threading.Event()
DEFAULT_WATCHLIST = ["SPY", "QQQ", "GLD", "SLV", "USO"]
REFRESH_INTERVAL_SECONDS = 10 * 60 # well under the 40-min refresh-token lifetime
def get_watchlist() -> list[str]:
from .database import get_config
raw = get_config("saxo_watchlist")
if not raw:
return DEFAULT_WATCHLIST
try:
return json.loads(raw)
except Exception:
return DEFAULT_WATCHLIST
def set_watchlist(symbols: list[str]):
from .database import set_config
set_config("saxo_watchlist", json.dumps([s.upper() for s in symbols]))
def _refresh_loop(stop: threading.Event):
while not stop.wait(0):
try:
from .saxo_auth import get_status, refresh_tokens
if get_status().get("connected"):
refresh_tokens()
logger.info("[Saxo Scheduler] Token refreshed proactively")
except Exception as e:
logger.error(f"[Saxo Scheduler] Refresh loop error: {e}")
stop.wait(timeout=REFRESH_INTERVAL_SECONDS)
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")
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}")
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
stop.wait(timeout=minutes * 60)
def start_saxo_scheduler():
global _refresh_thread, _snapshot_thread
_refresh_stop.clear()
_snapshot_stop.clear()
if not (_refresh_thread and _refresh_thread.is_alive()):
_refresh_thread = threading.Thread(target=_refresh_loop, args=(_refresh_stop,), name="saxo-refresh", daemon=True)
_refresh_thread.start()
if not (_snapshot_thread and _snapshot_thread.is_alive()):
_snapshot_thread = threading.Thread(target=_snapshot_loop, args=(_snapshot_stop,), name="saxo-snapshot", daemon=True)
_snapshot_thread.start()
logger.info("[Saxo Scheduler] Started")
def stop_saxo_scheduler():
_refresh_stop.set()
_snapshot_stop.set()

View File

@@ -1664,3 +1664,59 @@ export const useDeleteSavedStrategy = () => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-strategies'] }),
})
}
// ── Saxo connection ───────────────────────────────────────────────────────────
export type SaxoStatus = {
connected: boolean; configured: boolean; environment: string
expires_at?: string; refresh_expires_at?: string
}
export const useSaxoStatus = () =>
useQuery<SaxoStatus>({
queryKey: ['saxo-status'],
queryFn: () => api.get('/saxo/status').then(r => r.data),
refetchInterval: 60_000,
})
export const useDisconnectSaxo = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/saxo/disconnect').then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saxo-status'] }),
})
}
export const useSaxoWatchlist = () =>
useQuery<{ symbols: string[] }>({
queryKey: ['saxo-watchlist'],
queryFn: () => api.get('/saxo/watchlist').then(r => r.data),
})
export const useUpdateSaxoWatchlist = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.put('/saxo/watchlist', { symbols }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saxo-watchlist'] }),
})
}
export const useSnapshotSaxoNow = () =>
useMutation({
mutationFn: (symbol: string) => api.post(`/saxo/snapshot-now/${encodeURIComponent(symbol)}`).then(r => r.data),
})
export type SaxoSnapshotRow = {
id: string; symbol: string; snapshot_date: string; spot: number | null
expiry_date: string; strike: number; option_type: 'call' | 'put'
bid: number | null; ask: number | null; mid: number | null; volatility_pct: number | null
delta: number | null; gamma: number | null; theta: number | null; vega: number | null
created_at: string
}
export const useSaxoHistory = (symbol?: string) =>
useQuery<SaxoSnapshotRow[]>({
queryKey: ['saxo-history', symbol],
queryFn: () => api.get('/saxo/history', { params: symbol ? { symbol } : {} }).then(r => r.data),
enabled: !!symbol,
})

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
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, 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 } from 'lucide-react'
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, 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 } from 'lucide-react'
import clsx from 'clsx'
const API = ''
@@ -409,6 +409,137 @@ function WatchlistCard() {
)
}
// ── Saxo OpenAPI connection ────────────────────────────────────────────────────
function timeUntil(iso?: string): string {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (ms <= 0) return 'expiré'
const min = Math.round(ms / 60000)
return min < 60 ? `${min} min` : `${(min / 60).toFixed(1)} h`
}
function SaxoConnectionCard() {
const qc = useQueryClient()
const { data: status } = useSaxoStatus()
const { data: watchlistData } = useSaxoWatchlist()
const disconnect = useDisconnectSaxo()
const updateWatchlist = useUpdateSaxoWatchlist()
const snapshotNow = useSnapshotSaxoNow()
const [input, setInput] = useState('')
const [snapMsg, setSnapMsg] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('saxo_connected') || params.has('saxo_error')) {
qc.invalidateQueries({ queryKey: ['saxo-status'] })
setSnapMsg(params.has('saxo_connected') ? '✓ Connecté à Saxo' : "✗ Échec de la connexion Saxo — vérifiez l'App Secret dans backend/.env")
params.delete('saxo_connected'); params.delete('saxo_error')
const qs = params.toString()
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const symbols = watchlistData?.symbols ?? []
const addSymbol = () => {
const sym = input.trim().toUpperCase()
if (!sym || symbols.includes(sym)) return
updateWatchlist.mutate([...symbols, sym])
setInput('')
}
const removeSymbol = (sym: string) => updateWatchlist.mutate(symbols.filter(s => s !== sym))
const handleSnapshotNow = async (sym: string) => {
setSnapMsg('')
try {
const r = await snapshotNow.mutateAsync(sym)
setSnapMsg(`${sym}${r.rows_saved} lignes enregistrées`)
} catch (e: any) {
setSnapMsg(`${sym}${e?.response?.data?.detail ?? 'échec'}`)
}
}
return (
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="section-title mb-0 flex items-center gap-1">
<Link2 className="w-3 h-3" /> Saxo OpenAPI (SIM)
</div>
<span className={clsx('badge', status?.connected ? 'badge-green' : 'badge-red')}>
{status?.connected ? 'Connecté' : 'Déconnecté'}
</span>
</div>
{!status?.configured && (
<div className="text-xs text-amber-400 bg-amber-900/10 border border-amber-700/30 rounded px-3 py-2 mb-3">
SAXO_APP_KEY / SAXO_APP_SECRET manquants dans backend/.env ajoutez-les puis redémarrez le backend.
</div>
)}
{status?.connected && (
<div className="text-xs text-slate-500 mb-3 space-y-0.5">
<div>Token valide encore <span className="text-slate-300">{timeUntil(status.expires_at)}</span></div>
<div>Rafraîchi automatiquement en tâche de fond pas besoin de te reconnecter.</div>
</div>
)}
<div className="flex items-center gap-2 mb-4">
{status?.connected ? (
<button
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
className="flex items-center gap-1.5 bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-3 py-1.5 rounded text-xs font-semibold disabled:opacity-40"
>
<Unlink className="w-3.5 h-3.5" /> Déconnecter
</button>
) : (
<a
href="/oauth/saxo/login"
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold text-white',
status?.configured ? 'bg-blue-600 hover:bg-blue-500' : 'bg-dark-700 opacity-40 pointer-events-none',
)}
>
<Link2 className="w-3.5 h-3.5" /> Se connecter à Saxo
</a>
)}
</div>
<div className="text-xs text-slate-500 mb-2">
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
</div>
<div className="flex items-center gap-2 mb-2">
<input
value={input}
onChange={e => 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"
/>
<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">
<Plus className="w-3.5 h-3.5" /> Ajouter
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{symbols.map(sym => (
<span key={sym} className="badge-blue flex items-center gap-1.5">
{sym}
<button onClick={() => handleSnapshotNow(sym)} title="Snapshot maintenant" className="hover:text-white">
<Camera className="w-3 h-3" />
</button>
<button onClick={() => removeSymbol(sym)}><X className="w-3 h-3" /></button>
</span>
))}
</div>
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
</div>
)
}
// ── Cycle step config — generic renderer driven by GET /api/cycle/step-catalog ─
// Same declarative pattern as AI Desks' SIGNAL_CATALOG/SignalToggle: adding a new
// knob to CYCLE_STEP_CATALOG (backend) needs zero new frontend code here.
@@ -1355,6 +1486,8 @@ export default function Config() {
</button>
</div>
<SaxoConnectionCard />
{/* RSS / data sources */}
{isLoading ? (
<div className="card animate-pulse h-40 bg-dark-700" />