aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWolfgang Müller2025-02-18 13:21:20 +0100
committerWolfgang Müller2025-02-18 13:21:20 +0100
commite6c6285479ca6ae22901e3e909898cda2fe8fbb5 (patch)
tree438827b4a331e5c19b3f54825de1a78a4f94f01e
downloadbeancount-oriole-trunk.tar.gz
Initial commitHEADtrunk
-rw-r--r--.gitignore3
-rw-r--r--README.md11
-rw-r--r--beancount_oriole/__init__.py0
-rw-r--r--beancount_oriole/prices/__init__.py0
-rw-r--r--beancount_oriole/prices/ing_csv.py72
-rw-r--r--beancount_oriole/tag_by_account.py37
-rw-r--r--pyproject.toml38
7 files changed, 161 insertions, 0 deletions
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
--- /dev/null
+++ b/beancount_oriole/__init__.py
diff --git a/beancount_oriole/prices/__init__.py b/beancount_oriole/prices/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/beancount_oriole/prices/__init__.py
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
+]