]> git.ipfire.org Git - thirdparty/python-fints.git/commitdiff
client.get_holdings() retrieves all holdings from an account (#7)
authorMathias Dalheimer <md@gonium.net>
Fri, 5 May 2017 14:44:54 +0000 (16:44 +0200)
committerRaphael Michel <mail@raphaelmichel.de>
Fri, 5 May 2017 14:44:54 +0000 (16:44 +0200)
* client.get_holdings() retrieves all holdings from an account

* addressed the concerns of @raphaelm, see https://github.com/raphaelm/python-fints/pull/7

.gitignore
README.md
fints/client.py
fints/models.py
fints/segments/depot.py [new file with mode: 0644]
fints/segments/saldo.py
fints/utils.py

index 4b5b0981348340d37f622ddbbee83c88ef44f821..92e06a1b88dfa721b5f8f45a3cb6bbd36e3ede47 100644 (file)
@@ -5,4 +5,4 @@ dist/
 env
 .idea/
 test*.py
-
+*.pyc
index 244204c8c0c248be5059902b75d76bef51fc8f36..79768040c99d544cdece9c18a90e43d32f6074d2 100644 (file)
--- 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
index 671af70d0c9dcd592c37a0ffe2163d38cd8288ee..f9fe7551a4eba001ac2ed1373e71de9dca1833f7 100644 (file)
@@ -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
index f2ebc6933007aa1f1f40697d75898284613ce52e..67f6640d2356260c763e021cc71cfbd2f7556c99 100644 (file)
@@ -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 (file)
index 0000000..218d6b8
--- /dev/null
@@ -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)
index 76ef8e172cc6436dbb3bb16628fd3ea1f5492b05..1c923161b3751f885014098a94e8936724b19a04 100644 (file)
@@ -1,5 +1,6 @@
 from . import FinTS3Segment
 
+
 class HKSAL(FinTS3Segment):
     """
     HKSAL (Konto Saldo anfordern)
index 2ed12b485bf912f9ce79c014ce8646daf7331214..c06ec82d68e34d41f253ec896d35953f63162c13 100644 (file)
@@ -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