From f45d7d89b916745fe7036ae006d4e1eea5ec84c5 Mon Sep 17 00:00:00 2001 From: Mathias Dalheimer Date: Fri, 5 May 2017 16:44:54 +0200 Subject: [PATCH] client.get_holdings() retrieves all holdings from an account (#7) * client.get_holdings() retrieves all holdings from an account * addressed the concerns of @raphaelm, see https://github.com/raphaelm/python-fints/pull/7 --- .gitignore | 2 +- README.md | 9 +++- fints/client.py | 61 ++++++++++++++++++++++--- fints/models.py | 2 + fints/segments/depot.py | 23 ++++++++++ fints/segments/saldo.py | 1 + fints/utils.py | 98 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 fints/segments/depot.py diff --git a/.gitignore b/.gitignore index 4b5b098..92e06a1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ dist/ env .idea/ test*.py - +*.pyc diff --git a/README.md b/README.md index 244204c..7976804 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ Banks tested: * Triodos Bank * BBBank eG * Postbank -* [1822direkt](https://www.1822direkt.de/service/zugang-zum-konto/softwarebanking-mit-hbci/) +* [1822direkt](https://www.1822direkt.de/service/zugang-zum-konto/softwarebanking-mit-hbci/), including access to holding accounts * Sparkasse * Ing-Diba +* CortalConsors, including access to holding accounts Usage ----- @@ -48,6 +49,12 @@ print([t.data for t in statement]) # https://mt940.readthedocs.io/en/latest/mt940.html#mt940.models.Transaction # for documentation. Most information is contained in a dict accessible via their # ``data`` property + +# for retrieving the holdings of an account: +holdings = f.get_holdings() +# holdings contains a list of namedtuple values containing ISIN, name, +# market_value, pieces, total_value and valuation_date as parsed from +# the MT535 message. ``` Credits and License diff --git a/fints/client.py b/fints/client.py index 671af70..f9fe755 100644 --- a/fints/client.py +++ b/fints/client.py @@ -6,11 +6,11 @@ from .connection import FinTSHTTPSConnection from .dialog import FinTSDialog from .message import FinTSMessage from .models import SEPAAccount -from .models import Saldo from .segments.accounts import HKSPA from .segments.statement import HKKAZ from .segments.saldo import HKSAL -from .utils import mt940_to_array +from .segments.depot import HKWPD +from .utils import mt940_to_array, MT535_Miniparser from mt940.models import Balance logger = logging.getLogger(__name__) @@ -128,17 +128,17 @@ class FinTS3Client: logger.debug('Sending HKSAL: {}'.format(msg)) resp = dialog.send(msg) logger.debug('Got HKSAL response: {}'.format(resp)) - + # end dialog dialog.end() # find segment and split up to balance part seg = resp._find_segment('HISAL') arr = seg.split('+')[4].split(':') - + # get balance date date = datetime.datetime.strptime(arr[3], "%Y%m%d").date() - + # return balance return Balance(arr[0], arr[1], date, currency=arr[2]) @@ -164,7 +164,58 @@ class FinTS3Client: ) ]) + def get_holdings(self, account): + # init dialog + dialog = self._new_dialog() + dialog.sync() + dialog.init() + + # execute job + msg = self._create_get_holdings_message(dialog, account) + logger.debug('Sending HKWPD: {}'.format(msg)) + resp = dialog.send(msg) + logger.debug('Got HIWPD response: {}'.format(resp)) + + # end dialog + dialog.end() + + # find segment and split up to balance part + seg = resp._find_segment('HIWPD') + if seg: + mt535_lines = str.splitlines(seg) + # The first line contains a FinTS HIWPD header - drop it. + del mt535_lines[0] + mt535 = MT535_Miniparser() + return mt535.parse(mt535_lines) + else: + logger.debug('No HIWPD response segment found - maybe account has no holdings?') + return [] + + def _create_get_holdings_message(self, dialog: FinTSDialog, account: SEPAAccount): + hversion = dialog.hksalversion + + if hversion in (1, 2, 3, 4, 5, 6): + acc = ':'.join([ + account.accountnumber, account.subaccount, str(280), account.blz + ]) + elif hversion == 7: + acc = ':'.join([ + account.iban, account.bic, account.accountnumber, account.subaccount, str(280), account.blz + ]) + else: + raise ValueError('Unsupported HKSAL version {}'.format(hversion)) + + return self._new_message(dialog, [ + HKWPD( + 3, + hversion, + acc, + ) + ]) + + class FinTS3PinTanClient(FinTS3Client): + def __init__(self, blz, username, pin, server): self.username = username self.blz = blz diff --git a/fints/models.py b/fints/models.py index f2ebc69..67f6640 100644 --- a/fints/models.py +++ b/fints/models.py @@ -3,3 +3,5 @@ from collections import namedtuple SEPAAccount = namedtuple('SEPAAccount', 'iban bic accountnumber subaccount blz') Saldo = namedtuple('Saldo', 'account date value currency') + +Holding = namedtuple('Holding', 'ISIN name market_value value_symbol valuation_date pieces total_value') diff --git a/fints/segments/depot.py b/fints/segments/depot.py new file mode 100644 index 0000000..218d6b8 --- /dev/null +++ b/fints/segments/depot.py @@ -0,0 +1,23 @@ +from . import FinTS3Segment + + +class HKWPD(FinTS3Segment): + """ + HKWPD (Depotaufstellung anfordern) + Section C.4.3.1 + Example: HKWPD:3:7+23456::280:10020030+USD+2' + """ + type = 'HKWPD' + + def __init__(self, segno, version, account): + self.version = version + data = [ + account + # The spec allows the specification of currency and + # quality of valuations, as shown in the docstring above. + # However, both 1822direkt and CortalConsors reject the + # call if these two are present, claiming an invalid input. + # 'EUR' # Währung der Depotaufstellung" + # 2 # Kursqualität + ] + super().__init__(segno, data) diff --git a/fints/segments/saldo.py b/fints/segments/saldo.py index 76ef8e1..1c92316 100644 --- a/fints/segments/saldo.py +++ b/fints/segments/saldo.py @@ -1,5 +1,6 @@ from . import FinTS3Segment + class HKSAL(FinTS3Segment): """ HKSAL (Konto Saldo anfordern) diff --git a/fints/utils.py b/fints/utils.py index 2ed12b4..c06ec82 100644 --- a/fints/utils.py +++ b/fints/utils.py @@ -1,4 +1,7 @@ import mt940 +import re +from .models import Holding +from datetime import datetime def mt940_to_array(data): @@ -6,3 +9,98 @@ def mt940_to_array(data): data = data.replace("-0000", "+0000") transactions = mt940.models.Transactions() return transactions.parse(data) + + +def print_segments(message): + segments = str(message).split("'") + for idx, seg in enumerate(segments): + print(u"{}: {}".format(idx, seg.encode('utf-8'))) + + +class MT535_Miniparser: + re_identification = re.compile(r"^:35B:ISIN\s(.*)\|(.*)\|(.*)$") + re_marketprice = re.compile(r"^:90B::MRKT\/\/ACTU\/([A-Z]{3})(\d*),{1}(\d*)$") + re_pricedate = re.compile(r"^:98A::PRIC\/\/(\d*)$") + re_pieces = re.compile(r"^:93B::AGGR\/\/UNIT\/(\d*),(\d*)$") + re_totalvalue = re.compile(r"^:19A::HOLD\/\/([A-Z]{3})(\d*),{1}(\d*)$") + + def parse(self, lines): + retval = [] + # First: Collapse multiline clauses into one clause + clauses = self.collapse_multilines(lines) + # Second: Scan sequence of clauses for financial instrument + # sections + finsegs = self.grab_financial_instrument_segments(clauses) + # Third: Extract financial instrument data + for finseg in finsegs: + isin, name, market_price, price_symbol, price_date, pieces = (None,)*6 + for clause in finseg: + # identification of instrument + # e.g. ':35B:ISIN LU0635178014|/DE/ETF127|COMS.-MSCI EM.M.T.U.ETF I' + m = self.re_identification.match(clause) + if m: + isin = m.group(1) + name = m.group(3) + # current market price + # e.g. ':90B::MRKT//ACTU/EUR38,82' + m = self.re_marketprice.match(clause) + if m: + price_symbol = m.group(1) + market_price = float(m.group(2) + "." + m.group(3)) + # date of market price + # e.g. ':98A::PRIC//20170428' + m = self.re_pricedate.match(clause) + if m: + price_date = datetime.strptime(m.group(1), "%Y%m%d").date() + # number of pieces + # e.g. ':93B::AGGR//UNIT/16,8211' + m = self.re_pieces.match(clause) + if m: + pieces = float(m.group(1) + "." + m.group(2)) + # total value of holding + # e.g. ':19A::HOLD//EUR970,17' + m = self.re_totalvalue.match(clause) + if m: + total_value = float(m.group(2) + "." + m.group(3)) + # processed all clauses + retval.append( + Holding( + ISIN=isin, name=name, market_value=market_price, + value_symbol=price_symbol, valuation_date=price_date, + pieces=pieces, total_value=total_value)) + return retval + + def collapse_multilines(self, lines): + clauses = [] + prevline = "" + for line in lines: + if line.startswith(":"): + if prevline != "": + clauses.append(prevline) + prevline = line + elif line.startswith("-"): + # last line + clauses.append(prevline) + clauses.append(line) + else: + prevline += "|{}".format(line) + return clauses + + def grab_financial_instrument_segments(self, clauses): + retval = [] + stack = [] + within_financial_instrument = False + for clause in clauses: + if clause.startswith(":16R:FIN"): + # start of financial instrument + within_financial_instrument = True + elif clause.startswith(":16S:FIN"): + # end of financial instrument - move stack over to + # return value + retval.append(stack) + stack = [] + within_financial_instrument = False + else: + if within_financial_instrument: + stack.append(clause) + return retval -- 2.39.5