From d93eb8e936e057fc700b2769c34411b750d6703e Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Sat, 9 Apr 2022 19:59:22 +0100 Subject: [PATCH] Implement fetching account balances --- handler/lib/serde/nordigen.py | 15 ++++++++++ handler/lib/serde/truelayer.py | 14 +++++++++ handler/money.py | 13 ++++++++ handler/sinks/__init__.py | 13 ++++++++ handler/sinks/nordigen.py | 55 ++++++++++++++++++++++++++++++---- handler/sinks/truelayer.py | 53 ++++++++++++++++++++++++++++++-- handler/transactions.py | 16 +++++----- 7 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 handler/lib/serde/truelayer.py diff --git a/handler/lib/serde/nordigen.py b/handler/lib/serde/nordigen.py index f546526..7f4ec04 100644 --- a/handler/lib/serde/nordigen.py +++ b/handler/lib/serde/nordigen.py @@ -82,3 +82,18 @@ class Account(Model): class AccountDetails(Model): account: fields.Nested(Account) + + +class AccountBalanceAmount(Model): + amount: fields.Str() + currency: fields.Str() + + +class AccountBalances(Model): + balanceAmount: fields.Nested(AccountBalanceAmount) + balanceType: fields.Str() + referenceDate: fields.Date() + + +class AccountBalancesRoot(Model): + balances = fields.List(AccountBalances) diff --git a/handler/lib/serde/truelayer.py b/handler/lib/serde/truelayer.py new file mode 100644 index 0000000..ccc15fe --- /dev/null +++ b/handler/lib/serde/truelayer.py @@ -0,0 +1,14 @@ +from serde import Model, fields + + +class AccountBalances(Model): + currency: fields.Str() + available: fields.Float() + current: fields.Float() + overdraft: fields.Float() + update_timestamp: fields.DateTime() + + +class AccountBalancesRoot(Model): + results: fields.List(AccountBalances) + status: fields.Str() diff --git a/handler/money.py b/handler/money.py index 0a47bd6..a9beb6c 100644 --- a/handler/money.py +++ b/handler/money.py @@ -98,6 +98,19 @@ class Money(util.Base): rates = self.get_rates_all() return float(amount) / rates[currency] + def multiple_to_usd(self, currency_map): + """ + Convert multiple curencies to USD while saving API calls. + """ + rates = self.get_rates_all() + cumul = 0 + for currency, amount in currency_map.items(): + if currency == "USD": + cumul += float(amount) + else: + cumul += float(amount) / rates[currency] + return cumul + # TODO: move to money def get_profit(self, trades=False): """ diff --git a/handler/sinks/__init__.py b/handler/sinks/__init__.py index 75d8795..6efa00d 100644 --- a/handler/sinks/__init__.py +++ b/handler/sinks/__init__.py @@ -87,3 +87,16 @@ class Sinks(util.Base): # {"EUR": {"IBAN": "xxx", "BIC": "xxx"}, # "GBP": {"SORT": "04-04-04", "ACCOUNT": "1922-2993"}} # self.markets.distribute_account_details(currencies, account_infos) + + def get_total_usd(self): + """ + Get the total balance of our accounts in USD. + """ + total_nordigen = self.nordigen.get_total_map() + total_truelayer = self.truelayer.get_total_map() + + # Yes, we can save an API call by merging but I think this is clearer + total_nordigen_usd = self.money.multiple_to_usd(total_nordigen) + total_truelayer_usd = self.money.multiple_to_usd(total_truelayer) + + return total_truelayer_usd + total_nordigen_usd diff --git a/handler/sinks/nordigen.py b/handler/sinks/nordigen.py index fc7624b..dcdfed6 100644 --- a/handler/sinks/nordigen.py +++ b/handler/sinks/nordigen.py @@ -5,7 +5,7 @@ from twisted.internet.task import LoopingCall import requests from simplejson.errors import JSONDecodeError from json import dumps, loads -from lib.serde.nordigen import TXRoot, AccessToken, Institutions, Agreement, Requisitions, AccountDetails +from lib.serde.nordigen import TXRoot, AccessToken, Institutions, Agreement, Requisitions, AccountDetails, AccountBalancesRoot from serde import ValidationError # Project imports @@ -242,9 +242,6 @@ class Nordigen(util.Base): continue accounts = self.get_accounts(req["id"]) for account_id in accounts: - # if not account_id in self.banks: - # print("account_id", account_id, "not in self.banks!") - # continue account_info = self.get_account(account_id) if not account_info: continue @@ -281,7 +278,55 @@ class Nordigen(util.Base): headers = {"accept": "application/json", "Authorization": f"Bearer {self.token}"} path = f"{settings.Nordigen.Base}/accounts/{account_id}/transactions/" r = requests.get(path, headers=headers) - obj = TXRoot.from_json(r.content) + try: + obj = TXRoot.from_json(r.content) + except ValidationError as err: + self.log.error(f"Validation error: {err}") + return parsed = obj.to_dict()["transactions"]["booked"] self.normalise_transactions(parsed) return parsed + + def get_balance(self, account_id): + """ + Get the balance and currency of an account. + :param account_id: the account ID + :return: tuple of (currency, amount) + :rtype: tuple + """ + headers = {"accept": "application/json", "Authorization": f"Bearer {self.token}"} + path = f"{settings.Nordigen.Base}/accounts/{account_id}/balances/" + r = requests.get(path, headers=headers) + try: + obj = AccountBalancesRoot.from_json(r.content) + except ValidationError as err: + self.log.error(f"Validation error: {err}") + return + parsed = obj.to_dict()["balances"] + total = 0 + currency = None + for entry in parsed: + if currency: + if not currency == entry["balanceAmount"]["currency"]: + self.log.error("Different currencies in balance query.") + return + total += float(entry["balanceAmount"]["amount"]) + currency = entry["balanceAmount"]["currency"] + return (currency, total) + + def get_total_map(self): + """ + Return a dictionary keyed by currencies with the amounts as values. + :return: dict keyed by currency, values are amounts + :rtype: dict + """ + totals = {} + for account_id in self.banks: + currency, amount = self.get_balance(account_id) + if not amount: + continue + if currency in totals: + totals[currency] += amount + else: + totals[currency] = amount + return totals diff --git a/handler/sinks/truelayer.py b/handler/sinks/truelayer.py index 6ed0d19..7a60f36 100644 --- a/handler/sinks/truelayer.py +++ b/handler/sinks/truelayer.py @@ -6,7 +6,9 @@ import requests from simplejson.errors import JSONDecodeError from time import time from json import dumps, loads +from lib.serde.truelayer import AccountBalancesRoot import urllib +from serde import ValidationError # Project imports from settings import settings @@ -274,8 +276,55 @@ class TrueLayer(util.Base): parsed = r.json() except JSONDecodeError: self.log.error(f"Error parsing transactions response: {r.content}") - return False + return (False, False) if "results" in parsed: return parsed["results"] else: - return False + return (False, False) + + def get_balance(self, bank, account_id): + """ + Get the balance of an account. + :param bank: the bank to check + :param account_id: the account ID + :return: tuple of (currency, amount) + :rtype: tuple + """ + token = self.get_key(bank) + headers = {"Authorization": f"Bearer {token}"} + path = f"{settings.TrueLayer.DataBase}/accounts/{account_id}/balance" + r = requests.get(path, headers=headers) + try: + obj = AccountBalancesRoot.from_json(r.content) + except ValidationError as err: + self.log.error(f"Validation error: {err}") + return + parsed = obj.to_dict()["results"] + total = 0 + currency = None + for entry in parsed: + if currency: + if not currency == entry["currency"]: + self.log.error("Different currencies in balance query.") + return + total += entry["available"] + currency = entry["currency"] + return (currency, total) + + def get_total_map(self): + """ + Return a dictionary keyed by currencies with the amounts as values. + :return: dict keyed by currency, values are amounts + :rtype: dict + """ + totals = {} + for bank in self.banks: + for account_id in self.banks[bank]: + currency, amount = self.get_balance(bank, account_id) + if not amount: + continue + if currency in totals: + totals[currency] += amount + else: + totals[currency] = amount + return totals diff --git a/handler/transactions.py b/handler/transactions.py index 5ee57b4..4d9a3d3 100644 --- a/handler/transactions.py +++ b/handler/transactions.py @@ -263,8 +263,11 @@ class Transactions(util.Base): matching_refs = [] # TODO: use get_ref_map in this function instead of calling get_ref multiple times for ref in refs: + print(f"ITER REF {ref}") stored_trade = self.get_ref(ref) + print(f"ITER REF STORED TRADE {stored_trade}") if stored_trade["currency"] == currency and float(stored_trade["amount"]) == float(amount): + print(f"APPENDING STORED TRADE AS MATCH {stored_trade}") matching_refs.append(stored_trade) if len(matching_refs) != 1: self.log.error(f"Find trade returned multiple results for TXID {txid}: {matching_refs}") @@ -368,7 +371,7 @@ class Transactions(util.Base): :return: value in USD :rtype float: """ - # TODO: get Sink totals + total_sinks_usd = self.sinks.get_total_usd() agora_wallet_xmr = self.agora.agora.wallet_balance_xmr() if not agora_wallet_xmr["success"]: return False @@ -391,9 +394,7 @@ class Transactions(util.Base): # Add it all up total_usd_agora = total_usd_agora_xmr + total_usd_agora_btc - # total_usd = total_usd_agora + total_usd_revolut - # TODO: add sinks value here - total_usd = total_usd_agora + total_usd = total_usd_agora + total_sinks_usd cast_es = { "price_usd": total_usd, "total_usd_agora_xmr": total_usd_agora_xmr, @@ -416,8 +417,7 @@ class Transactions(util.Base): :return: ((total SEK, total USD, total GBP), (total XMR USD, total BTC USD), (total XMR, total BTC)) :rtype: tuple(tuple(float, float, float), tuple(float, float), tuple(float, float)) """ - # TODO: get sinks value here - # total_usd_revolut = self.revolut.get_total_usd() + total_sinks_usd = self.sinks.get_total_usd() agora_wallet_xmr = self.agora.agora.wallet_balance_xmr() if not agora_wallet_xmr["success"]: self.log.error("Could not get Agora XMR wallet total.") @@ -442,9 +442,7 @@ class Transactions(util.Base): # Add it all up total_usd_agora = total_usd_agora_xmr + total_usd_agora_btc - # total_usd = total_usd_agora + total_usd_revolut - # TODO: add sinks value here - total_usd = total_usd_agora + total_usd = total_usd_agora + total_sinks_usd # Convert the total USD price to GBP and SEK rates = self.money.get_rates_all()