Saxo connector
This commit is contained in:
1
appsecret.txt
Normal file
1
appsecret.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
d1ea611b126140dda0f3a4c541346d7a
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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
|
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 wavelet as wavelet_router
|
||||||
from routers import ai_chat as ai_chat_router
|
from routers import ai_chat as ai_chat_router
|
||||||
from routers import strategy_builder as strategy_builder_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
|
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -134,6 +139,9 @@ def startup():
|
|||||||
# Start institutional reports weekly scheduler (COT Fridays, EIA Wednesdays)
|
# Start institutional reports weekly scheduler (COT Fridays, EIA Wednesdays)
|
||||||
from services.institutional_scheduler import start_institutional_scheduler
|
from services.institutional_scheduler import start_institutional_scheduler
|
||||||
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
|
# One-time migration: remove FXStreet-sourced rows (decimal format) — FF sync below will refill from FF
|
||||||
try:
|
try:
|
||||||
@@ -211,6 +219,8 @@ def shutdown():
|
|||||||
stop_pnl_scheduler()
|
stop_pnl_scheduler()
|
||||||
from services.institutional_scheduler import stop_institutional_scheduler
|
from services.institutional_scheduler import stop_institutional_scheduler
|
||||||
stop_institutional_scheduler()
|
stop_institutional_scheduler()
|
||||||
|
from services.saxo_scheduler import stop_saxo_scheduler
|
||||||
|
stop_saxo_scheduler()
|
||||||
|
|
||||||
|
|
||||||
app.include_router(market_data.router)
|
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(wavelet_router.router)
|
||||||
app.include_router(ai_chat_router.router)
|
app.include_router(ai_chat_router.router)
|
||||||
app.include_router(strategy_builder_router.router)
|
app.include_router(strategy_builder_router.router)
|
||||||
|
app.include_router(saxo_oauth_router.router)
|
||||||
|
app.include_router(saxo_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
59
backend/routers/saxo.py
Normal file
59
backend/routers/saxo.py
Normal 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)
|
||||||
41
backend/routers/saxo_oauth.py
Normal file
41
backend/routers/saxo_oauth.py
Normal 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}
|
||||||
@@ -44,6 +44,36 @@ def init_db():
|
|||||||
created_at TEXT DEFAULT (datetime('now'))
|
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 (
|
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,
|
||||||
@@ -6017,3 +6047,72 @@ def delete_saved_strategy(strategy_id: str) -> bool:
|
|||||||
deleted = cur.rowcount > 0
|
deleted = cur.rowcount > 0
|
||||||
conn.close()
|
conn.close()
|
||||||
return deleted
|
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]
|
||||||
|
|||||||
144
backend/services/saxo_auth.py
Normal file
144
backend/services/saxo_auth.py
Normal 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()
|
||||||
182
backend/services/saxo_client.py
Normal file
182
backend/services/saxo_client.py
Normal 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
|
||||||
97
backend/services/saxo_scheduler.py
Normal file
97
backend/services/saxo_scheduler.py
Normal 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()
|
||||||
@@ -1664,3 +1664,59 @@ export const useDeleteSavedStrategy = () => {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-strategies'] }),
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
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, 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, 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 { 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'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
const API = ''
|
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 ─
|
// ── Cycle step config — generic renderer driven by GET /api/cycle/step-catalog ─
|
||||||
// Same declarative pattern as AI Desks' SIGNAL_CATALOG/SignalToggle: adding a new
|
// Same declarative pattern as AI Desks' SIGNAL_CATALOG/SignalToggle: adding a new
|
||||||
// knob to CYCLE_STEP_CATALOG (backend) needs zero new frontend code here.
|
// knob to CYCLE_STEP_CATALOG (backend) needs zero new frontend code here.
|
||||||
@@ -1355,6 +1486,8 @@ export default function Config() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SaxoConnectionCard />
|
||||||
|
|
||||||
{/* RSS / data sources */}
|
{/* RSS / data sources */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="card animate-pulse h-40 bg-dark-700" />
|
<div className="card animate-pulse h-40 bg-dark-700" />
|
||||||
|
|||||||
Reference in New Issue
Block a user