diff --git a/backend/services/saxo_auth.py b/backend/services/saxo_auth.py index cce0a7d..00638ea 100644 --- a/backend/services/saxo_auth.py +++ b/backend/services/saxo_auth.py @@ -1,10 +1,10 @@ """ -Saxo OpenAPI OAuth2 Authorization Code Grant — SIM environment only. +Saxo OpenAPI OAuth2 Authorization Code Grant — SIM or Live, set via SAXO_ENVIRONMENT. -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). +Access tokens last ~20 min, refresh tokens ~40 min (SIM figures — Live may differ), 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 @@ -19,8 +19,17 @@ 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") + +# SAXO_ENVIRONMENT=sim (default) or live — picks the matching auth/API host pair. +# Explicit SAXO_AUTH_BASE_URL/SAXO_API_BASE_URL env vars still win if set, for edge cases. +SAXO_ENVIRONMENT = os.environ.get("SAXO_ENVIRONMENT", "sim").strip().lower() +_DEFAULT_HOSTS = { + "sim": ("https://sim.logonvalidation.net", "https://gateway.saxobank.com/sim/openapi"), + "live": ("https://live.logonvalidation.net", "https://gateway.saxobank.com/openapi"), +} +_default_auth, _default_api = _DEFAULT_HOSTS.get(SAXO_ENVIRONMENT, _DEFAULT_HOSTS["sim"]) +SAXO_AUTH_BASE_URL = os.environ.get("SAXO_AUTH_BASE_URL", _default_auth) +SAXO_API_BASE_URL = os.environ.get("SAXO_API_BASE_URL", _default_api) # In-memory CSRF state for the OAuth login->callback round trip (single-user desktop app). _pending_state: Optional[str] = None @@ -64,7 +73,7 @@ def _store_token_response(token_resp: Dict[str, Any]): refresh_token=token_resp["refresh_token"], expires_at=expires_at, refresh_expires_at=refresh_expires_at, - environment="sim", + environment=SAXO_ENVIRONMENT, ) @@ -129,7 +138,7 @@ 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": False, "configured": is_configured(), "environment": SAXO_ENVIRONMENT} return { "connected": True, "configured": is_configured(), diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 0474409..d66278d 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -253,7 +253,10 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, space = get_option_space(root_uic) legs = _extract_option_legs(space) - snapshot = _snapshot_via_subscription(root_uic, asset_type=instrument["asset_type"] or "StockOption") + subscription = _snapshot_via_subscription(root_uic, asset_type=instrument["asset_type"] or "StockOption") + # The POST response is the streaming-subscription envelope (ContextId/ReferenceId/Format/ + # RefreshRate/InactivityTimeout/State) — the actual chain payload is nested under "Snapshot". + snapshot = subscription.get("Snapshot") or subscription spot = _first(snapshot, "UnderlyingSpotPrice", "Spot", "UnderlyingPrice") snapshot_date = date.today().isoformat() diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 6403f3c..cddb242 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -474,7 +474,7 @@ function SaxoConnectionCard() {
- Saxo OpenAPI (SIM) + Saxo OpenAPI ({(status?.environment ?? 'sim').toUpperCase()})
{status?.connected ? 'Connecté' : 'Déconnecté'} diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index e1ffb43..3442b49 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -8,10 +8,13 @@ import { useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, + useWatchlistTickers, type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate, type OptimizeConstraints, type SavedScenario, } from '../hooks/useApi' +type WatchlistEntry = { ticker: string; is_active: number | boolean; added_by: string } + const STRIKE_PCTS = [80, 85, 90, 95, 100, 105, 110, 115, 120] const DELTA_NEUTRAL_THRESHOLD = 0.15 @@ -94,11 +97,12 @@ function GreeksTile({ label, now, scenario }: { label: string; now: number; scen // ── Scenario panel ──────────────────────────────────────────────────────────── function ScenarioPanel({ - symbol, setSymbol, horizonDays, setHorizonDays, scenario, setScenario, + symbol, setSymbol, horizonDays, setHorizonDays, scenario, setScenario, watchlistTickers, }: { symbol: string; setSymbol: (v: string) => void horizonDays: number; setHorizonDays: (v: number) => void scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void + watchlistTickers: string[] }) { const slider = ( key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift', @@ -127,7 +131,11 @@ function ScenarioPanel({ onChange={(e) => setSymbol(e.target.value.toUpperCase())} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" placeholder="SPY, QQQ, GLD…" + list="strategy-builder-watchlist" /> + + {watchlistTickers.map(t =>
@@ -483,6 +491,11 @@ export default function StrategyBuilder() { }) const [activeTemplate, setActiveTemplate] = useState(null) + const { data: watchlistData } = useWatchlistTickers() + const watchlistTickers = ((watchlistData?.tickers ?? []) as WatchlistEntry[]) + .filter(t => t.is_active) + .map(t => t.ticker) + const { data: chain, isLoading: chainLoading, isError: chainError, refetch: refetchChain, isFetching } = useOptionChainSlice(symbol, horizonDays, 3) @@ -584,7 +597,7 @@ export default function StrategyBuilder() { )} + scenario={scenario} setScenario={setScenario} watchlistTickers={watchlistTickers} /> { setActiveTemplate(templateName); setLegs(legs) }} />