Files
mmd/saxo_broker.py

323 lines
11 KiB
Python
Raw Normal View History

2026-05-26 22:21:27 +02:00
"""
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()