From d7085df55f6e1fcf892d7322297fdde98e27cf0e Mon Sep 17 00:00:00 2001 From: Patrick Ruckstuhl Date: Fri, 5 Jul 2024 23:58:59 +0000 Subject: [PATCH 1/2] Initial version of an importer for netbenefits trx --- docs/importers.rst | 25 ++- .../importers/general/priceLookup.py | 15 +- .../importers/netbenefits/__init__.py | 0 .../importers/netbenefits/importer.py | 173 ++++++++++++++++++ 4 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 src/tariochbctools/importers/netbenefits/__init__.py create mode 100644 src/tariochbctools/importers/netbenefits/importer.py diff --git a/docs/importers.rst b/docs/importers.rst index 800a902..13c2f4f 100644 --- a/docs/importers.rst +++ b/docs/importers.rst @@ -402,10 +402,33 @@ Import mt940 from `BCGE `__ Swisscard cards --------------- -Import Swisscard's `Cashback Cards ` transactions from a CSV export.__ +Import Swisscard's `Cashback Cards ` transactions from a CSV export. .. code-block:: python from tariochbctools.importers.swisscard import importer as swisscard CONFIG = [swisscard.SwisscardImporter("swisscard/.*\.csv", "Liabilities:Cashback")] + +Fidelity Netbenefits +-------------------- + +Import Fidelity Netbenefits `` transactions from a CSV export of the activities. + +.. code-block:: python + + from tariochbctools.importers.netbenefits import importer as netbenefits + + CONFIG = [ + netbenefits.Importer( + regexps="Transaction history\.csv", + cashAccount="Assets:Netbenefits:USD", + investmentAccount="Assets:Netbenefits:SYMBOL", + dividendAccount="Income:Interest", + taxAccount="Expenses:Tax", + capGainAccount="Income:Capitalgain", + symbol="SYMBOL", + ignoreTypes=["REINVESTMENT REINVEST @ $1.000"], + baseCcy="CHF", + ) + ] diff --git a/src/tariochbctools/importers/general/priceLookup.py b/src/tariochbctools/importers/general/priceLookup.py index 9f7557e..05090d0 100644 --- a/src/tariochbctools/importers/general/priceLookup.py +++ b/src/tariochbctools/importers/general/priceLookup.py @@ -1,16 +1,25 @@ from datetime import date from beancount.core import amount, prices +from beancount.core.number import D class PriceLookup: def __init__(self, existing_entries, baseCcy: str): - self.priceMap = prices.build_price_map(existing_entries) + if existing_entries: + self.priceMap = prices.build_price_map(existing_entries) + else: + self.priceMap = None self.baseCcy = baseCcy def fetchPriceAmount(self, instrument: str, date: date): - price = prices.get_price(self.priceMap, tuple([instrument, self.baseCcy]), date) - return price[1] + if self.priceMap: + price = prices.get_price( + self.priceMap, tuple([instrument, self.baseCcy]), date + ) + return price[1] + else: + return D(1) def fetchPrice(self, instrument: str, date: date): if instrument == self.baseCcy: diff --git a/src/tariochbctools/importers/netbenefits/__init__.py b/src/tariochbctools/importers/netbenefits/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tariochbctools/importers/netbenefits/importer.py b/src/tariochbctools/importers/netbenefits/importer.py new file mode 100644 index 0000000..b01e506 --- /dev/null +++ b/src/tariochbctools/importers/netbenefits/importer.py @@ -0,0 +1,173 @@ +import csv +from collections.abc import Iterable +from datetime import date +from io import StringIO + +from beancount.core import amount, data +from beancount.core.number import D +from beancount.core.position import CostSpec +from beancount.ingest import importer +from beancount.ingest.importers.mixins import identifier +from dateutil.parser import parse + +from tariochbctools.importers.general.priceLookup import PriceLookup + + +class Importer(identifier.IdentifyMixin, importer.ImporterProtocol): + """An importer for Fidelity Netbenefits Activity CSV files.""" + + def __init__( + self, + regexps: str | Iterable[str], + cashAccount: str, + investmentAccount: str, + dividendAccount: str, + taxAccount: str, + capGainAccount: str, + symbol: str, + ignoreTypes: Iterable[str], + baseCcy: str, + ): + identifier.IdentifyMixin.__init__(self, matchers=[("filename", regexps)]) + self.cashAccount = cashAccount + self.investmentAccount = investmentAccount + self.dividendAccount = dividendAccount + self.taxAccount = taxAccount + self.capGainAccount = capGainAccount + self.symbol = symbol + self.ignoreTypes = ignoreTypes + self.baseCcy = baseCcy + + def name(self): + return super().name() + self.cashAccount + + def file_account(self, file): + return self.cashAccount + + def extract(self, file, existing_entries): + entries = [] + + self.priceLookup = PriceLookup(existing_entries, self.baseCcy) + + with StringIO(file.contents()) as csvfile: + reader = csv.DictReader( + csvfile, + [ + "Transaction date", + "Transaction type", + "Investment name", + "Shares", + "Amount", + ], + delimiter=",", + skipinitialspace=True, + ) + next(reader) + for row in reader: + if not row["Transaction type"]: + break + + if row["Transaction type"] in self.ignoreTypes: + continue + + book_date = parse(row["Transaction date"].strip()).date() + amt = amount.Amount(D(row["Amount"].replace("$", "")), "USD") + shares = None + if row["Shares"] != "-": + shares = amount.Amount(D(row["Shares"]), self.symbol) + + metakv = {} + + if not amt and not shares: + continue + + meta = data.new_metadata(file.name, 0, metakv) + description = row["Transaction type"].strip() + + if "TAX" in description: + postings = self.__createDividend(amt, book_date, self.taxAccount) + elif "DIVIDEND" in description: + postings = self.__createDividend( + amt, book_date, self.dividendAccount + ) + elif "YOU BOUGHT" in description: + postings = self.__createBuy(amt, shares, book_date) + elif "YOU SOLD" in description: + postings = self.__createSell(amt, shares, book_date) + else: + postings = [ + data.Posting(self.cashAccount, amt, None, None, None, None), + ] + + if shares is not None: + postings.append( + data.Posting( + self.investmentAccount, shares, None, None, None, None + ), + ) + + entry = data.Transaction( + meta, + book_date, + "*", + "", + description, + data.EMPTY_SET, + data.EMPTY_SET, + postings, + ) + entries.append(entry) + + return entries + + def __createBuy(self, amt: amount, shares: amount, book_date: date): + price = self.priceLookup.fetchPrice("USD", book_date) + cost = CostSpec( + number_per=None, + number_total=round(-amt.number * price.number, 2), + currency=self.baseCcy, + date=None, + label=None, + merge=None, + ) + postings = [ + data.Posting(self.investmentAccount, shares, cost, None, None, None), + data.Posting(self.cashAccount, amt, None, price, None, None), + ] + + return postings + + def __createSell(self, amt: amount, shares: amount, book_date: date): + price = self.priceLookup.fetchPrice("USD", book_date) + cost = CostSpec( + number_per=None, + number_total=None, + currency=None, + date=None, + label=None, + merge=None, + ) + postings = [ + data.Posting(self.investmentAccount, shares, cost, None, None, None), + data.Posting(self.cashAccount, amt, None, price, None, None), + data.Posting(self.capGainAccount, None, None, None, None, None), + ] + + return postings + + def __createDividend(self, amt: amount, book_date: date, incomeAccount: str): + price = self.priceLookup.fetchPrice("USD", book_date) + postings = [ + data.Posting( + self.investmentAccount, + amount.Amount(D(0), self.symbol), + None, + None, + None, + None, + ), + data.Posting(self.cashAccount, amt, None, price, None, None), + data.Posting(incomeAccount, None, None, None, None, None), + ] + + return postings From a14a1592acd08d106d0b720abc252c7a7124a2cc Mon Sep 17 00:00:00 2001 From: Patrick Ruckstuhl Date: Sat, 6 Jul 2024 00:13:33 +0000 Subject: [PATCH 2/2] Upgrade precommit hooks --- .pre-commit-config.yaml | 10 +++++----- setup.py | 1 + src/tariochbctools/plugins/check_portfolio_sum.py | 1 + src/tariochbctools/plugins/generate_base_ccy_prices.py | 1 + 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 175c089..bf36254 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: '^docs/conf.py' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: check-added-large-files @@ -23,19 +23,19 @@ repos: - id: isort - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.4.2 hooks: - id: black language_version: python3 - repo: https://github.com/asottile/blacken-docs - rev: 1.16.0 + rev: 1.18.0 hooks: - id: blacken-docs additional_dependencies: [black] - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 additional_dependencies: [flake8-print] @@ -49,7 +49,7 @@ repos: files: README.rst - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.10.1 hooks: - id: mypy args: [--install-types, --non-interactive, --ignore-missing-imports] diff --git a/setup.py b/setup.py index 9ac1fee..7c422ae 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,7 @@ PyScaffold helps you to put up the scaffold of your new Python project. Learn more under: https://pyscaffold.org/ """ + from setuptools import setup if __name__ == "__main__": diff --git a/src/tariochbctools/plugins/check_portfolio_sum.py b/src/tariochbctools/plugins/check_portfolio_sum.py index 484c75e..14b5c90 100644 --- a/src/tariochbctools/plugins/check_portfolio_sum.py +++ b/src/tariochbctools/plugins/check_portfolio_sum.py @@ -1,6 +1,7 @@ """A plugin that verifies that on each transaction, all the "portfolios" have the same weight. """ + import collections from collections import defaultdict from decimal import Decimal diff --git a/src/tariochbctools/plugins/generate_base_ccy_prices.py b/src/tariochbctools/plugins/generate_base_ccy_prices.py index 36a66d3..e13b1f8 100644 --- a/src/tariochbctools/plugins/generate_base_ccy_prices.py +++ b/src/tariochbctools/plugins/generate_base_ccy_prices.py @@ -1,6 +1,7 @@ """A plugin that inserts an additional price to the base rate by applying fx rate to a price. """ + from beancount.core import amount, data, prices __plugins__ = ["generate"]