""" saxo_broker.py — Saxo Bank SIM paper trading integration. Reads SAXO_TOKEN + SAXO_BASE from .env (or environment). Token expires after 24h — refresh at developer.saxobank.com/openapi/token """ import os import sys import json import time import requests from pathlib import Path from datetime import datetime, timezone from dotenv import load_dotenv from db import get_conn load_dotenv() BASE = os.getenv("SAXO_BASE", "https://gateway.saxobank.com/sim/openapi") def _get_token() -> str: """Get valid token — prefers OAuth2 stored token, falls back to 24h env token.""" try: from saxo_auth import get_token return get_token() except Exception: return os.getenv("SAXO_TOKEN", "") # C25 tickers: our local ticker → (search keyword, Saxo ExchangeId) # ExchangeId "CSE" = Copenhagen Stock Exchange (Saxo's code) SAXO_SYMBOLS = { "NOVO-B": ("Novo Nordisk B", "CSE"), "DANSKE": ("Danske Bank", "CSE"), "DSV": ("DSV", "CSE"), "DEMANT": ("Demant", "CSE"), "ORSTED": ("Orsted", "CSE"), "ROCK-B": ("Rockwool", "CSE"), "VWS": ("Vestas", "CSE"), "COLOB": ("Coloplast", "CSE"), "GMAB": ("Genmab", "CSE"), "MAERSK-B": ("Maersk B", "CSE"), "NZYM-B": ("Novozymes", "CSE"), "NKT": ("NKT", "CSE"), "GN": ("GN Store Nord", "CSE"), "BAVAR": ("Bavarian Nordic", "CSE"), "TRYG": ("Tryg", "CSE"), "PNDORA": ("Pandora", "CSE"), "CARL-B": ("Carlsberg", "CSE"), "CHR": ("Chr. Hansen", "CSE"), "FLS": ("FLSmidth", "CSE"), "RBREW": ("Royal Unibrew", "CSE"), "SYDB": ("Sydbank", "CSE"), } def _headers(): return {"Authorization": f"Bearer {_get_token()}", "Content-Type": "application/json"} def _get(path, params=None): r = requests.get(f"{BASE}{path}", headers=_headers(), params=params, timeout=10) r.raise_for_status() return r.json() def _post(path, body): r = requests.post(f"{BASE}{path}", headers=_headers(), json=body, timeout=10) r.raise_for_status() return r.json() # ── Account info ──────────────────────────────────────────────────────────── def get_account_info(): """Return (ClientKey, AccountKey) for the SIM account.""" data = _get("/port/v1/accounts/me") accounts = data.get("Data", []) if not accounts: raise RuntimeError("No accounts found — are you logged into SIM?") acct = accounts[0] return acct["ClientKey"], acct["AccountKey"] def get_balances(): """Return cash balance dict from Saxo SIM.""" return _get("/port/v1/balances/me") # ── Instrument lookup ─────────────────────────────────────────────────────── def find_uic(ticker: str) -> int | None: """Resolve our local ticker to Saxo UIC (instrument ID).""" if ticker not in SAXO_SYMBOLS: return None symbol, exchange = SAXO_SYMBOLS[ticker] results = _get("/ref/v1/instruments", params={ "Keywords": symbol, "ExchangeId": exchange, "AssetTypes": "Stock", "$top": 5, }) for item in results.get("Data", []): if item.get("ExchangeId") == exchange: return item["Identifier"] return None def get_price(uic: int) -> float | None: """ Get last traded price for a UIC. NOTE: SIM 24h tokens have no market data access — returns None. Use yfinance for prices instead (see signals.py). """ try: data = _get("/trade/v1/infoprices", params={ "Uic": uic, "AssetType": "Stock", "FieldGroups": "Quote", }) last = data.get("Quote", {}).get("Last") return float(last) if last else None except Exception: return None # ── Orders ────────────────────────────────────────────────────────────────── def place_order( ticker: str, shares: int, direction: str = "Buy", *, price_dkk: float | None = None, signal_score: float | None = None, analyst_rec: str | None = None, note: str | None = None, ) -> dict: """ Place a market order on Saxo SIM. Args: ticker: Our C25 ticker (e.g. "NOVO-B") shares: Number of shares direction: "Buy" or "Sell" price_dkk: Estimated fill price in DKK (for fee calc + logging) signal_score: NLP signal score that triggered this order analyst_rec: Analyst recommendation label note: Free-text note Returns: Saxo order response dict """ _, acct_key = get_account_info() uic = find_uic(ticker) if uic is None: raise ValueError(f"Unknown ticker: {ticker}") body = { "Uic": uic, "AssetType": "Stock", "Amount": shares, "BuySell": direction, "OrderType": "Market", "OrderDuration": {"DurationType": "DayOrder"}, "AccountKey": acct_key, "ManualOrder": True, } resp = _post("/trade/v2/orders", body) # Log to saxo_orders table regardless of outcome saxo_order_id = resp.get("OrderId") or resp.get("Orders", [{}])[0].get("OrderId") status = "filled" if saxo_order_id else "rejected" log_order( ticker=ticker, direction=direction.lower(), shares=shares, price_dkk=price_dkk, saxo_order_id=str(saxo_order_id) if saxo_order_id else None, status=status, signal_score=signal_score, analyst_rec=analyst_rec, note=note, ) return resp def log_order( ticker: str, direction: str, shares: float, price_dkk: float | None, saxo_order_id: str | None = None, status: str = "pending", signal_score: float | None = None, analyst_rec: str | None = None, note: str | None = None, ) -> None: """ Write a trade record to saxo_orders table. Fee model: max(25 DKK, total_dkk * 0.001) """ total_dkk = (shares * price_dkk) if price_dkk else None fee_dkk = round(max(25.0, total_dkk * 0.001), 2) if total_dkk else None now_ts = datetime.now(timezone.utc).isoformat() db = get_conn() db.execute( """INSERT INTO saxo_orders (ticker, direction, shares, price_dkk, total_dkk, fee_dkk, saxo_order_id, status, signal_score, analyst_rec, placed_at, note) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (ticker, direction, shares, price_dkk, total_dkk, fee_dkk, saxo_order_id, status, signal_score, analyst_rec, now_ts, note), ) db.commit() db.close() # ── Positions ─────────────────────────────────────────────────────────────── def get_positions() -> list[dict]: """Return open positions from Saxo SIM.""" data = _get("/port/v1/positions/me", params={"FieldGroups": "PositionBase,PositionView"}) return data.get("Data", []) def print_positions(): """Pretty-print open positions.""" positions = get_positions() if not positions: print("Ingen åbne positioner i Saxo SIM") return print(f"\n{'Ticker':<12} {'Antal':>6} {'Åbn kurs':>10} {'Akt kurs':>10} {'P/L':>10}") print("-" * 52) for p in positions: base = p.get("PositionBase", {}) view = p.get("PositionView", {}) symbol = base.get("Symbol", "?") amount = base.get("Amount", 0) open_price = base.get("OpenPrice", 0) current = view.get("CurrentPrice", 0) pl = view.get("ProfitLossOnTrade", 0) pl_str = f"+{pl:.0f}" if pl >= 0 else f"{pl:.0f}" print(f"{symbol:<12} {amount:>6} {open_price:>10.2f} {current:>10.2f} {pl_str:>10}") def print_balances(): """Pretty-print account balances.""" b = get_balances() cash = b.get("CashBalance", 0) equity = b.get("TotalValue", 0) margin = b.get("MarginAvailableForTrading", 0) currency = b.get("Currency", "EUR") print(f"\n Kontant saldo: {cash:>12,.0f} {currency}") print(f" Total portefølje: {equity:>12,.0f} {currency}") print(f" Fri margin: {margin:>12,.0f} {currency}") # ── CLI ────────────────────────────────────────────────────────────────────── def main(): try: _get_token() except Exception as e: print(f"FEJL: {e}") print("Kør: python saxo_auth.py login") sys.exit(1) cmd = sys.argv[1] if len(sys.argv) > 1 else "status" if cmd == "status": print("\n=== SAXO SIM KONTO ===") client_key, acct_key = get_account_info() print(f" ClientKey: {client_key}") print(f" AccountKey: {acct_key}") print_balances() print_positions() elif cmd == "buy": # saxo_broker.py buy NOVO-B 5 if len(sys.argv) < 4: print("Brug: saxo_broker.py buy TICKER ANTAL") sys.exit(1) ticker = sys.argv[2].upper() shares = int(sys.argv[3]) print(f"Placerer KØB {shares} × {ticker} på Saxo SIM ...") result = place_order(ticker, shares, "Buy") print(f"Ordre bekræftet: {json.dumps(result, indent=2)}") elif cmd == "sell": # saxo_broker.py sell NOVO-B 5 if len(sys.argv) < 4: print("Brug: saxo_broker.py sell TICKER ANTAL") sys.exit(1) ticker = sys.argv[2].upper() shares = int(sys.argv[3]) print(f"Placerer SÆLG {shares} × {ticker} på Saxo SIM ...") result = place_order(ticker, shares, "Sell") print(f"Ordre bekræftet: {json.dumps(result, indent=2)}") elif cmd == "positions": print_positions() elif cmd == "balances": print_balances() elif cmd == "uic": # saxo_broker.py uic NOVO-B ticker = sys.argv[2].upper() if len(sys.argv) > 2 else "NOVO-B" uic = find_uic(ticker) price = get_price(uic) if uic else None print(f"{ticker}: UIC={uic}, pris={price}") else: print(f"Ukendt kommando: {cmd}") print("Kommandoer: status | buy TICKER N | sell TICKER N | positions | balances | uic TICKER") sys.exit(1) if __name__ == "__main__": main()