21/05/2021: Dette program virker ikke længere pga. opdatering i loginproceduren. Hent det opdaterede program i stedet.
Opdateret d. 20/10/2019: Nogle gange har Saxo Bank en “disclaimer” (i dette tilfælde omkring Brexit), som de vil vise, inden man får lov at logge på. Jeg har tilpasset koden, sådan programmet kan håndtere dette tilfælde.
Jeg har tidligere skrevet om, hvordan jeg trækker transaktionsdata ud fra mine konti hos Nordnet.
Nu er jeg også blevet kunde hos Saxo Bank. (Hvorfor? Mulighed for at oprette en aktiesparekonto og ingen minimumskurtage.)
Selvbetjeningsløsningen hos Saxo Bank er rigtig dårlig (sammenlignet med Nordnets).
Derfor var jeg interesseret i, om jeg kunne finde en måde at trække transaktionsdata ud fra min konto hos Saxo Bank – uden at have brug for at se på hjemmesiden.
Det kunne jeg. Måske ikke på den smarteste måde i verden, for Saxo Bank har faktisk en API-løsning, man kan bruge, hvis man har mod på at udfylde en aftale i hånden og scanne den ind (det gad jeg ikke).
Her kan du læse, hvordan jeg fik fat i mine data.
Snakke http med Saxo
Ligesom da jeg hentede mine transaktioner hos Nordnet, undersøgte jeg, hvordan min browser snakker med – og viser data fra – min konto hos Saxo Bank.
Allerførst kiggede jeg på, hvad hjemmesiden gør, når den skal vise mine data. Jeg valgte gennemførte handler i menuen:

Og så kiggede jeg på, hvad browseren gjorde. Det viser sig at hjemmesiden – fornuftigt nok – bruger Saxo Banks API, når den skal vise data til brugeren:

Jeg kunne se, at API’et modtog en lang streng (“Authorization” med ordet BEARER foran). Den gik jeg ud fra, var nødvendig, for at få data tilbage.
Så spørgsmålet var egentlig bare: Hvordan bliver sådan en BEARER-streng genereret?
Tilbage til start
For at komme frem til, hvordan BEARER-strengen genereres, gik jeg tilbage til start: Jeg gik til loginsiden og trykkede F12 i min browser (Chrome) for at følge med i netværksforespørgslerne.
Loginsidens formular sender mit brugernavn og password af sted, sammen med en streng – “AuthnRequest” – der genereres på ny hver gang, loginsiden hentes:

I mit Python-program prøver jeg at sende sådan en formular af sted, og undersøger hvad jeg får tilbage.
# Visit login page and get AuthnRequest token value from input form
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.get(url)
soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"id":"AuthnRequest"})
authnrequest = input[0]["value"]
# Login step 1: Submit username, password and token and get another token back
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.post(url, data = {'field_userid': user, 'field_password': password, 'AuthnRequest': authnrequest})
Lidt forkortet ser det sådan her ud:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="Application-State" content="service=IDP;federated=False;env=Live;state=Ok;authenticated=True;"><meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<noscript><p><strong>Note:</strong> Since your browser does not support Javascript, you must press the Continue button to proceed.</p></noscript>
<form id="form" action="https://www.saxoinvestor.dk/investor/login.sso.ashx" method="post"><div>
<input type="hidden" name="SAMLResponse" value="PHNhbWxwOlJlc3BvbnNlIElEPSJfNjQzZGI4ODQtMDMzZi00MWVhLWE4ZjEtYzVjOWVlMWIxM2IwIiBJblJlc3BvbnNlVG89Il9mN2E3ODBlZi0yZjdmLTQyYmItODk1[...]G9uc2U+"/>
<input type="hidden" name="RelayState" value=""/>
<input type="hidden" name="PageLoadInfo" id="PageLoadInfo" value=""/></div>
<noscript><div>
<input type="submit" value="Continue"/></div></noscript></form><script type="text/javascript">function doSubmit(){var t=-1;if(window.location.hash){var m=window.location.hash.match(/\/lst\/(\d+)/);if(m) t=parseInt(m[1]);}if(t>=0 && document.getElementById("PageLoadInfo").value=='')document.getElementById("PageLoadInfo").value=t;document.forms.form.submit();}</script><script type="text/javascript">doSubmit();</script></body></html>
Og hvad er det så? En formular (“<input>”) til browsere uden Javascript, med et felt der hedder “SAMLResponse” med en lang (her forkortet) streng som værdi.
I Chrome kan jeg se, at allersidste trin. inden jeg når ind på forsiden af selvbetjeningen, faktisk er, at min browser sender “SAMLResponse” af sted til en side, der hedder “login.sso.ashx”:

Så jeg sender trygt formularen af sted med SAMLResponse-.værdien og ser, hvad jeg får tilbage:
soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})
samlresponse = input[0]["value"]
# Login step 2: Get bearer token necessary for API requests
url = 'https://www.saxoinvestor.dk/investor/login.sso.ashx'
r = requests.post(url, data = {'SAMLResponse': samlresponse})
Og vupti: Siden videresender mig til API’et med et BEARER-token, jeg kan benytte mig af. Det får jeg fat i (og skærer lidt til) sådan her::
bearer = r.history[0].headers['Location']
bearer = bearer[bearer.find("BEARER"):bearer.find("/exp/")]
bearer = bearer.replace("%20"," ")
Og så er jeg ellers klar til at hente data fra API’et. Det starter sådan her:
# START API CALLS
# Documentation at https://www.developer.saxo/openapi/learn
# Set bearer token as header
headers = {'Authorization': bearer}
# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
r = requests.get(url, headers=headers)
clientdata = r.json()
clientkey = clientdata['ClientKey']
Hele programmet
Hele programmet – inklusive den måde, jeg omdanner Saxo Bank-data til samme format som Nordnets transaktionsdata – er her.
Du må gøre præcis som du har lyst til med det (på eget ansvar).
# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
"""This program logs into a Saxo Bank account and lets you make API requests."""
import requests
from datetime import datetime
from datetime import date
from bs4 import BeautifulSoup
import json
# USER ACCOUNT AND PERIOD DATA. SHOULD BE EDITED FOR YOUR NEEDS #
# Saxo user account credentials
user = '' # your user id
password = '' # your password
# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '2019-01-01'
today = date.today()
enddate = datetime.strftime(today, '%Y-%m-%d')
# LOGIN TO SAXO BANK
# Visit login page and get AuthnRequest token value from input form
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.get(url)
soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"id":"AuthnRequest"})
authnrequest = input[0]["value"]
# Login step 1: Submit username, password and token and get another token back
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.post(url, data = {'field_userid': user, 'field_password': password, 'AuthnRequest': authnrequest})
soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})
# Most of the time this works
if input:
samlresponse = input[0]["value"]
# But sometimes there's a disclaimer that Saxo Bank would like you to accept
else:
input = soup.find_all('input')
inputs = {}
try:
for i in input:
inputs[i['name']] = i['value']
except:
pass
url = 'https://www.saxotrader.com/disclaimer'
request = requests.post(url, data=inputs)
cook = request.cookies['DisclaimerApp']
returnurl = cook[cook.find("ReturnUrl")+10:cook.find("&IsClientStation")]
url = 'https://live.logonvalidation.net/complete-app-consent/' + returnurl[returnurl.find("complete-app-consent/")+21:]
request = requests.get(url)
soup = BeautifulSoup(request.text, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})
samlresponse = input[0]["value"]
# Login step 2: Get bearer token necessary for API requests
url = 'https://www.saxoinvestor.dk/investor/login.sso.ashx'
r = requests.post(url, data = {'SAMLResponse': samlresponse})
bearer = r.history[0].headers['Location']
bearer = bearer[bearer.find("BEARER"):bearer.find("/exp/")]
bearer = bearer.replace("%20"," ")
# START API CALLS
# Documentation at https://www.developer.saxo/openapi/learn
# Set bearer token as header
headers = {'Authorization': bearer}
# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
r = requests.get(url, headers=headers)
clientdata = r.json()
clientkey = clientdata['ClientKey']
# Example API call #1
url = 'https://www.saxoinvestor.dk/openapi/cs/v1/reports/aggregatedAmounts/' + clientkey + '/' + startdate + '/' + enddate + '/'
r = requests.get(url, headers=headers)
data = r.json()
# Working on that data to add some transaction types to personal system
saxoaccountname = "Aktiesparekonto: Saxo Bank"
currency = "DKK"
saxotransactions = ""
for item in data['Data']:
if item['AffectsBalance'] == True:
date = item['Date']
amount = item['Amount']
amount_str = str(amount).replace(".",",")
if item['UnderlyingInstrumentDescription'] == 'Cash deposit or withdrawal' or item['UnderlyingInstrumentDescription'] == 'Cash inter-account transfer':
if amount > 0:
transactiontype = 'INDBETALING'
elif amount < 0:
transactiontype = 'HÆVNING'
saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";;;;;;;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\r\n"
if item['AmountTypeName'] == 'Corporate Actions - Cash Dividends':
transactiontype = "UDB."
if item['InstrumentDescription'] == "Novo Nordisk B A/S":
paper = "Novo B"
papertype = "Aktie"
if item['InstrumentDescription'] == "Tryg A/S":
paper = "TRYG"
papertype = "Aktie"
saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";" + paper + ";" + papertype + ";;;;;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\n"
# Example API call #2
url = "https://www.saxoinvestor.dk/openapi/cs/v1/reports/trades/" + clientkey + "?fromDate=" + startdate + "&" + "toDate=" + enddate
r = requests.get(url, headers=headers)
data = r.json()
# Working on that data to add trades to personal system
for item in data['Data']:
date = item['AdjustedTradeDate']
numberofpapers = str(int(item['Amount']))
amount_str = str(item['BookedAmountAccountCurrency']).replace(".",",")
priceperpaper = str(item['BookedAmountAccountCurrency'] / item['Amount']).replace(".",",")
if item['TradeEventType'] == 'Bought':
transactiontype = "KØBT"
if item['AssetType'] == 'Stock':
papertype = "Aktie"
if item['InstrumentDescription'] == "Novo Nordisk B A/S":
paper = "Novo B"
isin = "DK0060534915"
if item['InstrumentDescription'] == "Tryg A/S":
paper = "TRYG"
isin = "DK0060636678"
saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";" + paper + ";" + papertype + ";" + isin + ";" + numberofpapers + ";" + priceperpaper + ";;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\n"