feat: saxo connector
This commit is contained in:
@@ -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
|
Access tokens last ~20 min, refresh tokens ~40 min (SIM figures — Live may differ), so a
|
||||||
(services/saxo_scheduler.py) must proactively refresh well before either expires —
|
background scheduler (services/saxo_scheduler.py) must proactively refresh well before
|
||||||
this module only exposes the primitives (build the login URL, exchange code, refresh,
|
either expires — this module only exposes the primitives (build the login URL, exchange
|
||||||
read a currently-valid token).
|
code, refresh, read a currently-valid token).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -19,8 +19,17 @@ logger = logging.getLogger(__name__)
|
|||||||
SAXO_APP_KEY = os.environ.get("SAXO_APP_KEY", "")
|
SAXO_APP_KEY = os.environ.get("SAXO_APP_KEY", "")
|
||||||
SAXO_APP_SECRET = os.environ.get("SAXO_APP_SECRET", "")
|
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_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).
|
# In-memory CSRF state for the OAuth login->callback round trip (single-user desktop app).
|
||||||
_pending_state: Optional[str] = None
|
_pending_state: Optional[str] = None
|
||||||
@@ -64,7 +73,7 @@ def _store_token_response(token_resp: Dict[str, Any]):
|
|||||||
refresh_token=token_resp["refresh_token"],
|
refresh_token=token_resp["refresh_token"],
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
refresh_expires_at=refresh_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
|
from services.database import get_saxo_tokens
|
||||||
stored = get_saxo_tokens()
|
stored = get_saxo_tokens()
|
||||||
if not stored or not stored.get("access_token"):
|
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 {
|
return {
|
||||||
"connected": True,
|
"connected": True,
|
||||||
"configured": is_configured(),
|
"configured": is_configured(),
|
||||||
|
|||||||
@@ -253,7 +253,10 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
|
|||||||
space = get_option_space(root_uic)
|
space = get_option_space(root_uic)
|
||||||
legs = _extract_option_legs(space)
|
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")
|
spot = _first(snapshot, "UnderlyingSpotPrice", "Spot", "UnderlyingPrice")
|
||||||
snapshot_date = date.today().isoformat()
|
snapshot_date = date.today().isoformat()
|
||||||
|
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ function SaxoConnectionCard() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="section-title mb-0 flex items-center gap-1">
|
<div className="section-title mb-0 flex items-center gap-1">
|
||||||
<Link2 className="w-3 h-3" /> Saxo OpenAPI (SIM)
|
<Link2 className="w-3 h-3" /> Saxo OpenAPI ({(status?.environment ?? 'sim').toUpperCase()})
|
||||||
</div>
|
</div>
|
||||||
<span className={clsx('badge', status?.connected ? 'badge-green' : 'badge-red')}>
|
<span className={clsx('badge', status?.connected ? 'badge-green' : 'badge-red')}>
|
||||||
{status?.connected ? 'Connecté' : 'Déconnecté'}
|
{status?.connected ? 'Connecté' : 'Déconnecté'}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy,
|
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy,
|
||||||
useScenarios, useSaveScenario, useDeleteScenario,
|
useScenarios, useSaveScenario, useDeleteScenario,
|
||||||
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
|
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
|
||||||
|
useWatchlistTickers,
|
||||||
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
|
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
|
||||||
type OptimizeConstraints, type SavedScenario,
|
type OptimizeConstraints, type SavedScenario,
|
||||||
} from '../hooks/useApi'
|
} 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 STRIKE_PCTS = [80, 85, 90, 95, 100, 105, 110, 115, 120]
|
||||||
const DELTA_NEUTRAL_THRESHOLD = 0.15
|
const DELTA_NEUTRAL_THRESHOLD = 0.15
|
||||||
|
|
||||||
@@ -94,11 +97,12 @@ function GreeksTile({ label, now, scenario }: { label: string; now: number; scen
|
|||||||
// ── Scenario panel ────────────────────────────────────────────────────────────
|
// ── Scenario panel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ScenarioPanel({
|
function ScenarioPanel({
|
||||||
symbol, setSymbol, horizonDays, setHorizonDays, scenario, setScenario,
|
symbol, setSymbol, horizonDays, setHorizonDays, scenario, setScenario, watchlistTickers,
|
||||||
}: {
|
}: {
|
||||||
symbol: string; setSymbol: (v: string) => void
|
symbol: string; setSymbol: (v: string) => void
|
||||||
horizonDays: number; setHorizonDays: (v: number) => void
|
horizonDays: number; setHorizonDays: (v: number) => void
|
||||||
scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void
|
scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void
|
||||||
|
watchlistTickers: string[]
|
||||||
}) {
|
}) {
|
||||||
const slider = (
|
const slider = (
|
||||||
key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift',
|
key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift',
|
||||||
@@ -127,7 +131,11 @@ function ScenarioPanel({
|
|||||||
onChange={(e) => setSymbol(e.target.value.toUpperCase())}
|
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"
|
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…"
|
placeholder="SPY, QQQ, GLD…"
|
||||||
|
list="strategy-builder-watchlist"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="strategy-builder-watchlist">
|
||||||
|
{watchlistTickers.map(t => <option key={t} value={t} />)}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-28">
|
||||||
<label className="stat-label block mb-1">Horizon (j)</label>
|
<label className="stat-label block mb-1">Horizon (j)</label>
|
||||||
@@ -483,6 +491,11 @@ export default function StrategyBuilder() {
|
|||||||
})
|
})
|
||||||
const [activeTemplate, setActiveTemplate] = useState<string | null>(null)
|
const [activeTemplate, setActiveTemplate] = useState<string | null>(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 } =
|
const { data: chain, isLoading: chainLoading, isError: chainError, refetch: refetchChain, isFetching } =
|
||||||
useOptionChainSlice(symbol, horizonDays, 3)
|
useOptionChainSlice(symbol, horizonDays, 3)
|
||||||
|
|
||||||
@@ -584,7 +597,7 @@ export default function StrategyBuilder() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
|
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
|
||||||
scenario={scenario} setScenario={setScenario} />
|
scenario={scenario} setScenario={setScenario} watchlistTickers={watchlistTickers} />
|
||||||
|
|
||||||
<ScenarioLibrary symbol={symbol} scenario={scenario} onLoad={handleLoadScenario} />
|
<ScenarioLibrary symbol={symbol} scenario={scenario} onLoad={handleLoadScenario} />
|
||||||
<SavedStrategiesLibrary symbol={symbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
|
<SavedStrategiesLibrary symbol={symbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
|
||||||
|
|||||||
Reference in New Issue
Block a user