Files
mmd/saxo_broker.py
Henrik Jess Nielsen 05eed51e7d First commit
2026-05-26 22:21:27 +02:00

323 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()