From e6c6285479ca6ae22901e3e909898cda2fe8fbb5 Mon Sep 17 00:00:00 2001 From: Wolfgang Müller Date: Tue, 18 Feb 2025 13:21:20 +0100 Subject: Initial commit --- .gitignore | 3 ++ README.md | 11 ++++++ beancount_oriole/__init__.py | 0 beancount_oriole/prices/__init__.py | 0 beancount_oriole/prices/ing_csv.py | 72 +++++++++++++++++++++++++++++++++++++ beancount_oriole/tag_by_account.py | 37 +++++++++++++++++++ pyproject.toml | 38 ++++++++++++++++++++ 7 files changed, 161 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 beancount_oriole/__init__.py create mode 100644 beancount_oriole/prices/__init__.py create mode 100644 beancount_oriole/prices/ing_csv.py create mode 100644 beancount_oriole/tag_by_account.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4176048 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.egg-info/ +/build/ +/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d87b31 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# beancount-oriole + +This is a collection of my personal beancount plugins. + +## Plugins + +- `tag_by_account`: Automatically applies tags on certain accounts. + +## Prices + +- `ing_csv`: Extract prices from weekly watch list reports from wertpapiere.ing.de. diff --git a/beancount_oriole/__init__.py b/beancount_oriole/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beancount_oriole/prices/__init__.py b/beancount_oriole/prices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beancount_oriole/prices/ing_csv.py b/beancount_oriole/prices/ing_csv.py new file mode 100644 index 0000000..a6d394d --- /dev/null +++ b/beancount_oriole/prices/ing_csv.py @@ -0,0 +1,72 @@ +import csv +import os +import re +from dataclasses import dataclass +from datetime import datetime + +import pytz +from beancount.core.number import D +from beanprices import source + +FOLDER = "~/net/downloads" +PATTERN = re.compile(r"Depot_\d{2}\.\d{2}\.\d{4}\.csv") +TZ = pytz.timezone("Europe/Berlin") + + +@dataclass +class WatchlistEntry: + date: datetime + price_map: dict + + +def parse_header(line): + datestr = line.strip().removeprefix("Depotbewertung vom ") + + return datetime.strptime(datestr, "%d.%m.%Y %H:%M:%S") + + +def parse_file(handle): + date = parse_header(handle.readline()).replace(tzinfo=TZ) + handle.readline() + handle.readline() + + handle.readline() + handle.readline() + handle.readline() + + reader = csv.DictReader(handle, delimiter=";") + + prices = dict() + + for row in reader: + if isin := row["ISIN"]: + price = D(row["Aktueller Preis"].replace(",", ".")) + prices[isin] = source.SourcePrice(price, date, row["Währung"]) + + return WatchlistEntry(date, prices) + + +def get_entries(): + for entry in os.scandir(os.path.expanduser(FOLDER)): + if entry.is_file(): + if not PATTERN.match(entry.name): + continue + + with open(entry, encoding="cp1252") as handle: + yield parse_file(handle) + + +class Source(source.Source): + def get_latest_price(self, isin): + entries = sorted(get_entries(), key=lambda e: e.date, reverse=True) + if not entries: + return None + + return entries[0].price_map.get(isin, None) + + def get_historical_price(self, isin, time): + for entry in get_entries(): + if entry.date.date() == time.date(): + return entry.price_map.get(isin, None) + + return None diff --git a/beancount_oriole/tag_by_account.py b/beancount_oriole/tag_by_account.py new file mode 100644 index 0000000..4ccd203 --- /dev/null +++ b/beancount_oriole/tag_by_account.py @@ -0,0 +1,37 @@ +import ast +import collections + +from beancount.core import data + +__plugins__ = ["tag_by_account"] + +ConfigError = collections.namedtuple("ConfigError", ["source", "message", "entry"]) + + +def tag_by_account(entries, option_map, config): + errors = [] + + if config.strip(): + try: + tag_map = ast.literal_eval(config) + except (SyntaxError, ValueError): + filename = data.new_metadata(option_map["filename"], 0) + errors.append( + ConfigError(filename, f"Syntax error in config: {config}", None) + ) + return entries, errors + else: + return entries, errors + + for index, entry in enumerate(entries): + if not isinstance(entry, data.Transaction): + continue + + for posting in entry.postings: + if posting.account in tag_map: + new_tags = tag_map[posting.account] + old_tags = entry.tags or [] + entries[index] = entry._replace(tags=set(old_tags).union(new_tags)) + break + + return entries, errors diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1142dba --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "beancount-oriole" +version = "0.1.0" +description = "A collection of beancount plugins" +requires-python = ">=3.12" +license = {text = "MIT License"} +authors = [ + {name = "Wolfgang Müller", email = "wolf@oriole.systems"} +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "beancount>=2,<4", +] + +[project.urls] +Homepage = "https://git.oriole.systems/beancount-oriole/" +Source = "https://git.oriole.systems/beancount-oriole/" + +[build-system] +requires = ["setuptools >= 61.1"] +build-backend = "setuptools.build_meta" + +[tool.ruff.lint] +# https://docs.astral.sh/ruff/rules/ +select = [ + "F", # pyflakes + "E", # pycodestyle + "W", # pycodestyle + "I", # isort + "UP", # pyupgrade + "A", # flake8-builtins + "B", # flake8-bugbear + "SIM", # flake8-simplify + "FURB" # refurb +] -- cgit v1.2.3-2-gb3c3