From 51fca76c8420b92ff0723da973379199cf91565c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 18:59:13 +0200 Subject: [PATCH 001/141] CHANGE save list of rows for each operation --- src/book.py | 5 +++-- src/taxman.py | 4 ++-- src/transaction.py | 14 ++++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/book.py b/src/book.py index 98879b07..67654e99 100644 --- a/src/book.py +++ b/src/book.py @@ -72,7 +72,7 @@ def create_operation( ) raise RuntimeError - op = Op(utc_time, platform, change, coin, row, file_path) + op = Op(utc_time, platform, change, coin, [row], file_path) assert isinstance(op, tr.Operation) return op @@ -654,7 +654,8 @@ def _read_kraken_ledgers(self, file_path: Path) -> None: op.coin == self.kraken_held_ops[refid]["operation"].coin ), "coin" except AssertionError as e: - first_row = self.kraken_held_ops[refid]["operation"].line + # Row is internally saved as list[int]. + first_row = self.kraken_held_ops[refid]["operation"].line[0] log.error( "Two internal kraken operations matched by the " f"same {refid=} don't have the same {e}.\n" diff --git a/src/taxman.py b/src/taxman.py index edf26f02..14099b9d 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -91,7 +91,7 @@ def evaluate_sell( # Queue ran out of items to sell and not all coins # could be sold. log.error( - f"{op.file_path.name}: Line {op.line}: " + f"{op.file_path.name}: Lines {op.line}: " f"Not enough {coin} in queue to sell: " f"missing {unsold_coins} {coin} " f"(transaction from {op.utc_time} " @@ -237,7 +237,7 @@ def evaluate_sell( op.platform, left_coin, coin, - -1, + [-1], Path(""), ) if tx_ := evaluate_sell(virtual_sell, force=True): diff --git a/src/transaction.py b/src/transaction.py index 1c3c4fbf..880e5099 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -33,7 +33,7 @@ class Operation: platform: str change: decimal.Decimal coin: str - line: int + line: list[int] file_path: Path def __post_init__(self): @@ -52,10 +52,16 @@ def validate_types(self) -> bool: actual_type = typing.get_origin(field.type) or field.type - if isinstance(actual_type, str): - actual_type = eval(actual_type) - elif isinstance(actual_type, typing._SpecialForm): + if isinstance(actual_type, typing._SpecialForm): actual_type = field.type.__args__ + elif isinstance(actual_type, str): + while isinstance(actual_type, str): + # BUG row:list[int] value gets only checked for list. + # not as list[int] + if actual_type.startswith("list["): + actual_type = list + else: + actual_type = eval(actual_type) actual_value = getattr(self, field.name) if not isinstance(actual_value, actual_type): From a6ccf11dce705e5331a11eeea44fdc97c2872e8c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 19:28:47 +0200 Subject: [PATCH 002/141] UPDATE misc.group_by to also accept list[str] --- src/misc.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/misc.py b/src/misc.py index af80261a..80e4b130 100644 --- a/src/misc.py +++ b/src/misc.py @@ -172,7 +172,7 @@ def parse_iso_timestamp_to_decimal_timestamp(d: str) -> decimal.Decimal: return to_decimal_timestamp(datetime.datetime.fromisoformat(d)) -def group_by(lst: L, key: str) -> dict[Any, L]: +def group_by(lst: L, key: Union[str, list[str]]) -> dict[Any, L]: """Group a list of objects by `key`. Args: @@ -183,8 +183,15 @@ def group_by(lst: L, key: str) -> dict[Any, L]: dict[Any, list]: Dict with different `key`as keys. """ d = collections.defaultdict(list) - for e in lst: - d[getattr(e, key)].append(e) + if isinstance(key, str): + for e in lst: + d[getattr(e, key)].append(e) + elif isinstance(key, list): + assert all(isinstance(k, str) for k in key) + for e in lst: + d[tuple(getattr(e, k) for k in key)].append(e) + else: + raise TypeError return dict(d) From b39b77bd7af538e81320e9637f5b20416b255eb5 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 19:32:57 +0200 Subject: [PATCH 003/141] ADD dsum which sums decimals and returns decimal normal sum would return Union[decimal.Decimal, Literal[0]] which is unwanted --- src/misc.py | 5 +++++ src/taxman.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/misc.py b/src/misc.py index 80e4b130..8b82092e 100644 --- a/src/misc.py +++ b/src/misc.py @@ -25,6 +25,7 @@ from typing import ( Any, Callable, + Iterable, Optional, SupportsFloat, SupportsInt, @@ -68,6 +69,10 @@ def xdecimal( return None if x is None or x == "" else decimal.Decimal(x) +def dsum(__iterable: Iterable[decimal.Decimal]) -> decimal.Decimal: + return decimal.Decimal(sum(__iterable)) + + def force_decimal(x: Union[None, str, int, float, decimal.Decimal]) -> decimal.Decimal: """Convert to decimal, but make sure, that empty values raise an error. diff --git a/src/taxman.py b/src/taxman.py index 14099b9d..5835718d 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -227,7 +227,11 @@ def evaluate_sell( # Calculate the amount of coins which should be left on the platform # and evaluate the (taxed) gain, if the coin would be sold right now. if config.CALCULATE_UNREALIZED_GAINS and ( - (left_coin := sum(((bop.op.change - bop.sold) for bop in balance.queue))) + ( + left_coin := misc.dsum( + ((bop.op.change - bop.sold) for bop in balance.queue) + ) + ) ): assert isinstance(left_coin, decimal.Decimal) # Calculate unrealized gains for the last time of `TAX_YEAR`. @@ -285,7 +289,7 @@ def print_evaluation(self) -> None: for taxation_type, tax_events in misc.group_by( self.tax_events, "taxation_type" ).items(): - taxed_gains = sum(tx.taxed_gain for tx in tax_events) + taxed_gains = misc.dsum(tx.taxed_gain for tx in tax_events) eval_str += f"{taxation_type}: {taxed_gains:.2f} {config.FIAT}\n" else: eval_str += ( @@ -301,9 +305,9 @@ def print_evaluation(self) -> None: ) lo_date = latest_operation.op.utc_time.strftime("%d.%m.%y") - invsted = sum(tx.sell_price for tx in self.virtual_tax_events) - real_gains = sum(tx.real_gain for tx in self.virtual_tax_events) - taxed_gains = sum(tx.taxed_gain for tx in self.virtual_tax_events) + invsted = misc.dsum(tx.sell_price for tx in self.virtual_tax_events) + real_gains = misc.dsum(tx.real_gain for tx in self.virtual_tax_events) + taxed_gains = misc.dsum(tx.taxed_gain for tx in self.virtual_tax_events) eval_str += "\n" eval_str += ( f"Deadline {config.TAX_YEAR}: {lo_date}\n" From abeca6a7acdbff46692fb4558a1e83df15314fce Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 19:38:08 +0200 Subject: [PATCH 004/141] CHANGE merge identical operations together --- src/book.py | 4 ++++ src/main.py | 2 ++ src/transaction.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/book.py b/src/book.py index 67654e99..3c95923e 100644 --- a/src/book.py +++ b/src/book.py @@ -1253,6 +1253,10 @@ def get_price_from_csv(self) -> None: overwrite=True, ) + def merge_identical_operations(self) -> None: + grouped_ops = misc.group_by(self.operations, tr.Operation.identical_columns) + self.operations = [tr.Operation.merge(*ops) for ops in grouped_ops.values()] + def read_file(self, file_path: Path) -> None: """Import transactions form an account statement. diff --git a/src/main.py b/src/main.py index 77a089c8..492ba231 100644 --- a/src/main.py +++ b/src/main.py @@ -40,6 +40,8 @@ def main() -> None: return book.get_price_from_csv() + book.merge_identical_operations() + taxman.evaluate_taxation() evaluation_file_path = taxman.export_evaluation_as_csv() taxman.print_evaluation() diff --git a/src/transaction.py b/src/transaction.py index 880e5099..37f95ea9 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -19,10 +19,14 @@ import dataclasses import datetime import decimal +import itertools import typing +from copy import copy from pathlib import Path +from typing import ClassVar import log_config +import misc log = log_config.getLogger(__name__) @@ -36,6 +40,17 @@ class Operation: line: list[int] file_path: Path + @property + def type_name(self) -> str: + return self.__class__.__name__ + + identical_columns: ClassVar[list[str]] = [ + "type_name", + "utc_time", + "platform", + "coin", + ] + def __post_init__(self): assert self.validate_types() @@ -72,6 +87,32 @@ def validate_types(self) -> bool: ret = False return ret + def identical_to(self, op: Operation) -> bool: + identical_to = all( + getattr(self, i) == getattr(op, i) for i in self.identical_columns + ) + + if identical_to: + assert ( + self.file_path == op.file_path + ), "Identical operations should also be in the same file." + + return identical_to + + @staticmethod + def merge(*operations: Operation) -> Operation: + assert len(operations) > 0, "There have to be operations to be merged." + assert all( + op1.identical_to(op2) for op1, op2 in itertools.combinations(operations, 2) + ), "Operations have to be identical to be mereged" + + # Select arbitray operation from list. + o = copy(operations[0]) + # Update this operation with merged entries. + o.change = misc.dsum(op.change for op in operations) + o.line = list(itertools.chain(*(op.line for op in operations))) + return o + class Fee(Operation): pass From a16c6df83ee4d1daaedf62f434d0664b53ae4360 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 21:57:07 +0200 Subject: [PATCH 005/141] UPDATE book: ignore operations with change=0 --- src/book.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/book.py b/src/book.py index 3c95923e..451c2414 100644 --- a/src/book.py +++ b/src/book.py @@ -78,11 +78,12 @@ def create_operation( def _append_operation( self, - operation: tr.Operation, + op: tr.Operation, ) -> None: # Discard operations after the `TAX_YEAR`. - if operation.utc_time.year <= config.TAX_YEAR: - self.operations.append(operation) + # Ignore operations which make no change. + if op.utc_time.year <= config.TAX_YEAR and op.change != 0: + self.operations.append(op) def append_operation( self, @@ -95,7 +96,8 @@ def append_operation( file_path: Path, ) -> None: # Discard operations after the `TAX_YEAR`. - if utc_time.year <= config.TAX_YEAR: + # Ignore operations which make no change. + if utc_time.year <= config.TAX_YEAR and change != 0: op = self.create_operation( operation, utc_time, From 86a1390cf8d4e3c27392012cc4ec2630f13285b1 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 22:41:27 +0200 Subject: [PATCH 006/141] CHANGE match fees with buy/sell operations, remove fees from book.operations.list TODO sell.fees and buy.fees are currently not, but should be, considered for tax calculation --- src/book.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.py | 4 +++ src/taxman.py | 3 +++ src/transaction.py | 27 ++++++++++++++++--- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/book.py b/src/book.py index 451c2414..7c6e2c73 100644 --- a/src/book.py +++ b/src/book.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import collections import csv import datetime import decimal @@ -1259,6 +1260,70 @@ def merge_identical_operations(self) -> None: grouped_ops = misc.group_by(self.operations, tr.Operation.identical_columns) self.operations = [tr.Operation.merge(*ops) for ops in grouped_ops.values()] + def match_fees_with_operations(self) -> None: + # Split operations in fees and other operations. + operations = [] + all_fees: list[tr.Fee] = [] + for op in self.operations: + if isinstance(op, tr.Fee): + all_fees.append(op) + else: + operations.append(op) + + # Only keep none fee operations in book. + self.operations = operations + + # Match fees to book operations. + platform_fees = misc.group_by(all_fees, "platform") + for platform, fees in platform_fees.items(): + time_fees = misc.group_by(all_fees, "utc_time") + for utc_time, fees in time_fees.items(): + + # Find matching operations by platform and time. + matching_operations = { + idx: op + for idx, op in enumerate(self.operations) + if op.platform == platform and op.utc_time == utc_time + } + + # Group matching operations in dict with + # { operation typename: list of indices } + t_op = collections.defaultdict(list) + for idx, op in matching_operations.items(): + t_op[op.type_name].append(idx) + + # Check if this is a buy/sell-pair. + # Fees might occure by other operation types, + # but this is currently not implemented. + is_buy_sell_pair = all( + ( + len(matching_operations) == 2, + len(t_op[tr.Buy.type_name_c()]) == 1, + len(t_op[tr.Sell.type_name_c()]) == 1, + ) + ) + if is_buy_sell_pair: + # Fees have to be added to all buys and sells. + # 1. Fees on sells are the transaction cost, + # which might be fully tax relevant for this sell + # and which gets removed from the account balance + # 2. Fees on buys increase the buy-in price of the coins + # which is relevant when selling these (not buying) + (sell_idx,) = t_op[tr.Buy.type_name_c()] + (buy_idx,) = t_op[tr.Sell.type_name_c()] + assert self.operations[sell_idx].fees is None + assert self.operations[buy_idx].fees is None + self.operations[sell_idx].fees = fees + self.operations[buy_idx].fees = fees + else: + log.warning( + "Fee matching is not implemented for this case. " + "Your fees will be discarded and are not evaluated in " + "the tax evaluation.\n" + "Please create an Issue or PR.\n\n" + f"{matching_operations=}\n{fees=}" + ) + def read_file(self, file_path: Path) -> None: """Import transactions form an account statement. diff --git a/src/main.py b/src/main.py index 492ba231..831bae7a 100644 --- a/src/main.py +++ b/src/main.py @@ -40,7 +40,11 @@ def main() -> None: return book.get_price_from_csv() + # Merge identical operations together, which makes it easier to match fees + # afterwards (as long as there are only one buy/sell pair per time). + # And reduced database accesses. book.merge_identical_operations() + book.match_fees_with_operations() taxman.evaluate_taxation() evaluation_file_path = taxman.export_evaluation_as_csv() diff --git a/src/taxman.py b/src/taxman.py index 5835718d..33dc2d1f 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -154,8 +154,11 @@ def evaluate_sell( remark, ) + # TODO handle buy.fees and sell.fees. + for op in operations: if isinstance(op, transaction.Fee): + raise RuntimeError("single fee operations shouldn't exist") balance.remove_fee(op.change) if self.in_tax_year(op): # Fees reduce taxed gain. diff --git a/src/transaction.py b/src/transaction.py index 37f95ea9..01250a1f 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -23,7 +23,7 @@ import typing from copy import copy from pathlib import Path -from typing import ClassVar +from typing import ClassVar, Optional import log_config import misc @@ -31,6 +31,11 @@ log = log_config.getLogger(__name__) +# TODO Implementation might be cleaner, when we add a class AbstractOperation +# which gets inherited by Fee and Operation +# Currently it might be possible for fees to have fees, which is unwanted. + + @dataclasses.dataclass class Operation: utc_time: datetime.datetime @@ -39,10 +44,15 @@ class Operation: coin: str line: list[int] file_path: Path + fees: "Optional[list[Fee]]" = None + + @classmethod + def type_name_c(cls) -> str: + return cls.__name__ @property def type_name(self) -> str: - return self.__class__.__name__ + return self.type_name_c() identical_columns: ClassVar[list[str]] = [ "type_name", @@ -65,6 +75,14 @@ def validate_types(self) -> bool: # (without parameters) continue + actual_value = getattr(self, field.name) + + if field.name == "fees": + # BUG currently kind of ignored, would be nice when + # implemented correctly. + assert actual_value is None + continue + actual_type = typing.get_origin(field.type) or field.type if isinstance(actual_type, typing._SpecialForm): @@ -78,7 +96,6 @@ def validate_types(self) -> bool: else: actual_type = eval(actual_type) - actual_value = getattr(self, field.name) if not isinstance(actual_value, actual_type): log.warning( f"\t{field.name}: '{type(actual_value)}' " @@ -111,6 +128,10 @@ def merge(*operations: Operation) -> Operation: # Update this operation with merged entries. o.change = misc.dsum(op.change for op in operations) o.line = list(itertools.chain(*(op.line for op in operations))) + if not all(op.fees is None for op in operations): + raise NotImplementedError( + "merging operations with fees is currently not supported" + ) return o From 71f5c0dd1a08a0dbf3b80e1ac6f1fc6c76be7b59 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 7 Apr 2022 22:55:28 +0200 Subject: [PATCH 007/141] FIX typo invsted --- src/taxman.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 33dc2d1f..2a4976a3 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -308,13 +308,13 @@ def print_evaluation(self) -> None: ) lo_date = latest_operation.op.utc_time.strftime("%d.%m.%y") - invsted = misc.dsum(tx.sell_price for tx in self.virtual_tax_events) + invested = misc.dsum(tx.sell_price for tx in self.virtual_tax_events) real_gains = misc.dsum(tx.real_gain for tx in self.virtual_tax_events) taxed_gains = misc.dsum(tx.taxed_gain for tx in self.virtual_tax_events) eval_str += "\n" eval_str += ( f"Deadline {config.TAX_YEAR}: {lo_date}\n" - f"You were invested with {invsted:.2f} {config.FIAT}.\n" + f"You were invested with {invested:.2f} {config.FIAT}.\n" f"If you would have sold everything then, " f"you would have realized {real_gains:.2f} {config.FIAT} gains " f"({taxed_gains:.2f} {config.FIAT} taxed gain).\n" From e872a05380dfb9e5ca6fa378f1fd17dcbd24fd0c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 15:08:12 +0200 Subject: [PATCH 008/141] FIX typo mereged --- src/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index 01250a1f..1cf73fed 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -121,7 +121,7 @@ def merge(*operations: Operation) -> Operation: assert len(operations) > 0, "There have to be operations to be merged." assert all( op1.identical_to(op2) for op1, op2 in itertools.combinations(operations, 2) - ), "Operations have to be identical to be mereged" + ), "Operations have to be identical to be merged" # Select arbitray operation from list. o = copy(operations[0]) From 89f9412d394c32d89c6e49a89d9ddf68826df396 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 16:42:35 +0200 Subject: [PATCH 009/141] UPDATE Extend information in error message when kraken internal deposit/withdrawal matching fails (6ea275f3b41e9df45e51602f9b01c95830a68c35) --- src/book.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/book.py b/src/book.py index 7c6e2c73..bcc421a3 100644 --- a/src/book.py +++ b/src/book.py @@ -648,14 +648,26 @@ def _read_kraken_ledgers(self, file_path: Path) -> None: # of change and same coin. assert isinstance( op, type(self.kraken_held_ops[refid]["operation"]) - ), "operation" + ), ( + "operation " + f"({op.type_name} != " + f'{self.kraken_held_ops[refid]["operation"].type_name})' + ) assert ( op.change == self.kraken_held_ops[refid]["operation"].change - ), "change" + ), ( + "change " + f"({op.change} != " + f'{self.kraken_held_ops[refid]["operation"].change})' + ) assert ( op.coin == self.kraken_held_ops[refid]["operation"].coin - ), "coin" + ), ( + "coin " + f"({op.coin} != " + f'{self.kraken_held_ops[refid]["operation"].coin})' + ) except AssertionError as e: # Row is internally saved as list[int]. first_row = self.kraken_held_ops[refid]["operation"].line[0] From d19c35ebe2122c876e5767b92a26c1fa8125851b Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 16:44:35 +0200 Subject: [PATCH 010/141] UPDATE Make taxman helper functions static --- src/taxman.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 2a4976a3..c263d2d6 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -61,10 +61,12 @@ def __init__(self, book: Book, price_data: PriceData) -> None: f"Unable to evaluate taxation for {config.PRINCIPLE=}." ) - def in_tax_year(self, op: transaction.Operation) -> bool: + @staticmethod + def in_tax_year(op: transaction.Operation) -> bool: return op.utc_time.year == config.TAX_YEAR - def tax_deadline(self) -> datetime.datetime: + @staticmethod + def tax_deadline() -> datetime.datetime: return min( datetime.datetime(config.TAX_YEAR, 12, 31, 23, 59, 59), datetime.datetime.now(), From 581fb9f5f1a28f139dc36541610f259644714e3e Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 16:44:52 +0200 Subject: [PATCH 011/141] UPDATE comment in tax evaluation --- src/taxman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index c263d2d6..acbfe366 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -281,7 +281,7 @@ def evaluate_taxation(self) -> None: ).items(): self._evaluate_taxation_per_coin(operations) else: - # Evaluate taxation separated by coins in a single virtual depot. + # Evaluate taxation separated by coins "in a single virtual depot". self._evaluate_taxation_per_coin(self.book.operations) def print_evaluation(self) -> None: From 41c0ee3f89f94f9a464ab3f742d21bef21577082 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 17:19:34 +0200 Subject: [PATCH 012/141] ADD config error when country is not implemented credits to @Griffsano --- src/config.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/config.py b/src/config.py index 567fd24a..adaad4c4 100644 --- a/src/config.py +++ b/src/config.py @@ -34,7 +34,14 @@ # General config config = configparser.ConfigParser() config.read(CONFIG_FILE) -COUNTRY = core.Country[config["BASE"].get("COUNTRY", "GERMANY")] + +try: + COUNTRY = core.Country[config["BASE"].get("COUNTRY", "GERMANY")] +except KeyError as e: + raise NotImplementedError( + f"Your country {e} is currently not supported. Please create an Issue or PR." + ) + TAX_YEAR = int(config["BASE"].get("TAX_YEAR", "2021")) REFETCH_MISSING_PRICES = config["BASE"].getboolean("REFETCH_MISSING_PRICES") MEAN_MISSING_PRICES = config["BASE"].getboolean("MEAN_MISSING_PRICES") @@ -62,7 +69,10 @@ def IS_LONG_TERM(buy: datetime, sell: datetime) -> bool: else: - raise NotImplementedError(f"Your country {COUNTRY} is not supported.") + raise NotImplementedError( + f"Your country {COUNTRY} is currently not supported. " + "Please create an Issue or PR." + ) # Program specific constants. FIAT = FIAT_CLASS.name # Convert to string. From 3721933dcac7cd00d432e6e5ca356a14951e78b2 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 17:19:49 +0200 Subject: [PATCH 013/141] UPDATE is_fiat docstring credits to @Griffsano --- src/misc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/misc.py b/src/misc.py index 8b82092e..2d026bca 100644 --- a/src/misc.py +++ b/src/misc.py @@ -224,13 +224,13 @@ def wrapper(*args, **kwargs): def is_fiat(symbol: Union[str, core.Fiat]) -> bool: - """Check if `symbol` is a fiat. + """Check if `symbol` is a fiat currency. Args: fiat (str): Currency Symbol. Returns: - bool: True if `symbol` is fiat. False otherwise. + bool: True if `symbol` is a fiat currency. False otherwise. """ return isinstance(symbol, core.Fiat) or symbol in core.Fiat.__members__ From e76ffe96f0a7c01c35660c489ba10a0861c24bb9 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 17:20:41 +0200 Subject: [PATCH 014/141] RENAME TaxEvent.sell_price to sell_value credits to @Griffsano --- src/taxman.py | 18 +++++++++--------- src/transaction.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index acbfe366..f9f5a596 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -113,7 +113,7 @@ def evaluate_sell( taxation_type = "Sonstige Einkünfte" # Price of the sell. - sell_price = self.price_data.get_cost(op) + sell_value = self.price_data.get_cost(op) taxed_gain = decimal.Decimal() real_gain = decimal.Decimal() # Coins which are older than (in this case) one year or @@ -136,9 +136,9 @@ def evaluate_sell( ) # Only calculate the gains if necessary. if is_taxable or config.CALCULATE_UNREALIZED_GAINS: - partial_sell_price = (sc.sold / op.change) * sell_price + partial_sell_value = (sc.sold / op.change) * sell_value sold_coin_cost = self.price_data.get_cost(sc) - gain = partial_sell_price - sold_coin_cost + gain = partial_sell_value - sold_coin_cost if is_taxable: taxed_gain += gain if config.CALCULATE_UNREALIZED_GAINS: @@ -151,7 +151,7 @@ def evaluate_sell( taxation_type, taxed_gain, op, - sell_price, + sell_value, real_gain, remark, ) @@ -310,7 +310,7 @@ def print_evaluation(self) -> None: ) lo_date = latest_operation.op.utc_time.strftime("%d.%m.%y") - invested = misc.dsum(tx.sell_price for tx in self.virtual_tax_events) + invested = misc.dsum(tx.sell_value for tx in self.virtual_tax_events) real_gains = misc.dsum(tx.real_gain for tx in self.virtual_tax_events) taxed_gains = misc.dsum(tx.taxed_gain for tx in self.virtual_tax_events) eval_str += "\n" @@ -326,13 +326,13 @@ def print_evaluation(self) -> None: eval_str += f"Your portfolio on {lo_date} was:\n" for tx in sorted( self.virtual_tax_events, - key=lambda tx: tx.sell_price, + key=lambda tx: tx.sell_value, reverse=True, ): eval_str += ( f"{tx.op.platform}: " f"{tx.op.change:.6f} {tx.op.coin} > " - f"{tx.sell_price:.2f} {config.FIAT} " + f"{tx.sell_value:.2f} {config.FIAT} " f"({tx.real_gain:.2f} gain, {tx.taxed_gain:.2f} taxed gain)\n" ) @@ -371,7 +371,7 @@ def export_evaluation_as_csv(self) -> Path: "Action", "Amount", "Asset", - f"Sell Price in {config.FIAT}", + f"Sell Value in {config.FIAT}", "Remark", ] writer.writerow(header) @@ -384,7 +384,7 @@ def export_evaluation_as_csv(self) -> Path: tx.op.__class__.__name__, tx.op.change, tx.op.coin, - tx.sell_price, + tx.sell_value, tx.remark, ] writer.writerow(line) diff --git a/src/transaction.py b/src/transaction.py index 1cf73fed..fef9a91d 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -205,7 +205,7 @@ class TaxEvent: taxation_type: str taxed_gain: decimal.Decimal op: Operation - sell_price: decimal.Decimal = decimal.Decimal() + sell_value: decimal.Decimal = decimal.Decimal() real_gain: decimal.Decimal = decimal.Decimal() remark: str = "" From 3aaf6f8f0ab14a9b9af80daa0ee765fbe80ced12 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 17:20:59 +0200 Subject: [PATCH 015/141] ADD platform to export and specify date column header credits to @Griffsano --- src/taxman.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index f9f5a596..89f82a7b 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -365,7 +365,8 @@ def export_evaluation_as_csv(self) -> Path: writer.writerow(["# updated", datetime.date.today().strftime("%x")]) header = [ - "Date", + "Date and Time UTC", + "Platform", "Taxation Type", f"Taxed Gain in {config.FIAT}", "Action", @@ -378,7 +379,8 @@ def export_evaluation_as_csv(self) -> Path: # Tax events are currently sorted by coin. Sort by time instead. for tx in sorted(self.tax_events, key=lambda tx: tx.op.utc_time): line = [ - tx.op.utc_time, + tx.op.utc_time.strftime("%Y-%m-%d %H:%M:%S"), + tx.op.platform, tx.taxation_type, tx.taxed_gain, tx.op.__class__.__name__, From 75f527d74a3244ec4511940e31dca0f1617ca5ec Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 17:21:33 +0200 Subject: [PATCH 016/141] CHANGE do not upper config.FIAT value in bitpanda import credits to @Griffsano --- src/book.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/book.py b/src/book.py index bcc421a3..b78fa4f4 100644 --- a/src/book.py +++ b/src/book.py @@ -1007,7 +1007,7 @@ def _read_bitpanda(self, file_path: Path) -> None: elif operation in ["Buy", "Sell"]: if asset_price_currency != config.FIAT: log.error( - f"Only {config.FIAT.upper()} is supported as " + f"Only {config.FIAT} is supported as " "'Asset market price currency', since price fetching for " "fiat currencies is not fully implemented yet." ) @@ -1016,7 +1016,7 @@ def _read_bitpanda(self, file_path: Path) -> None: change_fiat = misc.force_decimal(amount_fiat) # Save price in our local database for later. price = misc.force_decimal(asset_price) - set_price_db(platform, asset, config.FIAT.upper(), utc_time, price) + set_price_db(platform, asset, config.FIAT, utc_time, price) if change < 0: log.error( @@ -1036,7 +1036,7 @@ def _read_bitpanda(self, file_path: Path) -> None: utc_time, platform, change_fiat, - config.FIAT.upper(), + config.FIAT, row, file_path, ) @@ -1046,7 +1046,7 @@ def _read_bitpanda(self, file_path: Path) -> None: utc_time, platform, change_fiat, - config.FIAT.upper(), + config.FIAT, row, file_path, ) From 9cc3c6c391216257b1f843f2ee2019a3fe922765 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 17:24:30 +0200 Subject: [PATCH 017/141] ADD make drun. Shortcut for development (format lint run) --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index e4ddabc9..59d2899e 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,9 @@ build: run: python src/main.py +# Shortcut for development (developer run) +drun: format lint run + run-container: docker run --name cointaxman -it --rm \ -v `pwd`/account_statements:/CoinTaxman/account_statements:Z \ From d456c507edd302b31cc142831494ebcd27ccafa5 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 19:15:00 +0200 Subject: [PATCH 018/141] REFACTOR BalanceQueue - RENAME class methods - UPDATE comments - REFACTOR code - CHANGE Keep track of home fiat balance --- src/balance_queue.py | 197 +++++++++++++++++++++++++++++++------------ src/taxman.py | 53 ++++-------- 2 files changed, 162 insertions(+), 88 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index a4b005bd..185d5d09 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -31,80 +31,115 @@ class BalancedOperation: op: transaction.Operation sold: decimal.Decimal = decimal.Decimal() + @property + def not_sold(self) -> decimal.Decimal: + """Calculate the amount of coins which are not sold yet. + + Returns: + decimal.Decimal: Amount of coins which are not sold yet. + """ + not_sold = self.op.change - self.sold + # If the left over amount is <= 0, this coin shouldn't be in the queue. + assert not_sold > 0, f"{not_sold=} should be > 0" + return not_sold + class BalanceQueue(abc.ABC): - def __init__(self) -> None: + def __init__(self, coin: str) -> None: + self.coin = coin self.queue: collections.deque[BalancedOperation] = collections.deque() - # Buffer fees which could not be directly set off - # with the current coins in the queue. - # This can happen if the exchange takes the fees before - # the buy/sell process. + # It might happen, that the exchange takes fees before the buy/sell- + # transaction. Keep fees, which couldn't be removed directly from the + # queue and remove them as soon as possible. + # At the end, all fees should have been paid (removed from the buffer). self.buffer_fee = decimal.Decimal() + @abc.abstractmethod + def _put(self, bop: BalancedOperation) -> None: + """Put a new item in the queue. + + Args: + item (BalancedOperation) + """ + raise NotImplementedError + + @abc.abstractmethod + def _pop(self) -> BalancedOperation: + """Pop an item from the queue. + + Returns: + BalancedOperation + """ + raise NotImplementedError + + @abc.abstractmethod + def _peek(self) -> BalancedOperation: + """Peek at the next item in the queue. + + Returns: + BalancedOperation + """ + raise NotImplementedError + def put(self, item: Union[transaction.Operation, BalancedOperation]) -> None: - """Put a new item in the queue and set off buffered fees. + """Put a new item in the queue and remove buffered fees. Args: item (Union[Operation, BalancedOperation]) """ if isinstance(item, transaction.Operation): item = BalancedOperation(item) - - if not isinstance(item, BalancedOperation): - raise ValueError + elif not isinstance(item, BalancedOperation): + raise TypeError self._put(item) - # Remove fees which could not be set off before now. + # Remove fees which couldn't be removed before. if self.buffer_fee: - # Clear the buffer and remove the buffered fee from the queue. + # Clear the buffer. fee, self.buffer_fee = self.buffer_fee, decimal.Decimal() + # Try to remove the fees. self.remove_fee(fee) - @abc.abstractmethod - def _put(self, bop: BalancedOperation) -> None: - """Put a new item in the queue. - - Args: - item (BalancedOperation) - """ - raise NotImplementedError - - @abc.abstractmethod - def get(self) -> BalancedOperation: - """Get an item from the queue. + def pop(self) -> BalancedOperation: + """Pop an item from the queue. Returns: BalancedOperation """ - raise NotImplementedError + return self._pop() - @abc.abstractmethod def peek(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: BalancedOperation """ - raise NotImplementedError + return self._peek() + + def add(self, op: transaction.Operation) -> None: + """Add an operation with coins to the balance. - def sell( + Args: + op (transaction.Operation) + """ + self.put(op) + + def _remove( self, change: decimal.Decimal, ) -> tuple[list[transaction.SoldCoin], decimal.Decimal]: - """Sell/remove as many coins as possible from the queue. + """Remove as many coins as necessary from the queue. - Depending on the QueueType, the coins will be removed FIFO or LIFO. + The removement logic is defined by the BalanceQueue child class. Args: - change (decimal.Decimal): Amount of sold coins which will be removed - from the queue. + change (decimal.Decimal): Amount of coins to be removed. Returns: - - list[transaction.SoldCoin]: List of specific coins which were - (depending on the tax regulation) sold in the transaction. + - list[transaction.SoldCoin]: List of coins which were removed. - decimal.Decimal: Amount of change which could not be removed - because the queue ran out of coins to sell. + because the queue ran out of coins. """ sold_coins: list[transaction.SoldCoin] = [] @@ -112,44 +147,102 @@ def sell( # Look at the next coin in the queue. bop = self.peek() - # Calculate the amount of coins, which are not sold yet. - not_sold = bop.op.change - bop.sold - assert not_sold > 0 + # Get the amount of not sold coins. + not_sold = bop.not_sold if not_sold > change: # There are more coins left than change. # Update the sold value, bop.sold += change - # keep track of the sold amount and + # keep track of the sold amount sold_coins.append(transaction.SoldCoin(bop.op, change)) - # Set the change to 0. + # and set the change to 0. change = decimal.Decimal() + # All demanded change was removed. break - else: # change >= not_sold - # The change is higher than or equal to the (left over) coin. + else: # not_sold <= change + # The change is higher than or equal to the left over coins. # Update the left over change, change -= not_sold - # remove the fully sold coin from the queue and - self.get() - # keep track of the sold amount. + # remove the fully sold coin from the queue + self.pop() + # and keep track of the sold amount. sold_coins.append(transaction.SoldCoin(bop.op, not_sold)) - assert change >= 0 + assert change >= 0, "Removed more than necessary from the queue." return sold_coins, change + def remove( + self, + op: transaction.Operation, + ) -> list[transaction.SoldCoin]: + """Remove as many coins as necessary from the queue. + + The removement logic is defined by the BalanceQueue child class. + + Args: + op (transaction.Operation): Operation with coins to be removed. + + Raises: + RuntimeError: When there are not enough coins in queue to be sold. + + Returns: + - list[transaction.SoldCoin]: List of coins which were removed. + """ + sold_coins, unsold_change = self._remove(op.change) + + if unsold_change: + # Queue ran out of items to sell and not all coins could be sold. + log.error( + f"Not enough {op.coin} in queue to sell: " + f"missing {unsold_change} {op.coin} " + f"(transaction from {op.utc_time} on {op.platform}, " + f"see {op.file_path.name} lines {op.line})\n" + "\tThis error occurs when you sold more coins than you have " + "according to your account statements. Have you added every " + "account statement, including these from the last years?\n" + "\tThis error may also occur after deposits from unknown " + "sources. CoinTaxman requires the full transaction history to " + "evaluate taxation (when where these deposited coins bought?).\n" + ) + raise RuntimeError + + return sold_coins + def remove_fee(self, fee: decimal.Decimal) -> None: """Remove fee from the last added transaction. Args: fee: decimal.Decimal """ - _, left_over_fee = self.sell(fee) + _, left_over_fee = self._remove(fee) if left_over_fee: # Not enough coins in queue to remove fee. # Buffer the fee for next time. self.buffer_fee += left_over_fee + def sanity_check(self) -> None: + """Validate that all fees were paid or raise an exception. + + At the end, all fees should have been paid. + + Raises: + RuntimeError: Not all fees were paid. + """ + if self.buffer_fee: + log.error( + f"Not enough {self.coin} in queue to pay left over fees: " + f"missing {self.buffer_fee} {self.coin}.\n" + "\tThis error occurs when you sold more coins than you have " + "according to your account statements. Have you added every " + "account statement, including these from the last years?\n" + "\tThis error may also occur after deposits from unknown " + "sources. CoinTaxman requires the full transaction history to " + "evaluate taxation (when where these deposited coins bought?).\n" + ) + raise RuntimeError + class BalanceFIFOQueue(BalanceQueue): def _put(self, bop: BalancedOperation) -> None: @@ -160,15 +253,15 @@ def _put(self, bop: BalancedOperation) -> None: """ self.queue.append(bop) - def get(self) -> BalancedOperation: - """Get an item from the queue. + def _pop(self) -> BalancedOperation: + """Pop an item from the queue. Returns: BalancedOperation """ return self.queue.popleft() - def peek(self) -> BalancedOperation: + def _peek(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: @@ -186,15 +279,15 @@ def _put(self, bop: BalancedOperation) -> None: """ self.queue.append(bop) - def get(self) -> BalancedOperation: - """Get an item from the queue. + def _pop(self) -> BalancedOperation: + """Pop an item from the queue. Returns: BalancedOperation """ return self.queue.pop() - def peek(self) -> BalancedOperation: + def _peek(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: diff --git a/src/taxman.py b/src/taxman.py index 89f82a7b..3503afda 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -83,30 +83,12 @@ def evaluate_sell( op: transaction.Operation, force: bool = False ) -> Optional[transaction.TaxEvent]: # Remove coins from queue. - sold_coins, unsold_coins = balance.sell(op.change) + sold_coins = balance.remove(op) if coin == config.FIAT: # Not taxable. return None - if unsold_coins: - # Queue ran out of items to sell and not all coins - # could be sold. - log.error( - f"{op.file_path.name}: Lines {op.line}: " - f"Not enough {coin} in queue to sell: " - f"missing {unsold_coins} {coin} " - f"(transaction from {op.utc_time} " - f"on {op.platform})\n" - "\tThis error occurs if your account statements " - "have unmatched buy/sell positions.\n" - "\tHave you added all your account statements " - "of the last years?\n" - "\tThis error may also occur after deposits " - "from unknown sources.\n" - ) - raise RuntimeError - if not self.in_tax_year(op) and not force: # Sell is only taxable in the respective year. return None @@ -177,14 +159,14 @@ def evaluate_sell( elif isinstance(op, transaction.StakingEnd): pass elif isinstance(op, transaction.Buy): - balance.put(op) + balance.add(op) elif isinstance(op, transaction.Sell): if tx_ := evaluate_sell(op): self.tax_events.append(tx_) elif isinstance( op, (transaction.CoinLendInterest, transaction.StakingInterest) ): - balance.put(op) + balance.add(op) if self.in_tax_year(op): if misc.is_fiat(coin): assert not isinstance( @@ -197,23 +179,31 @@ def evaluate_sell( tx = transaction.TaxEvent(taxation_type, taxed_gain, op) self.tax_events.append(tx) elif isinstance(op, transaction.Airdrop): - balance.put(op) + balance.add(op) elif isinstance(op, transaction.Commission): - balance.put(op) + balance.add(op) if self.in_tax_year(op): taxation_type = "Einkünfte aus sonstigen Leistungen" taxed_gain = self.price_data.get_cost(op) tx = transaction.TaxEvent(taxation_type, taxed_gain, op) self.tax_events.append(tx) elif isinstance(op, transaction.Deposit): - if coin != config.FIAT: + if coin == config.FIAT: + # Add to balance; + # we do not care, where our home fiat comes from. + balance.add(op) + else: # coin != config.FIAT log.warning( f"Unresolved deposit of {op.change} {coin} " f"on {op.platform} at {op.utc_time}. " "The evaluation might be wrong." ) elif isinstance(op, transaction.Withdrawal): - if coin != config.FIAT: + if coin == config.FIAT: + # Remove from balance; + # we do not care, where our home fiat goes to. + balance.remove(op) + else: # coin != config.FIAT log.warning( f"Unresolved withdrawal of {op.change} {coin} " f"from {op.platform} at {op.utc_time}. " @@ -222,21 +212,12 @@ def evaluate_sell( else: raise NotImplementedError - # Check that all relevant positions were considered. - if balance.buffer_fee: - log.warning( - "Balance has outstanding fees which were not considered: " - f"{balance.buffer_fee} {coin}" - ) + balance.sanity_check() # Calculate the amount of coins which should be left on the platform # and evaluate the (taxed) gain, if the coin would be sold right now. if config.CALCULATE_UNREALIZED_GAINS and ( - ( - left_coin := misc.dsum( - ((bop.op.change - bop.sold) for bop in balance.queue) - ) - ) + (left_coin := misc.dsum((bop.not_sold for bop in balance.queue))) ): assert isinstance(left_coin, decimal.Decimal) # Calculate unrealized gains for the last time of `TAX_YEAR`. From d238d8ebdbfb86d92fd5d018bc248184ed8b6d99 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 19:50:58 +0200 Subject: [PATCH 019/141] MOVE taxman staticmethods outside the class, convert tax_deadline to constant --- src/taxman.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 3503afda..3c426e06 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -31,6 +31,14 @@ log = log_config.getLogger(__name__) +TAX_DEADLINE = min( + datetime.datetime(config.TAX_YEAR, 12, 31, 23, 59, 59), datetime.datetime.now() +) + + +def in_tax_year(op: transaction.Operation) -> bool: + return op.utc_time.year == config.TAX_YEAR + class Taxman: def __init__(self, book: Book, price_data: PriceData) -> None: @@ -89,7 +97,7 @@ def evaluate_sell( # Not taxable. return None - if not self.in_tax_year(op) and not force: + if not in_tax_year(op) and not force: # Sell is only taxable in the respective year. return None @@ -144,7 +152,7 @@ def evaluate_sell( if isinstance(op, transaction.Fee): raise RuntimeError("single fee operations shouldn't exist") balance.remove_fee(op.change) - if self.in_tax_year(op): + if in_tax_year(op): # Fees reduce taxed gain. taxation_type = "Sonstige Einkünfte" taxed_gain = -self.price_data.get_cost(op) @@ -167,7 +175,7 @@ def evaluate_sell( op, (transaction.CoinLendInterest, transaction.StakingInterest) ): balance.add(op) - if self.in_tax_year(op): + if in_tax_year(op): if misc.is_fiat(coin): assert not isinstance( op, transaction.StakingInterest @@ -182,7 +190,7 @@ def evaluate_sell( balance.add(op) elif isinstance(op, transaction.Commission): balance.add(op) - if self.in_tax_year(op): + if in_tax_year(op): taxation_type = "Einkünfte aus sonstigen Leistungen" taxed_gain = self.price_data.get_cost(op) tx = transaction.TaxEvent(taxation_type, taxed_gain, op) @@ -223,7 +231,7 @@ def evaluate_sell( # Calculate unrealized gains for the last time of `TAX_YEAR`. # If we are currently in ´TAX_YEAR` take now. virtual_sell = transaction.Sell( - self.tax_deadline(), + TAX_DEADLINE, op.platform, left_coin, coin, From ed8f70561249d78b5834462f58e4d95a83605a35 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 20:42:42 +0200 Subject: [PATCH 020/141] fixup REFACTOR BalanceQueue --- src/balance_queue.py | 47 +++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 185d5d09..04687e00 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -55,7 +55,7 @@ def __init__(self, coin: str) -> None: self.buffer_fee = decimal.Decimal() @abc.abstractmethod - def _put(self, bop: BalancedOperation) -> None: + def _put_(self, bop: BalancedOperation) -> None: """Put a new item in the queue. Args: @@ -64,7 +64,7 @@ def _put(self, bop: BalancedOperation) -> None: raise NotImplementedError @abc.abstractmethod - def _pop(self) -> BalancedOperation: + def _pop_(self) -> BalancedOperation: """Pop an item from the queue. Returns: @@ -73,7 +73,7 @@ def _pop(self) -> BalancedOperation: raise NotImplementedError @abc.abstractmethod - def _peek(self) -> BalancedOperation: + def _peek_(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: @@ -81,7 +81,7 @@ def _peek(self) -> BalancedOperation: """ raise NotImplementedError - def put(self, item: Union[transaction.Operation, BalancedOperation]) -> None: + def _put(self, item: Union[transaction.Operation, BalancedOperation]) -> None: """Put a new item in the queue and remove buffered fees. Args: @@ -92,30 +92,30 @@ def put(self, item: Union[transaction.Operation, BalancedOperation]) -> None: elif not isinstance(item, BalancedOperation): raise TypeError - self._put(item) + self._put_(item) # Remove fees which couldn't be removed before. if self.buffer_fee: # Clear the buffer. fee, self.buffer_fee = self.buffer_fee, decimal.Decimal() # Try to remove the fees. - self.remove_fee(fee) + self._remove_fee(fee) - def pop(self) -> BalancedOperation: + def _pop(self) -> BalancedOperation: """Pop an item from the queue. Returns: BalancedOperation """ - return self._pop() + return self._pop_() - def peek(self) -> BalancedOperation: + def _peek(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: BalancedOperation """ - return self._peek() + return self._peek_() def add(self, op: transaction.Operation) -> None: """Add an operation with coins to the balance. @@ -123,7 +123,9 @@ def add(self, op: transaction.Operation) -> None: Args: op (transaction.Operation) """ - self.put(op) + assert not isinstance(op, transaction.Fee) + assert op.coin == self.coin + self._put(op) def _remove( self, @@ -145,7 +147,7 @@ def _remove( while self.queue and change > 0: # Look at the next coin in the queue. - bop = self.peek() + bop = self._peek() # Get the amount of not sold coins. not_sold = bop.not_sold @@ -166,7 +168,7 @@ def _remove( # Update the left over change, change -= not_sold # remove the fully sold coin from the queue - self.pop() + self._pop() # and keep track of the sold amount. sold_coins.append(transaction.SoldCoin(bop.op, not_sold)) @@ -190,6 +192,7 @@ def remove( Returns: - list[transaction.SoldCoin]: List of coins which were removed. """ + assert op.coin == self.coin sold_coins, unsold_change = self._remove(op.change) if unsold_change: @@ -210,7 +213,7 @@ def remove( return sold_coins - def remove_fee(self, fee: decimal.Decimal) -> None: + def _remove_fee(self, fee: decimal.Decimal) -> None: """Remove fee from the last added transaction. Args: @@ -222,6 +225,10 @@ def remove_fee(self, fee: decimal.Decimal) -> None: # Buffer the fee for next time. self.buffer_fee += left_over_fee + def remove_fee(self, fee: transaction.Fee) -> None: + assert fee.coin == self.coin + self._remove_fee(fee.change) + def sanity_check(self) -> None: """Validate that all fees were paid or raise an exception. @@ -245,7 +252,7 @@ def sanity_check(self) -> None: class BalanceFIFOQueue(BalanceQueue): - def _put(self, bop: BalancedOperation) -> None: + def _put_(self, bop: BalancedOperation) -> None: """Put a new item in the queue. Args: @@ -253,7 +260,7 @@ def _put(self, bop: BalancedOperation) -> None: """ self.queue.append(bop) - def _pop(self) -> BalancedOperation: + def _pop_(self) -> BalancedOperation: """Pop an item from the queue. Returns: @@ -261,7 +268,7 @@ def _pop(self) -> BalancedOperation: """ return self.queue.popleft() - def _peek(self) -> BalancedOperation: + def _peek_(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: @@ -271,7 +278,7 @@ def _peek(self) -> BalancedOperation: class BalanceLIFOQueue(BalanceQueue): - def _put(self, bop: BalancedOperation) -> None: + def _put_(self, bop: BalancedOperation) -> None: """Put a new item in the queue. Args: @@ -279,7 +286,7 @@ def _put(self, bop: BalancedOperation) -> None: """ self.queue.append(bop) - def _pop(self) -> BalancedOperation: + def _pop_(self) -> BalancedOperation: """Pop an item from the queue. Returns: @@ -287,7 +294,7 @@ def _pop(self) -> BalancedOperation: """ return self.queue.pop() - def _peek(self) -> BalancedOperation: + def _peek_(self) -> BalancedOperation: """Peek at the next item in the queue. Returns: From 9bba7b921239510bad664d6c9779b0feb70e73ca Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 20:56:50 +0200 Subject: [PATCH 021/141] REFACTOR rename module import transaction to tr --- src/balance_queue.py | 34 +++++++++++----------- src/price_data.py | 14 ++++----- src/taxman.py | 68 +++++++++++++++++++++----------------------- 3 files changed, 57 insertions(+), 59 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 04687e00..433288d0 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -21,14 +21,14 @@ from typing import Union import log_config -import transaction +import transaction as tr log = log_config.getLogger(__name__) @dataclasses.dataclass class BalancedOperation: - op: transaction.Operation + op: tr.Operation sold: decimal.Decimal = decimal.Decimal() @property @@ -81,13 +81,13 @@ def _peek_(self) -> BalancedOperation: """ raise NotImplementedError - def _put(self, item: Union[transaction.Operation, BalancedOperation]) -> None: + def _put(self, item: Union[tr.Operation, BalancedOperation]) -> None: """Put a new item in the queue and remove buffered fees. Args: item (Union[Operation, BalancedOperation]) """ - if isinstance(item, transaction.Operation): + if isinstance(item, tr.Operation): item = BalancedOperation(item) elif not isinstance(item, BalancedOperation): raise TypeError @@ -117,20 +117,20 @@ def _peek(self) -> BalancedOperation: """ return self._peek_() - def add(self, op: transaction.Operation) -> None: + def add(self, op: tr.Operation) -> None: """Add an operation with coins to the balance. Args: - op (transaction.Operation) + op (tr.Operation) """ - assert not isinstance(op, transaction.Fee) + assert not isinstance(op, tr.Fee) assert op.coin == self.coin self._put(op) def _remove( self, change: decimal.Decimal, - ) -> tuple[list[transaction.SoldCoin], decimal.Decimal]: + ) -> tuple[list[tr.SoldCoin], decimal.Decimal]: """Remove as many coins as necessary from the queue. The removement logic is defined by the BalanceQueue child class. @@ -139,11 +139,11 @@ def _remove( change (decimal.Decimal): Amount of coins to be removed. Returns: - - list[transaction.SoldCoin]: List of coins which were removed. + - list[tr.SoldCoin]: List of coins which were removed. - decimal.Decimal: Amount of change which could not be removed because the queue ran out of coins. """ - sold_coins: list[transaction.SoldCoin] = [] + sold_coins: list[tr.SoldCoin] = [] while self.queue and change > 0: # Look at the next coin in the queue. @@ -157,7 +157,7 @@ def _remove( # Update the sold value, bop.sold += change # keep track of the sold amount - sold_coins.append(transaction.SoldCoin(bop.op, change)) + sold_coins.append(tr.SoldCoin(bop.op, change)) # and set the change to 0. change = decimal.Decimal() # All demanded change was removed. @@ -170,27 +170,27 @@ def _remove( # remove the fully sold coin from the queue self._pop() # and keep track of the sold amount. - sold_coins.append(transaction.SoldCoin(bop.op, not_sold)) + sold_coins.append(tr.SoldCoin(bop.op, not_sold)) assert change >= 0, "Removed more than necessary from the queue." return sold_coins, change def remove( self, - op: transaction.Operation, - ) -> list[transaction.SoldCoin]: + op: tr.Operation, + ) -> list[tr.SoldCoin]: """Remove as many coins as necessary from the queue. The removement logic is defined by the BalanceQueue child class. Args: - op (transaction.Operation): Operation with coins to be removed. + op (tr.Operation): Operation with coins to be removed. Raises: RuntimeError: When there are not enough coins in queue to be sold. Returns: - - list[transaction.SoldCoin]: List of coins which were removed. + - list[tr.SoldCoin]: List of coins which were removed. """ assert op.coin == self.coin sold_coins, unsold_change = self._remove(op.change) @@ -225,7 +225,7 @@ def _remove_fee(self, fee: decimal.Decimal) -> None: # Buffer the fee for next time. self.buffer_fee += left_over_fee - def remove_fee(self, fee: transaction.Fee) -> None: + def remove_fee(self, fee: tr.Fee) -> None: assert fee.coin == self.coin self._remove_fee(fee.change) diff --git a/src/price_data.py b/src/price_data.py index 3ccd4a1b..d816dd31 100644 --- a/src/price_data.py +++ b/src/price_data.py @@ -28,7 +28,7 @@ import config import log_config import misc -import transaction +import transaction as tr from core import kraken_pair_map from database import get_price_db, get_tablenames_from_db, mean_price_db, set_price_db @@ -562,15 +562,15 @@ def get_price( def get_cost( self, - tr: Union[transaction.Operation, transaction.SoldCoin], + op_sc: Union[tr.Operation, tr.SoldCoin], reference_coin: str = config.FIAT, ) -> decimal.Decimal: - op = tr if isinstance(tr, transaction.Operation) else tr.op + op = op_sc if isinstance(op_sc, tr.Operation) else op_sc.op price = self.get_price(op.platform, op.coin, op.utc_time, reference_coin) - if isinstance(tr, transaction.Operation): - return price * tr.change - if isinstance(tr, transaction.SoldCoin): - return price * tr.sold + if isinstance(op_sc, tr.Operation): + return price * op_sc.change + if isinstance(op_sc, tr.SoldCoin): + return price * op_sc.sold raise NotImplementedError def check_database(self): diff --git a/src/taxman.py b/src/taxman.py index 3c426e06..3393da18 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -25,7 +25,7 @@ import core import log_config import misc -import transaction +import transaction as tr from book import Book from price_data import PriceData @@ -36,7 +36,7 @@ ) -def in_tax_year(op: transaction.Operation) -> bool: +def in_tax_year(op: tr.Operation) -> bool: return op.utc_time.year == config.TAX_YEAR @@ -45,9 +45,9 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self.book = book self.price_data = price_data - self.tax_events: list[transaction.TaxEvent] = [] + self.tax_events: list[tr.TaxEvent] = [] # Tax Events which would occur if all left over coins were sold now. - self.virtual_tax_events: list[transaction.TaxEvent] = [] + self.virtual_tax_events: list[tr.TaxEvent] = [] # Determine used functions/classes depending on the config. country = config.COUNTRY.name @@ -70,7 +70,7 @@ def __init__(self, book: Book, price_data: PriceData) -> None: ) @staticmethod - def in_tax_year(op: transaction.Operation) -> bool: + def in_tax_year(op: tr.Operation) -> bool: return op.utc_time.year == config.TAX_YEAR @staticmethod @@ -83,13 +83,13 @@ def tax_deadline() -> datetime.datetime: def _evaluate_taxation_GERMANY( self, coin: str, - operations: list[transaction.Operation], + operations: list[tr.Operation], ) -> None: balance = self.BalanceType() def evaluate_sell( - op: transaction.Operation, force: bool = False - ) -> Optional[transaction.TaxEvent]: + op: tr.Operation, force: bool = False + ) -> Optional[tr.TaxEvent]: # Remove coins from queue. sold_coins = balance.remove(op) @@ -116,10 +116,10 @@ def evaluate_sell( isinstance( sc.op, ( - transaction.Airdrop, - transaction.CoinLendInterest, - transaction.StakingInterest, - transaction.Commission, + tr.Airdrop, + tr.CoinLendInterest, + tr.StakingInterest, + tr.Commission, ), ) and not sc.op.coin == config.FIAT @@ -137,7 +137,7 @@ def evaluate_sell( f"{sc.sold} from {sc.op.utc_time} " f"({sc.op.__class__.__name__})" for sc in sold_coins ) - return transaction.TaxEvent( + return tr.TaxEvent( taxation_type, taxed_gain, op, @@ -149,53 +149,51 @@ def evaluate_sell( # TODO handle buy.fees and sell.fees. for op in operations: - if isinstance(op, transaction.Fee): + if isinstance(op, tr.Fee): raise RuntimeError("single fee operations shouldn't exist") balance.remove_fee(op.change) if in_tax_year(op): # Fees reduce taxed gain. taxation_type = "Sonstige Einkünfte" taxed_gain = -self.price_data.get_cost(op) - tx = transaction.TaxEvent(taxation_type, taxed_gain, op) + tx = tr.TaxEvent(taxation_type, taxed_gain, op) self.tax_events.append(tx) - elif isinstance(op, transaction.CoinLend): + elif isinstance(op, tr.CoinLend): pass - elif isinstance(op, transaction.CoinLendEnd): + elif isinstance(op, tr.CoinLendEnd): pass - elif isinstance(op, transaction.Staking): + elif isinstance(op, tr.Staking): pass - elif isinstance(op, transaction.StakingEnd): + elif isinstance(op, tr.StakingEnd): pass - elif isinstance(op, transaction.Buy): + elif isinstance(op, tr.Buy): balance.add(op) - elif isinstance(op, transaction.Sell): + elif isinstance(op, tr.Sell): if tx_ := evaluate_sell(op): self.tax_events.append(tx_) - elif isinstance( - op, (transaction.CoinLendInterest, transaction.StakingInterest) - ): + elif isinstance(op, (tr.CoinLendInterest, tr.StakingInterest)): balance.add(op) if in_tax_year(op): if misc.is_fiat(coin): assert not isinstance( - op, transaction.StakingInterest + op, tr.StakingInterest ), "You can not stake fiat currencies." taxation_type = "Einkünfte aus Kapitalvermögen" else: taxation_type = "Einkünfte aus sonstigen Leistungen" taxed_gain = self.price_data.get_cost(op) - tx = transaction.TaxEvent(taxation_type, taxed_gain, op) + tx = tr.TaxEvent(taxation_type, taxed_gain, op) self.tax_events.append(tx) - elif isinstance(op, transaction.Airdrop): + elif isinstance(op, tr.Airdrop): balance.add(op) - elif isinstance(op, transaction.Commission): + elif isinstance(op, tr.Commission): balance.add(op) if in_tax_year(op): taxation_type = "Einkünfte aus sonstigen Leistungen" taxed_gain = self.price_data.get_cost(op) - tx = transaction.TaxEvent(taxation_type, taxed_gain, op) + tx = tr.TaxEvent(taxation_type, taxed_gain, op) self.tax_events.append(tx) - elif isinstance(op, transaction.Deposit): + elif isinstance(op, tr.Deposit): if coin == config.FIAT: # Add to balance; # we do not care, where our home fiat comes from. @@ -206,7 +204,7 @@ def evaluate_sell( f"on {op.platform} at {op.utc_time}. " "The evaluation might be wrong." ) - elif isinstance(op, transaction.Withdrawal): + elif isinstance(op, tr.Withdrawal): if coin == config.FIAT: # Remove from balance; # we do not care, where our home fiat goes to. @@ -230,7 +228,7 @@ def evaluate_sell( assert isinstance(left_coin, decimal.Decimal) # Calculate unrealized gains for the last time of `TAX_YEAR`. # If we are currently in ´TAX_YEAR` take now. - virtual_sell = transaction.Sell( + virtual_sell = tr.Sell( TAX_DEADLINE, op.platform, left_coin, @@ -243,16 +241,16 @@ def evaluate_sell( def _evaluate_taxation_per_coin( self, - operations: list[transaction.Operation], + operations: list[tr.Operation], ) -> None: """Evaluate the taxation for a list of operations per coin using country specific functions. Args: - operations (list[transaction.Operation]) + operations (list[tr.Operation]) """ for coin, coin_operations in misc.group_by(operations, "coin").items(): - coin_operations = transaction.sort_operations(coin_operations, ["utc_time"]) + coin_operations = tr.sort_operations(coin_operations, ["utc_time"]) self.__evaluate_taxation(coin, coin_operations) def evaluate_taxation(self) -> None: From 6d9d70cd1f458df478f7df5a731244663b05ef31 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 22:59:34 +0200 Subject: [PATCH 022/141] CHANGE Binance operation "Launchpool Interest" to CoinLendInterest --- src/book.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/book.py b/src/book.py index b78fa4f4..9192940e 100644 --- a/src/book.py +++ b/src/book.py @@ -115,21 +115,26 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: platform = "binance" operation_mapping = { "Distribution": "Airdrop", + "Cash Voucher distribution": "Airdrop", + # "Savings Interest": "CoinLendInterest", "Savings purchase": "CoinLend", "Savings Principal redemption": "CoinLendEnd", + # "Commission History": "Commission", "Commission Fee Shared With You": "Commission", "Referrer rebates": "Commission", "Referral Kickback": "Commission", - "Launchpool Interest": "StakingInterest", - "Cash Voucher distribution": "Airdrop", - "Super BNB Mining": "StakingInterest", + # DeFi yield farming "Liquid Swap add": "CoinLend", "Liquid Swap remove": "CoinLendEnd", + "Launchpool Interest": "CoindLendInterest", + # + "Super BNB Mining": "StakingInterest", "POS savings interest": "StakingInterest", "POS savings purchase": "Staking", "POS savings redemption": "StakingEnd", + # "Withdraw": "Withdrawal", } From 484b47e8bb7fead1a44b0bf7241d83198584767b Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 22:59:52 +0200 Subject: [PATCH 023/141] UPDATE error message when operation type was not found --- src/book.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/book.py b/src/book.py index 9192940e..1d42b260 100644 --- a/src/book.py +++ b/src/book.py @@ -65,11 +65,10 @@ def create_operation( Op = getattr(tr, operation) except AttributeError: log.error( - "Could not recognize operation `%s` in %s file `%s:%i`.", - operation, - platform, - file_path, - row, + f"Could not recognize {operation=} from {platform=} in " + f"{file_path=} {row=}. " + "The operation type might have been removed or renamed. " + "Please open an issue or PR." ) raise RuntimeError From 7b8132cb31cd22523707ae7ddba7f22d5f0bf86e Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 23:00:06 +0200 Subject: [PATCH 024/141] ADD docstring to Staking operations --- src/transaction.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/transaction.py b/src/transaction.py index fef9a91d..ceb6b32a 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -148,10 +148,14 @@ class CoinLendEnd(Operation): class Staking(Operation): + """Cold Staking or Proof Of Stake (not for mining)""" + pass class StakingEnd(Operation): + """Cold Staking or Proof Of Stake (not for mining)""" + pass @@ -172,6 +176,8 @@ class CoinLendInterest(Transaction): class StakingInterest(Transaction): + """Cold Staking or Proof Of Stake (not for mining)""" + pass From c7ad990702e166cacc09456c91a58b4d3ccefba0 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 23:24:48 +0200 Subject: [PATCH 025/141] UPDATE Add warning when buffer fee is used, shouldn't happen --- src/balance_queue.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 433288d0..5654f967 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -221,8 +221,12 @@ def _remove_fee(self, fee: decimal.Decimal) -> None: """ _, left_over_fee = self._remove(fee) if left_over_fee: - # Not enough coins in queue to remove fee. - # Buffer the fee for next time. + log.warning( + "Not enough coins in queue to remove fee. Buffer the fee for " + "next adding time... " + "This should not happen. You might be missing a account " + "statement. Please open issue or PR if you need help." + ) self.buffer_fee += left_over_fee def remove_fee(self, fee: tr.Fee) -> None: From 7f8fa37a89bc349020ad89ecf9ea57b9adc9bc8f Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 23:59:21 +0200 Subject: [PATCH 026/141] FIX merge identical operations before getting prices from csv prices from csv are only calculated for exactly one buy/sell pair. merging operations makes sure, that there is only one buy/sell pair --- src/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main.py b/src/main.py index 831bae7a..49c1603d 100644 --- a/src/main.py +++ b/src/main.py @@ -39,11 +39,12 @@ def main() -> None: log.warning("Stopping CoinTaxman.") return - book.get_price_from_csv() - # Merge identical operations together, which makes it easier to match fees - # afterwards (as long as there are only one buy/sell pair per time). - # And reduced database accesses. + # Merge identical operations together, which makes it easier to match + # buy/sell to get prices from csv, match fees and reduces database access + # (as long as there are only one buy/sell pair per time, + # might be problematic otherwhise). book.merge_identical_operations() + book.get_price_from_csv() book.match_fees_with_operations() taxman.evaluate_taxation() From 539fd5bdf8e1c0385c70e0865d51dde41aa60a5c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 8 Apr 2022 23:59:25 +0200 Subject: [PATCH 027/141] WORK IN PROGRESS this commit will be removed as soon as i get back to work --- src/taxman.py | 429 +++++++++++++++++++++++++++++--------------------- 1 file changed, 252 insertions(+), 177 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 3393da18..e3756e73 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -18,7 +18,7 @@ import datetime import decimal from pathlib import Path -from typing import Optional, Type +from typing import Any, Optional, Type import balance_queue import config @@ -45,6 +45,7 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self.book = book self.price_data = price_data + # TODO@now REFACTOR how TaxEvents are kept. self.tax_events: list[tr.TaxEvent] = [] # Tax Events which would occur if all left over coins were sold now. self.virtual_tax_events: list[tr.TaxEvent] = [] @@ -56,6 +57,7 @@ def __init__(self, book: Book, price_data: PriceData) -> None: except AttributeError: raise NotImplementedError(f"Unable to evaluate taxation for {country=}.") + # Determine the BalanceType. if config.PRINCIPLE == core.Principle.FIFO: # Explicity define type for BalanceType on first declaration # to avoid mypy errors. @@ -69,189 +71,262 @@ def __init__(self, book: Book, price_data: PriceData) -> None: f"Unable to evaluate taxation for {config.PRINCIPLE=}." ) - @staticmethod - def in_tax_year(op: tr.Operation) -> bool: - return op.utc_time.year == config.TAX_YEAR - - @staticmethod - def tax_deadline() -> datetime.datetime: - return min( - datetime.datetime(config.TAX_YEAR, 12, 31, 23, 59, 59), - datetime.datetime.now(), - ).astimezone() - - def _evaluate_taxation_GERMANY( - self, - coin: str, - operations: list[tr.Operation], - ) -> None: - balance = self.BalanceType() - - def evaluate_sell( - op: tr.Operation, force: bool = False - ) -> Optional[tr.TaxEvent]: - # Remove coins from queue. - sold_coins = balance.remove(op) - - if coin == config.FIAT: - # Not taxable. - return None - - if not in_tax_year(op) and not force: - # Sell is only taxable in the respective year. - return None - - taxation_type = "Sonstige Einkünfte" - # Price of the sell. - sell_value = self.price_data.get_cost(op) - taxed_gain = decimal.Decimal() - real_gain = decimal.Decimal() - # Coins which are older than (in this case) one year or - # which come from an Airdrop, CoinLend or Commission (in an - # foreign currency) will not be taxed. - for sc in sold_coins: - is_taxable = not config.IS_LONG_TERM( - sc.op.utc_time, op.utc_time - ) and not ( - isinstance( - sc.op, - ( - tr.Airdrop, - tr.CoinLendInterest, - tr.StakingInterest, - tr.Commission, - ), - ) - and not sc.op.coin == config.FIAT + self._balances: dict[Any, balance_queue.BalanceQueue] = {} + + # Helper functions for balances. + # TODO Refactor this into separated BalanceDict class? + + def balance(self, platform: str, coin: str) -> balance_queue.BalanceQueue: + key = (platform, coin) if config.MULTI_DEPOT else coin + try: + return self._balances[key] + except KeyError: + self._balances[key] = self.BalanceType(coin) + return self._balances[key] + + def balance_op(self, op: tr.Operation) -> balance_queue.BalanceQueue: + balance = self.balance(op.platform, op.coin) + return balance + + def add_to_balance(self, op: tr.Operation) -> None: + self.balance_op(op).add(op) + + def remove_from_balance(self, op: tr.Operation) -> list[tr.SoldCoin]: + return self.balance_op(op).remove(op) + + def remove_fees_from_balance(self, fees: Optional[list[tr.Fee]]) -> None: + if fees is not None: + for fee in fees: + self.balance_op(fee).remove_fee(fee) + + # Country specific evaluation functions. + + def evaluate_sell(self, op: tr.Sell, sold_coins: list[tr.SoldCoin]) -> tr.TaxEvent: + assert op.coin != config.FIAT + assert in_tax_year(op) + + # TODO REFACTOR Berechnung + # TODO Beachte Deposit als Quelle. Fehler, wenn Quelle fehlt + # TODO Werfe Fehler, falls bestimmte operation nicht beachtet wird. + # Veräußerungserlös + # Anschaffungskosten + # TODO Beachte buying fees zu anschaffungskosten + # Werbungskosten + # TODO Beachte fees + # Gewinn / Verlust + # davon steuerbar + + taxation_type = "Sonstige Einkünfte" + # Price of the sell. + sell_value = self.price_data.get_cost(op) + taxed_gain = decimal.Decimal() + real_gain = decimal.Decimal() + # Coins which are older than (in this case) one year or + # which come from an Airdrop, CoinLend or Commission (in an + # foreign currency) will not be taxed. + for sc in sold_coins: + if isinstance(sc.op, tr.Deposit): + # If these coins get sold, we need to now when and for which price + # they were bought. + # TODO Implement matching for Deposit and Withdrawals to determine + # the correct acquisition cost and to determine whether this stell + # is tax relevant. + log.warning( + f"You sold {sc.op.coin} which were deposited from " + f"somewhere else onto {sc.op.platform} (see " + f"{sc.op.file_path} {sc.op.line}). " + "Matching of Deposits and Withdrawals is currently not " + "implementeded. Therefore it is unknown when and for which " + f"price these {sc.op.coin} were bought. " + "A correct tax evaluation is not possible. " + "Please create an issue or PR to help solve this problem. " + "For now, we assume that the coins were bought at deposit, " + "The price is gathered from the platform onto which the coin " + f"was deposited ({sc.op.platform})." ) - # Only calculate the gains if necessary. - if is_taxable or config.CALCULATE_UNREALIZED_GAINS: - partial_sell_value = (sc.sold / op.change) * sell_value - sold_coin_cost = self.price_data.get_cost(sc) - gain = partial_sell_value - sold_coin_cost - if is_taxable: - taxed_gain += gain - if config.CALCULATE_UNREALIZED_GAINS: - real_gain += gain - remark = ", ".join( - f"{sc.sold} from {sc.op.utc_time} " f"({sc.op.__class__.__name__})" - for sc in sold_coins - ) - return tr.TaxEvent( - taxation_type, - taxed_gain, - op, - sell_value, - real_gain, - remark, + + is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) and not ( + isinstance( + sc.op, + ( + tr.Airdrop, + tr.CoinLendInterest, + tr.StakingInterest, + tr.Commission, + ), + ) + and not sc.op.coin == config.FIAT ) + # Only calculate the gains if necessary. + if is_taxable or config.CALCULATE_UNREALIZED_GAINS: + partial_sell_value = (sc.sold / op.change) * sell_value + sold_coin_cost = self.price_data.get_cost(sc) + gain = partial_sell_value - sold_coin_cost + if is_taxable: + taxed_gain += gain + if config.CALCULATE_UNREALIZED_GAINS: + real_gain += gain + remark = ", ".join( + f"{sc.sold} from {sc.op.utc_time} " f"({sc.op.__class__.__name__})" + for sc in sold_coins + ) + return tr.TaxEvent( + taxation_type, + taxed_gain, + op, + sell_value, + real_gain, + remark, + ) - # TODO handle buy.fees and sell.fees. - - for op in operations: - if isinstance(op, tr.Fee): - raise RuntimeError("single fee operations shouldn't exist") - balance.remove_fee(op.change) - if in_tax_year(op): - # Fees reduce taxed gain. - taxation_type = "Sonstige Einkünfte" - taxed_gain = -self.price_data.get_cost(op) - tx = tr.TaxEvent(taxation_type, taxed_gain, op) - self.tax_events.append(tx) - elif isinstance(op, tr.CoinLend): - pass - elif isinstance(op, tr.CoinLendEnd): - pass - elif isinstance(op, tr.Staking): - pass - elif isinstance(op, tr.StakingEnd): - pass - elif isinstance(op, tr.Buy): - balance.add(op) - elif isinstance(op, tr.Sell): - if tx_ := evaluate_sell(op): - self.tax_events.append(tx_) - elif isinstance(op, (tr.CoinLendInterest, tr.StakingInterest)): - balance.add(op) - if in_tax_year(op): - if misc.is_fiat(coin): - assert not isinstance( - op, tr.StakingInterest - ), "You can not stake fiat currencies." - taxation_type = "Einkünfte aus Kapitalvermögen" - else: - taxation_type = "Einkünfte aus sonstigen Leistungen" - taxed_gain = self.price_data.get_cost(op) - tx = tr.TaxEvent(taxation_type, taxed_gain, op) - self.tax_events.append(tx) - elif isinstance(op, tr.Airdrop): - balance.add(op) - elif isinstance(op, tr.Commission): - balance.add(op) - if in_tax_year(op): + def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: + + if isinstance(op, (tr.CoinLend, tr.Staking)): + # TODO determine which coins get lended/etc., use fifo if it's + # unclear. it might be worth to optimize the order + # of coins given away (is this legal?) + # TODO mark them as currently lended/etc., so they don't get sold + pass + + elif isinstance(op, (tr.CoinLendEnd, tr.StakingEnd)): + # TODO determine which coins come back from lending/etc. use fifo + # if it's unclear; it might be nice to match Start and + # End of these operations like deposit and withdrawal operations. + # e.g. + # - lending 1 coin for 2 months + # - lending 2 coins for 1 month + # - getting back 2 coins from lending + # --> this should be the second and third coin, + # not the first and second + # TODO mark them as not lended/etc. anymore, so they could be sold + # again + # TODO lending/etc might increase the tax-free speculation period! + pass + + elif isinstance(op, tr.Buy): + # Buys and sells always come in a pair. The buying/receiving + # part is not tax relevant per se. + # The fees of this buy/sell-transaction are saved internally in + # both operations. The "buying fees" are only relevant when + # detemining the acquisition cost of the bought coins. + # For now we'll just add our bought coins to the balance. + self.add_to_balance(op) + + elif isinstance(op, tr.Sell): + # Buys and sells always come in a pair. The selling/redeeming + # time is tax relevant. + # Remove the sold coins and paid fees from the balance. + sold_coins = self.remove_from_balance(op) + self.remove_fees_from_balance(op.fees) + + if op.coin != config.FIAT and in_tax_year(op): + tx = self.evaluate_sell(op, sold_coins) + self.tax_events.append(tx) + + elif isinstance(op, (tr.CoinLendInterest, tr.StakingInterest)): + # TODO@now + self.add_to_balance(op) + # TODO@now REFACTOR + if in_tax_year(op): + if misc.is_fiat(op.coin): + assert not isinstance( + op, tr.StakingInterest + ), "You can not stake fiat currencies." + taxation_type = "Einkünfte aus Kapitalvermögen" + else: taxation_type = "Einkünfte aus sonstigen Leistungen" - taxed_gain = self.price_data.get_cost(op) - tx = tr.TaxEvent(taxation_type, taxed_gain, op) - self.tax_events.append(tx) - elif isinstance(op, tr.Deposit): - if coin == config.FIAT: - # Add to balance; - # we do not care, where our home fiat comes from. - balance.add(op) - else: # coin != config.FIAT - log.warning( - f"Unresolved deposit of {op.change} {coin} " - f"on {op.platform} at {op.utc_time}. " - "The evaluation might be wrong." - ) - elif isinstance(op, tr.Withdrawal): - if coin == config.FIAT: - # Remove from balance; - # we do not care, where our home fiat goes to. - balance.remove(op) - else: # coin != config.FIAT - log.warning( - f"Unresolved withdrawal of {op.change} {coin} " - f"from {op.platform} at {op.utc_time}. " - "The evaluation might be wrong." - ) - else: - raise NotImplementedError - - balance.sanity_check() - - # Calculate the amount of coins which should be left on the platform - # and evaluate the (taxed) gain, if the coin would be sold right now. - if config.CALCULATE_UNREALIZED_GAINS and ( - (left_coin := misc.dsum((bop.not_sold for bop in balance.queue))) - ): - assert isinstance(left_coin, decimal.Decimal) - # Calculate unrealized gains for the last time of `TAX_YEAR`. - # If we are currently in ´TAX_YEAR` take now. - virtual_sell = tr.Sell( - TAX_DEADLINE, - op.platform, - left_coin, - coin, - [-1], - Path(""), - ) - if tx_ := evaluate_sell(virtual_sell, force=True): - self.virtual_tax_events.append(tx_) - - def _evaluate_taxation_per_coin( - self, - operations: list[tr.Operation], - ) -> None: - """Evaluate the taxation for a list of operations per coin using + taxed_gain = self.price_data.get_cost(op) + tx = tr.TaxEvent(taxation_type, taxed_gain, op) + self.tax_events.append(tx) + + elif isinstance(op, tr.Airdrop): + # TODO write information text + self.add_to_balance(op) + + if in_tax_year(op): + # TODO do correct taxation. + log.warning( + "You received an Aridrop. An airdrop could be taxed as " + "`Einkünfte aus sonstigen Leistungen` or `Schenkung` or " + "something else?, as the case may be. " + "In the current implementation, all airdrops are taxed as " + "`Einkünfte aus sonstigen Leistungen`. " + "This can result in paying more taxes than necessary. " + "Please inform yourself and open a PR to fix this." + ) + taxation_type = "Einkünfte aus sonstigen Leistungen" + taxed_gain = self.price_data.get_cost(op) + tx = tr.TaxEvent(taxation_type, taxed_gain, op) + self.tax_events.append(tx) + + elif isinstance(op, tr.Commission): + # TODO write information text + self.add_to_balance(op) + if in_tax_year(op): + # TODO do correct taxation. + log.warning( + "You have received a Commission. " + "I am currently unsure how Commissions get taxed. " + "For now they are taxed as `Einkünfte aus sonstigen " + "Leistungen`. " + "Please inform yourself and help us to fix this problem " + "by opening and issue or creating a PR." + ) + taxation_type = "Einkünfte aus sonstigen Leistungen" + taxed_gain = self.price_data.get_cost(op) + tx = tr.TaxEvent(taxation_type, taxed_gain, op) + self.tax_events.append(tx) + + elif isinstance(op, tr.Deposit): + # Coins get deposited onto this platform/balance. + # TODO are transaction costs deductable from the tax? if yes, when? + # on withdrawal or deposit or on sell of the moved coin?? + self.add_to_balance(op) + + elif isinstance(op, tr.Withdrawal): + # Coins get moved to somewhere else. At this point, we only have + # to remove them from the corresponding balance. + self.remove_from_balance(op) + + else: + raise NotImplementedError + + # General tax evaluation functions. + + def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: + """Evaluate the taxation for a list of operations using country specific functions. Args: operations (list[tr.Operation]) """ - for coin, coin_operations in misc.group_by(operations, "coin").items(): - coin_operations = tr.sort_operations(coin_operations, ["utc_time"]) - self.__evaluate_taxation(coin, coin_operations) + operations = tr.sort_operations(operations, ["utc_time"]) + for operation in operations: + self.__evaluate_taxation(operation) + + for balance in self._balances.values(): + balance.sanity_check() + + # TODO REFACTOR and try to integrate this into balance.close + + # # Calculate the amount of coins which should be left on the platform + # # and evaluate the (taxed) gain, if the coin would be sold right now. + # if config.CALCULATE_UNREALIZED_GAINS and ( + # (left_coin := misc.dsum((bop.not_sold for bop in balance.queue))) + # ): + # assert isinstance(left_coin, decimal.Decimal) + # # Calculate unrealized gains for the last time of `TAX_YEAR`. + # # If we are currently in ´TAX_YEAR` take now. + # virtual_sell = tr.Sell( + # TAX_DEADLINE, + # op.platform, + # left_coin, + # coin, + # [-1], + # Path(""), + # ) + # if tx_ := self._funktion_verändert_evaluate_sell(virtual_sell, force=True): + # self.virtual_tax_events.append(tx_) def evaluate_taxation(self) -> None: """Evaluate the taxation using country specific function.""" @@ -266,10 +341,10 @@ def evaluate_taxation(self) -> None: for _, operations in misc.group_by( self.book.operations, "platform" ).items(): - self._evaluate_taxation_per_coin(operations) + self._evaluate_taxation(operations) else: # Evaluate taxation separated by coins "in a single virtual depot". - self._evaluate_taxation_per_coin(self.book.operations) + self._evaluate_taxation(self.book.operations) def print_evaluation(self) -> None: """Print short summary of evaluation to stdout.""" From 7426249194647ef1a36abf0c4aaab81ce7ca7e29 Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat <34964599+jhoogstraat@users.noreply.github.com> Date: Tue, 12 Apr 2022 21:47:31 +0200 Subject: [PATCH 028/141] ADD basic transfer matching algorithm --- src/book.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.py | 1 + src/transaction.py | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/book.py b/src/book.py index 1d42b260..103c710f 100644 --- a/src/book.py +++ b/src/book.py @@ -1212,6 +1212,56 @@ def detect_exchange(self, file_path: Path) -> Optional[str]: return None + def resolve_deposits(self) -> None: + """Matches withdrawals and deposits by referencing the related + withdrawal on the deposit. + + A match is found when: + A. The coin is the same + B. The deposit amount is between 0.99 and 1 times the withdrawal amount. + + Returns: + None + """ + sorted_ops = tr.sort_operations(self.operations, ["utc_time"]) + + def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: + return ( + withdrawal.coin == deposit.coin + and withdrawal.change * decimal.Decimal(0.99) + <= deposit.change + <= withdrawal.change + ) + + withdrawal_queue: list[tr.Withdrawal] = [] + + for op in sorted_ops: + # log.debug(op.utc_time) + if isinstance(op, tr.Withdrawal): + if not misc.is_fiat(op.coin): + withdrawal_queue.append(op) + elif isinstance(op, tr.Deposit): + if not misc.is_fiat(op.coin): + try: + match = next(w for w in withdrawal_queue if is_match(w, op)) + op.link = match + withdrawal_queue.remove(match) + log.info( + "Linking transfer: " + f"{match.change} {match.coin} " + f"({match.platform}, {match.utc_time}) " + f"-> {op.change} {op.coin} " + f"({op.platform}, {op.utc_time})" + ) + except StopIteration: + log.warning( + "No matching withdrawal operation found for deposit of " + f"{match.change} {match.coin} " + f"({match.platform}, {match.utc_time})" + ) + + log.info("Finished matching") + def get_price_from_csv(self) -> None: """Calculate coin prices from buy/sell operations in CSV files. diff --git a/src/main.py b/src/main.py index 49c1603d..3f56725b 100644 --- a/src/main.py +++ b/src/main.py @@ -44,6 +44,7 @@ def main() -> None: # (as long as there are only one buy/sell pair per time, # might be problematic otherwhise). book.merge_identical_operations() + book.resolve_deposits() book.get_price_from_csv() book.match_fees_with_operations() diff --git a/src/transaction.py b/src/transaction.py index ceb6b32a..3bacf348 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -190,7 +190,7 @@ class Commission(Transaction): class Deposit(Transaction): - pass + link: typing.Optional[Withdrawal] = None class Withdrawal(Transaction): From 2b9cabd5df05027bc250f2110931d0d73fa861bb Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat <34964599+jhoogstraat@users.noreply.github.com> Date: Wed, 13 Apr 2022 14:13:05 +0200 Subject: [PATCH 029/141] Cleanup --- src/book.py | 1 - src/main.py | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/book.py b/src/book.py index 103c710f..72e011ef 100644 --- a/src/book.py +++ b/src/book.py @@ -1236,7 +1236,6 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: withdrawal_queue: list[tr.Withdrawal] = [] for op in sorted_ops: - # log.debug(op.utc_time) if isinstance(op, tr.Withdrawal): if not misc.is_fiat(op.coin): withdrawal_queue.append(op) diff --git a/src/main.py b/src/main.py index 3f56725b..0efc2c3c 100644 --- a/src/main.py +++ b/src/main.py @@ -44,6 +44,8 @@ def main() -> None: # (as long as there are only one buy/sell pair per time, # might be problematic otherwhise). book.merge_identical_operations() + # Resolve dependencies between withdrawals and deposits, which is + # necessary to correctly fetch prices and to calculate p/l. book.resolve_deposits() book.get_price_from_csv() book.match_fees_with_operations() From 00acedda72768a5059948366f549d276523c30c7 Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat <34964599+jhoogstraat@users.noreply.github.com> Date: Wed, 13 Apr 2022 18:03:06 +0200 Subject: [PATCH 030/141] Include foreign fiat currencies --- src/book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index 72e011ef..af58d24b 100644 --- a/src/book.py +++ b/src/book.py @@ -1237,10 +1237,10 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: for op in sorted_ops: if isinstance(op, tr.Withdrawal): - if not misc.is_fiat(op.coin): + if op.coin != config.FIAT: withdrawal_queue.append(op) elif isinstance(op, tr.Deposit): - if not misc.is_fiat(op.coin): + if op.coin != config.FIAT: try: match = next(w for w in withdrawal_queue if is_match(w, op)) op.link = match From e6563c2f6d63189f4343a3ab7083b0bdd9059c4a Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 09:39:43 +0200 Subject: [PATCH 031/141] UPDATE format --- src/book.py | 57 +++++++++++++++++++++++++++------------------- src/transaction.py | 2 +- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/book.py b/src/book.py index af58d24b..508ca3ca 100644 --- a/src/book.py +++ b/src/book.py @@ -1213,11 +1213,10 @@ def detect_exchange(self, file_path: Path) -> Optional[str]: return None def resolve_deposits(self) -> None: - """Matches withdrawals and deposits by referencing the related - withdrawal on the deposit. + """Match withdrawals to deposits. A match is found when: - A. The coin is the same + A. The coin is the same and B. The deposit amount is between 0.99 and 1 times the withdrawal amount. Returns: @@ -1236,28 +1235,40 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: withdrawal_queue: list[tr.Withdrawal] = [] for op in sorted_ops: + if op.coin == config.FIAT: + # Do not match home fiat deposit/withdrawals. + continue + if isinstance(op, tr.Withdrawal): - if op.coin != config.FIAT: - withdrawal_queue.append(op) + # Add new withdrawal to queue. + withdrawal_queue.append(op) + elif isinstance(op, tr.Deposit): - if op.coin != config.FIAT: - try: - match = next(w for w in withdrawal_queue if is_match(w, op)) - op.link = match - withdrawal_queue.remove(match) - log.info( - "Linking transfer: " - f"{match.change} {match.coin} " - f"({match.platform}, {match.utc_time}) " - f"-> {op.change} {op.coin} " - f"({op.platform}, {op.utc_time})" - ) - except StopIteration: - log.warning( - "No matching withdrawal operation found for deposit of " - f"{match.change} {match.coin} " - f"({match.platform}, {match.utc_time})" - ) + try: + # Find a matching withdrawal for this deposit. + # If multiple are found, take the first (regarding utc_time). + match = next(w for w in withdrawal_queue if is_match(w, op)) + except StopIteration: + log.warning( + "No matching withdrawal operation found for deposit of " + f"{match.change} {match.coin} " + f"({match.platform}, {match.utc_time}). " + "The tax evaluation might be wrong. " + "Have you added all account statements? " + "For tax evaluation, it might be importend when " + "and for which price these coins were bought." + ) + else: + # Match the found withdrawal and remove it from queue. + op.link = match + withdrawal_queue.remove(match) + log.debug( + "Linking withdrawal with deposit: " + f"{match.change} {match.coin} " + f"({match.platform}, {match.utc_time}) " + f"-> {op.change} {op.coin} " + f"({op.platform}, {op.utc_time})" + ) log.info("Finished matching") diff --git a/src/transaction.py b/src/transaction.py index 3bacf348..ddcc2247 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -190,7 +190,7 @@ class Commission(Transaction): class Deposit(Transaction): - link: typing.Optional[Withdrawal] = None + link: Optional[Withdrawal] = None class Withdrawal(Transaction): From 443b4d786ecd350af052b14f59af268660481d1c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 09:41:14 +0200 Subject: [PATCH 032/141] FIX Wrong variable used --- src/book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index 508ca3ca..2cb193a5 100644 --- a/src/book.py +++ b/src/book.py @@ -1251,8 +1251,8 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: except StopIteration: log.warning( "No matching withdrawal operation found for deposit of " - f"{match.change} {match.coin} " - f"({match.platform}, {match.utc_time}). " + f"{op.change} {op.coin} " + f"({op.platform}, {op.utc_time}). " "The tax evaluation might be wrong. " "Have you added all account statements? " "For tax evaluation, it might be importend when " From a60ac32622581ede61c628ea7ddfae2f398f5e32 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 09:49:30 +0200 Subject: [PATCH 033/141] ADD docstring for misc.dsum --- src/misc.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/misc.py b/src/misc.py index 2d026bca..37e2d28a 100644 --- a/src/misc.py +++ b/src/misc.py @@ -70,6 +70,14 @@ def xdecimal( def dsum(__iterable: Iterable[decimal.Decimal]) -> decimal.Decimal: + """Builtin sum function, which always returns a decimal.Decimal. + + Args: + __iterable (Iterable[decimal.Decimal]) + + Returns: + decimal.Decimal + """ return decimal.Decimal(sum(__iterable)) From fd6c867e91ff86ec14d4bf335664093f6726d2fe Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 09:51:42 +0200 Subject: [PATCH 034/141] FIX misc.group_by docstring --- src/misc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/misc.py b/src/misc.py index 37e2d28a..05fd292e 100644 --- a/src/misc.py +++ b/src/misc.py @@ -189,11 +189,11 @@ def group_by(lst: L, key: Union[str, list[str]]) -> dict[Any, L]: """Group a list of objects by `key`. Args: - lst (list) - key (str) + lst (L) + key (Union[str, list[str]]) Returns: - dict[Any, list]: Dict with different `key`as keys. + dict[Any, L]: Dict with different `key`as keys. """ d = collections.defaultdict(list) if isinstance(key, str): From b67a175b92dc791a3c5f25f9471eafd7344da6bf Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 09:52:14 +0200 Subject: [PATCH 035/141] FIX misc.is_fiat docstring --- src/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/misc.py b/src/misc.py index 05fd292e..0e5db510 100644 --- a/src/misc.py +++ b/src/misc.py @@ -235,7 +235,7 @@ def is_fiat(symbol: Union[str, core.Fiat]) -> bool: """Check if `symbol` is a fiat currency. Args: - fiat (str): Currency Symbol. + fiat (Union[str, core.Fiat]): Currency Symbol. Returns: bool: True if `symbol` is a fiat currency. False otherwise. From 0fbb8e32212655a7b137a7f24be3816640cab262 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 10:03:28 +0200 Subject: [PATCH 036/141] UPDATE format --- src/taxman.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index e3756e73..3f5ab139 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -32,7 +32,8 @@ log = log_config.getLogger(__name__) TAX_DEADLINE = min( - datetime.datetime(config.TAX_YEAR, 12, 31, 23, 59, 59), datetime.datetime.now() + datetime.datetime.now(), # now + datetime.datetime(config.TAX_YEAR, 12, 31, 23, 59, 59), # end of year ) @@ -73,8 +74,10 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self._balances: dict[Any, balance_queue.BalanceQueue] = {} + ########################################################################### # Helper functions for balances. # TODO Refactor this into separated BalanceDict class? + ########################################################################### def balance(self, platform: str, coin: str) -> balance_queue.BalanceQueue: key = (platform, coin) if config.MULTI_DEPOT else coin @@ -99,7 +102,9 @@ def remove_fees_from_balance(self, fees: Optional[list[tr.Fee]]) -> None: for fee in fees: self.balance_op(fee).remove_fee(fee) + ########################################################################### # Country specific evaluation functions. + ########################################################################### def evaluate_sell(self, op: tr.Sell, sold_coins: list[tr.SoldCoin]) -> tr.TaxEvent: assert op.coin != config.FIAT @@ -226,6 +231,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: elif isinstance(op, (tr.CoinLendInterest, tr.StakingInterest)): # TODO@now self.add_to_balance(op) + # TODO@now REFACTOR if in_tax_year(op): if misc.is_fiat(op.coin): @@ -235,6 +241,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: taxation_type = "Einkünfte aus Kapitalvermögen" else: taxation_type = "Einkünfte aus sonstigen Leistungen" + taxed_gain = self.price_data.get_cost(op) tx = tr.TaxEvent(taxation_type, taxed_gain, op) self.tax_events.append(tx) @@ -262,6 +269,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: elif isinstance(op, tr.Commission): # TODO write information text self.add_to_balance(op) + if in_tax_year(op): # TODO do correct taxation. log.warning( @@ -291,7 +299,9 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: else: raise NotImplementedError + ########################################################################### # General tax evaluation functions. + ########################################################################### def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: """Evaluate the taxation for a list of operations using @@ -346,6 +356,10 @@ def evaluate_taxation(self) -> None: # Evaluate taxation separated by coins "in a single virtual depot". self._evaluate_taxation(self.book.operations) + ########################################################################### + # Export / Summary + ########################################################################### + def print_evaluation(self) -> None: """Print short summary of evaluation to stdout.""" eval_str = "Evaluation:\n\n" From 436a70102dcd33731cafb4eb335abbbd15e24f7f Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 10:13:18 +0200 Subject: [PATCH 037/141] ADD warning when not all withdrawals could be matched --- src/book.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/book.py b/src/book.py index 2cb193a5..635261ff 100644 --- a/src/book.py +++ b/src/book.py @@ -1270,7 +1270,20 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: f"({op.platform}, {op.utc_time})" ) - log.info("Finished matching") + if withdrawal_queue: + log.warning( + "Unable to match all withdrawals with deposits. " + "Have you added all account statements? " + "Following withdrawals couldn't be matched:\n" + + ( + "\n".join( + f" - {op.change} {op.coin} from {op.platform} at{op.utc_time}" + for op in withdrawal_queue + ) + ) + ) + + log.info("Finished withdrawal/deposit matching") def get_price_from_csv(self) -> None: """Calculate coin prices from buy/sell operations in CSV files. From 3e391f3b453a3511f2086fb015f02fce7947d036 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 23 Apr 2022 11:11:25 +0200 Subject: [PATCH 038/141] FIX matching fees looped over wrong variable --- src/book.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/book.py b/src/book.py index 635261ff..34857f39 100644 --- a/src/book.py +++ b/src/book.py @@ -18,6 +18,7 @@ import csv import datetime import decimal +import itertools import re from collections import defaultdict from pathlib import Path @@ -1353,6 +1354,7 @@ def match_fees_with_operations(self) -> None: # Split operations in fees and other operations. operations = [] all_fees: list[tr.Fee] = [] + for op in self.operations: if isinstance(op, tr.Fee): all_fees.append(op) @@ -1363,10 +1365,8 @@ def match_fees_with_operations(self) -> None: self.operations = operations # Match fees to book operations. - platform_fees = misc.group_by(all_fees, "platform") - for platform, fees in platform_fees.items(): - time_fees = misc.group_by(all_fees, "utc_time") - for utc_time, fees in time_fees.items(): + for platform, _fees in misc.group_by(all_fees, "platform").items(): + for utc_time, fees in misc.group_by(_fees, "utc_time").items(): # Find matching operations by platform and time. matching_operations = { From 0eae371d8d267cdb934c60311b30694a6b2cbf07 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Mon, 25 Apr 2022 00:34:32 +0200 Subject: [PATCH 039/141] ADD misc.cdecimal --- src/misc.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/misc.py b/src/misc.py index 0e5db510..e3ee4d95 100644 --- a/src/misc.py +++ b/src/misc.py @@ -69,6 +69,21 @@ def xdecimal( return None if x is None or x == "" else decimal.Decimal(x) +def cdecimal(x: Union[None, str, int, float, decimal.Decimal]) -> decimal.Decimal: + """Convert to decimal and change None to 0. + + See xdecimal for further informations. + + Args: + x (Union[None, str, int, float, decimal.Decimal]) + + Returns: + decimal.Decimal + """ + dec = xdecimal(x) + return decimal.Decimal() if dec is None else dec + + def dsum(__iterable: Iterable[decimal.Decimal]) -> decimal.Decimal: """Builtin sum function, which always returns a decimal.Decimal. From 8409bd553d6e6b2485767fdbf4bed760763e609c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Mon, 25 Apr 2022 00:35:28 +0200 Subject: [PATCH 040/141] ADD price_data.get_partial_cost --- src/price_data.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/price_data.py b/src/price_data.py index d816dd31..7a515d46 100644 --- a/src/price_data.py +++ b/src/price_data.py @@ -573,6 +573,14 @@ def get_cost( return price * op_sc.sold raise NotImplementedError + def get_partial_cost( + self, + op_sc: Union[tr.Operation, tr.SoldCoin], + percent: decimal.Decimal, + reference_coin: str = config.FIAT, + ) -> decimal.Decimal: + return percent * self.get_cost(op_sc, reference_coin=reference_coin) + def check_database(self): stats = {} From 943f47bcbb9baf911038e5613dc3e90585e49fbc Mon Sep 17 00:00:00 2001 From: Jeppy Date: Mon, 25 Apr 2022 00:35:48 +0200 Subject: [PATCH 041/141] FIX typo --- src/taxman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index 3f5ab139..e0fdad05 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -253,7 +253,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: if in_tax_year(op): # TODO do correct taxation. log.warning( - "You received an Aridrop. An airdrop could be taxed as " + "You received an Airdrop. An airdrop could be taxed as " "`Einkünfte aus sonstigen Leistungen` or `Schenkung` or " "something else?, as the case may be. " "In the current implementation, all airdrops are taxed as " From ae80beb51e049cc731c4a0a0fd790ca88c086961 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Mon, 25 Apr 2022 00:37:20 +0200 Subject: [PATCH 042/141] ADD save withdrawn_coins in tr.Withdrawal --- src/transaction.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index ddcc2247..e7f8cd0a 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -194,7 +194,12 @@ class Deposit(Transaction): class Withdrawal(Transaction): - pass + withdrawn_coins: Optional[list[SoldCoin]] + + def partial_withdrawn_coins(self, percent: decimal.Decimal) -> list[SoldCoin]: + assert self.withdrawn_coins + withdrawn_coins = [wc.partial(percent) for wc in self.withdrawn_coins] + return withdrawn_coins # Helping variables @@ -205,6 +210,12 @@ class SoldCoin: op: Operation sold: decimal.Decimal + def partial(self, percent: decimal.Decimal) -> SoldCoin: + sc = copy(self) + sc.sold *= percent + sc.op.change *= percent + return sc + @dataclasses.dataclass class TaxEvent: From 3df6700a30c0c41c3a52851611bce870889e8d8a Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:09:17 +0200 Subject: [PATCH 043/141] FIX set country specific locale and local timezone --- src/config.py | 8 ++++++-- src/taxman.py | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/config.py b/src/config.py index adaad4c4..33b311c5 100644 --- a/src/config.py +++ b/src/config.py @@ -15,7 +15,8 @@ # along with this program. If not, see . import configparser -from datetime import datetime +import datetime as dt +import locale from os import environ from pathlib import Path @@ -63,8 +64,10 @@ if COUNTRY == core.Country.GERMANY: FIAT_CLASS = core.Fiat.EUR PRINCIPLE = core.Principle.FIFO + LOCAL_TIMEZONE = dt.datetime.now(dt.timezone.utc).astimezone().tzinfo + locale_str = "de_DE" - def IS_LONG_TERM(buy: datetime, sell: datetime) -> bool: + def IS_LONG_TERM(buy: dt.datetime, sell: dt.datetime) -> bool: return buy + relativedelta(years=1) < sell @@ -76,3 +79,4 @@ def IS_LONG_TERM(buy: datetime, sell: datetime) -> bool: # Program specific constants. FIAT = FIAT_CLASS.name # Convert to string. +locale.setlocale(locale.LC_ALL, locale_str) diff --git a/src/taxman.py b/src/taxman.py index e0fdad05..ccecbfe5 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -32,8 +32,10 @@ log = log_config.getLogger(__name__) TAX_DEADLINE = min( - datetime.datetime.now(), # now - datetime.datetime(config.TAX_YEAR, 12, 31, 23, 59, 59), # end of year + datetime.datetime.now().replace(tzinfo=config.LOCAL_TIMEZONE), # now + datetime.datetime( + config.TAX_YEAR, 12, 31, 23, 59, 59, tzinfo=config.LOCAL_TIMEZONE + ), # end of year ) From f8d0dbf218da727d55dfbfd35ca24086315c5422 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:09:54 +0200 Subject: [PATCH 044/141] ADD Withdrawal.partial_withdrawn_coins assert --- src/transaction.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/transaction.py b/src/transaction.py index e7f8cd0a..19f68d69 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -199,6 +199,9 @@ class Withdrawal(Transaction): def partial_withdrawn_coins(self, percent: decimal.Decimal) -> list[SoldCoin]: assert self.withdrawn_coins withdrawn_coins = [wc.partial(percent) for wc in self.withdrawn_coins] + assert self.change == misc.dsum( + (wsc.sold for wsc in withdrawn_coins) + ), "Withdrawn coins total must be equal to the sum if the single coins." return withdrawn_coins From 3ac4d9fe558e1722df103f84ecf5b1d28f2c9228 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:11:14 +0200 Subject: [PATCH 045/141] ADD new TaxReportEntry class for improved export --- src/transaction.py | 435 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 434 insertions(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index 19f68d69..88e455d2 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -23,7 +23,7 @@ import typing from copy import copy from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Iterator, Optional import log_config import misc @@ -230,6 +230,439 @@ class TaxEvent: remark: str = "" +@dataclasses.dataclass +class TaxReportEntry: + event_type = "virtual" + + first_platform: Optional[str] = None + second_platform: Optional[str] = None + + amount: Optional[decimal.Decimal] = None + coin: Optional[str] = None + + first_utc_time: Optional[datetime.datetime] = None + second_utc_time: Optional[datetime.datetime] = None + + # Fee might be paid in multiple coin types (e.g. Binance BNB) + first_fee_amount: Optional[decimal.Decimal] = None + first_fee_coin: Optional[str] = None + first_fee_in_fiat: Optional[decimal.Decimal] = None + # + second_fee_amount: Optional[decimal.Decimal] = None + second_fee_coin: Optional[str] = None + second_fee_in_fiat: Optional[decimal.Decimal] = None + + first_value_in_fiat: Optional[decimal.Decimal] = None + second_value_in_fiat: Optional[decimal.Decimal] = None + total_fee_in_fiat: Optional[decimal.Decimal] = dataclasses.field(init=False) + + @property + def _total_fee_in_fiat(self) -> Optional[decimal.Decimal]: + if self.first_fee_in_fiat == self.second_fee_in_fiat == None: + return None + return misc.dsum( + map( + # TODO Report mypy bug + misc.cdecimal, # type: ignore + (self.first_fee_in_fiat, self.second_fee_in_fiat), + ) + ) + + gain_in_fiat: Optional[decimal.Decimal] = dataclasses.field(init=False) + + @property + def _gain_in_fiat(self) -> Optional[decimal.Decimal]: + if ( + self.first_value_in_fiat + == self.second_value_in_fiat + == self._total_fee_in_fiat + == None + ): + return None + return ( + misc.cdecimal(self.first_value_in_fiat) + - misc.cdecimal(self.second_value_in_fiat) + - misc.cdecimal(self._total_fee_in_fiat) + ) + + is_taxable: Optional[bool] = None + taxation_type: Optional[str] = None + remark: Optional[str] = None + + @property + def taxable_gain(self) -> decimal.Decimal: + if self.is_taxable and self._gain_in_fiat: + return self._gain_in_fiat + return decimal.Decimal() + + # Copy-paste template for subclasses. + # def __init__( + # self, + # first_platform: str, + # second_platform: str, + # amount: decimal.Decimal, + # coin: str, + # first_utc_time: datetime.datetime, + # second_utc_time: datetime.datetime, + # first_fee_amount: decimal.Decimal, + # first_fee_coin: str, + # first_fee_in_fiat: decimal.Decimal, + # second_fee_amount: decimal.Decimal, + # second_fee_coin: str, + # second_fee_in_fiat: decimal.Decimal, + # first_value_in_fiat: decimal.Decimal, + # second_value_in_fiat: decimal.Decimal, + # is_taxable: bool, + # taxation_type: str, + # remark: str, + # ) -> None: + # super().__init__( + # first_platform=first_platform, + # second_platform=second_platform, + # amount=amount, + # coin=coin, + # first_utc_time=first_utc_time, + # second_utc_time=second_utc_time, + # first_fee_amount=first_fee_amount, + # first_fee_coin=first_fee_coin, + # first_fee_in_fiat=first_fee_in_fiat, + # second_fee_amount=second_fee_amount, + # second_fee_coin=second_fee_coin, + # second_fee_in_fiat=second_fee_in_fiat, + # first_value_in_fiat=first_value_in_fiat, + # second_value_in_fiat=second_value_in_fiat, + # is_taxable=is_taxable, + # taxation_type=taxation_type, + # remark=remark, + # ) + + def __post_init__(self) -> None: + """Validate that all required fields (label != '-') are given.""" + missing_field_values = [ + field_name + for label, field_name in zip(self.labels(), self.field_names()) + if label != "-" and getattr(self, field_name) is None + ] + assert not missing_field_values, ( + f"{self=} : missing values for fields " f"{', '.join(missing_field_values)}" + ) + + @classmethod + def field_names(cls) -> Iterator[str]: + return (field.name for field in dataclasses.fields(cls)) + + @classmethod + def _labels(cls) -> list[str]: + return list(cls.field_names()) + + @classmethod + def labels(cls) -> list[str]: + l = cls._labels() + assert len(l) == len(dataclasses.fields(cls)) + return l + + def values(self) -> Iterator: + return (getattr(self, f) for f in self.field_names()) + + +# Bypass dataclass machinery, add a custom property function to a dataclass field. +TaxReportEntry.total_fee_in_fiat = TaxReportEntry._total_fee_in_fiat # type:ignore +TaxReportEntry.gain_in_fiat = TaxReportEntry._gain_in_fiat # type:ignore + + +class LendingReportEntry(TaxReportEntry): + event_type = "Coin-Lending Zeitraum" + + @classmethod + def _labels(cls) -> list[str]: + return [ + "Börse", + "-", + # + "Anzahl", + "Währung", + # + "Wiedererhalten am", + "Verliehen am", + # + "-", + "-", + "-", + "-", + "-", + "-", + # + "-", + "-", + "-", + # + "Gewinn/Verlust in EUR", + "davon steuerbar", + "Einkunftsart", + "Bemerkung", + ] + + +class StakingReportEntry(LendingReportEntry): + event_type = "Staking Zeitaraum" + + +class SellReportEntry(TaxReportEntry): + event_type = "Verkauf" + + def __init__( + self, + sell_platform: str, + buy_platform: str, + amount: decimal.Decimal, + coin: str, + sell_utc_time: datetime.datetime, + buy_utc_time: datetime.datetime, + first_fee_amount: decimal.Decimal, + first_fee_coin: str, + first_fee_in_fiat: decimal.Decimal, + second_fee_amount: decimal.Decimal, + second_fee_coin: str, + second_fee_in_fiat: decimal.Decimal, + sell_value_in_fiat: decimal.Decimal, + buy_value_in_fiat: decimal.Decimal, + is_taxable: bool, + taxation_type: str, + remark: str, + ) -> None: + super().__init__( + first_platform=sell_platform, + second_platform=buy_platform, + amount=amount, + coin=coin, + first_utc_time=sell_utc_time, + second_utc_time=buy_utc_time, + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + second_fee_amount=second_fee_amount, + second_fee_coin=second_fee_coin, + second_fee_in_fiat=second_fee_in_fiat, + first_value_in_fiat=sell_value_in_fiat, + second_value_in_fiat=buy_value_in_fiat, + is_taxable=is_taxable, + taxation_type=taxation_type, + remark=remark, + ) + + @classmethod + def _labels(cls) -> list[str]: + return [ + "Verkauf auf Börse", + "Erworben von Börse", + # + "Anzahl", + "Währung", + # + "Verkaufsdatum", + "Erwerbsdatum", + # + "(1) Anzahl Transaktionsgebühr", + "(1) Währung Transaktionsgebühr", + "(1) Transaktionsgebühr in EUR", + "(2) Anzahl Transaktionsgebühr", + "(2) Währung Transaktionsgebühr", + "(2) Transaktionsgebühr in EUR", + # + "Veräußerungserlös in EUR", + "Anschaffungskosten in EUR", + "Gesamt Transaktionsgebühr in EUR", + # + "Gewinn/Verlust in EUR", + "davon steuerbar", + "Einkunftsart", + "Bemerkung", + ] + + +class UnrealizedSellReportEntry(SellReportEntry): + event_type = "Offene Positionen" + + +class InterestReportEntry(TaxReportEntry): + event_type = "Zinsen" + + def __init__( + self, + platform: str, + amount: decimal.Decimal, + utc_time: datetime.datetime, + coin: str, + interest_in_fiat: decimal.Decimal, + taxation_type: str, + remark: str, + ) -> None: + super().__init__( + first_platform=platform, + amount=amount, + first_utc_time=utc_time, + coin=coin, + first_value_in_fiat=interest_in_fiat, + is_taxable=True, + taxation_type=taxation_type, + remark=remark, + ) + + @classmethod + def _labels(cls) -> list[str]: + return [ + "Börse", + "-", + # + "Anzahl", + "Währung", + # + "Erhalten am", + "-", + # + "-", + "-", + "-", + "-", + "-", + "-", + # + "Wert in EUR", + "-", + "-", + # + "Gewinn/Verlust in EUR", + "davon steuerbar", + "Einkunftsart", + "Bemerkung", + ] + + +class LendingInterestReportEntry(InterestReportEntry): + event_type = "Einkünfte durch Coin-Lending" + + +class StakingInterestReportEntry(InterestReportEntry): + event_type = "Einkünfte durch Staking" + + +class AirdropReportEntry(TaxReportEntry): + event_type = "Airdrop" + + def __init__( + self, + platform: str, + amount: decimal.Decimal, + coin: str, + utc_time: datetime.datetime, + in_fiat: decimal.Decimal, + taxation_type: str, + remark: str, + ) -> None: + super().__init__( + first_platform=platform, + amount=amount, + coin=coin, + first_utc_time=utc_time, + first_value_in_fiat=in_fiat, + is_taxable=True, + taxation_type=taxation_type, + remark=remark, + ) + + @classmethod + def _labels(cls) -> list[str]: + return [ + "Börse", + "-", + # + "Anzahl", + "Währung", + # + "Erhalten am", + "-", + # + "-", + "-", + "-", + "-", + "-", + "-", + # + "Wert in EUR", + "-", + "-", + # + "Gewinn/Verlust in EUR", + "davon steuerbar", + "Einkunftsart", + "Bemerkung", + ] + + +class CommissionReportEntry(AirdropReportEntry): + event_type = "Kommission" # TODO gibt es eine bessere Bezeichnung? + + +class TransferReportEntry(TaxReportEntry): + event_type = "Transfer von Kryptowährung" + + def __init__( + self, + first_platform: str, + second_platform: str, + amount: decimal.Decimal, + coin: str, + first_utc_time: datetime.datetime, + second_utc_time: datetime.datetime, + first_fee_amount: decimal.Decimal, + first_fee_coin: str, + first_fee_in_fiat: decimal.Decimal, + remark: str, + ) -> None: + super().__init__( + first_platform=first_platform, + second_platform=second_platform, + amount=amount, + coin=coin, + first_utc_time=first_utc_time, + second_utc_time=second_utc_time, + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + remark=remark, + ) + + @classmethod + def _labels(cls) -> list[str]: + return [ + "Eingang auf Börse", + "Ausgang von Börse", + # + "Anzahl", + "Währung", + # + "Eingangsdatum", + "Ausgangsdatum", + # + "(1) Anzahl Transaktionsgebühr", + "(1) Währung Transaktionsgebühr", + "(1) Transaktionsgebühr in EUR", + "-", + "-", + "-", + # + "-", + "-", + "Gesamt Transaktionsgebühr in EUR", + # + "Gewinn/Verlust in EUR", + "-", + "-", + "Bemerkung", + ] + + gain_operations = [ CoinLendEnd, StakingEnd, From 51d2c9f5b826e29f07226bcfbced1f540318ef6e Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:11:39 +0200 Subject: [PATCH 046/141] CHANGE Rework tax evaluation and export --- src/balance_queue.py | 8 + src/main.py | 3 +- src/taxman.py | 541 ++++++++++++++++++++++++++++--------------- 3 files changed, 361 insertions(+), 191 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 5654f967..8c00ff81 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -254,6 +254,14 @@ def sanity_check(self) -> None: ) raise RuntimeError + def remove_all(self) -> list[tr.SoldCoin]: + sold_coins = [] + while self.queue: + bop = self._pop() + not_sold = bop.not_sold + sold_coins.append(tr.SoldCoin(bop.op, not_sold)) + return sold_coins + class BalanceFIFOQueue(BalanceQueue): def _put_(self, bop: BalancedOperation) -> None: diff --git a/src/main.py b/src/main.py index 0efc2c3c..b8a59fa4 100644 --- a/src/main.py +++ b/src/main.py @@ -51,7 +51,8 @@ def main() -> None: book.match_fees_with_operations() taxman.evaluate_taxation() - evaluation_file_path = taxman.export_evaluation_as_csv() + # evaluation_file_path = taxman.export_evaluation_as_csv() + evaluation_file_path = taxman.export_evaluation_as_excel() taxman.print_evaluation() # Save log diff --git a/src/taxman.py b/src/taxman.py index ccecbfe5..d07bff02 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -14,12 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import collections import csv import datetime import decimal from pathlib import Path from typing import Any, Optional, Type +import xlsxwriter + import balance_queue import config import core @@ -48,10 +51,10 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self.book = book self.price_data = price_data - # TODO@now REFACTOR how TaxEvents are kept. - self.tax_events: list[tr.TaxEvent] = [] - # Tax Events which would occur if all left over coins were sold now. - self.virtual_tax_events: list[tr.TaxEvent] = [] + self.tax_report_entries: list[tr.TaxReportEntry] = [] + self.portfolio_at_deadline: dict[ + str, dict[str, decimal.Decimal] + ] = collections.defaultdict(lambda: collections.defaultdict(decimal.Decimal)) # Determine used functions/classes depending on the config. country = config.COUNTRY.name @@ -108,85 +111,140 @@ def remove_fees_from_balance(self, fees: Optional[list[tr.Fee]]) -> None: # Country specific evaluation functions. ########################################################################### - def evaluate_sell(self, op: tr.Sell, sold_coins: list[tr.SoldCoin]) -> tr.TaxEvent: + def _evaluate_fee( + self, + fee: tr.Fee, + percent: decimal.Decimal, + ) -> tuple[decimal.Decimal, str, decimal.Decimal]: + return ( + fee.change * percent, + fee.coin, + self.price_data.get_partial_cost(fee, percent), + ) + + def _evaluate_sell( + self, + op: tr.Sell, + sc: tr.SoldCoin, + additional_fee: decimal.Decimal = decimal.Decimal(), + ReportType: Type[tr.SellReportEntry] = tr.SellReportEntry, + ) -> None: + assert op.coin == sc.op.coin + + # Share the fees and sell_value proportionally to the coins sold. + percent = sc.sold / op.change + + # fee amount/coin/in_fiat + first_fee_amount = decimal.Decimal(0) + first_fee_coin = "" + first_fee_in_fiat = decimal.Decimal(0) + second_fee_amount = decimal.Decimal(0) + second_fee_coin = "" + second_fee_in_fiat = decimal.Decimal(0) + if op.fees is None or len(op.fees) == 0: + pass + elif len(op.fees) >= 1: + first_fee_amount, first_fee_coin, first_fee_in_fiat = self._evaluate_fee( + op.fees[0], percent + ) + elif len(op.fees) >= 2: + second_fee_amount, second_fee_coin, second_fee_in_fiat = self._evaluate_fee( + op.fees[1], percent + ) + else: + raise NotImplementedError("More than two fee coins are not supported") + + # buying_fees + if sc.op.fees: + sc_percent = sc.sold / sc.op.change + buying_fees = misc.dsum( + self.price_data.get_partial_cost(f, sc_percent) for f in sc.op.fees + ) + else: + buying_fees = decimal.Decimal() + # buy_value_in_fiat + buy_value_in_fiat = self.price_data.get_cost(sc) + buying_fees + additional_fee + + # TODO Recognized increased speculation period for lended/staked coins? + # TODO handle operations on sell differently? are some not tax relevant? + # gifted Airdrops, Commission? + is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) + + sell_report_entry = ReportType( + sell_platform=op.platform, + buy_platform=sc.op.platform, + amount=sc.sold, + coin=op.coin, + sell_utc_time=op.utc_time, + buy_utc_time=sc.op.utc_time, + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + second_fee_amount=second_fee_amount, + second_fee_coin=second_fee_coin, + second_fee_in_fiat=second_fee_in_fiat, + sell_value_in_fiat=self.price_data.get_partial_cost(op, percent), + buy_value_in_fiat=buy_value_in_fiat, + is_taxable=is_taxable, + taxation_type="Sonstige Einkünfte", + remark="", + ) + + self.tax_report_entries.append(sell_report_entry) + + def evaluate_sell( + self, + op: tr.Sell, + sold_coins: list[tr.SoldCoin], + ) -> None: assert op.coin != config.FIAT assert in_tax_year(op) + assert op.change == misc.dsum(sc.sold for sc in sold_coins) - # TODO REFACTOR Berechnung - # TODO Beachte Deposit als Quelle. Fehler, wenn Quelle fehlt - # TODO Werfe Fehler, falls bestimmte operation nicht beachtet wird. - # Veräußerungserlös - # Anschaffungskosten - # TODO Beachte buying fees zu anschaffungskosten - # Werbungskosten - # TODO Beachte fees - # Gewinn / Verlust - # davon steuerbar - - taxation_type = "Sonstige Einkünfte" - # Price of the sell. - sell_value = self.price_data.get_cost(op) - taxed_gain = decimal.Decimal() - real_gain = decimal.Decimal() - # Coins which are older than (in this case) one year or - # which come from an Airdrop, CoinLend or Commission (in an - # foreign currency) will not be taxed. for sc in sold_coins: - if isinstance(sc.op, tr.Deposit): - # If these coins get sold, we need to now when and for which price - # they were bought. - # TODO Implement matching for Deposit and Withdrawals to determine - # the correct acquisition cost and to determine whether this stell - # is tax relevant. - log.warning( - f"You sold {sc.op.coin} which were deposited from " - f"somewhere else onto {sc.op.platform} (see " - f"{sc.op.file_path} {sc.op.line}). " - "Matching of Deposits and Withdrawals is currently not " - "implementeded. Therefore it is unknown when and for which " - f"price these {sc.op.coin} were bought. " - "A correct tax evaluation is not possible. " - "Please create an issue or PR to help solve this problem. " - "For now, we assume that the coins were bought at deposit, " - "The price is gathered from the platform onto which the coin " - f"was deposited ({sc.op.platform})." - ) - is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) and not ( - isinstance( - sc.op, - ( - tr.Airdrop, - tr.CoinLendInterest, - tr.StakingInterest, - tr.Commission, - ), - ) - and not sc.op.coin == config.FIAT - ) - # Only calculate the gains if necessary. - if is_taxable or config.CALCULATE_UNREALIZED_GAINS: - partial_sell_value = (sc.sold / op.change) * sell_value - sold_coin_cost = self.price_data.get_cost(sc) - gain = partial_sell_value - sold_coin_cost - if is_taxable: - taxed_gain += gain - if config.CALCULATE_UNREALIZED_GAINS: - real_gain += gain - remark = ", ".join( - f"{sc.sold} from {sc.op.utc_time} " f"({sc.op.__class__.__name__})" - for sc in sold_coins - ) - return tr.TaxEvent( - taxation_type, - taxed_gain, - op, - sell_value, - real_gain, - remark, - ) + if isinstance(sc.op, tr.Deposit) and sc.op.link: + # TODO Are withdrawal/deposit fees tax relevant? + assert ( + sc.op.link.change >= sc.op.change + ), "Withdrawal must be equal or greather the deposited amount." + deposit_fee = sc.op.link.change - sc.op.change + sold_percent = sc.sold / sc.op.change + sold_deposit_fee = deposit_fee * sold_percent + + for wsc in sc.op.link.partial_withdrawn_coins(sold_percent): + wsc_percent = wsc.sold / sc.op.link.change + wsc_deposit_fee = sold_deposit_fee * wsc_percent + + if wsc_deposit_fee: + # Deposit fees are evaluated on deposited platform. + wsc_fee_in_fiat = ( + self.price_data.get_price( + sc.op.platform, sc.op.coin, sc.op.utc_time, config.FIAT + ) + * wsc_deposit_fee + ) + else: + wsc_fee_in_fiat = decimal.Decimal() + + self._evaluate_sell(op, wsc, wsc_fee_in_fiat) + + else: + + if isinstance(sc.op, tr.Deposit): + # Raise a warning when a deposit link is missing. + log.warning( + f"You sold {sc.op.change} {sc.op.coin} which were deposited " + f"from somewhere unknown onto {sc.op.platform} (see " + f"{sc.op.file_path} {sc.op.line}). " + "A correct tax evaluation is not possible! " + "For now, we assume that the coins were bought at deposit." + ) + + self._evaluate_sell(op, sc) def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: + report_entry: tr.TaxReportEntry if isinstance(op, (tr.CoinLend, tr.Staking)): # TODO determine which coins get lended/etc., use fifo if it's @@ -208,6 +266,9 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # TODO mark them as not lended/etc. anymore, so they could be sold # again # TODO lending/etc might increase the tax-free speculation period! + # TODO Add Lending/Staking TaxReportEntry (duration of lend) + # TODO maybe add total accumulated fees? + # might be impossible to match CoinInterest with CoinLend periods pass elif isinstance(op, tr.Buy): @@ -227,29 +288,43 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: self.remove_fees_from_balance(op.fees) if op.coin != config.FIAT and in_tax_year(op): - tx = self.evaluate_sell(op, sold_coins) - self.tax_events.append(tx) + self.evaluate_sell(op, sold_coins) elif isinstance(op, (tr.CoinLendInterest, tr.StakingInterest)): - # TODO@now + # Received coins from lending or staking. Add the received coins + # to the balance. self.add_to_balance(op) - # TODO@now REFACTOR if in_tax_year(op): - if misc.is_fiat(op.coin): - assert not isinstance( - op, tr.StakingInterest - ), "You can not stake fiat currencies." - taxation_type = "Einkünfte aus Kapitalvermögen" - else: + # Determine the taxation type depending on the received coin. + if isinstance(op, tr.CoinLendInterest): + if misc.is_fiat(op.coin): + ReportType = tr.InterestReportEntry + taxation_type = "Einkünfte aus Kapitalvermögen" + else: + ReportType = tr.LendingInterestReportEntry + taxation_type = "Einkünfte aus sonstigen Leistungen" + elif isinstance(op, tr.StakingInterest): + ReportType = tr.StakingInterestReportEntry taxation_type = "Einkünfte aus sonstigen Leistungen" - - taxed_gain = self.price_data.get_cost(op) - tx = tr.TaxEvent(taxation_type, taxed_gain, op) - self.tax_events.append(tx) + else: + raise NotImplementedError + + report_entry = ReportType( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + interest_in_fiat=self.price_data.get_cost(op), + taxation_type=taxation_type, + remark="", + ) + self.tax_report_entries.append(report_entry) elif isinstance(op, tr.Airdrop): - # TODO write information text + # Depending on how you received the coins, the taxation varies. + # If you didn't "do anything" to get the coins, the airdrop counts + # as a gift. self.add_to_balance(op) if in_tax_year(op): @@ -263,10 +338,20 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: "This can result in paying more taxes than necessary. " "Please inform yourself and open a PR to fix this." ) - taxation_type = "Einkünfte aus sonstigen Leistungen" - taxed_gain = self.price_data.get_cost(op) - tx = tr.TaxEvent(taxation_type, taxed_gain, op) - self.tax_events.append(tx) + if True: + taxation_type = "Einkünfte aus sonstigen Leistungen" + else: + taxation_type = "Schenkung" + report_entry = tr.AirdropReportEntry( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + in_fiat=self.price_data.get_cost(op), + taxation_type=taxation_type, + remark="", + ) + self.tax_report_entries.append(report_entry) elif isinstance(op, tr.Commission): # TODO write information text @@ -280,23 +365,46 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: "For now they are taxed as `Einkünfte aus sonstigen " "Leistungen`. " "Please inform yourself and help us to fix this problem " - "by opening and issue or creating a PR." + "by opening an issue or creating a PR." ) - taxation_type = "Einkünfte aus sonstigen Leistungen" - taxed_gain = self.price_data.get_cost(op) - tx = tr.TaxEvent(taxation_type, taxed_gain, op) - self.tax_events.append(tx) + report_entry = tr.CommissionReportEntry( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + in_fiat=self.price_data.get_cost(op), + taxation_type="Einkünfte aus sonstigen Leistungen", + remark="", + ) + self.tax_report_entries.append(report_entry) elif isinstance(op, tr.Deposit): # Coins get deposited onto this platform/balance. # TODO are transaction costs deductable from the tax? if yes, when? # on withdrawal or deposit or on sell of the moved coin?? + # > currently tax relevant on sell self.add_to_balance(op) + if op.link: + assert op.coin == op.link.coin + report_entry = tr.TransferReportEntry( + first_platform=op.platform, + second_platform=op.link.platform, + amount=op.change, + coin=op.coin, + first_utc_time=op.utc_time, + second_utc_time=op.link.utc_time, + first_fee_amount=op.link.change - op.change, + first_fee_coin=op.coin, + first_fee_in_fiat=self.price_data.get_cost(op), + remark="", + ) + self.tax_report_entries.append(report_entry) + elif isinstance(op, tr.Withdrawal): # Coins get moved to somewhere else. At this point, we only have # to remove them from the corresponding balance. - self.remove_from_balance(op) + op.withdrawn_coins = self.remove_from_balance(op) else: raise NotImplementedError @@ -316,29 +424,35 @@ def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: for operation in operations: self.__evaluate_taxation(operation) + # Evaluate the balance at deadline. for balance in self._balances.values(): balance.sanity_check() - # TODO REFACTOR and try to integrate this into balance.close - - # # Calculate the amount of coins which should be left on the platform - # # and evaluate the (taxed) gain, if the coin would be sold right now. - # if config.CALCULATE_UNREALIZED_GAINS and ( - # (left_coin := misc.dsum((bop.not_sold for bop in balance.queue))) - # ): - # assert isinstance(left_coin, decimal.Decimal) - # # Calculate unrealized gains for the last time of `TAX_YEAR`. - # # If we are currently in ´TAX_YEAR` take now. - # virtual_sell = tr.Sell( - # TAX_DEADLINE, - # op.platform, - # left_coin, - # coin, - # [-1], - # Path(""), - # ) - # if tx_ := self._funktion_verändert_evaluate_sell(virtual_sell, force=True): - # self.virtual_tax_events.append(tx_) + # Calculate the unrealized profit/loss. + sold_coins = balance.remove_all() + for sc in sold_coins: + # Sum up the portfolio at deadline. + self.portfolio_at_deadline[sc.op.platform][sc.op.coin] += sc.sold + + # "Sell" these coins which makes it possible to calculate the + # unrealized gain afterwards. + unrealized_sell = tr.Sell( + utc_time=TAX_DEADLINE, + platform=sc.op.platform, + change=sc.sold, + coin=sc.op.coin, + line=[-1], + file_path=Path(), + fees=None, + ) + self._evaluate_sell( + unrealized_sell, + sc, + ReportType=tr.UnrealizedSellReportEntry, + ) + # TODO UnrealizedSellReportEntry nicht von irgendwas vererben? + # TODO _evaluate_sell darf noch nicht hinzufügen, nur anlegen? return ReportType + # TODO offene Positionen nur platform/coin, wert,... ohne kauf und verkaufsdatum def evaluate_taxation(self) -> None: """Evaluate the taxation using country specific function.""" @@ -364,55 +478,44 @@ def evaluate_taxation(self) -> None: def print_evaluation(self) -> None: """Print short summary of evaluation to stdout.""" - eval_str = "Evaluation:\n\n" - - # Summarize the tax evaluation. - if self.tax_events: - eval_str += f"Your tax evaluation for {config.TAX_YEAR}:\n" - for taxation_type, tax_events in misc.group_by( - self.tax_events, "taxation_type" - ).items(): - taxed_gains = misc.dsum(tx.taxed_gain for tx in tax_events) - eval_str += f"{taxation_type}: {taxed_gains:.2f} {config.FIAT}\n" - else: - eval_str += ( - "Either the evaluation has not run or there are no tax events " - f"for {config.TAX_YEAR}.\n" - ) - - # Summarize the virtual sell, if all left over coins would be sold right now. - if self.virtual_tax_events: - assert config.CALCULATE_UNREALIZED_GAINS - latest_operation = max( - self.virtual_tax_events, key=lambda tx: tx.op.utc_time - ) - lo_date = latest_operation.op.utc_time.strftime("%d.%m.%y") - - invested = misc.dsum(tx.sell_value for tx in self.virtual_tax_events) - real_gains = misc.dsum(tx.real_gain for tx in self.virtual_tax_events) - taxed_gains = misc.dsum(tx.taxed_gain for tx in self.virtual_tax_events) - eval_str += "\n" - eval_str += ( - f"Deadline {config.TAX_YEAR}: {lo_date}\n" - f"You were invested with {invested:.2f} {config.FIAT}.\n" - f"If you would have sold everything then, " - f"you would have realized {real_gains:.2f} {config.FIAT} gains " - f"({taxed_gains:.2f} {config.FIAT} taxed gain).\n" + eval_str = ( + f"Your tax evaluation for {config.TAX_YEAR} " + f"(Deadline {TAX_DEADLINE.strftime('%d.%m.%Y')}):\n\n" + ) + for taxation_type, tax_report_entries in misc.group_by( + self.tax_report_entries, "taxation_type" + ).items(): + taxable_gain = misc.dsum( + tre.taxable_gain + for tre in tax_report_entries + if not isinstance(tre, tr.UnrealizedSellReportEntry) ) + eval_str += f"{taxation_type}: {taxable_gain:.2f} {config.FIAT}\n" + + unrealized_report_entries = [ + tre + for tre in self.tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ] + assert all(tre.gain_in_fiat is not None for tre in unrealized_report_entries) + unrealized_gain = misc.dsum( + tre.gain_in_fiat for tre in unrealized_report_entries + ) + unrealized_taxable_gain = misc.dsum( + tre.taxable_gain for tre in unrealized_report_entries + ) + eval_str += ( + "----------------------------------------\n" + f"Unrealized gain: {unrealized_gain:.2f} {config.FIAT}\n" + "Unrealized taxable gain at deadline: " + f"{unrealized_taxable_gain:.2f} {config.FIAT}\n" + "----------------------------------------\n" + f"Your portfolio on {TAX_DEADLINE.strftime('%x')} was:\n" + ) - eval_str += "\n" - eval_str += f"Your portfolio on {lo_date} was:\n" - for tx in sorted( - self.virtual_tax_events, - key=lambda tx: tx.sell_value, - reverse=True, - ): - eval_str += ( - f"{tx.op.platform}: " - f"{tx.op.change:.6f} {tx.op.coin} > " - f"{tx.sell_value:.2f} {config.FIAT} " - f"({tx.real_gain:.2f} gain, {tx.taxed_gain:.2f} taxed gain)\n" - ) + for platform, platform_portfolio in self.portfolio_at_deadline.items(): + for coin, amount in platform_portfolio.items(): + eval_str += f"{platform} {coin}: {amount:.2f}\n" log.info(eval_str) @@ -442,32 +545,90 @@ def export_evaluation_as_csv(self) -> Path: writer.writerow(["# commit", commit_hash]) writer.writerow(["# updated", datetime.date.today().strftime("%x")]) + # Header header = [ - "Date and Time UTC", - "Platform", - "Taxation Type", - f"Taxed Gain in {config.FIAT}", - "Action", - "Amount", - "Asset", - f"Sell Value in {config.FIAT}", - "Remark", + "Verkauf auf Börse", + "Erworben von Börse", + # + "Anzahl", + "Währung", + # + "Verkaufsdatum", + "Erwerbsdatum", + # + "(1) Anzahl Transaktionsgebühr", + "(1) Währung Transaktionsgebühr", + "(1) Transaktionsgebühr in EUR", + "(2) Anzahl Transaktionsgebühr", + "(2) Währung Transaktionsgebühr", + "(2) Transaktionsgebühr in EUR", + # + "Veräußerungserlös in EUR", + "Anschaffungskosten in EUR", + "Gesamt Transaktionsgebühr in EUR", + # + "Gewinn/Verlust in EUR", + "davon steuerbar", + "Einkunftsart", + "Bemerkung", ] writer.writerow(header) + # Tax events are currently sorted by coin. Sort by time instead. - for tx in sorted(self.tax_events, key=lambda tx: tx.op.utc_time): - line = [ - tx.op.utc_time.strftime("%Y-%m-%d %H:%M:%S"), - tx.op.platform, - tx.taxation_type, - tx.taxed_gain, - tx.op.__class__.__name__, - tx.op.change, - tx.op.coin, - tx.sell_value, - tx.remark, - ] - writer.writerow(line) + assert all( + tre.first_utc_time is not None for tre in self.tax_report_entries + ) + for tre in sorted( + self.tax_report_entries, key=lambda tre: tre.first_utc_time + ): + assert isinstance(tre, tr.TaxReportEntry) + writer.writerow(tre.values()) + + log.info("Saved evaluation in %s.", file_path) + return file_path + + def export_evaluation_as_excel(self) -> Path: + """Export detailed summary of all tax events to Excel. + File will be placed in export/ with ascending revision numbers + (in case multiple evaluations will be done). + + When no tax events occured, the Excel will be exported only with + a header line and a general sheet. + + Returns: + Path: Path to the exported file. + """ + file_path = misc.get_next_file_path( + config.EXPORT_PATH, str(config.TAX_YEAR), "xlsx" + ) + wb = xlsxwriter.Workbook(file_path, {"remove_timezone": True}) + + # General + ws_general = wb.add_worksheet("Allgemein") + commit_hash = misc.get_current_commit_hash(default="undetermined") + general_data = [ + ["Allgemeine Daten"], + ["Stichtag", TAX_DEADLINE], + ["Erstellt am", datetime.datetime.now()], + ["Software", "CoinTaxman "], + ["Commit", commit_hash], + ] + for row, data in enumerate(general_data): + ws_general.write_row(row, 0, data) + + # Sheets per ReportType + for ReportType, tax_report_entries in misc.group_by( + self.tax_report_entries, "__class__" + ).items(): + ws = wb.add_worksheet(ReportType.event_type) + # Header + ws.write_row(0, 0, ReportType.labels()) + # TODO formatiere Spalten korrekt, Datum+Uhrzeit, Währung, ...Anzahl mit 8 Nachkommastellen + + for row, entry in enumerate(tax_report_entries, 1): + ws.write_row(row, 0, entry.values()) + + wb.close() log.info("Saved evaluation in %s.", file_path) return file_path From b46529d46330f14b5d88e99127fcec4c69758614 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:11:49 +0200 Subject: [PATCH 047/141] RM old tr.TaxEvent class --- src/transaction.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 88e455d2..12383727 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -220,16 +220,6 @@ def partial(self, percent: decimal.Decimal) -> SoldCoin: return sc -@dataclasses.dataclass -class TaxEvent: - taxation_type: str - taxed_gain: decimal.Decimal - op: Operation - sell_value: decimal.Decimal = decimal.Decimal() - real_gain: decimal.Decimal = decimal.Decimal() - remark: str = "" - - @dataclasses.dataclass class TaxReportEntry: event_type = "virtual" From 5f548a12c8509008deca15e601cd7c84ef2e79fb Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:13:44 +0200 Subject: [PATCH 048/141] RENAME event_type of Lending/StakingInterestReportEntry --- src/transaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 12383727..abd39c4f 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -529,11 +529,11 @@ def _labels(cls) -> list[str]: class LendingInterestReportEntry(InterestReportEntry): - event_type = "Einkünfte durch Coin-Lending" + event_type = "Coin-Lending" class StakingInterestReportEntry(InterestReportEntry): - event_type = "Einkünfte durch Staking" + event_type = "Staking" class AirdropReportEntry(TaxReportEntry): From d2c86fde24512ca1c868214b2ef257742e357670 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:18:30 +0200 Subject: [PATCH 049/141] ADD TODO for export --- src/taxman.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index d07bff02..4fddd817 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -454,6 +454,9 @@ def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: # TODO _evaluate_sell darf noch nicht hinzufügen, nur anlegen? return ReportType # TODO offene Positionen nur platform/coin, wert,... ohne kauf und verkaufsdatum + # TODO ODER Offene Position bei "Einkunftsart" -> "Herkunft" (Kauf, Interest, ...) + # TODO dann noch eine Zusammenfassung der offenen Positionen + def evaluate_taxation(self) -> None: """Evaluate the taxation using country specific function.""" log.debug("Starting evaluation...") @@ -623,8 +626,9 @@ def export_evaluation_as_excel(self) -> Path: ).items(): ws = wb.add_worksheet(ReportType.event_type) # Header + # TODO increase height of first row ws.write_row(0, 0, ReportType.labels()) - # TODO formatiere Spalten korrekt, Datum+Uhrzeit, Währung, ...Anzahl mit 8 Nachkommastellen + # TODO set column width (custom?) and correct format (datetime, change up to 8 decimal places, ...) for row, entry in enumerate(tax_report_entries, 1): ws.write_row(row, 0, entry.values()) From 70e8d637b2fcfeeedd618a763c9724cccc36fd5e Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:22:32 +0200 Subject: [PATCH 050/141] ADD archive hint after evaluation --- src/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.py b/src/main.py index b8a59fa4..3ef9a2cc 100644 --- a/src/main.py +++ b/src/main.py @@ -60,6 +60,8 @@ def main() -> None: log_config.shutdown() os.rename(TMP_LOG_FILEPATH, log_file_path) + print("If you want to archive the evaluation, run `make archive`.") + if __name__ == "__main__": main() From f93721432457846e536615cb5a8da925dbda9290 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 27 Apr 2022 23:35:17 +0200 Subject: [PATCH 051/141] FIX mypy linting errors --- setup.cfg | 3 +++ src/book.py | 1 - src/misc.py | 9 +++++++++ src/taxman.py | 21 ++++++++++++++------- src/transaction.py | 17 ++++++++--------- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/setup.cfg b/setup.cfg index 10a17170..5336fe78 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,6 +9,9 @@ warn_return_any = True show_error_codes = True warn_unused_configs = True +[mypy-xlsxwriter] +ignore_missing_imports = True + [flake8] exclude = *py*env*/ max_line_length = 88 diff --git a/src/book.py b/src/book.py index 34857f39..11364383 100644 --- a/src/book.py +++ b/src/book.py @@ -18,7 +18,6 @@ import csv import datetime import decimal -import itertools import re from collections import defaultdict from pathlib import Path diff --git a/src/misc.py b/src/misc.py index e3ee4d95..80c3dcfb 100644 --- a/src/misc.py +++ b/src/misc.py @@ -302,3 +302,12 @@ def get_current_commit_hash(default: Optional[str] = None) -> str: if default is None: raise RuntimeError("Unable to determine commit hash") from e return default + + +T = TypeVar("T") + + +def not_none(v: Optional[T]) -> T: + if v is None: + raise ValueError() + return v diff --git a/src/taxman.py b/src/taxman.py index 4fddd817..be9703f5 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -126,10 +126,12 @@ def _evaluate_sell( self, op: tr.Sell, sc: tr.SoldCoin, - additional_fee: decimal.Decimal = decimal.Decimal(), + additional_fee: Optional[decimal.Decimal] = None, ReportType: Type[tr.SellReportEntry] = tr.SellReportEntry, ) -> None: assert op.coin == sc.op.coin + if additional_fee is None: + additional_fee = decimal.Decimal() # Share the fees and sell_value proportionally to the coins sold. percent = sc.sold / op.change @@ -451,10 +453,13 @@ def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: ReportType=tr.UnrealizedSellReportEntry, ) # TODO UnrealizedSellReportEntry nicht von irgendwas vererben? - # TODO _evaluate_sell darf noch nicht hinzufügen, nur anlegen? return ReportType - # TODO offene Positionen nur platform/coin, wert,... ohne kauf und verkaufsdatum + # TODO _evaluate_sell darf noch nicht hinzufügen, nur anlegen? + # return ReportType + # TODO offene Positionen nur platform/coin, wert,... ohne kauf + # und verkaufsdatum - # TODO ODER Offene Position bei "Einkunftsart" -> "Herkunft" (Kauf, Interest, ...) + # TODO ODER Offene Position bei "Einkunftsart" -> "Herkunft" + # (Kauf, Interest, ...) # TODO dann noch eine Zusammenfassung der offenen Positionen def evaluate_taxation(self) -> None: @@ -502,7 +507,7 @@ def print_evaluation(self) -> None: ] assert all(tre.gain_in_fiat is not None for tre in unrealized_report_entries) unrealized_gain = misc.dsum( - tre.gain_in_fiat for tre in unrealized_report_entries + misc.not_none(tre.gain_in_fiat) for tre in unrealized_report_entries ) unrealized_taxable_gain = misc.dsum( tre.taxable_gain for tre in unrealized_report_entries @@ -582,7 +587,8 @@ def export_evaluation_as_csv(self) -> Path: tre.first_utc_time is not None for tre in self.tax_report_entries ) for tre in sorted( - self.tax_report_entries, key=lambda tre: tre.first_utc_time + self.tax_report_entries, + key=lambda tre: misc.not_none(tre.first_utc_time), ): assert isinstance(tre, tr.TaxReportEntry) writer.writerow(tre.values()) @@ -628,7 +634,8 @@ def export_evaluation_as_excel(self) -> Path: # Header # TODO increase height of first row ws.write_row(0, 0, ReportType.labels()) - # TODO set column width (custom?) and correct format (datetime, change up to 8 decimal places, ...) + # TODO set column width (custom?) and correct format (datetime, + # change up to 8 decimal places, ...) for row, entry in enumerate(tax_report_entries, 1): ws.write_row(row, 0, entry.values()) diff --git a/src/transaction.py b/src/transaction.py index abd39c4f..3df268d5 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -248,12 +248,12 @@ class TaxReportEntry: @property def _total_fee_in_fiat(self) -> Optional[decimal.Decimal]: - if self.first_fee_in_fiat == self.second_fee_in_fiat == None: + if self.first_fee_in_fiat is None and self.second_fee_in_fiat is None: return None return misc.dsum( map( # TODO Report mypy bug - misc.cdecimal, # type: ignore + misc.cdecimal, (self.first_fee_in_fiat, self.second_fee_in_fiat), ) ) @@ -263,10 +263,9 @@ def _total_fee_in_fiat(self) -> Optional[decimal.Decimal]: @property def _gain_in_fiat(self) -> Optional[decimal.Decimal]: if ( - self.first_value_in_fiat - == self.second_value_in_fiat - == self._total_fee_in_fiat - == None + self.first_value_in_fiat is None + and self.second_value_in_fiat is None + and self._total_fee_in_fiat is None ): return None return ( @@ -347,9 +346,9 @@ def _labels(cls) -> list[str]: @classmethod def labels(cls) -> list[str]: - l = cls._labels() - assert len(l) == len(dataclasses.fields(cls)) - return l + labels = cls._labels() + assert len(labels) == len(dataclasses.fields(cls)) + return labels def values(self) -> Iterator: return (getattr(self, f) for f in self.field_names()) From 1b8fa1816e532030674f186109d117bfcb0254f7 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 20:02:58 +0200 Subject: [PATCH 052/141] ADD docstring Taxman._evaluate_sell --- src/taxman.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/taxman.py b/src/taxman.py index be9703f5..bff4c336 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -129,6 +129,19 @@ def _evaluate_sell( additional_fee: Optional[decimal.Decimal] = None, ReportType: Type[tr.SellReportEntry] = tr.SellReportEntry, ) -> None: + """Evaluate a (partial) sell operation. + + Args: + op (tr.Sell): The sell operation. + sc (tr.SoldCoin): The sold coin. + additional_fee (Optional[decimal.Decimal], optional): + The additional fee. Defaults to None. + ReportType (Type[tr.SellReportEntry], optional): + The type of the report entry. Defaults to tr.SellReportEntry. + + Raises: + NotImplementedError: When there are more than two different fee coins. + """ assert op.coin == sc.op.coin if additional_fee is None: additional_fee = decimal.Decimal() From 67524c4b74ad95131ddb71c50b13967aeae5b151 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 20:03:28 +0200 Subject: [PATCH 053/141] CHANGE ignore deposit/withdrawal fees and raise warning instead asking for help --- src/taxman.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index bff4c336..8ff93b1b 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -231,16 +231,23 @@ def evaluate_sell( wsc_percent = wsc.sold / sc.op.link.change wsc_deposit_fee = sold_deposit_fee * wsc_percent + wsc_fee_in_fiat = decimal.Decimal() if wsc_deposit_fee: - # Deposit fees are evaluated on deposited platform. - wsc_fee_in_fiat = ( - self.price_data.get_price( - sc.op.platform, sc.op.coin, sc.op.utc_time, config.FIAT - ) - * wsc_deposit_fee + # TODO Are withdrawal/deposit fees tax relevant? + log.warning( + "You paid fees for withdrawal and deposit of coins. " + "I am currently not sure if you can reduce your taxed " + "gain with these. For now, the deposit/withdrawal fees " + "are not included in the tax report. " + "Please open an issue or PR if you can resolve this." ) - else: - wsc_fee_in_fiat = decimal.Decimal() + # Deposit fees are evaluated on deposited platform. + # wsc_fee_in_fiat = ( + # self.price_data.get_price( + # sc.op.platform, sc.op.coin, sc.op.utc_time, config.FIAT + # ) + # * wsc_deposit_fee + # ) self._evaluate_sell(op, wsc, wsc_fee_in_fiat) From cacdb5cf1a16510245b970e00a5766f55204c083 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 20:18:41 +0200 Subject: [PATCH 054/141] ADD config option ALL_AIRDROPS_ARE_GIFTS to tax all airdrops as gifts --- config.ini | 8 +++++++- src/config.py | 1 + src/taxman.py | 16 +++------------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/config.ini b/config.ini index 7ec53f77..da465c3a 100644 --- a/config.ini +++ b/config.ini @@ -21,4 +21,10 @@ CALCULATE_UNREALIZED_GAINS = True MULTI_DEPOT = True # Set logging level # DEBUG, INFO, WARNING, ERROR, FATAL -LOG_LEVEL = DEBUG \ No newline at end of file +LOG_LEVEL = DEBUG +# Taxation of Airdrops is currently only partly implemented (#115). +# If True, all airdrops will be taxed as `Schenkung`. +# If False, all airdrops will be taxed as `Einkünfte aus sonstigen Leistungen`. +# Setting this config falsly will result in a wrong tax calculation. +# Please inform yourself and help to resolve this issue by working on/with #115. +ALL_AIRDROPS_ARE_GIFTS = True diff --git a/src/config.py b/src/config.py index 00b77731..a7574aac 100644 --- a/src/config.py +++ b/src/config.py @@ -49,6 +49,7 @@ CALCULATE_UNREALIZED_GAINS = config["BASE"].getboolean("CALCULATE_UNREALIZED_GAINS") MULTI_DEPOT = config["BASE"].getboolean("MULTI_DEPOT") LOG_LEVEL = config["BASE"].get("LOG_LEVEL", "INFO") +ALL_AIRDROPS_ARE_GIFTS = config["BASE"].getboolean("ALL_AIRDROPS_ARE_GIFTS") # Read in environmental variables. if _env_country := environ.get("COUNTRY"): diff --git a/src/taxman.py b/src/taxman.py index 8ff93b1b..0d19b7dc 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -350,20 +350,10 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: self.add_to_balance(op) if in_tax_year(op): - # TODO do correct taxation. - log.warning( - "You received an Airdrop. An airdrop could be taxed as " - "`Einkünfte aus sonstigen Leistungen` or `Schenkung` or " - "something else?, as the case may be. " - "In the current implementation, all airdrops are taxed as " - "`Einkünfte aus sonstigen Leistungen`. " - "This can result in paying more taxes than necessary. " - "Please inform yourself and open a PR to fix this." - ) - if True: - taxation_type = "Einkünfte aus sonstigen Leistungen" - else: + if config.ALL_AIRDROPS_ARE_GIFTS: taxation_type = "Schenkung" + else: + taxation_type = "Einkünfte aus sonstigen Leistungen" report_entry = tr.AirdropReportEntry( platform=op.platform, amount=op.change, From c1cfcc5aab9a17c12f40575cf534a133b5e51104 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 20:19:30 +0200 Subject: [PATCH 055/141] UPDATE format, style ADD helping comments --- src/taxman.py | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 0d19b7dc..1d84ccd6 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -170,19 +170,18 @@ def _evaluate_sell( raise NotImplementedError("More than two fee coins are not supported") # buying_fees + buying_fees = decimal.Decimal() if sc.op.fees: + assert sc.sold <= sc.op.change sc_percent = sc.sold / sc.op.change buying_fees = misc.dsum( self.price_data.get_partial_cost(f, sc_percent) for f in sc.op.fees ) - else: - buying_fees = decimal.Decimal() + # buy_value_in_fiat buy_value_in_fiat = self.price_data.get_cost(sc) + buying_fees + additional_fee # TODO Recognized increased speculation period for lended/staked coins? - # TODO handle operations on sell differently? are some not tax relevant? - # gifted Airdrops, Commission? is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) sell_report_entry = ReportType( @@ -219,10 +218,9 @@ def evaluate_sell( for sc in sold_coins: if isinstance(sc.op, tr.Deposit) and sc.op.link: - # TODO Are withdrawal/deposit fees tax relevant? assert ( sc.op.link.change >= sc.op.change - ), "Withdrawal must be equal or greather the deposited amount." + ), "Withdrawal must be equal or greater than the deposited amount." deposit_fee = sc.op.link.change - sc.op.change sold_percent = sc.sold / sc.op.change sold_deposit_fee = deposit_fee * sold_percent @@ -260,7 +258,8 @@ def evaluate_sell( f"from somewhere unknown onto {sc.op.platform} (see " f"{sc.op.file_path} {sc.op.line}). " "A correct tax evaluation is not possible! " - "For now, we assume that the coins were bought at deposit." + "For now, we assume that the coins were bought at " + "the timestamp of the deposit." ) self._evaluate_sell(op, sc) @@ -277,8 +276,8 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: elif isinstance(op, (tr.CoinLendEnd, tr.StakingEnd)): # TODO determine which coins come back from lending/etc. use fifo - # if it's unclear; it might be nice to match Start and - # End of these operations like deposit and withdrawal operations. + # if it's unclear; it might be nice to match start and + # end of these operations like deposit and withdrawal operations. # e.g. # - lending 1 coin for 2 months # - lending 2 coins for 1 month @@ -306,6 +305,8 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # Buys and sells always come in a pair. The selling/redeeming # time is tax relevant. # Remove the sold coins and paid fees from the balance. + # Evaluate the sell to determine the taxed gain and other relevant + # informations for the tax declaration. sold_coins = self.remove_from_balance(op) self.remove_fees_from_balance(op.fees) @@ -366,19 +367,12 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: self.tax_report_entries.append(report_entry) elif isinstance(op, tr.Commission): - # TODO write information text + # You received a commission. It is assumed that his is a customer- + # recruit-customer-bonus which is taxed as `Einkünfte aus sonstigen + # Leistungen`. self.add_to_balance(op) if in_tax_year(op): - # TODO do correct taxation. - log.warning( - "You have received a Commission. " - "I am currently unsure how Commissions get taxed. " - "For now they are taxed as `Einkünfte aus sonstigen " - "Leistungen`. " - "Please inform yourself and help us to fix this problem " - "by opening an issue or creating a PR." - ) report_entry = tr.CommissionReportEntry( platform=op.platform, amount=op.change, @@ -392,9 +386,6 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: elif isinstance(op, tr.Deposit): # Coins get deposited onto this platform/balance. - # TODO are transaction costs deductable from the tax? if yes, when? - # on withdrawal or deposit or on sell of the moved coin?? - # > currently tax relevant on sell self.add_to_balance(op) if op.link: From 391ca2253be1393c0318168ac352e45bb07f1205 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 20:20:27 +0200 Subject: [PATCH 056/141] UPDATE increase size of disclaimer in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5f39656..3652f1f4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CoinTaxman hilft dir dabei deine Einkünfte aus dem Krypto-Handel/-Verleih/... i Momentan deckt der CoinTaxman nur meinen Anwendungsbereich ab. Pull Requests und Anfragen über Issues sind gerne gesehen (siehe `Key notes for users` für weitere Informationen). -**Disclaimer: use at your own risk** +# **Disclaimer: use at your own risk** ### Currently supported countries - Germany From 3c392bd2ddf5f6a081df87c45a2c6bb647c1b9fe Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 20:50:49 +0200 Subject: [PATCH 057/141] UPDATE README.md --- README.md | 66 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3652f1f4..b8167e57 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ Quick and easy installation can be done with `pip`. ### Usage -1. Adjust `src/config.py` to your liking -2. Add account statements from supported exchanges in `account_statements/` +1. Adjust `src/config.ini` to your liking +2. Add all your account statements in `account_statements/` 2. Run `python "src/main.py"` +If not all your exchanges are supported, you can not (directly) calculate your taxes with this tool. + Have a look at our [Wiki](https://github.com/provinzio/CoinTaxman/wiki) for more information on how to obtain the account statement for your exchange. #### Makefile @@ -80,7 +82,7 @@ Information I require are for example Not every aspect has to be implemented directly. We are free to start by implementing the stuff you need for your tax declaration. -I am looking forward to your [issue](https://github.com/provinzio/CoinTaxman/issues). +I am looking forward to your [issue](https://github.com/provinzio/CoinTaxman/issues) or pull request. Your country was already requested? Hit the thumbs up button of that issue or participate in the process. @@ -95,7 +97,7 @@ Please provide an example account statement for the requested exchange or some o Are you already familiar with the API of that exchange or know some other way to request historical prices for that exchange? Share your knowledge. -I am looking forward to your [issue](https://github.com/provinzio/CoinTaxman/issues). +I am looking forward to your [issue](https://github.com/provinzio/CoinTaxman/issues) or pull request. Your exchange was already requested? Hit the thumbs up button of that issue or participate in the process. @@ -106,7 +108,7 @@ Hit the thumbs up button of that issue or participate in the process. - Add your country to the Country enum in `src/core.py` - Extend `src/config.py` to fit your tax regulation - Add a country specific tax evaluation function in `src/taxman.py` like `Taxman._evaluate_taxation_GERMANY` -- Depending on your specific tax regulation, you might need to add additional functionality and might want to add or edit the enums in `src/core.py` +- Depending on your specific tax regulation, you might need to add additional functionality - Update the README with a documentation about the taxation guidelines in your country #### Adding a new exchange @@ -124,9 +126,18 @@ Feel free to commit details about the taxation in your country. ## Taxation in Germany Meine Interpretation rund um die Besteuerung von Kryptowährung in Deutschland wird durch die Texte von den [Rechtsanwälten und Steuerberatern WINHELLER](https://www.winheller.com/) sehr gut artikuliert. -Meine kurzen Zusammenfassungen am Ende von jedem Abschnitt werden durch einen ausführlicheren Text von [WINHELLER](https://www.winheller.com/) ergänzt. -An dieser Stelle sei explizit erwähnt, dass dies meine Interpretation ist. Es ist weder sichergestellt, dass ich aktuell noch nach diesen praktiziere (falls ich das Repo in Zukunft nicht mehr aktiv pflege), noch ob diese Art der Versteuerung gesetzlich zulässig ist. +Zusätzlich hat das Bundesministerium für Finanzen (BMF) am 17.06.2021 einen Entwurf über [Einzelfragen zur ertragssteuerrechtlichen Behandlung von virtuellen Währungen und von Token](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Einkommensteuer/2021-06-17-est-kryptowaehrungen.html) veröffentlicht. + +Beide Verweise geben schon einmal einen guten Einblick in die Versteuerung von Kryptowährung. +Viele Kleinigkeiten könnten jedoch noch steuerlich ungeklärt oder in einen Graubereich fallen. +Insofern ist es wichtig, sich über die genaue Besteuerung seines eigenen Sachverhaltes zu informieren oder (noch besser) einen Steuerberater diesbezüglich zu kontaktieren. + +Im Folgenden werde ich einen groben Überblick über die Besteuerungs-Methode in diesem Tool geben. +Genauere Informationen finden sich im Source-Code in `src\taxman.py`. + +An dieser Stelle sei explizit erwähnt, dass dies meine Interpretation ist. +Es ist weder sichergestellt, dass ich aktuell noch nach diesen praktiziere (falls ich das Repo in Zukunft nicht mehr aktiv pflege), noch ob diese Art der Versteuerung gesetzlich zulässig ist. Meine Interpretation steht gerne zur Debatte. ### Allgemein @@ -144,25 +155,43 @@ Meine Interpretation steht gerne zur Debatte. Zusammenfassung in meinen Worten: - Kryptowährung sind immaterielle Wirtschaftsgüter. -- Der Verkauf innerhalb eines Jahres gilt als privates Veräußerungsgeschäft und ist als Sonstiges Einkommen zu versteuern (Freigrenze 600 €). -- Der Tausch von Kryptowährung wird ebenfalls versteuert. +- Der Verkauf innerhalb der Spekulationsfirst gilt als privates Veräußerungsgeschäft und ist als Sonstiges Einkommen zu versteuern (Freigrenze 600 €). +- Jeder Tausch von Kryptowährung wird (wie ein Verkauf) versteuert, indem der getauschte Betrag virtuell in EUR umgewandelt wird. - Gebühren zum Handel sind steuerlich abzugsfähig. - Es kann einmalig entschieden werden, ob nach FIFO oder LIFO versteuert werden soll. Weitere Grundsätze, die oben nicht angesprochen wurden: -- Versteuerung erfolgt separat getrennt nach Depots (Wallets, Exchanges, etc.) [cryptotax](https://cryptotax.io/fifo-oder-lifo-bitcoin-gewinnermittlung/). +- Versteuerung kann getrennt nach Depots (Wallets, Exchanges, etc.) erfolgen (Multi-Depot-Methode) [cryptotax](https://cryptotax.io/fifo-oder-lifo-bitcoin-gewinnermittlung/). ### Airdrops -> Im Rahmen eines Airdrops erhält der Nutzer Kryptowährungen, ohne diese angeschafft oder eine sonstige Leistung hierfür erbracht zu haben. Die Kryptowährungen werden nicht aus dem Rechtskreis eines Dritten auf den Nutzer übertragen. Vielmehr beginnen sie ihre „Existenz“ überhaupt erst in dessen Vermögen. Die Kryptowährung entsteht direkt in den Wallets der Nutzer, wobei die Wallets bestimmte Kriterien erfüllen müssen. Airdrops ähneln insofern einem Lottogewinn oder einem Zufallsfund (sog. Windfall Profits). -> -> Mangels Anschaffungsvorgangs kommt bei einer anschließenden Veräußerung eine Besteuerung nach § 23 Abs. 1 Nr. 2 Einkommensteuergesetz (EStG) nicht in Betracht. Mangels Leistungserbringung seitens des Nutzers liegen auch keine sonstigen Einkünfte i.S.d. § 22 Nr. 3 EStG vor. Damit ist der Verkauf von Airdrops steuerfrei. - -[Quelle](https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-airdrops.html) -[Wörtlich zitiert vom 14.02.2021] +> Der Erhalt zusätzlicher Einheiten einer virtuellen Währung oder von Token kann zu +Einkünften aus einer Leistung im Sinne des § 22 Nummer 3 EStG führen. Beim Airdrop +werden Einheiten einer virtuellen Währung oder Token „unentgeltlich“ verteilt. In der Regel +handelt es sich dabei um eine Marketingmaßnahme. Allerdings müssen sich Kunden für die +Teilnahme am Airdrop anmelden und Daten von sich preisgeben. Als Belohnung erhalten +diese Kunden Einheiten einer virtuellen Währung oder Token zugeteilt. Hängt die Zuteilung +der Einheiten einer virtuellen Währung oder Token davon ab, dass der Steuerpflichtige Daten +von sich angibt, die über die Informationen hinausgehen, die für die schlichte technische Zuteilung/Bereitstellung erforderlich sind, liegt in der Datenüberlassung eine Leistung des +Steuerpflichtigen im Sinne des § 22 Nummer 3 EStG, für die er als Gegenleistung Einheiten +einer virtuellen Währung oder Token erhält. Davon ist im Zusammenhang mit einem Airdrop +jedenfalls dann auszugehen, wenn der Steuerpflichtige verpflichtet ist oder sich bereit +erklären muss, dem Emittenten als Gegenleistung für die Einheiten einer virtuellen Währung +oder Token personenbezogene Daten zur Verfügung zu stellen. +Die Einheiten der virtuellen Währung oder Token sind mit dem Marktkurs im Zeitpunkt des +Erwerbs anzusetzen (vgl. zur Ermittlung des Marktkurses Rz. 32). +Erfolgt keine Gegenleistung, kommt eine Schenkung in Betracht, für die die +schenkungssteuerrechtlichen Regelungen gelten. +Eine Leistung im Sinne des § 22 Nummer 3 EStG erbringt der Steuerpflichtige auch dann, +wenn er eigene Bilder/Fotos oder private Filme (Videos) auf einer Plattform hochlädt und +hierfür Einheiten einer virtuellen Währung oder Token erhält, sofern das Eigentum an den +Bildern/Fotos/Filmen beim Steuerpflichtigen verbleibt. + +[Quelle 79-80](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Einkommensteuer/2021-06-17-est-kryptowaehrungen.html) +[Wörtlich zitiert vom 04.05.2022] Zusammenfassung in meinen Worten: -- Erhalt und Verkauf von Airdrops ist steuerfrei. +- Falls man etwas gemacht hat, um die Airdrops zu erhalten (bspw. sich irgendwo angemeldet oder anderweitig Daten preisgegeben), handelt es sich um Einkünfte aus sonstigen Leistungen; ansonsten handelt es sich um eine Schenkung ### Coin Lending @@ -185,7 +214,7 @@ Zusammenfassung in meinen Worten: Zusammenfassung in meinen Worten: - Erhaltene Kryptowährung durch Coin Lending wird im Zeitpunkt des Zuflusses als Einkunft aus sonstigen Leistungen versteuert (Freigrenze 256 €). -- Der Verkauf ist nicht steuerbar. +- Der Verkauf ist steuerbar. - Coin Lending beeinflusst nicht die Haltefrist der verliehenen Coins. ### Staking @@ -207,4 +236,3 @@ Es ist also keine typische Kunden-werben-Kunden-Prämie sondern eher eine Kommis Für das Erste handhabe ich es wie eine Kunden-werben-Kunden-Prämie in Form eines Sachwerts. Sprich, die BTC werden zum Zeitpunkt des Erhalts in ihren EUR-Gegenwert umgerechnet und den Einkünften aus sonstigen Leistungen hinzugefügt. -Aufgrund eines fehlenden steuerlichen Anschaffungsvorgangs ist eine Veräußerung steuerfrei. From dc6d1745b70fd8b1c76f46d810e2bd6b0dc2ed8c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:04:07 +0200 Subject: [PATCH 058/141] ADD labels for tr.UnrealizedSellReportEntry --- src/transaction.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/transaction.py b/src/transaction.py index 3df268d5..e864e626 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -472,6 +472,35 @@ def _labels(cls) -> list[str]: class UnrealizedSellReportEntry(SellReportEntry): event_type = "Offene Positionen" + @classmethod + def _labels(cls) -> list[str]: + return [ + "Virtueller Verkauf auf Börse", + "Erworben von Börse", + # + "Anzahl", + "Währung", + # + "Virtuelles Verkaufsdatum", + "Erwerbsdatum", + # + "(1) Anzahl Transaktionsgebühr", + "(1) Währung Transaktionsgebühr", + "(1) Transaktionsgebühr in EUR", + "(2) Anzahl Transaktionsgebühr", + "(2) Währung Transaktionsgebühr", + "(2) Transaktionsgebühr in EUR", + # + "Virtueller Veräußerungserlös in EUR", + "Virtuelle Anschaffungskosten in EUR", + "Virtuelle Gesamt Transaktionsgebühr in EUR", + # + "Virtueller Gewinn/Verlust in EUR", + "davon wären steuerbar", + "Einkunftsart", + "Bemerkung", + ] + class InterestReportEntry(TaxReportEntry): event_type = "Zinsen" From 8078788cd4509834445d2ebf3ab096ff2a97ee87 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:04:24 +0200 Subject: [PATCH 059/141] RM unrealized TODOs --- src/taxman.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 1d84ccd6..226eb0b9 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -453,15 +453,6 @@ def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: sc, ReportType=tr.UnrealizedSellReportEntry, ) - # TODO UnrealizedSellReportEntry nicht von irgendwas vererben? - # TODO _evaluate_sell darf noch nicht hinzufügen, nur anlegen? - # return ReportType - # TODO offene Positionen nur platform/coin, wert,... ohne kauf - # und verkaufsdatum - - # TODO ODER Offene Position bei "Einkunftsart" -> "Herkunft" - # (Kauf, Interest, ...) - # TODO dann noch eine Zusammenfassung der offenen Positionen def evaluate_taxation(self) -> None: """Evaluate the taxation using country specific function.""" From 025167360f6a0f9638ad2bb0961f67af061cdd2d Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:04:32 +0200 Subject: [PATCH 060/141] UPDATE docstring of evaluate_taxation --- src/taxman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index 226eb0b9..6940127f 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -455,7 +455,7 @@ def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: ) def evaluate_taxation(self) -> None: - """Evaluate the taxation using country specific function.""" + """Evaluate the taxation using the country specific function.""" log.debug("Starting evaluation...") assert all( From fe6359917a18efac69286c5180e46fd42422d498 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:04:57 +0200 Subject: [PATCH 061/141] RM export_evaluation_as_csv --- src/main.py | 1 - src/taxman.py | 69 --------------------------------------------------- 2 files changed, 70 deletions(-) diff --git a/src/main.py b/src/main.py index 3ef9a2cc..f27f4a53 100644 --- a/src/main.py +++ b/src/main.py @@ -51,7 +51,6 @@ def main() -> None: book.match_fees_with_operations() taxman.evaluate_taxation() - # evaluation_file_path = taxman.export_evaluation_as_csv() evaluation_file_path = taxman.export_evaluation_as_excel() taxman.print_evaluation() diff --git a/src/taxman.py b/src/taxman.py index 6940127f..47714fa1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -519,75 +519,6 @@ def print_evaluation(self) -> None: log.info(eval_str) - def export_evaluation_as_csv(self) -> Path: - """Export detailed summary of all tax events to CSV. - - File will be placed in export/ with ascending revision numbers - (in case multiple evaluations will be done). - - When no tax events occured, the CSV will be exported only with - a header line. - - Returns: - Path: Path to the exported file. - """ - file_path = misc.get_next_file_path( - config.EXPORT_PATH, str(config.TAX_YEAR), "csv" - ) - - with open(file_path, "w", newline="", encoding="utf8") as f: - writer = csv.writer(f) - # Add embedded metadata info - writer.writerow( - ["# software", "CoinTaxman "] - ) - commit_hash = misc.get_current_commit_hash(default="undetermined") - writer.writerow(["# commit", commit_hash]) - writer.writerow(["# updated", datetime.date.today().strftime("%x")]) - - # Header - header = [ - "Verkauf auf Börse", - "Erworben von Börse", - # - "Anzahl", - "Währung", - # - "Verkaufsdatum", - "Erwerbsdatum", - # - "(1) Anzahl Transaktionsgebühr", - "(1) Währung Transaktionsgebühr", - "(1) Transaktionsgebühr in EUR", - "(2) Anzahl Transaktionsgebühr", - "(2) Währung Transaktionsgebühr", - "(2) Transaktionsgebühr in EUR", - # - "Veräußerungserlös in EUR", - "Anschaffungskosten in EUR", - "Gesamt Transaktionsgebühr in EUR", - # - "Gewinn/Verlust in EUR", - "davon steuerbar", - "Einkunftsart", - "Bemerkung", - ] - writer.writerow(header) - - # Tax events are currently sorted by coin. Sort by time instead. - assert all( - tre.first_utc_time is not None for tre in self.tax_report_entries - ) - for tre in sorted( - self.tax_report_entries, - key=lambda tre: misc.not_none(tre.first_utc_time), - ): - assert isinstance(tre, tr.TaxReportEntry) - writer.writerow(tre.values()) - - log.info("Saved evaluation in %s.", file_path) - return file_path - def export_evaluation_as_excel(self) -> Path: """Export detailed summary of all tax events to Excel. From 8435b0f0314095504eeafecdde170266baa3cd5c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:22:05 +0200 Subject: [PATCH 062/141] RM unused csv module --- src/taxman.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index 47714fa1..eb1201f1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import collections -import csv import datetime import decimal from pathlib import Path From 15ad9bb55189bfa64bb141134ab83522757ab4b1 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:22:40 +0200 Subject: [PATCH 063/141] FIX linting error --- src/taxman.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index eb1201f1..9410bd20 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -238,10 +238,13 @@ def evaluate_sell( "are not included in the tax report. " "Please open an issue or PR if you can resolve this." ) - # Deposit fees are evaluated on deposited platform. + # # Deposit fees are evaluated on deposited platform. # wsc_fee_in_fiat = ( # self.price_data.get_price( - # sc.op.platform, sc.op.coin, sc.op.utc_time, config.FIAT + # sc.op.platform, + # sc.op.coin, + # sc.op.utc_time, + # config.FIAT, # ) # * wsc_deposit_fee # ) From 19accac3104f4ca930a275f102aee0e368acf8a8 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 21:48:25 +0200 Subject: [PATCH 064/141] FIX __patch_002 allow time without time zone in old database --- src/patch_database.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/patch_database.py b/src/patch_database.py index 0b8ed292..c8121de9 100644 --- a/src/patch_database.py +++ b/src/patch_database.py @@ -146,15 +146,29 @@ def __patch_002(db_path: Path) -> None: for _utc_time, _price in list(cur.fetchall()): # Convert the data. - # Try non-fractional seconds first, then fractional seconds - try: - utc_time = datetime.datetime.strptime( - _utc_time, "%Y-%m-%d %H:%M:%S%z" - ) - except ValueError: - utc_time = datetime.datetime.strptime( - _utc_time, "%Y-%m-%d %H:%M:%S.%f%z" + # Try non-fractional seconds first, then fractional seconds, + # then the same without timezone + for dateformat in ( + "%Y-%m-%d %H:%M:%S%z", + "%Y-%m-%d %H:%M:%S.%f%z", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M:%S.%f", + ): + try: + utc_time = datetime.datetime.strptime(_utc_time, dateformat) + except ValueError: + continue + else: + if not dateformat.endswith("%z"): + # Add the missing time zone information. + utc_time = utc_time.replace(tzinfo=None) + break + else: + raise ValueError( + f"Could not parse date `{_utc_time}` " + "in table `{tablename}`." ) + price = decimal.Decimal(_price) set_price_db("", base_asset, quote_asset, utc_time, price, db_path) conn.execute(f"DROP TABLE `{tablename}`;") From df04a957de6d662717ca0573c64efc06f0058193 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 22:07:45 +0200 Subject: [PATCH 065/141] ADD TaxReportEntry.fields helper function --- src/transaction.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index e864e626..6ec803c7 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -336,9 +336,13 @@ def __post_init__(self) -> None: f"{self=} : missing values for fields " f"{', '.join(missing_field_values)}" ) + @classmethod + def fields(cls) -> tuple[dataclasses.Field, ...]: + return dataclasses.fields(cls) + @classmethod def field_names(cls) -> Iterator[str]: - return (field.name for field in dataclasses.fields(cls)) + return (field.name for field in cls.fields()) @classmethod def _labels(cls) -> list[str]: From a46f3bd09fe6aab321e2539440c18aa53232a3ef Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 22:08:07 +0200 Subject: [PATCH 066/141] FIX add "utc" to all TaxReportEntry date labels --- src/transaction.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 6ec803c7..1ed7486b 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -375,8 +375,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Wiedererhalten am", - "Verliehen am", + "Wiedererhalten am (UTC)", + "Verliehen am (UTC)", # "-", "-", @@ -452,8 +452,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Verkaufsdatum", - "Erwerbsdatum", + "Verkaufsdatum (UTC)", + "Erwerbsdatum (UTC)", # "(1) Anzahl Transaktionsgebühr", "(1) Währung Transaktionsgebühr", @@ -485,8 +485,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Virtuelles Verkaufsdatum", - "Erwerbsdatum", + "Virtuelles Verkaufsdatum (UTC)", + "Erwerbsdatum (UTC)", # "(1) Anzahl Transaktionsgebühr", "(1) Währung Transaktionsgebühr", @@ -539,7 +539,7 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Erhalten am", + "Erhalten am (UTC)", "-", # "-", @@ -601,7 +601,7 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Erhalten am", + "Erhalten am (UTC)", "-", # "-", @@ -664,8 +664,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Eingangsdatum", - "Ausgangsdatum", + "Eingangsdatum (UTC)", + "Ausgangsdatum (UTC)", # "(1) Anzahl Transaktionsgebühr", "(1) Währung Transaktionsgebühr", From 61fdf73f2e363bf16a894d96677729f4df308ea9 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 22:08:25 +0200 Subject: [PATCH 067/141] ADD column_num_to_string, column_string_to_num for excel column manipulation --- src/misc.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/misc.py b/src/misc.py index 80c3dcfb..7eecb4d8 100644 --- a/src/misc.py +++ b/src/misc.py @@ -311,3 +311,22 @@ def not_none(v: Optional[T]) -> T: if v is None: raise ValueError() return v + + +def column_num_to_string(n: int) -> str: + # References: https://stackoverflow.com/a/63013258/8979290 + n, rem = divmod(n - 1, 26) + char = chr(65 + rem) + if n: + return column_num_to_string(n) + char + else: + return char + + +def column_string_to_num(s: str) -> int: + # References: https://stackoverflow.com/a/63013258/8979290 + n = ord(s[-1]) - 64 + if s[:-1]: + return 26 * (column_string_to_num(s[:-1])) + n + else: + return n From 9d3af09185a6fd6995faef45f5cf88acca936434 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 22:27:22 +0200 Subject: [PATCH 068/141] ADD TaxReportEntry.excel_labels helper function --- src/transaction.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/transaction.py b/src/transaction.py index 1ed7486b..1e9c88c2 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -344,6 +344,14 @@ def fields(cls) -> tuple[dataclasses.Field, ...]: def field_names(cls) -> Iterator[str]: return (field.name for field in cls.fields()) + @classmethod + def excel_labels(self) -> list[str]: + labels = [] + for label in self.labels(): + label = label.replace("Transaktionsgebühr", "Transaktions-gebühr") + labels.append(label) + return labels + @classmethod def _labels(cls) -> list[str]: return list(cls.field_names()) From 2dc72bd1451c72d25cadfffa758d0c2f186da6b2 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Wed, 4 May 2022 23:14:49 +0200 Subject: [PATCH 069/141] UPDATE evaluation export - improve cell formatting - add taxable gain in fiat to excel output - rename taxable_gain to taxable_gain_in_fiat - freeze panes (first row) - add summary worksheet --- src/taxman.py | 97 ++++++++++++++++++++++++++++++++++++++-------- src/transaction.py | 46 ++++++++++++++-------- 2 files changed, 110 insertions(+), 33 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 9410bd20..441638f1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import collections +import dataclasses import datetime import decimal from pathlib import Path @@ -488,7 +489,7 @@ def print_evaluation(self) -> None: self.tax_report_entries, "taxation_type" ).items(): taxable_gain = misc.dsum( - tre.taxable_gain + tre.taxable_gain_in_fiat for tre in tax_report_entries if not isinstance(tre, tr.UnrealizedSellReportEntry) ) @@ -504,7 +505,7 @@ def print_evaluation(self) -> None: misc.not_none(tre.gain_in_fiat) for tre in unrealized_report_entries ) unrealized_taxable_gain = misc.dsum( - tre.taxable_gain for tre in unrealized_report_entries + tre.taxable_gain_in_fiat for tre in unrealized_report_entries ) eval_str += ( "----------------------------------------\n" @@ -537,33 +538,97 @@ def export_evaluation_as_excel(self) -> Path: config.EXPORT_PATH, str(config.TAX_YEAR), "xlsx" ) wb = xlsxwriter.Workbook(file_path, {"remove_timezone": True}) + datetime_format = wb.add_format({"num_format": "dd.mm.yyyy hh:mm;@"}) + date_format = wb.add_format({"num_format": "dd.mm.yyyy;@"}) + change_format = wb.add_format({"num_format": "#,##0.00000000"}) + fiat_format = wb.add_format({"num_format": "#,##0.00"}) + header_format = wb.add_format( + { + "bold": True, + "border": 5, + "align": "center", + "valign": "center", + "text_wrap": True, + } + ) + + def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: + if field.type in ("datetime.datetime", "Optional[datetime.datetime]"): + return datetime_format + if field.type in ("decimal.Decimal", "Optional[decimal.Decimal]"): + if field.name.endswith("in_fiat"): + return fiat_format + return change_format + return None + # TODO Increase width of columns. Autoresize? + + # # General + # ws_general = wb.add_worksheet("Allgemein") + ws_general.write_row(0, 0, ["Allgemeine Daten"], header_format) + ws_general.write_row(1, 0, ["Stichtag", TAX_DEADLINE.date()], date_format) + ws_general.write_row( + 2, 0, ["Erstellt am", datetime.datetime.now()], datetime_format + ) + ws_general.write_row( + 3, 0, ["Software", "CoinTaxman "] + ) commit_hash = misc.get_current_commit_hash(default="undetermined") - general_data = [ - ["Allgemeine Daten"], - ["Stichtag", TAX_DEADLINE], - ["Erstellt am", datetime.datetime.now()], - ["Software", "CoinTaxman "], - ["Commit", commit_hash], - ] - for row, data in enumerate(general_data): - ws_general.write_row(row, 0, data) + ws_general.write_row(4, 0, ["Commit", commit_hash]) + # Set column format and freeze first row. + ws_general.freeze_panes(1, 0) + + # + # Add summary of tax relevant amounts. + # + ws_summary = wb.add_worksheet("Zusammenfassung") + ws_summary.write_row( + 0, 0, ["Einkunftsart", "steuerbarer Betrag in EUR"], header_format + ) + ws_summary.set_row(0, 30) + for row, (taxation_type, tax_report_entries) in enumerate( + misc.group_by(self.tax_report_entries, "taxation_type").items(), 1 + ): + taxable_gain = misc.dsum( + tre.taxable_gain_in_fiat + for tre in tax_report_entries + if not isinstance(tre, tr.UnrealizedSellReportEntry) + ) + ws_summary.write_row(row, 0, [taxation_type, taxable_gain]) + # Set column format and freeze first row. + ws_summary.set_column("B:B", None, fiat_format) + ws_summary.freeze_panes(1, 0) + # # Sheets per ReportType + # for ReportType, tax_report_entries in misc.group_by( self.tax_report_entries, "__class__" ).items(): + assert issubclass(ReportType, tr.TaxReportEntry) + ws = wb.add_worksheet(ReportType.event_type) + # Header - # TODO increase height of first row - ws.write_row(0, 0, ReportType.labels()) - # TODO set column width (custom?) and correct format (datetime, - # change up to 8 decimal places, ...) + ws.write_row(0, 0, ReportType.excel_labels(), header_format) + ws.set_row(0, 45) + # Data for row, entry in enumerate(tax_report_entries, 1): - ws.write_row(row, 0, entry.values()) + ws.write_row(row, 0, entry.excel_values()) + + # Set column format and freeze first row. + for col, field in enumerate(ReportType.excel_fields(), 1): + if cell_format := get_format(field): + column = misc.column_num_to_string(col) + ws.set_column( + f"{column}:{column}", + None, + cell_format, + ) + ws.freeze_panes(1, 0) wb.close() log.info("Saved evaluation in %s.", file_path) diff --git a/src/transaction.py b/src/transaction.py index 1e9c88c2..5f59b1bf 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -274,16 +274,18 @@ def _gain_in_fiat(self) -> Optional[decimal.Decimal]: - misc.cdecimal(self._total_fee_in_fiat) ) - is_taxable: Optional[bool] = None - taxation_type: Optional[str] = None - remark: Optional[str] = None + taxable_gain_in_fiat: decimal.Decimal = dataclasses.field(init=False) @property - def taxable_gain(self) -> decimal.Decimal: + def _taxable_gain_in_fiat(self) -> decimal.Decimal: if self.is_taxable and self._gain_in_fiat: return self._gain_in_fiat return decimal.Decimal() + is_taxable: Optional[bool] = None + taxation_type: Optional[str] = None + remark: Optional[str] = None + # Copy-paste template for subclasses. # def __init__( # self, @@ -344,14 +346,6 @@ def fields(cls) -> tuple[dataclasses.Field, ...]: def field_names(cls) -> Iterator[str]: return (field.name for field in cls.fields()) - @classmethod - def excel_labels(self) -> list[str]: - labels = [] - for label in self.labels(): - label = label.replace("Transaktionsgebühr", "Transaktions-gebühr") - labels.append(label) - return labels - @classmethod def _labels(cls) -> list[str]: return list(cls.field_names()) @@ -359,16 +353,34 @@ def _labels(cls) -> list[str]: @classmethod def labels(cls) -> list[str]: labels = cls._labels() - assert len(labels) == len(dataclasses.fields(cls)) + assert len(labels) == len(dataclasses.fields(cls)) - 1 return labels def values(self) -> Iterator: return (getattr(self, f) for f in self.field_names()) + @staticmethod + def is_excel_label(label: str) -> bool: + return label != "is_taxable" + + @classmethod + def excel_fields(cls) -> tuple[dataclasses.Field, ...]: + return tuple(field for field in cls.fields() if cls.is_excel_label(field.name)) + + @classmethod + def excel_labels(self) -> list[str]: + return [label for label in self.labels() if self.is_excel_label(label)] + + def excel_values(self) -> Iterator: + return (getattr(self, f) for f in self.field_names() if self.is_excel_label(f)) + # Bypass dataclass machinery, add a custom property function to a dataclass field. TaxReportEntry.total_fee_in_fiat = TaxReportEntry._total_fee_in_fiat # type:ignore TaxReportEntry.gain_in_fiat = TaxReportEntry._gain_in_fiat # type:ignore +TaxReportEntry.taxable_gain_in_fiat = ( + TaxReportEntry._taxable_gain_in_fiat # type:ignore +) class LendingReportEntry(TaxReportEntry): @@ -398,7 +410,7 @@ def _labels(cls) -> list[str]: "-", # "Gewinn/Verlust in EUR", - "davon steuerbar", + "davon steuerbar in EUR", "Einkunftsart", "Bemerkung", ] @@ -475,7 +487,7 @@ def _labels(cls) -> list[str]: "Gesamt Transaktionsgebühr in EUR", # "Gewinn/Verlust in EUR", - "davon steuerbar", + "davon steuerbar in EUR", "Einkunftsart", "Bemerkung", ] @@ -562,7 +574,7 @@ def _labels(cls) -> list[str]: "-", # "Gewinn/Verlust in EUR", - "davon steuerbar", + "davon steuerbar in EUR", "Einkunftsart", "Bemerkung", ] @@ -624,7 +636,7 @@ def _labels(cls) -> list[str]: "-", # "Gewinn/Verlust in EUR", - "davon steuerbar", + "davon steuerbar in EUR", "Einkunftsart", "Bemerkung", ] From 529e126e06b9cc22774e39b9011ba97a84dc1bd3 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 10:12:01 +0200 Subject: [PATCH 070/141] ADD Savings as allowed account type for binance book --- src/book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index d5d89a1a..6e5dfa25 100644 --- a/src/book.py +++ b/src/book.py @@ -193,8 +193,8 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: change = abs(change) # Validate data. - assert account == "Spot", ( - "Other types than Spot are currently not supported. " + assert account in ("Spot", "Savings"), ( + "Other types than Spot or Savings are currently not supported. " "Please create an Issue or PR." ) assert operation From db732a3e4e547b6646506eae1d467fb1eb67839b Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 11:47:50 +0200 Subject: [PATCH 071/141] FIX typo --- src/book.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/book.py b/src/book.py index 6e5dfa25..4d8fb755 100644 --- a/src/book.py +++ b/src/book.py @@ -1282,7 +1282,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: "Following withdrawals couldn't be matched:\n" + ( "\n".join( - f" - {op.change} {op.coin} from {op.platform} at{op.utc_time}" + f" - {op.change} {op.coin} from {op.platform} at {op.utc_time}" for op in withdrawal_queue ) ) From b83b03973892f7c4dca88b97e17ab94e2c2fa2eb Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 11:48:54 +0200 Subject: [PATCH 072/141] UPDATE move unmatched deposit warning out of the forloop for a summarizing warning message --- src/book.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/book.py b/src/book.py index 4d8fb755..c46979ba 100644 --- a/src/book.py +++ b/src/book.py @@ -1238,6 +1238,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: ) withdrawal_queue: list[tr.Withdrawal] = [] + unmatched_deposits: list[tr.Deposit] = [] for op in sorted_ops: if op.coin == config.FIAT: @@ -1254,15 +1255,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: # If multiple are found, take the first (regarding utc_time). match = next(w for w in withdrawal_queue if is_match(w, op)) except StopIteration: - log.warning( - "No matching withdrawal operation found for deposit of " - f"{op.change} {op.coin} " - f"({op.platform}, {op.utc_time}). " - "The tax evaluation might be wrong. " - "Have you added all account statements? " - "For tax evaluation, it might be importend when " - "and for which price these coins were bought." - ) + unmatched_deposits.append(op) else: # Match the found withdrawal and remove it from queue. op.link = match @@ -1275,6 +1268,18 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: f"({op.platform}, {op.utc_time})" ) + if unmatched_deposits: + log.warning( + "Unable to match all deposits with withdrawals. " + "Have you added all account statements? " + "Following deposits couldn't be matched:\n" + + ( + "\n".join( + f" - {op.change} {op.coin} to {op.platform} at {op.utc_time}" + for op in unmatched_deposits + ) + ) + ) if withdrawal_queue: log.warning( "Unable to match all withdrawals with deposits. " From faca8db9f9318c1c298e5af0c1c8fe955cf997f1 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 11:49:33 +0200 Subject: [PATCH 073/141] FIX resolve deposits : sort deposits after withdrawals --- src/book.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index c46979ba..f78b6b70 100644 --- a/src/book.py +++ b/src/book.py @@ -1227,7 +1227,15 @@ def resolve_deposits(self) -> None: Returns: None """ - sorted_ops = tr.sort_operations(self.operations, ["utc_time"]) + transfer_operations = ( + op for op in self.operations if isinstance(op, (tr.Deposit, tr.Withdrawal)) + ) + # Sort deposit and withdrawal operations by time so that deposits + # come after withdrawal. + sorted_transfer_operations = sorted( + transfer_operations, + key=lambda op: (isinstance(op, tr.Deposit), op.utc_time), + ) def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: return ( @@ -1240,7 +1248,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: withdrawal_queue: list[tr.Withdrawal] = [] unmatched_deposits: list[tr.Deposit] = [] - for op in sorted_ops: + for op in sorted_transfer_operations: if op.coin == config.FIAT: # Do not match home fiat deposit/withdrawals. continue From 696ed83d55e13251693b40de4abf0e4f2a558746 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 11:50:01 +0200 Subject: [PATCH 074/141] FIX resolve deposit docstring remove unneccessary Return: None statement --- src/book.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/book.py b/src/book.py index f78b6b70..86f981b0 100644 --- a/src/book.py +++ b/src/book.py @@ -1223,9 +1223,6 @@ def resolve_deposits(self) -> None: A match is found when: A. The coin is the same and B. The deposit amount is between 0.99 and 1 times the withdrawal amount. - - Returns: - None """ transfer_operations = ( op for op in self.operations if isinstance(op, (tr.Deposit, tr.Withdrawal)) From 395bb1e21611da217b180ceb1309e89468123a6c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:30:35 +0200 Subject: [PATCH 075/141] UPDATE improve comments, warning and error messages --- src/balance_queue.py | 5 +++-- src/price_data.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 8c00ff81..16621539 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -207,7 +207,8 @@ def remove( "account statement, including these from the last years?\n" "\tThis error may also occur after deposits from unknown " "sources. CoinTaxman requires the full transaction history to " - "evaluate taxation (when where these deposited coins bought?).\n" + "evaluate taxation (when and where were these deposited coins " + "bought?).\n" ) raise RuntimeError @@ -224,7 +225,7 @@ def _remove_fee(self, fee: decimal.Decimal) -> None: log.warning( "Not enough coins in queue to remove fee. Buffer the fee for " "next adding time... " - "This should not happen. You might be missing a account " + "This should not happen. You might be missing an account " "statement. Please open issue or PR if you need help." ) self.buffer_fee += left_over_fee diff --git a/src/price_data.py b/src/price_data.py index 7a515d46..ee642420 100644 --- a/src/price_data.py +++ b/src/price_data.py @@ -546,7 +546,7 @@ def get_price( try: get_price = getattr(self, f"_get_price_{platform}") except AttributeError: - raise NotImplementedError("Unable to read data from %s", platform) + raise NotImplementedError(f"Unable to read data from {platform=}") price = get_price(coin, utc_time, reference_coin, **kwargs) assert isinstance(price, decimal.Decimal) From 36d4a486b16370c7df9f15f86b2ae0e953fca4a7 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:31:05 +0200 Subject: [PATCH 076/141] FIX do not raise error when unrealized sell can not be calculated, only raise warning --- src/taxman.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index 441638f1..eebaa627 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -30,6 +30,7 @@ import misc import transaction as tr from book import Book +from database import get_sorted_tablename from price_data import PriceData log = log_config.getLogger(__name__) @@ -184,6 +185,26 @@ def _evaluate_sell( # TODO Recognized increased speculation period for lended/staked coins? is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) + try: + sell_value_in_fiat = self.price_data.get_partial_cost(op, percent) + except NotImplementedError: + # Do not raise an error when we are unable to calculate an + # unrealized sell value. + if ReportType is tr.UnrealizedSellReportEntry: + log.warning( + f"Gathering prices for platform {op.platform} is currently " + "not implemented. Therefore I am unable to calculate the " + f"unrealized sell value for your {op.coin} at evaluation " + "deadline. If you want to see your unrealized sell value " + "in the evaluation, please add a price by hand in the " + f"table {get_sorted_tablename(op.coin, config.FIAT)[0]} " + f"at {op.utc_time}; " + "or open an issue/PR to gather prices for your platform." + ) + sell_value_in_fiat = decimal.Decimal() + else: + raise + sell_report_entry = ReportType( sell_platform=op.platform, buy_platform=sc.op.platform, @@ -197,7 +218,7 @@ def _evaluate_sell( second_fee_amount=second_fee_amount, second_fee_coin=second_fee_coin, second_fee_in_fiat=second_fee_in_fiat, - sell_value_in_fiat=self.price_data.get_partial_cost(op, percent), + sell_value_in_fiat=sell_value_in_fiat, buy_value_in_fiat=buy_value_in_fiat, is_taxable=is_taxable, taxation_type="Sonstige Einkünfte", From 7d4552018230bc06db5a37e217d255c8320e99ed Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:32:07 +0200 Subject: [PATCH 077/141] FIX evaluate operations one by one splitting by platform for multi depot is now done in Taxman.balance --- src/taxman.py | 42 +++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index eebaa627..8110bbb2 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -440,22 +440,28 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # General tax evaluation functions. ########################################################################### - def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: - """Evaluate the taxation for a list of operations using - country specific functions. + def evaluate_taxation(self) -> None: + """Evaluate the taxation using country specific functions.""" + log.debug("Starting evaluation...") - Args: - operations (list[tr.Operation]) - """ - operations = tr.sort_operations(operations, ["utc_time"]) + assert all( + op.utc_time.year <= config.TAX_YEAR for op in self.book.operations + ), "For tax evaluation, no operation should happen after the tax year." + + # Sort the operations by time. + operations = tr.sort_operations(self.book.operations, ["utc_time"]) + + # Evaluate the operations one by one. + # Difference between the config.MULTI_DEPOT and "single depot" method + # is done by keeping balances per platform and coin or only + # per coin (see self.balance). for operation in operations: self.__evaluate_taxation(operation) - # Evaluate the balance at deadline. + # Evaluate the balance at deadline to calculate unrealized sells. for balance in self._balances.values(): balance.sanity_check() - # Calculate the unrealized profit/loss. sold_coins = balance.remove_all() for sc in sold_coins: # Sum up the portfolio at deadline. @@ -478,24 +484,6 @@ def _evaluate_taxation(self, operations: list[tr.Operation]) -> None: ReportType=tr.UnrealizedSellReportEntry, ) - def evaluate_taxation(self) -> None: - """Evaluate the taxation using the country specific function.""" - log.debug("Starting evaluation...") - - assert all( - op.utc_time.year <= config.TAX_YEAR for op in self.book.operations - ), "For tax evaluation, no operation should happen after the tax year." - - if config.MULTI_DEPOT: - # Evaluate taxation separated by platforms and coins. - for _, operations in misc.group_by( - self.book.operations, "platform" - ).items(): - self._evaluate_taxation(operations) - else: - # Evaluate taxation separated by coins "in a single virtual depot". - self._evaluate_taxation(self.book.operations) - ########################################################################### # Export / Summary ########################################################################### From 75a0585e1af22cf844a0fe9bd79b1d4038d1a234 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:32:21 +0200 Subject: [PATCH 078/141] FIX ignore None taxation_type in evaluation summary --- src/taxman.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/taxman.py b/src/taxman.py index 8110bbb2..48c354bd 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -497,6 +497,8 @@ def print_evaluation(self) -> None: for taxation_type, tax_report_entries in misc.group_by( self.tax_report_entries, "taxation_type" ).items(): + if taxation_type is None: + continue taxable_gain = misc.dsum( tre.taxable_gain_in_fiat for tre in tax_report_entries @@ -600,6 +602,8 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: for row, (taxation_type, tax_report_entries) in enumerate( misc.group_by(self.tax_report_entries, "taxation_type").items(), 1 ): + if taxation_type is None: + continue taxable_gain = misc.dsum( tre.taxable_gain_in_fiat for tre in tax_report_entries From d18743594e1deffd5a4c9d9d931b3fa46eca7b96 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:33:08 +0200 Subject: [PATCH 079/141] FIX Withdrawal.withdrawn_coins --- src/transaction.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 5f59b1bf..98f08503 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -194,12 +194,12 @@ class Deposit(Transaction): class Withdrawal(Transaction): - withdrawn_coins: Optional[list[SoldCoin]] + withdrawn_coins: Optional[list[SoldCoin]] = None def partial_withdrawn_coins(self, percent: decimal.Decimal) -> list[SoldCoin]: assert self.withdrawn_coins withdrawn_coins = [wc.partial(percent) for wc in self.withdrawn_coins] - assert self.change == misc.dsum( + assert percent * self.change == misc.dsum( (wsc.sold for wsc in withdrawn_coins) ), "Withdrawn coins total must be equal to the sum if the single coins." return withdrawn_coins @@ -213,11 +213,14 @@ class SoldCoin: op: Operation sold: decimal.Decimal + def __post_init__(self): + self.validate() + + def validate(self) -> None: + assert self.sold <= self.op.change + def partial(self, percent: decimal.Decimal) -> SoldCoin: - sc = copy(self) - sc.sold *= percent - sc.op.change *= percent - return sc + return SoldCoin(self.op, self.sold * percent) @dataclasses.dataclass From a3fed35524b975c12682d4509222d5c137f9d2f3 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:33:47 +0200 Subject: [PATCH 080/141] FIX TaxReportEntry.__post_init__ : check excel values and fields not the default name/fields --- src/transaction.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 98f08503..ebac6ca1 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -333,9 +333,9 @@ def _taxable_gain_in_fiat(self) -> decimal.Decimal: def __post_init__(self) -> None: """Validate that all required fields (label != '-') are given.""" missing_field_values = [ - field_name - for label, field_name in zip(self.labels(), self.field_names()) - if label != "-" and getattr(self, field_name) is None + field.name + for label, field in zip(self.excel_labels(), self.excel_fields()) + if label != "-" and getattr(self, field.name) is None ] assert not missing_field_values, ( f"{self=} : missing values for fields " f"{', '.join(missing_field_values)}" From df37ebf57ac67af5e44ec04a07ea3f317c1d8178 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:35:17 +0200 Subject: [PATCH 081/141] FIX allow TaxReportEntry.taxable_gain to be None in case a ReportEntry is never taxable and should not have this column --- src/transaction.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index ebac6ca1..c8898bc2 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -280,9 +280,11 @@ def _gain_in_fiat(self) -> Optional[decimal.Decimal]: taxable_gain_in_fiat: decimal.Decimal = dataclasses.field(init=False) @property - def _taxable_gain_in_fiat(self) -> decimal.Decimal: + def _taxable_gain_in_fiat(self) -> Optional[decimal.Decimal]: if self.is_taxable and self._gain_in_fiat: return self._gain_in_fiat + if self.get_label("taxable_gain_in_fiat") == "-": + return None return decimal.Decimal() is_taxable: Optional[bool] = None @@ -359,6 +361,13 @@ def labels(cls) -> list[str]: assert len(labels) == len(dataclasses.fields(cls)) - 1 return labels + @classmethod + def get_label(cls, field_name: str) -> str: + for label, field in zip(cls.labels(), cls.fields()): + if field.name == field_name: + return label + raise ValueError(f"{field_name} is not a field of {cls=}") + def values(self) -> Iterator: return (getattr(self, f) for f in self.field_names()) From 8272587db460cbe1b7b6e8332e1098a617b7769a Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 14:43:30 +0200 Subject: [PATCH 082/141] ADD custom format for importing (#55) --- README.md | 4 ++ src/book.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/README.md b/README.md index b8167e57..baa99c12 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Pull Requests und Anfragen über Issues sind gerne gesehen (siehe `Key notes for - [coinbase (pro)](https://github.com/provinzio/CoinTaxman/wiki/Exchange:-coinbase) - [Kraken](https://github.com/provinzio/CoinTaxman/wiki/Exchange:-Kraken) +It is also possible to import a custom transaction history file. +See [here](https://github.com/provinzio/CoinTaxman/wiki/Custom-import-format) for more informations. +The format is based on the awesome `bittytax_conv`-tool from [BittyTax](https://github.com/BittyTax/BittyTax). + ### Requirements - Python 3.9 diff --git a/src/book.py b/src/book.py index 86f981b0..456d3741 100644 --- a/src/book.py +++ b/src/book.py @@ -1071,6 +1071,112 @@ def _read_bitpanda(self, file_path: Path) -> None: file_path, ) + def _read_custom_eur(self, file_path: Path) -> None: + fiat = "EUR" + + with open(file_path, encoding="utf8") as f: + reader = csv.reader(f) + + # Skip header. + next(reader) + + for line in reader: + row = reader.line_num + + # Skip empty lines. + if not line: + continue + + ( + operation_type, + _buy_quantity, + buy_asset, + _buy_value_in_fiat, + _sell_quantity, + sell_asset, + _sell_value_in_fiat, + _fee_quantity, + fee_asset, + _fee_value_in_fiat, + platform, + _timestamp, + remark, + ) = line + + # Parse data. + try: + utc_time = datetime.datetime.strptime( + _timestamp, "%m/%d/%Y %H:%M:%S" + ) + except ValueError: + utc_time = datetime.datetime.strptime( + _timestamp, "%m/%d/%Y %H:%M:%S.%f" + ) + utc_time = utc_time.replace(tzinfo=datetime.timezone.utc) + buy_quantity = misc.xdecimal(_buy_quantity) + buy_value_in_fiat = misc.xdecimal(_buy_value_in_fiat) + sell_quantity = misc.xdecimal(_sell_quantity) + sell_value_in_fiat = misc.xdecimal(_sell_value_in_fiat) + fee_quantity = misc.xdecimal(_fee_quantity) + fee_value_in_fiat = misc.xdecimal(_fee_value_in_fiat) + + # ... and define which operation to add. + add_operations: list[ + tuple[str, decimal.Decimal, str, Optional[decimal.Decimal]] + ] = [] + if operation_type != "Withdrawal": + assert buy_quantity + assert buy_asset + + op = "Buy" if operation_type == "Trade" else operation_type + add_operations.append( + (op, buy_quantity, buy_asset, buy_value_in_fiat) + ) + + if operation_type != "Deposit": + assert sell_quantity + assert sell_asset + + op = "Sell" if operation_type == "Trade" else operation_type + add_operations.append( + (op, sell_quantity, sell_asset, sell_value_in_fiat) + ) + + if fee_asset: + assert fee_quantity + assert fee_value_in_fiat + + add_operations.append( + ("Fee", fee_quantity, fee_asset, fee_value_in_fiat) + ) + + if remark: + log.warning( + "Remarks from custom CSV import will be ignored. " + f"Ignored remark in file {file_path}:{row}: {remark}" + ) + + for operation, change, coin, change_in_fiat in add_operations: + # Add operation to book. + self.append_operation( + operation, utc_time, platform, change, coin, row, file_path + ) + # Add price from csv. + if change_in_fiat and coin != fiat: + price = change_in_fiat / change + log.debug( + f"Adding {fiat}/{coin} price from custom CSV: " + f"{price} for {platform} at {utc_time}" + ) + set_price_db( + platform, + coin, + fiat, + utc_time, + price, + overwrite=True, + ) + def detect_exchange(self, file_path: Path) -> Optional[str]: if file_path.suffix == ".csv": @@ -1085,6 +1191,7 @@ def detect_exchange(self, file_path: Path) -> Optional[str]: "kraken_trades": 1, "bitpanda_pro_trades": 4, "bitpanda": 7, + "custom_eur": 1, } expected_headers = { @@ -1201,6 +1308,21 @@ def detect_exchange(self, file_path: Path) -> Optional[str]: "Spread", "Spread Currency", ], + "custom_eur": [ + "Type", + "Buy Quantity", + "Buy Asset", + "Buy Value in EUR", + "Sell Quantity", + "Sell Asset", + "Sell Value in EUR", + "Fee Quantity", + "Fee Asset", + "Fee Value in EUR", + "Wallet", + "Timestamp UTC", + "Note", + ], } with open(file_path, encoding="utf8") as f: reader = csv.reader(f) From e952fe258be98d8ccb80b413d857649b5f24fb41 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 17:39:53 +0200 Subject: [PATCH 083/141] FIX add xlsxwriter to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 897756d5..22ff74ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python-dateutil==2.8.1 requests==2.25.1 six==1.15.0 urllib3==1.26.5 +xlsxwriter==3.0.3 From 66f11a3fa6cd3025e7233832aedfdf85fcef9701 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 17:47:10 +0200 Subject: [PATCH 084/141] ADD remark from book operations to export file --- src/book.py | 20 ++++++++++++-------- src/taxman.py | 10 +++++----- src/transaction.py | 1 + 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/book.py b/src/book.py index 456d3741..1e9e77d2 100644 --- a/src/book.py +++ b/src/book.py @@ -59,6 +59,7 @@ def create_operation( coin: str, row: int, file_path: Path, + remark: str = "", ) -> tr.Operation: try: @@ -72,7 +73,7 @@ def create_operation( ) raise RuntimeError - op = Op(utc_time, platform, change, coin, [row], file_path) + op = Op(utc_time, platform, change, coin, [row], file_path, remark=remark) assert isinstance(op, tr.Operation) return op @@ -94,6 +95,7 @@ def append_operation( coin: str, row: int, file_path: Path, + remark: str = "", ) -> None: # Discard operations after the `TAX_YEAR`. # Ignore operations which make no change. @@ -106,6 +108,7 @@ def append_operation( coin, row, file_path, + remark=remark, ) self._append_operation(op) @@ -1150,16 +1153,17 @@ def _read_custom_eur(self, file_path: Path) -> None: ("Fee", fee_quantity, fee_asset, fee_value_in_fiat) ) - if remark: - log.warning( - "Remarks from custom CSV import will be ignored. " - f"Ignored remark in file {file_path}:{row}: {remark}" - ) - for operation, change, coin, change_in_fiat in add_operations: # Add operation to book. self.append_operation( - operation, utc_time, platform, change, coin, row, file_path + operation, + utc_time, + platform, + change, + coin, + row, + file_path, + remark=remark, ) # Add price from csv. if change_in_fiat and coin != fiat: diff --git a/src/taxman.py b/src/taxman.py index 48c354bd..7beb66ef 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -222,7 +222,7 @@ def _evaluate_sell( buy_value_in_fiat=buy_value_in_fiat, is_taxable=is_taxable, taxation_type="Sonstige Einkünfte", - remark="", + remark=op.remark, ) self.tax_report_entries.append(sell_report_entry) @@ -364,7 +364,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: utc_time=op.utc_time, interest_in_fiat=self.price_data.get_cost(op), taxation_type=taxation_type, - remark="", + remark=op.remark, ) self.tax_report_entries.append(report_entry) @@ -386,7 +386,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: utc_time=op.utc_time, in_fiat=self.price_data.get_cost(op), taxation_type=taxation_type, - remark="", + remark=op.remark, ) self.tax_report_entries.append(report_entry) @@ -404,7 +404,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: utc_time=op.utc_time, in_fiat=self.price_data.get_cost(op), taxation_type="Einkünfte aus sonstigen Leistungen", - remark="", + remark=op.remark, ) self.tax_report_entries.append(report_entry) @@ -424,7 +424,7 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: first_fee_amount=op.link.change - op.change, first_fee_coin=op.coin, first_fee_in_fiat=self.price_data.get_cost(op), - remark="", + remark=op.remark, ) self.tax_report_entries.append(report_entry) diff --git a/src/transaction.py b/src/transaction.py index c8898bc2..047011a8 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -45,6 +45,7 @@ class Operation: line: list[int] file_path: Path fees: "Optional[list[Fee]]" = None + remark: str = "" @classmethod def type_name_c(cls) -> str: From 5e5407135f5f58a0a174255f32b118b6f34f5640 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 18:19:39 +0200 Subject: [PATCH 085/141] UPDATE save transaction remarks as list --- src/book.py | 14 +++++++++++--- src/transaction.py | 6 +++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/book.py b/src/book.py index 1e9e77d2..6b5a6417 100644 --- a/src/book.py +++ b/src/book.py @@ -59,7 +59,7 @@ def create_operation( coin: str, row: int, file_path: Path, - remark: str = "", + remark: Optional[str] = None, ) -> tr.Operation: try: @@ -73,7 +73,11 @@ def create_operation( ) raise RuntimeError - op = Op(utc_time, platform, change, coin, [row], file_path, remark=remark) + kwargs = {} + if remark: + kwargs["remarks"] = [remark] + + op = Op(utc_time, platform, change, coin, [row], file_path, **kwargs) assert isinstance(op, tr.Operation) return op @@ -95,7 +99,7 @@ def append_operation( coin: str, row: int, file_path: Path, - remark: str = "", + remark: Optional[str] = None, ) -> None: # Discard operations after the `TAX_YEAR`. # Ignore operations which make no change. @@ -1411,6 +1415,8 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: ) ) ) + for op in unmatched_deposits: + op.remarks.append("Einzahlung ohne zugehörige Auszahlung!") if withdrawal_queue: log.warning( "Unable to match all withdrawals with deposits. " @@ -1423,6 +1429,8 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: ) ) ) + for op in withdrawal_queue: + op.remarks.append("Auszahlung ohne zugehörige Einzahlung!") log.info("Finished withdrawal/deposit matching") diff --git a/src/transaction.py b/src/transaction.py index 047011a8..b1d8d2cc 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -45,7 +45,11 @@ class Operation: line: list[int] file_path: Path fees: "Optional[list[Fee]]" = None - remark: str = "" + remarks: list[str] = dataclasses.field(default_factory=list) + + @property + def remark(self) -> str: + return ", ".join(self.remarks) @classmethod def type_name_c(cls) -> str: From d559de6eecd6c1939f99e2fa5fe3c96f58fe708b Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 18:31:03 +0200 Subject: [PATCH 086/141] UPDATE All deposits / withdrawals are now shown in the export --- src/book.py | 1 + src/taxman.py | 51 ++++++++++++++++++++++++++++++------ src/transaction.py | 64 +++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/book.py b/src/book.py index 6b5a6417..6cee08b1 100644 --- a/src/book.py +++ b/src/book.py @@ -1394,6 +1394,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: else: # Match the found withdrawal and remove it from queue. op.link = match + match.has_link = True withdrawal_queue.remove(match) log.debug( "Linking withdrawal with deposit: " diff --git a/src/taxman.py b/src/taxman.py index 7beb66ef..eab9db9f 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -414,6 +414,14 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: if op.link: assert op.coin == op.link.coin + assert op.fees is None + first_fee_amount = op.link.change - op.change + first_fee_coin = op.coin if first_fee_amount else "" + first_fee_in_fiat = ( + self.price_data.get_price(op.platform, op.coin, op.utc_time) + if first_fee_amount + else decimal.Decimal() + ) report_entry = tr.TransferReportEntry( first_platform=op.platform, second_platform=op.link.platform, @@ -421,18 +429,45 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: coin=op.coin, first_utc_time=op.utc_time, second_utc_time=op.link.utc_time, - first_fee_amount=op.link.change - op.change, - first_fee_coin=op.coin, - first_fee_in_fiat=self.price_data.get_cost(op), + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + remark=op.remark, + ) + else: + assert op.fees is None + report_entry = tr.DepositReportEntry( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + first_fee_amount=decimal.Decimal(), + first_fee_coin="", + first_fee_in_fiat=decimal.Decimal(), remark=op.remark, ) - self.tax_report_entries.append(report_entry) + + self.tax_report_entries.append(report_entry) elif isinstance(op, tr.Withdrawal): # Coins get moved to somewhere else. At this point, we only have # to remove them from the corresponding balance. op.withdrawn_coins = self.remove_from_balance(op) + if not op.has_link: + assert op.fees is None + report_entry = tr.WithdrawalReportEntry( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + first_fee_amount=decimal.Decimal(), + first_fee_coin="", + first_fee_in_fiat=decimal.Decimal(), + remark=op.remark, + ) + self.tax_report_entries.append(report_entry) + else: raise NotImplementedError @@ -617,12 +652,12 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: # # Sheets per ReportType # - for ReportType, tax_report_entries in misc.group_by( - self.tax_report_entries, "__class__" + for event_type, tax_report_entries in misc.group_by( + self.tax_report_entries, "event_type" ).items(): - assert issubclass(ReportType, tr.TaxReportEntry) + ReportType = type(tax_report_entries[0]) - ws = wb.add_worksheet(ReportType.event_type) + ws = wb.add_worksheet(event_type) # Header ws.write_row(0, 0, ReportType.excel_labels(), header_format) diff --git a/src/transaction.py b/src/transaction.py index b1d8d2cc..a07ecf4b 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -200,6 +200,7 @@ class Deposit(Transaction): class Withdrawal(Transaction): withdrawn_coins: Optional[list[SoldCoin]] = None + has_link: bool = False def partial_withdrawn_coins(self, percent: decimal.Decimal) -> list[SoldCoin]: assert self.withdrawn_coins @@ -230,7 +231,8 @@ def partial(self, percent: decimal.Decimal) -> SoldCoin: @dataclasses.dataclass class TaxReportEntry: - event_type = "virtual" + event_type: ClassVar[str] = "virtual" + allowed_missing_fields: ClassVar[list[str]] = [] first_platform: Optional[str] = None second_platform: Optional[str] = None @@ -342,7 +344,9 @@ def __post_init__(self) -> None: missing_field_values = [ field.name for label, field in zip(self.excel_labels(), self.excel_fields()) - if label != "-" and getattr(self, field.name) is None + if label != "-" + and getattr(self, field.name) is None + and field.name not in self.allowed_missing_fields ] assert not missing_field_values, ( f"{self=} : missing values for fields " f"{', '.join(missing_field_values)}" @@ -664,7 +668,7 @@ class CommissionReportEntry(AirdropReportEntry): class TransferReportEntry(TaxReportEntry): - event_type = "Transfer von Kryptowährung" + event_type = "Ein-& Auszahlungen" def __init__( self, @@ -722,6 +726,60 @@ def _labels(cls) -> list[str]: ] +class DepositReportEntry(TransferReportEntry): + allowed_missing_fields = ["second_platform", "second_utc_time"] + + def __init__( + self, + platform: str, + amount: decimal.Decimal, + coin: str, + utc_time: datetime.datetime, + first_fee_amount: decimal.Decimal, + first_fee_coin: str, + first_fee_in_fiat: decimal.Decimal, + remark: str, + ) -> None: + TaxReportEntry.__init__( + self, + first_platform=platform, + amount=amount, + coin=coin, + first_utc_time=utc_time, + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + remark=remark, + ) + + +class WithdrawalReportEntry(TransferReportEntry): + allowed_missing_fields = ["first_platform", "first_utc_time"] + + def __init__( + self, + platform: str, + amount: decimal.Decimal, + coin: str, + utc_time: datetime.datetime, + first_fee_amount: decimal.Decimal, + first_fee_coin: str, + first_fee_in_fiat: decimal.Decimal, + remark: str, + ) -> None: + TaxReportEntry.__init__( + self, + second_platform=platform, + amount=amount, + coin=coin, + second_utc_time=utc_time, + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + remark=remark, + ) + + gain_operations = [ CoinLendEnd, StakingEnd, From 235e30fe13497fd6c4a34c95f05cfefad06b82bb Mon Sep 17 00:00:00 2001 From: Jeppy Date: Thu, 5 May 2022 18:31:25 +0200 Subject: [PATCH 087/141] UPDATE some ReportEntry event_type names --- src/transaction.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index a07ecf4b..775d6665 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -602,15 +602,15 @@ def _labels(cls) -> list[str]: class LendingInterestReportEntry(InterestReportEntry): - event_type = "Coin-Lending" + event_type = "Coin-Lending Einkünfte" class StakingInterestReportEntry(InterestReportEntry): - event_type = "Staking" + event_type = "Staking Einkünfte" class AirdropReportEntry(TaxReportEntry): - event_type = "Airdrop" + event_type = "Airdrops" def __init__( self, From b46752eee8b0701ce59fa4a6673723a1cdf23ef4 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Fri, 6 May 2022 16:54:41 +0200 Subject: [PATCH 088/141] FIX Do not raise error when not enough home fiat in queue --- src/balance_queue.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 16621539..b715906e 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -20,6 +20,7 @@ import decimal from typing import Union +import config import log_config import transaction as tr @@ -197,20 +198,32 @@ def remove( if unsold_change: # Queue ran out of items to sell and not all coins could be sold. - log.error( + msg = ( f"Not enough {op.coin} in queue to sell: " f"missing {unsold_change} {op.coin} " f"(transaction from {op.utc_time} on {op.platform}, " f"see {op.file_path.name} lines {op.line})\n" - "\tThis error occurs when you sold more coins than you have " + f"This can happen when you sold more {op.coin} than you have " "according to your account statements. Have you added every " - "account statement, including these from the last years?\n" - "\tThis error may also occur after deposits from unknown " - "sources. CoinTaxman requires the full transaction history to " - "evaluate taxation (when and where were these deposited coins " - "bought?).\n" + "account statement including these from last years and the " + f"all deposits of {op.coin}?" ) - raise RuntimeError + if self.coin == config.FIAT: + log.warning( + f"{msg}\n" + "Tracking of your home fiat is not important for tax " + f"evaluation but the {op.coin} in your portfolio at " + "deadline will be wrong." + ) + else: + log.error( + f"{msg}\n" + "\tThis error may also occur after deposits from unknown " + "sources. CoinTaxman requires the full transaction history to " + "evaluate taxation (when and where were these deposited coins " + "bought?).\n" + ) + raise RuntimeError return sold_coins From f77d12d276885d0f462f181d70c80d7efae9c795 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 09:06:08 +0200 Subject: [PATCH 089/141] UPDATE Respect existing xlsx and log files when finding filename for export files --- src/main.py | 1 + src/misc.py | 11 ++++++++--- src/taxman.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main.py b/src/main.py index f27f4a53..dec8c827 100644 --- a/src/main.py +++ b/src/main.py @@ -59,6 +59,7 @@ def main() -> None: log_config.shutdown() os.rename(TMP_LOG_FILEPATH, log_file_path) + print(f"Detailed export saved at {evaluation_file_path} and {log_file_path}") print("If you want to archive the evaluation, run `make archive`.") diff --git a/src/misc.py b/src/misc.py index 7eecb4d8..e6b2f1c1 100644 --- a/src/misc.py +++ b/src/misc.py @@ -258,7 +258,9 @@ def is_fiat(symbol: Union[str, core.Fiat]) -> bool: return isinstance(symbol, core.Fiat) or symbol in core.Fiat.__members__ -def get_next_file_path(path: Path, base_filename: str, extension: str) -> Path: +def get_next_file_path( + path: Path, base_filename: str, extensions: Union[str, list[str]] +) -> Path: """Looking for the next free filename in format {base_filename}_revXXX. The revision number starts with 001 and will always be +1 from the highest @@ -267,7 +269,7 @@ def get_next_file_path(path: Path, base_filename: str, extension: str) -> Path: Args: path (Path) base_filename (str) - extension (str) + extension (Union[str, list[str]]) Raises: AssertitionError: When {base_filename}_rev999.{extension} @@ -277,7 +279,10 @@ def get_next_file_path(path: Path, base_filename: str, extension: str) -> Path: Path: Path to next free file. """ i = 1 - regex = re.compile(base_filename + r"_rev(\d{3})." + extension) + extensions = [extensions] if isinstance(extensions, str) else extensions + extension = extensions[0] + regex = re.compile(base_filename + r"_rev(\d{3}).(" + "|".join(extensions) + ")") + for p in path.iterdir(): if p.is_file(): if m := regex.match(p.name): diff --git a/src/taxman.py b/src/taxman.py index eab9db9f..ff640815 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -581,7 +581,7 @@ def export_evaluation_as_excel(self) -> Path: Path: Path to the exported file. """ file_path = misc.get_next_file_path( - config.EXPORT_PATH, str(config.TAX_YEAR), "xlsx" + config.EXPORT_PATH, str(config.TAX_YEAR), ["xlsx", "log"] ) wb = xlsxwriter.Workbook(file_path, {"remove_timezone": True}) datetime_format = wb.add_format({"num_format": "dd.mm.yyyy hh:mm;@"}) From 0769ef2c0e885d4044447b5f04e3b7b2e26cd84b Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 09:30:40 +0200 Subject: [PATCH 090/141] UPDATE Group portfolio at deadline depending on SINGLE or MULTI DEPOT method; Respect config option CALCULATE_UNREALIZED_GAINS --- config.ini | 5 +-- src/taxman.py | 89 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/config.ini b/config.ini index da465c3a..a53fe861 100644 --- a/config.ini +++ b/config.ini @@ -12,8 +12,9 @@ REFETCH_MISSING_PRICES = False # the price inbetween. # Important: The code must be run twice for this option to take effect. MEAN_MISSING_PRICES = False -# Calculate the (taxed) gains, if the left over coins would be sold right now. -# This will fetch the current prices and therefore slow down repetitive runs. +# Calculate the (taxed) gains, if the left over coins would be sold at taxation +# deadline (end of the year). If the evaluated TAX_YEAR is ongoing, this will +# fetch the current prices at runtime and therefore slow down repetitive runs. CALCULATE_UNREALIZED_GAINS = True # Evaluate taxes for each depot/platform separately. This may reduce your # taxable gains. Make sure, that this method is accepted by your tax diff --git a/src/taxman.py b/src/taxman.py index ff640815..346cf69e 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -53,9 +53,12 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self.price_data = price_data self.tax_report_entries: list[tr.TaxReportEntry] = [] - self.portfolio_at_deadline: dict[ + self.multi_depot_portfolio: dict[ str, dict[str, decimal.Decimal] ] = collections.defaultdict(lambda: collections.defaultdict(decimal.Decimal)) + self.single_depot_portfolio: dict[ + str, decimal.Decimal + ] = collections.defaultdict(decimal.Decimal) # Determine used functions/classes depending on the config. country = config.COUNTRY.name @@ -471,6 +474,36 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: else: raise NotImplementedError + def _evaluate_unrealized_sells(self) -> None: + """Evaluate the unrealized sells at taxation deadline.""" + for balance in self._balances.values(): + # Get all left over coins from the balance. + sold_coins = balance.remove_all() + for sc in sold_coins: + # Sum up the portfolio at deadline. + # If the evaluation was done with a virtual single depot, + # the values per platform might not match the real values at + # platform. + self.multi_depot_portfolio[sc.op.platform][sc.op.coin] += sc.sold + self.single_depot_portfolio[sc.op.coin] += sc.sold + + # "Sell" these coins which makes it possible to calculate the + # unrealized gain afterwards. + unrealized_sell = tr.Sell( + utc_time=TAX_DEADLINE, + platform=sc.op.platform, + change=sc.sold, + coin=sc.op.coin, + line=[-1], + file_path=Path(), + fees=None, + ) + self._evaluate_sell( + unrealized_sell, + sc, + ReportType=tr.UnrealizedSellReportEntry, + ) + ########################################################################### # General tax evaluation functions. ########################################################################### @@ -493,31 +526,13 @@ def evaluate_taxation(self) -> None: for operation in operations: self.__evaluate_taxation(operation) - # Evaluate the balance at deadline to calculate unrealized sells. + # Make sure, that all fees were paid. for balance in self._balances.values(): balance.sanity_check() - sold_coins = balance.remove_all() - for sc in sold_coins: - # Sum up the portfolio at deadline. - self.portfolio_at_deadline[sc.op.platform][sc.op.coin] += sc.sold - - # "Sell" these coins which makes it possible to calculate the - # unrealized gain afterwards. - unrealized_sell = tr.Sell( - utc_time=TAX_DEADLINE, - platform=sc.op.platform, - change=sc.sold, - coin=sc.op.coin, - line=[-1], - file_path=Path(), - fees=None, - ) - self._evaluate_sell( - unrealized_sell, - sc, - ReportType=tr.UnrealizedSellReportEntry, - ) + # Evaluate the balance at deadline to calculate unrealized sells. + if config.CALCULATE_UNREALIZED_GAINS: + self._evaluate_unrealized_sells() ########################################################################### # Export / Summary @@ -553,18 +568,24 @@ def print_evaluation(self) -> None: unrealized_taxable_gain = misc.dsum( tre.taxable_gain_in_fiat for tre in unrealized_report_entries ) - eval_str += ( - "----------------------------------------\n" - f"Unrealized gain: {unrealized_gain:.2f} {config.FIAT}\n" - "Unrealized taxable gain at deadline: " - f"{unrealized_taxable_gain:.2f} {config.FIAT}\n" - "----------------------------------------\n" - f"Your portfolio on {TAX_DEADLINE.strftime('%x')} was:\n" - ) - for platform, platform_portfolio in self.portfolio_at_deadline.items(): - for coin, amount in platform_portfolio.items(): - eval_str += f"{platform} {coin}: {amount:.2f}\n" + if config.CALCULATE_UNREALIZED_GAINS: + eval_str += ( + "----------------------------------------\n" + f"Unrealized gain: {unrealized_gain:.2f} {config.FIAT}\n" + "Unrealized taxable gain at deadline: " + f"{unrealized_taxable_gain:.2f} {config.FIAT}\n" + "----------------------------------------\n" + f"Your portfolio on {TAX_DEADLINE.strftime('%x')} was:\n" + ) + + if config.MULTI_DEPOT: + for platform, platform_portfolio in self.multi_depot_portfolio.items(): + for coin, amount in platform_portfolio.items(): + eval_str += f"{platform} {coin}: {amount:.2f}\n" + else: + for coin, amount in self.single_depot_portfolio.items(): + eval_str += f"{coin}: {amount:.2f}\n" log.info(eval_str) From 7d935aa386eba17fb2512c22b474416098bb64a4 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 10:16:28 +0200 Subject: [PATCH 091/141] CHANGE Export dates in LOCAL_TIMEZONE --- requirements.txt | 1 + src/config.py | 4 +++- src/taxman.py | 6 +++++- src/transaction.py | 28 +++++++++++++++++----------- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/requirements.txt b/requirements.txt index 22ff74ee..26b32923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ requests==2.25.1 six==1.15.0 urllib3==1.26.5 xlsxwriter==3.0.3 +tzdata==2022.1 diff --git a/src/config.py b/src/config.py index a7574aac..6b1aca85 100644 --- a/src/config.py +++ b/src/config.py @@ -17,6 +17,7 @@ import configparser import datetime as dt import locale +import zoneinfo from os import environ from pathlib import Path @@ -66,7 +67,8 @@ if COUNTRY == core.Country.GERMANY: FIAT_CLASS = core.Fiat.EUR PRINCIPLE = core.Principle.FIFO - LOCAL_TIMEZONE = dt.datetime.now(dt.timezone.utc).astimezone().tzinfo + LOCAL_TIMEZONE = zoneinfo.ZoneInfo("CET") + LOCAL_TIMEZONE_KEY = "MEZ" locale_str = "de_DE" def IS_LONG_TERM(buy: dt.datetime, sell: dt.datetime) -> bool: diff --git a/src/taxman.py b/src/taxman.py index 346cf69e..e1314dfe 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -637,13 +637,17 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ws_general.write_row(0, 0, ["Allgemeine Daten"], header_format) ws_general.write_row(1, 0, ["Stichtag", TAX_DEADLINE.date()], date_format) ws_general.write_row( - 2, 0, ["Erstellt am", datetime.datetime.now()], datetime_format + 2, + 0, + ["Erstellt am", datetime.datetime.now(config.LOCAL_TIMEZONE)], + datetime_format, ) ws_general.write_row( 3, 0, ["Software", "CoinTaxman "] ) commit_hash = misc.get_current_commit_hash(default="undetermined") ws_general.write_row(4, 0, ["Commit", commit_hash]) + ws_general.write_row(5, 0, ["Alle Zeiten in", config.LOCAL_TIMEZONE_KEY]) # Set column format and freeze first row. ws_general.freeze_panes(1, 0) diff --git a/src/transaction.py b/src/transaction.py index 775d6665..2e4ea59b 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import ClassVar, Iterator, Optional +import config import log_config import misc @@ -393,7 +394,12 @@ def excel_labels(self) -> list[str]: return [label for label in self.labels() if self.is_excel_label(label)] def excel_values(self) -> Iterator: - return (getattr(self, f) for f in self.field_names() if self.is_excel_label(f)) + for f in self.field_names(): + if self.is_excel_label(f): + value = getattr(self, f) + if isinstance(value, datetime.datetime): + value = value.astimezone(config.LOCAL_TIMEZONE) + yield value # Bypass dataclass machinery, add a custom property function to a dataclass field. @@ -416,8 +422,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Wiedererhalten am (UTC)", - "Verliehen am (UTC)", + "Wiedererhalten am", + "Verliehen am", # "-", "-", @@ -493,8 +499,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Verkaufsdatum (UTC)", - "Erwerbsdatum (UTC)", + "Verkaufsdatum", + "Erwerbsdatum", # "(1) Anzahl Transaktionsgebühr", "(1) Währung Transaktionsgebühr", @@ -526,8 +532,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Virtuelles Verkaufsdatum (UTC)", - "Erwerbsdatum (UTC)", + "Virtuelles Verkaufsdatum", + "Erwerbsdatum", # "(1) Anzahl Transaktionsgebühr", "(1) Währung Transaktionsgebühr", @@ -580,7 +586,7 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Erhalten am (UTC)", + "Erhalten am", "-", # "-", @@ -642,7 +648,7 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Erhalten am (UTC)", + "Erhalten am", "-", # "-", @@ -705,8 +711,8 @@ def _labels(cls) -> list[str]: "Anzahl", "Währung", # - "Eingangsdatum (UTC)", - "Ausgangsdatum (UTC)", + "Eingangsdatum", + "Ausgangsdatum", # "(1) Anzahl Transaktionsgebühr", "(1) Währung Transaktionsgebühr", From 885a2ccce18037f452a984434b50231910b248c2 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 10:19:39 +0200 Subject: [PATCH 092/141] RENAME additional_fee parameter to add_fee_in_fiat --- src/taxman.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index e1314dfe..357a5975 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -130,7 +130,7 @@ def _evaluate_sell( self, op: tr.Sell, sc: tr.SoldCoin, - additional_fee: Optional[decimal.Decimal] = None, + add_fee_in_fiat: Optional[decimal.Decimal] = None, ReportType: Type[tr.SellReportEntry] = tr.SellReportEntry, ) -> None: """Evaluate a (partial) sell operation. @@ -138,7 +138,7 @@ def _evaluate_sell( Args: op (tr.Sell): The sell operation. sc (tr.SoldCoin): The sold coin. - additional_fee (Optional[decimal.Decimal], optional): + add_fee_in_fiat (Optional[decimal.Decimal], optional): The additional fee. Defaults to None. ReportType (Type[tr.SellReportEntry], optional): The type of the report entry. Defaults to tr.SellReportEntry. @@ -147,8 +147,8 @@ def _evaluate_sell( NotImplementedError: When there are more than two different fee coins. """ assert op.coin == sc.op.coin - if additional_fee is None: - additional_fee = decimal.Decimal() + if add_fee_in_fiat is None: + add_fee_in_fiat = decimal.Decimal() # Share the fees and sell_value proportionally to the coins sold. percent = sc.sold / op.change @@ -183,7 +183,7 @@ def _evaluate_sell( ) # buy_value_in_fiat - buy_value_in_fiat = self.price_data.get_cost(sc) + buying_fees + additional_fee + buy_value_in_fiat = self.price_data.get_cost(sc) + buying_fees + add_fee_in_fiat # TODO Recognized increased speculation period for lended/staked coins? is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) From 9f951f5255c89b5945365591bb549cd27ce21878 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 11:06:53 +0200 Subject: [PATCH 093/141] REFACTOR Calculation of buy cost for taxation - ADD BUG message - REMOVE additional_fee which is currently unused --- src/taxman.py | 70 ++++++++++++++++++++++++++++++++-------------- src/transaction.py | 4 +-- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 357a5975..bafa85cf 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -126,20 +126,60 @@ def _evaluate_fee( self.price_data.get_partial_cost(fee, percent), ) + def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: + """Calculate the buy cost of a sold coin. + + Args: + sc (tr.SoldCoin): The sold coin. + + Raises: + NotImplementedError: Calculation is currently not implemented + for buy operations. + + Returns: + decimal.Decimal: The buy value of the sold coin in fiat + """ + # Fees paid when buying the now sold coins. + buying_fees = decimal.Decimal() + if sc.op.fees: + assert sc.sold <= sc.op.change + sc_percent = sc.sold / sc.op.change + buying_fees = misc.dsum( + self.price_data.get_partial_cost(f, sc_percent) for f in sc.op.fees + ) + + if isinstance(sc.op, tr.Buy): + # BUG Buy cost of a bought coin should be the sell value of the + # previously sold coin and not the sell value of the bought coin. + # Gains of combinations like below are not correctly calculated: + # 1 BTC=1€, 1ETH=2€, 1BTC=1ETH + # e.g. buy 1 BTC for 1 €, buy 1 ETH for 1 BTC, buy 2 € for 1 ETH. + # Program sees - 1 €, +1 BTC, -1 BTC, +1 ETH, -1 ETH, +2 € + # gains from holding x, 1-1€, 2-2€ + # gains from "fortunate" trade 1BTC=1ETH are not recognized + # TODO Matching of buy and sell pairs ( trades ) necessary. + # Currently not implemented? But kind of! + buy_value = self.price_data.get_cost(sc) + else: + # All other operations "begin their existence" as that coin and + # weren't traded/exchanged before. + # The buy cost of these coins is the value from when yout got them. + buy_value = self.price_data.get_cost(sc) + + return buy_value + buying_fees + def _evaluate_sell( self, op: tr.Sell, sc: tr.SoldCoin, - add_fee_in_fiat: Optional[decimal.Decimal] = None, ReportType: Type[tr.SellReportEntry] = tr.SellReportEntry, ) -> None: """Evaluate a (partial) sell operation. Args: - op (tr.Sell): The sell operation. - sc (tr.SoldCoin): The sold coin. - add_fee_in_fiat (Optional[decimal.Decimal], optional): - The additional fee. Defaults to None. + op (tr.Sell): The general sell operation. + sc (tr.SoldCoin): The specific sold coins with their origin (sc.op). + `sc.sold` can be a partial sell of `op.change`. ReportType (Type[tr.SellReportEntry], optional): The type of the report entry. Defaults to tr.SellReportEntry. @@ -147,8 +187,7 @@ def _evaluate_sell( NotImplementedError: When there are more than two different fee coins. """ assert op.coin == sc.op.coin - if add_fee_in_fiat is None: - add_fee_in_fiat = decimal.Decimal() + assert op.change >= sc.sold # Share the fees and sell_value proportionally to the coins sold. percent = sc.sold / op.change @@ -173,17 +212,7 @@ def _evaluate_sell( else: raise NotImplementedError("More than two fee coins are not supported") - # buying_fees - buying_fees = decimal.Decimal() - if sc.op.fees: - assert sc.sold <= sc.op.change - sc_percent = sc.sold / sc.op.change - buying_fees = misc.dsum( - self.price_data.get_partial_cost(f, sc_percent) for f in sc.op.fees - ) - - # buy_value_in_fiat - buy_value_in_fiat = self.price_data.get_cost(sc) + buying_fees + add_fee_in_fiat + buy_cost_in_fiat = self.get_buy_cost(sc) # TODO Recognized increased speculation period for lended/staked coins? is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) @@ -222,7 +251,7 @@ def _evaluate_sell( second_fee_coin=second_fee_coin, second_fee_in_fiat=second_fee_in_fiat, sell_value_in_fiat=sell_value_in_fiat, - buy_value_in_fiat=buy_value_in_fiat, + buy_cost_in_fiat=buy_cost_in_fiat, is_taxable=is_taxable, taxation_type="Sonstige Einkünfte", remark=op.remark, @@ -253,7 +282,6 @@ def evaluate_sell( wsc_percent = wsc.sold / sc.op.link.change wsc_deposit_fee = sold_deposit_fee * wsc_percent - wsc_fee_in_fiat = decimal.Decimal() if wsc_deposit_fee: # TODO Are withdrawal/deposit fees tax relevant? log.warning( @@ -274,7 +302,7 @@ def evaluate_sell( # * wsc_deposit_fee # ) - self._evaluate_sell(op, wsc, wsc_fee_in_fiat) + self._evaluate_sell(op, wsc) else: diff --git a/src/transaction.py b/src/transaction.py index 2e4ea59b..1ed5b48c 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -465,7 +465,7 @@ def __init__( second_fee_coin: str, second_fee_in_fiat: decimal.Decimal, sell_value_in_fiat: decimal.Decimal, - buy_value_in_fiat: decimal.Decimal, + buy_cost_in_fiat: decimal.Decimal, is_taxable: bool, taxation_type: str, remark: str, @@ -484,7 +484,7 @@ def __init__( second_fee_coin=second_fee_coin, second_fee_in_fiat=second_fee_in_fiat, first_value_in_fiat=sell_value_in_fiat, - second_value_in_fiat=buy_value_in_fiat, + second_value_in_fiat=buy_cost_in_fiat, is_taxable=is_taxable, taxation_type=taxation_type, remark=remark, From 2f5a73f8ad3990a28e6b916b996d175f0b82cbd2 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 11:46:12 +0200 Subject: [PATCH 094/141] ADD link of trading pairs between buy and sell --- src/book.py | 6 +++++- src/main.py | 5 ++++- src/transaction.py | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/book.py b/src/book.py index 6cee08b1..a8421311 100644 --- a/src/book.py +++ b/src/book.py @@ -1499,7 +1499,7 @@ def merge_identical_operations(self) -> None: grouped_ops = misc.group_by(self.operations, tr.Operation.identical_columns) self.operations = [tr.Operation.merge(*ops) for ops in grouped_ops.values()] - def match_fees_with_operations(self) -> None: + def match_fees_and_resolve_trades(self) -> None: # Split operations in fees and other operations. operations = [] all_fees: list[tr.Fee] = [] @@ -1553,6 +1553,10 @@ def match_fees_with_operations(self) -> None: assert self.operations[buy_idx].fees is None self.operations[sell_idx].fees = fees self.operations[buy_idx].fees = fees + # Add link that this is a trade pair. + self.operations[ # type: ignore[attr-defined] + buy_idx + ].link = self.operations[sell_idx] else: log.warning( "Fee matching is not implemented for this case. " diff --git a/src/main.py b/src/main.py index dec8c827..07691216 100644 --- a/src/main.py +++ b/src/main.py @@ -48,7 +48,10 @@ def main() -> None: # necessary to correctly fetch prices and to calculate p/l. book.resolve_deposits() book.get_price_from_csv() - book.match_fees_with_operations() + # Match fees with operations AND + # Resolve dependencies between sells and buys, which is + # necessary to correctly calculate the buying cost of a sold coin + book.match_fees_and_resolve_trades() taxman.evaluate_taxation() evaluation_file_path = taxman.export_evaluation_as_excel() diff --git a/src/transaction.py b/src/transaction.py index 1ed5b48c..59fa8deb 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -169,12 +169,12 @@ class Transaction(Operation): pass -class Buy(Transaction): +class Sell(Transaction): pass -class Sell(Transaction): - pass +class Buy(Transaction): + link: Optional[Sell] = None class CoinLendInterest(Transaction): From aec4dff9407586a765862fd7d04e3fa7c82c3e80 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 12:02:27 +0200 Subject: [PATCH 095/141] FIX Buy/Sell types in fee matching interchanged --- src/book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index a8421311..be6791db 100644 --- a/src/book.py +++ b/src/book.py @@ -1547,8 +1547,8 @@ def match_fees_and_resolve_trades(self) -> None: # and which gets removed from the account balance # 2. Fees on buys increase the buy-in price of the coins # which is relevant when selling these (not buying) - (sell_idx,) = t_op[tr.Buy.type_name_c()] - (buy_idx,) = t_op[tr.Sell.type_name_c()] + (sell_idx,) = t_op[tr.Sell.type_name_c()] + (buy_idx,) = t_op[tr.Buy.type_name_c()] assert self.operations[sell_idx].fees is None assert self.operations[buy_idx].fees is None self.operations[sell_idx].fees = fees From 14ca9132d53d83e79e47950ae397e4480099b2ea Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 12:30:12 +0200 Subject: [PATCH 096/141] FIX Resolve all trades (not only trades with fees) --- src/book.py | 37 ++++++++++++++++++++++++++++++++----- src/main.py | 3 ++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/book.py b/src/book.py index be6791db..ffacf3d0 100644 --- a/src/book.py +++ b/src/book.py @@ -1499,7 +1499,7 @@ def merge_identical_operations(self) -> None: grouped_ops = misc.group_by(self.operations, tr.Operation.identical_columns) self.operations = [tr.Operation.merge(*ops) for ops in grouped_ops.values()] - def match_fees_and_resolve_trades(self) -> None: + def match_fees(self) -> None: # Split operations in fees and other operations. operations = [] all_fees: list[tr.Fee] = [] @@ -1553,10 +1553,6 @@ def match_fees_and_resolve_trades(self) -> None: assert self.operations[buy_idx].fees is None self.operations[sell_idx].fees = fees self.operations[buy_idx].fees = fees - # Add link that this is a trade pair. - self.operations[ # type: ignore[attr-defined] - buy_idx - ].link = self.operations[sell_idx] else: log.warning( "Fee matching is not implemented for this case. " @@ -1566,6 +1562,37 @@ def match_fees_and_resolve_trades(self) -> None: f"{matching_operations=}\n{fees=}" ) + def resolve_trades(self) -> None: + # Match trades which belong together (traded at same time). + for platform, _operations in misc.group_by(self.operations, "platform").items(): + for utc_time, matching_operations in misc.group_by( + _operations, "utc_time" + ).items(): + # Count matching operations by type with dict + # { operation typename: list of operations } + t_op = collections.defaultdict(list) + for op in matching_operations: + t_op[op.type_name].append(op) + + # Check if this is a buy/sell-pair. + # Fees might occure by other operation types, + # but this is currently not implemented. + is_buy_sell_pair = all( + ( + len(matching_operations) == 2, + len(t_op[tr.Buy.type_name_c()]) == 1, + len(t_op[tr.Sell.type_name_c()]) == 1, + ) + ) + if is_buy_sell_pair: + # Add link that this is a trade pair. + (sell_op,) = t_op[tr.Sell.type_name_c()] + assert isinstance(sell_op, tr.Sell) + (buy_op,) = t_op[tr.Buy.type_name_c()] + assert isinstance(buy_op, tr.Buy) + assert buy_op.link is None + buy_op.link = sell_op + def read_file(self, file_path: Path) -> None: """Import transactions form an account statement. diff --git a/src/main.py b/src/main.py index 07691216..692810ca 100644 --- a/src/main.py +++ b/src/main.py @@ -51,7 +51,8 @@ def main() -> None: # Match fees with operations AND # Resolve dependencies between sells and buys, which is # necessary to correctly calculate the buying cost of a sold coin - book.match_fees_and_resolve_trades() + book.match_fees() + book.resolve_trades() taxman.evaluate_taxation() evaluation_file_path = taxman.export_evaluation_as_excel() From 285866ea9d3a010e42378f5bd0cdd95ff02a74cf Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 12:30:51 +0200 Subject: [PATCH 097/141] FIX Calculate buying cost with selling value of previous trade --- src/taxman.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index bafa85cf..b89042d1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -139,27 +139,37 @@ def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: Returns: decimal.Decimal: The buy value of the sold coin in fiat """ + assert sc.sold <= sc.op.change + percent = sc.sold / sc.op.change + # Fees paid when buying the now sold coins. buying_fees = decimal.Decimal() if sc.op.fees: - assert sc.sold <= sc.op.change - sc_percent = sc.sold / sc.op.change buying_fees = misc.dsum( - self.price_data.get_partial_cost(f, sc_percent) for f in sc.op.fees + self.price_data.get_partial_cost(f, percent) for f in sc.op.fees ) if isinstance(sc.op, tr.Buy): - # BUG Buy cost of a bought coin should be the sell value of the + # Buy cost of a bought coin should be the sell value of the # previously sold coin and not the sell value of the bought coin. # Gains of combinations like below are not correctly calculated: # 1 BTC=1€, 1ETH=2€, 1BTC=1ETH # e.g. buy 1 BTC for 1 €, buy 1 ETH for 1 BTC, buy 2 € for 1 ETH. - # Program sees - 1 €, +1 BTC, -1 BTC, +1 ETH, -1 ETH, +2 € - # gains from holding x, 1-1€, 2-2€ - # gains from "fortunate" trade 1BTC=1ETH are not recognized - # TODO Matching of buy and sell pairs ( trades ) necessary. - # Currently not implemented? But kind of! - buy_value = self.price_data.get_cost(sc) + if sc.op.link is None: + log.warning( + "Unable to correctly determine buy cost of bought coins " + "because the link to the corresponding previous sell could " + "not be estalished. Buying cost will be set to the buy " + "value of the bought coins instead of the sell value of the " + "previously sold coins of the trade. " + "The calculated buy cost might be wrong. " + "This may lead to a false tax evaluation.\n" + f"{sc.op}" + ) + buy_value = self.price_data.get_cost(sc) + else: + prev_sell_value = self.price_data.get_partial_cost(sc.op.link, percent) + buy_value = prev_sell_value else: # All other operations "begin their existence" as that coin and # weren't traded/exchanged before. From 735359a1c9ac6d08c281a587120fe65e6f61e424 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 12:50:54 +0200 Subject: [PATCH 098/141] ADD Show buys in export file --- src/taxman.py | 72 +++++++++++++++++++++++++++++----------------- src/transaction.py | 67 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 28 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index b89042d1..141f7cc6 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -178,6 +178,37 @@ def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: return buy_value + buying_fees + def _get_fee_param_dict(self, op: tr.Operation, percent: decimal.Decimal) -> dict: + + # fee amount/coin/in_fiat + first_fee_amount = decimal.Decimal(0) + first_fee_coin = "" + first_fee_in_fiat = decimal.Decimal(0) + second_fee_amount = decimal.Decimal(0) + second_fee_coin = "" + second_fee_in_fiat = decimal.Decimal(0) + if op.fees is None or len(op.fees) == 0: + pass + elif len(op.fees) >= 1: + first_fee_amount, first_fee_coin, first_fee_in_fiat = self._evaluate_fee( + op.fees[0], percent + ) + elif len(op.fees) >= 2: + second_fee_amount, second_fee_coin, second_fee_in_fiat = self._evaluate_fee( + op.fees[1], percent + ) + else: + raise NotImplementedError("More than two fee coins are not supported") + + return dict( + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + second_fee_amount=second_fee_amount, + second_fee_coin=second_fee_coin, + second_fee_in_fiat=second_fee_in_fiat, + ) + def _evaluate_sell( self, op: tr.Sell, @@ -202,26 +233,7 @@ def _evaluate_sell( # Share the fees and sell_value proportionally to the coins sold. percent = sc.sold / op.change - # fee amount/coin/in_fiat - first_fee_amount = decimal.Decimal(0) - first_fee_coin = "" - first_fee_in_fiat = decimal.Decimal(0) - second_fee_amount = decimal.Decimal(0) - second_fee_coin = "" - second_fee_in_fiat = decimal.Decimal(0) - if op.fees is None or len(op.fees) == 0: - pass - elif len(op.fees) >= 1: - first_fee_amount, first_fee_coin, first_fee_in_fiat = self._evaluate_fee( - op.fees[0], percent - ) - elif len(op.fees) >= 2: - second_fee_amount, second_fee_coin, second_fee_in_fiat = self._evaluate_fee( - op.fees[1], percent - ) - else: - raise NotImplementedError("More than two fee coins are not supported") - + fee_params = self._get_fee_param_dict(op, percent) buy_cost_in_fiat = self.get_buy_cost(sc) # TODO Recognized increased speculation period for lended/staked coins? @@ -254,12 +266,7 @@ def _evaluate_sell( coin=op.coin, sell_utc_time=op.utc_time, buy_utc_time=sc.op.utc_time, - first_fee_amount=first_fee_amount, - first_fee_coin=first_fee_coin, - first_fee_in_fiat=first_fee_in_fiat, - second_fee_amount=second_fee_amount, - second_fee_coin=second_fee_coin, - second_fee_in_fiat=second_fee_in_fiat, + **fee_params, sell_value_in_fiat=sell_value_in_fiat, buy_cost_in_fiat=buy_cost_in_fiat, is_taxable=is_taxable, @@ -366,6 +373,19 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # For now we'll just add our bought coins to the balance. self.add_to_balance(op) + # Add tp export for informational purpose. + fee_params = self._get_fee_param_dict(op, decimal.Decimal(1)) + tax_report_entry = tr.BuyReportEntry( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + **fee_params, + buy_value_in_fiat=self.price_data.get_cost(op), + remark=op.remark, + ) + self.tax_report_entries.append(tax_report_entry) + elif isinstance(op, tr.Sell): # Buys and sells always come in a pair. The selling/redeeming # time is tax relevant. diff --git a/src/transaction.py b/src/transaction.py index 59fa8deb..ff5e05c3 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -494,7 +494,7 @@ def __init__( def _labels(cls) -> list[str]: return [ "Verkauf auf Börse", - "Erworben von Börse", + "Erworben auf Börse", # "Anzahl", "Währung", @@ -527,7 +527,7 @@ class UnrealizedSellReportEntry(SellReportEntry): def _labels(cls) -> list[str]: return [ "Virtueller Verkauf auf Börse", - "Erworben von Börse", + "Erworben auf Börse", # "Anzahl", "Währung", @@ -553,6 +553,69 @@ def _labels(cls) -> list[str]: ] +class BuyReportEntry(TaxReportEntry): + event_type = "Kauf" + + def __init__( + self, + platform: str, + amount: decimal.Decimal, + coin: str, + utc_time: datetime.datetime, + first_fee_amount: decimal.Decimal, + first_fee_coin: str, + first_fee_in_fiat: decimal.Decimal, + second_fee_amount: decimal.Decimal, + second_fee_coin: str, + second_fee_in_fiat: decimal.Decimal, + buy_value_in_fiat: decimal.Decimal, + remark: str, + ) -> None: + super().__init__( + second_platform=platform, + amount=amount, + coin=coin, + second_utc_time=utc_time, + first_fee_amount=first_fee_amount, + first_fee_coin=first_fee_coin, + first_fee_in_fiat=first_fee_in_fiat, + second_fee_amount=second_fee_amount, + second_fee_coin=second_fee_coin, + second_fee_in_fiat=second_fee_in_fiat, + second_value_in_fiat=buy_value_in_fiat, + remark=remark, + ) + + @classmethod + def _labels(cls) -> list[str]: + return [ + "-", + "Erworben auf Börse", + # + "Anzahl", + "Währung", + # + "-", + "Erwerbsdatum", + # + "(1) Anzahl Transaktionsgebühr", + "(1) Währung Transaktionsgebühr", + "(1) Transaktionsgebühr in EUR", + "(2) Anzahl Transaktionsgebühr", + "(2) Währung Transaktionsgebühr", + "(2) Transaktionsgebühr in EUR", + # + "-", + "Kaufpreis in EUR", + "Gesamt Transaktionsgebühr in EUR", + # + "Anschaffungskosten in EUR", + "-", + "-", + "Bemerkung", + ] + + class InterestReportEntry(TaxReportEntry): event_type = "Zinsen" From 5a86940b0d3347b571e5ec816ba93d0d022c39f4 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 12:52:11 +0200 Subject: [PATCH 099/141] FIX linting error, remove unused variables --- src/book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index ffacf3d0..7f7f727d 100644 --- a/src/book.py +++ b/src/book.py @@ -1564,8 +1564,8 @@ def match_fees(self) -> None: def resolve_trades(self) -> None: # Match trades which belong together (traded at same time). - for platform, _operations in misc.group_by(self.operations, "platform").items(): - for utc_time, matching_operations in misc.group_by( + for _, _operations in misc.group_by(self.operations, "platform").items(): + for _, matching_operations in misc.group_by( _operations, "utc_time" ).items(): # Count matching operations by type with dict From b84426d4fd728a8ffe8c99c2b205eb5729c6c152 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 19:37:09 +0200 Subject: [PATCH 100/141] FIX typo --- config.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.ini b/config.ini index a53fe861..95b51326 100644 --- a/config.ini +++ b/config.ini @@ -3,8 +3,8 @@ COUNTRY = GERMANY TAX_YEAR = 2021 # Missing prices are set as 0 in the database. -# Always refetching zeroes only slows down the evaluation, but at some time. -# It might be a good idea, to try to refetch missing prices. +# Always refetching zeroes only slows down the evaluation, but at some time, +# it might be a good idea, to try to refetch missing prices. # If you calculated the mean before, this has no effect. REFETCH_MISSING_PRICES = False # If the price for a coin is missing, check if there are known prices before From 5a80bc802708b27367b0f65c72d73b6478d7ecec Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 20:07:07 +0200 Subject: [PATCH 101/141] ADD excel autofilter to export file --- src/taxman.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index 141f7cc6..22d7496f 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -743,8 +743,11 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ws = wb.add_worksheet(event_type) # Header - ws.write_row(0, 0, ReportType.excel_labels(), header_format) + labels = ReportType.excel_labels() + ws.write_row(0, 0, labels, header_format) + # Set height ws.set_row(0, 45) + ws.autofilter(f"A1:{misc.column_num_to_string(len(labels))}1") # Data for row, entry in enumerate(tax_report_entries, 1): From c84a2992bb00dff4a5e06235b2e9a418c1197520 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 20:24:17 +0200 Subject: [PATCH 102/141] RENAME UnrealizedSellReportEntry.event_type --- src/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index ff5e05c3..5e28227a 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -521,7 +521,7 @@ def _labels(cls) -> list[str]: class UnrealizedSellReportEntry(SellReportEntry): - event_type = "Offene Positionen" + event_type = "Unrealizierter Gewinn-Verlust" @classmethod def _labels(cls) -> list[str]: From 8361afbccf5f0512384b359bfe3f9da4bc254c33 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 20:25:09 +0200 Subject: [PATCH 103/141] FIX remove empty row from excel export summary --- src/taxman.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 22d7496f..aecc03d1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -717,9 +717,10 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: 0, 0, ["Einkunftsart", "steuerbarer Betrag in EUR"], header_format ) ws_summary.set_row(0, 30) - for row, (taxation_type, tax_report_entries) in enumerate( - misc.group_by(self.tax_report_entries, "taxation_type").items(), 1 - ): + row = 1 + for taxation_type, tax_report_entries in misc.group_by( + self.tax_report_entries, "taxation_type" + ).items(): if taxation_type is None: continue taxable_gain = misc.dsum( @@ -728,6 +729,7 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: if not isinstance(tre, tr.UnrealizedSellReportEntry) ) ws_summary.write_row(row, 0, [taxation_type, taxable_gain]) + row += 1 # Set column format and freeze first row. ws_summary.set_column("B:B", None, fiat_format) ws_summary.freeze_panes(1, 0) From cd7e08c96626837e97c11afc63dde73df2a88056 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 21:25:43 +0200 Subject: [PATCH 104/141] UPDATE improve column width of exported xlsx file --- src/taxman.py | 28 ++++++++++++++++------------ src/transaction.py | 30 +++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index aecc03d1..10d0ea22 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -686,13 +686,11 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: return change_format return None - # TODO Increase width of columns. Autoresize? - # # General # ws_general = wb.add_worksheet("Allgemein") - ws_general.write_row(0, 0, ["Allgemeine Daten"], header_format) + ws_general.merge_range(0, 0, 0, 1, "Allgemeine Daten", header_format) ws_general.write_row(1, 0, ["Stichtag", TAX_DEADLINE.date()], date_format) ws_general.write_row( 2, @@ -707,6 +705,8 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ws_general.write_row(4, 0, ["Commit", commit_hash]) ws_general.write_row(5, 0, ["Alle Zeiten in", config.LOCAL_TIMEZONE_KEY]) # Set column format and freeze first row. + ws_general.set_column(0, 0, 13) + ws_general.set_column(1, 1, 20) ws_general.freeze_panes(1, 0) # @@ -731,7 +731,8 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ws_summary.write_row(row, 0, [taxation_type, taxable_gain]) row += 1 # Set column format and freeze first row. - ws_summary.set_column("B:B", None, fiat_format) + ws_summary.set_column(0, 0, 35) + ws_summary.set_column(1, 1, 13, fiat_format) ws_summary.freeze_panes(1, 0) # @@ -756,14 +757,17 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ws.write_row(row, 0, entry.excel_values()) # Set column format and freeze first row. - for col, field in enumerate(ReportType.excel_fields(), 1): - if cell_format := get_format(field): - column = misc.column_num_to_string(col) - ws.set_column( - f"{column}:{column}", - None, - cell_format, - ) + for col, (field, width, hidden) in enumerate( + ReportType.excel_field_and_width() + ): + cell_format = get_format(field) + ws.set_column( + col, + col, + width, + cell_format, + dict(hidden=hidden), + ) ws.freeze_panes(1, 0) wb.close() diff --git a/src/transaction.py b/src/transaction.py index 5e28227a..aad2ae7b 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -291,7 +291,7 @@ def _gain_in_fiat(self) -> Optional[decimal.Decimal]: def _taxable_gain_in_fiat(self) -> Optional[decimal.Decimal]: if self.is_taxable and self._gain_in_fiat: return self._gain_in_fiat - if self.get_label("taxable_gain_in_fiat") == "-": + if self.get_excel_label("taxable_gain_in_fiat") == "-": return None return decimal.Decimal() @@ -352,6 +352,7 @@ def __post_init__(self) -> None: assert not missing_field_values, ( f"{self=} : missing values for fields " f"{', '.join(missing_field_values)}" ) + assert len(self.excel_labels()) == len(self.excel_fields()) @classmethod def fields(cls) -> tuple[dataclasses.Field, ...]: @@ -372,8 +373,9 @@ def labels(cls) -> list[str]: return labels @classmethod - def get_label(cls, field_name: str) -> str: - for label, field in zip(cls.labels(), cls.fields()): + def get_excel_label(cls, field_name: str) -> str: + assert len(cls.excel_labels()) == len(cls.excel_fields()) + for label, field in zip(cls.excel_labels(), cls.excel_fields()): if field.name == field_name: return label raise ValueError(f"{field_name} is not a field of {cls=}") @@ -393,6 +395,28 @@ def excel_fields(cls) -> tuple[dataclasses.Field, ...]: def excel_labels(self) -> list[str]: return [label for label in self.labels() if self.is_excel_label(label)] + @classmethod + def excel_field_and_width(cls) -> Iterator[tuple[dataclasses.Field, float, bool]]: + for field in cls.fields(): + if cls.is_excel_label(field.name): + label = cls.get_excel_label(field.name) + if label == "-": + width = 15.0 + elif field.name == "taxation_type": + width = 35.0 + elif field.name == "taxable_gain_in_fiat": + width = 13.0 + elif field.name.endswith("_in_fiat") or "coin" in field.name: + width = 15.0 + elif field.type in ("datetime.datetime", "Optional[datetime.datetime]"): + width = 18.43 + elif field.type in ("decimal.Decimal", "Optional[decimal.Decimal]"): + width = 20.0 + else: + width = 18.0 + hidden = label == "-" + yield field, width, hidden + def excel_values(self) -> Iterator: for f in self.field_names(): if self.is_excel_label(f): From 4a808439674cc0609a56fdbad038b835d834cf4a Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 21:31:19 +0200 Subject: [PATCH 105/141] RENAME CommissionReportEntry.event_type --- src/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index aad2ae7b..2cc231b1 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -757,7 +757,7 @@ def _labels(cls) -> list[str]: class CommissionReportEntry(AirdropReportEntry): - event_type = "Kommission" # TODO gibt es eine bessere Bezeichnung? + event_type = "Werbeprämien" class TransferReportEntry(TaxReportEntry): From a3e8bbaf2ae6669d236977a37b7471aa979edee9 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 21:35:13 +0200 Subject: [PATCH 106/141] RM deprecated TODOs and refactor remaining TODos --- src/price_data.py | 6 ------ src/taxman.py | 3 +-- src/transaction.py | 9 ++++----- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/price_data.py b/src/price_data.py index 9c137dcd..59b239e0 100644 --- a/src/price_data.py +++ b/src/price_data.py @@ -35,12 +35,6 @@ log = log_config.getLogger(__name__) -# TODO Keep database connection open? -# TODO Combine multiple exchanges in one file? -# - Add a database for each exchange (added with ATTACH DATABASE) -# - Tables in database stay the same - - class FallbackPriceNotFound(Exception): pass diff --git a/src/taxman.py b/src/taxman.py index 10d0ea22..34cf65b5 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -84,8 +84,7 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self._balances: dict[Any, balance_queue.BalanceQueue] = {} ########################################################################### - # Helper functions for balances. - # TODO Refactor this into separated BalanceDict class? + # Helper functions for balances ########################################################################### def balance(self, platform: str, coin: str) -> balance_queue.BalanceQueue: diff --git a/src/transaction.py b/src/transaction.py index 2cc231b1..eb50157d 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -33,8 +33,8 @@ # TODO Implementation might be cleaner, when we add a class AbstractOperation -# which gets inherited by Fee and Operation -# Currently it might be possible for fees to have fees, which is unwanted. +# which gets inherited by Fee and Operation +# Currently it might be possible for fees to have fees, which is unwanted. @dataclasses.dataclass @@ -84,8 +84,8 @@ def validate_types(self) -> bool: actual_value = getattr(self, field.name) if field.name == "fees": - # BUG currently kind of ignored, would be nice when - # implemented correctly. + # TODO currently kind of ignored, would be nice when + # implemented correctly. assert actual_value is None continue @@ -263,7 +263,6 @@ def _total_fee_in_fiat(self) -> Optional[decimal.Decimal]: return None return misc.dsum( map( - # TODO Report mypy bug misc.cdecimal, (self.first_fee_in_fiat, self.second_fee_in_fiat), ) From 4d1ba6aa64223c03d8f4a17e71ec8bf5ba91f252 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 21:50:41 +0200 Subject: [PATCH 107/141] UPDATE resolve some unnecessary warnings --- src/book.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index 7f7f727d..53004fe0 100644 --- a/src/book.py +++ b/src/book.py @@ -209,7 +209,7 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: assert change # Check for problems. - if remark: + if remark and remark not in ("Withdraw fee is included",): log.warning( "I may have missed a remark in %s:%i: `%s`.", file_path, @@ -1617,7 +1617,10 @@ def read_file(self, file_path: Path) -> None: log.info("Reading file from exchange %s at %s", exchange, file_path) read_file(file_path) - else: + elif file_path.suffix not in ( + ".zip", + ".rar", + ): log.warning( f"Unable to detect the exchange of file `{file_path}`. " "Skipping file." From 8ee480ba007d01ca2f3742b44a0162b15a788ee6 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 21:50:58 +0200 Subject: [PATCH 108/141] CHANGE remove config.FIAT from unrealized sell report entries --- src/taxman.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 34cf65b5..79835480 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -544,22 +544,23 @@ def _evaluate_unrealized_sells(self) -> None: self.multi_depot_portfolio[sc.op.platform][sc.op.coin] += sc.sold self.single_depot_portfolio[sc.op.coin] += sc.sold - # "Sell" these coins which makes it possible to calculate the - # unrealized gain afterwards. - unrealized_sell = tr.Sell( - utc_time=TAX_DEADLINE, - platform=sc.op.platform, - change=sc.sold, - coin=sc.op.coin, - line=[-1], - file_path=Path(), - fees=None, - ) - self._evaluate_sell( - unrealized_sell, - sc, - ReportType=tr.UnrealizedSellReportEntry, - ) + if sc.op.coin != config.FIAT: + # "Sell" these coins which makes it possible to calculate + # the unrealized gain afterwards. + unrealized_sell = tr.Sell( + utc_time=TAX_DEADLINE, + platform=sc.op.platform, + change=sc.sold, + coin=sc.op.coin, + line=[-1], + file_path=Path(), + fees=None, + ) + self._evaluate_sell( + unrealized_sell, + sc, + ReportType=tr.UnrealizedSellReportEntry, + ) ########################################################################### # General tax evaluation functions. From 00e14737c722ac89f58df5edf726d14ff1f325d4 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 21:52:22 +0200 Subject: [PATCH 109/141] CHANGE increase log level of file handler to warning --- src/log_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/log_config.py b/src/log_config.py index 5fe53e91..be841576 100644 --- a/src/log_config.py +++ b/src/log_config.py @@ -28,13 +28,14 @@ # Handler ch = logging.StreamHandler() +ch.setLevel(logging.DEBUG) fh = logging.FileHandler(TMP_LOG_FILEPATH, "w") +fh.setLevel(logging.WARNING) # Formatter formatter = logging.Formatter("%(asctime)s %(name)-12s %(levelname)-8s %(message)s") for handler in (ch, fh): - handler.setLevel(logging.DEBUG) handler.setFormatter(formatter) log.addHandler(handler) From 5eac93fed62ad4013bb7dbc44ecc7075a75af290 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 22:04:18 +0200 Subject: [PATCH 110/141] FIX dirty fix to determine buying cost of bought bnb from small asset transfer --- src/book.py | 30 ++++++++++++++++++++++++++++++ src/taxman.py | 10 ++++++---- src/transaction.py | 1 + 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/book.py b/src/book.py index 53004fe0..f9b982c6 100644 --- a/src/book.py +++ b/src/book.py @@ -1591,7 +1591,37 @@ def resolve_trades(self) -> None: (buy_op,) = t_op[tr.Buy.type_name_c()] assert isinstance(buy_op, tr.Buy) assert buy_op.link is None + assert buy_op.buying_cost is None buy_op.link = sell_op + continue + + # Binance allows to convert small assets in one go to BNB. + # Our `merge_identical_column` function merges all BNB which + # gets bought at that time together. + # BUG Trade connection can not be established with our current + # method. + # Calculate the buying cost of this type of operation by all + # small asset sells. + is_binance_bnb_small_asset_transfer = all( + ( + all(op.platform == "binance" for op in matching_operations), + len(t_op[tr.Buy.type_name_c()]) == 1, + len(t_op[tr.Sell.type_name_c()]) >= 1, + len(t_op.keys()) == 2, + ) + ) + + if is_binance_bnb_small_asset_transfer: + sell_ops = t_op[tr.Sell.type_name_c()] + assert all(isinstance(op, tr.Sell) for op in sell_ops) + (buy_op,) = t_op[tr.Buy.type_name_c()] + assert isinstance(buy_op, tr.Buy) + assert buy_op.link is None + assert buy_op.buying_cost is None + buy_op.buying_cost = misc.dsum( + self.price_data.get_cost(op) for op in sell_ops + ) + continue def read_file(self, file_path: Path) -> None: """Import transactions form an account statement. diff --git a/src/taxman.py b/src/taxman.py index 79835480..dce49bc4 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -154,7 +154,12 @@ def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: # Gains of combinations like below are not correctly calculated: # 1 BTC=1€, 1ETH=2€, 1BTC=1ETH # e.g. buy 1 BTC for 1 €, buy 1 ETH for 1 BTC, buy 2 € for 1 ETH. - if sc.op.link is None: + if sc.op.buying_cost: + buy_value = sc.op.buying_cost * percent + elif sc.op.link: + prev_sell_value = self.price_data.get_partial_cost(sc.op.link, percent) + buy_value = prev_sell_value + else: log.warning( "Unable to correctly determine buy cost of bought coins " "because the link to the corresponding previous sell could " @@ -166,9 +171,6 @@ def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: f"{sc.op}" ) buy_value = self.price_data.get_cost(sc) - else: - prev_sell_value = self.price_data.get_partial_cost(sc.op.link, percent) - buy_value = prev_sell_value else: # All other operations "begin their existence" as that coin and # weren't traded/exchanged before. diff --git a/src/transaction.py b/src/transaction.py index eb50157d..c20f433e 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -175,6 +175,7 @@ class Sell(Transaction): class Buy(Transaction): link: Optional[Sell] = None + buying_cost: Optional[decimal.Decimal] = None class CoinLendInterest(Transaction): From f739ff7606ad6b2b3441c414d35ab12bca7fb32d Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 22:10:41 +0200 Subject: [PATCH 111/141] CHANGE only add buys of tax year to export file --- src/taxman.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index dce49bc4..dc9af22d 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -374,18 +374,19 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # For now we'll just add our bought coins to the balance. self.add_to_balance(op) - # Add tp export for informational purpose. - fee_params = self._get_fee_param_dict(op, decimal.Decimal(1)) - tax_report_entry = tr.BuyReportEntry( - platform=op.platform, - amount=op.change, - coin=op.coin, - utc_time=op.utc_time, - **fee_params, - buy_value_in_fiat=self.price_data.get_cost(op), - remark=op.remark, - ) - self.tax_report_entries.append(tax_report_entry) + # Add to export for informational purpose. + if in_tax_year(op): + fee_params = self._get_fee_param_dict(op, decimal.Decimal(1)) + tax_report_entry = tr.BuyReportEntry( + platform=op.platform, + amount=op.change, + coin=op.coin, + utc_time=op.utc_time, + **fee_params, + buy_value_in_fiat=self.price_data.get_cost(op), + remark=op.remark, + ) + self.tax_report_entries.append(tax_report_entry) elif isinstance(op, tr.Sell): # Buys and sells always come in a pair. The selling/redeeming From 5e21a53f9d169b21d1c55de0317b1c558dc9a826 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 22:10:51 +0200 Subject: [PATCH 112/141] FORMAT export file --- src/taxman.py | 4 ++-- src/transaction.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index dc9af22d..0be9af56 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -705,10 +705,10 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: 3, 0, ["Software", "CoinTaxman "] ) commit_hash = misc.get_current_commit_hash(default="undetermined") - ws_general.write_row(4, 0, ["Commit", commit_hash]) + ws_general.write_row(4, 0, ["Version (Commit)", commit_hash]) ws_general.write_row(5, 0, ["Alle Zeiten in", config.LOCAL_TIMEZONE_KEY]) # Set column format and freeze first row. - ws_general.set_column(0, 0, 13) + ws_general.set_column(0, 0, 17) ws_general.set_column(1, 1, 20) ws_general.freeze_panes(1, 0) diff --git a/src/transaction.py b/src/transaction.py index c20f433e..a84c299a 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -406,7 +406,11 @@ def excel_field_and_width(cls) -> Iterator[tuple[dataclasses.Field, float, bool] width = 35.0 elif field.name == "taxable_gain_in_fiat": width = 13.0 - elif field.name.endswith("_in_fiat") or "coin" in field.name: + elif ( + field.name.endswith("_in_fiat") + or "coin" in field.name + or "platform" in field.name + ): width = 15.0 elif field.type in ("datetime.datetime", "Optional[datetime.datetime]"): width = 18.43 From 4c818e71be952978d86073ce1c59df78bf86c1c9 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 22:35:17 +0200 Subject: [PATCH 113/141] UPDATE rename report columns Werbungskosten --- src/transaction.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index a84c299a..22e403dd 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -539,7 +539,7 @@ def _labels(cls) -> list[str]: # "Veräußerungserlös in EUR", "Anschaffungskosten in EUR", - "Gesamt Transaktionsgebühr in EUR", + "Werbungskosten in EUR", # "Gewinn/Verlust in EUR", "davon steuerbar in EUR", @@ -572,7 +572,7 @@ def _labels(cls) -> list[str]: # "Virtueller Veräußerungserlös in EUR", "Virtuelle Anschaffungskosten in EUR", - "Virtuelle Gesamt Transaktionsgebühr in EUR", + "Virtuelle Werbungskosten in EUR", # "Virtueller Gewinn/Verlust in EUR", "davon wären steuerbar", @@ -614,6 +614,12 @@ def __init__( remark=remark, ) + @property + def _gain_in_fiat(self) -> Optional[decimal.Decimal]: + gain_in_fiat = super()._gain_in_fiat + assert isinstance(gain_in_fiat, decimal.Decimal) + return decimal.Decimal(-1) * gain_in_fiat + @classmethod def _labels(cls) -> list[str]: return [ @@ -635,7 +641,7 @@ def _labels(cls) -> list[str]: # "-", "Kaufpreis in EUR", - "Gesamt Transaktionsgebühr in EUR", + "Werbungskosten in EUR", # "Anschaffungskosten in EUR", "-", @@ -814,7 +820,7 @@ def _labels(cls) -> list[str]: # "-", "-", - "Gesamt Transaktionsgebühr in EUR", + "Werbungskosten in EUR", # "Gewinn/Verlust in EUR", "-", From 48a57a1e4b59120296830fd80d7fe2d8b48f5055 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 22:54:53 +0200 Subject: [PATCH 114/141] CHANGE calculate sell value from bought coins market price --- src/book.py | 25 ++++++++++++------ src/taxman.py | 63 ++++++++++++++++++++++++++++++++-------------- src/transaction.py | 9 ++++--- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/book.py b/src/book.py index f9b982c6..d1a9de34 100644 --- a/src/book.py +++ b/src/book.py @@ -1586,13 +1586,16 @@ def resolve_trades(self) -> None: ) if is_buy_sell_pair: # Add link that this is a trade pair. - (sell_op,) = t_op[tr.Sell.type_name_c()] - assert isinstance(sell_op, tr.Sell) (buy_op,) = t_op[tr.Buy.type_name_c()] assert isinstance(buy_op, tr.Buy) + (sell_op,) = t_op[tr.Sell.type_name_c()] + assert isinstance(sell_op, tr.Sell) assert buy_op.link is None assert buy_op.buying_cost is None buy_op.link = sell_op + assert sell_op.link is None + assert sell_op.selling_value is None + sell_op.link = buy_op continue # Binance allows to convert small assets in one go to BNB. @@ -1612,15 +1615,23 @@ def resolve_trades(self) -> None: ) if is_binance_bnb_small_asset_transfer: - sell_ops = t_op[tr.Sell.type_name_c()] - assert all(isinstance(op, tr.Sell) for op in sell_ops) (buy_op,) = t_op[tr.Buy.type_name_c()] assert isinstance(buy_op, tr.Buy) + sell_ops = t_op[tr.Sell.type_name_c()] + assert all(isinstance(op, tr.Sell) for op in sell_ops) assert buy_op.link is None assert buy_op.buying_cost is None - buy_op.buying_cost = misc.dsum( - self.price_data.get_cost(op) for op in sell_ops - ) + buying_costs = [self.price_data.get_cost(op) for op in sell_ops] + buy_op.buying_cost = misc.dsum(buying_costs) + assert len(sell_ops) == len(buying_costs) + for sell_op, buying_cost in zip(sell_ops, buying_costs): + assert isinstance(sell_op, tr.Sell) + assert sell_op.link is None + assert sell_op.selling_value is None + percent = buying_cost / buy_op.buying_cost + sell_op.selling_value = self.price_data.get_partial_cost( + buy_op, percent + ) continue def read_file(self, file_path: Path) -> None: diff --git a/src/taxman.py b/src/taxman.py index 0be9af56..55729481 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -179,6 +179,49 @@ def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: return buy_value + buying_fees + def get_sell_value( + self, op: tr.Sell, sc: tr.SoldCoin, ReportType: Type[tr.SellReportEntry] + ) -> decimal.Decimal: + """Calculate the sell value by determining the market price for the + with that sell bought coins. + + Args: + sc (tr.SoldCoin): The sold coin. + ReportType (Type[tr.SellReportEntry]) + + Returns: + decimal.Decimal: The sell value. + """ + assert sc.op.coin == op.coin + percent = sc.sold / op.change + + if op.selling_value: + sell_value = op.selling_value * percent + elif op.link: + sell_value = self.price_data.get_partial_cost(op.link, percent) + else: + try: + sell_value = self.price_data.get_partial_cost(op, percent) + except NotImplementedError: + # Do not raise an error when we are unable to calculate an + # unrealized sell value. + if ReportType is tr.UnrealizedSellReportEntry: + log.warning( + f"Gathering prices for platform {op.platform} is currently " + "not implemented. Therefore I am unable to calculate the " + f"unrealized sell value for your {op.coin} at evaluation " + "deadline. If you want to see your unrealized sell value " + "in the evaluation, please add a price by hand in the " + f"table {get_sorted_tablename(op.coin, config.FIAT)[0]} " + f"at {op.utc_time}; " + "or open an issue/PR to gather prices for your platform." + ) + sell_value = decimal.Decimal() + else: + raise + + return sell_value + def _get_fee_param_dict(self, op: tr.Operation, percent: decimal.Decimal) -> dict: # fee amount/coin/in_fiat @@ -240,25 +283,7 @@ def _evaluate_sell( # TODO Recognized increased speculation period for lended/staked coins? is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) - try: - sell_value_in_fiat = self.price_data.get_partial_cost(op, percent) - except NotImplementedError: - # Do not raise an error when we are unable to calculate an - # unrealized sell value. - if ReportType is tr.UnrealizedSellReportEntry: - log.warning( - f"Gathering prices for platform {op.platform} is currently " - "not implemented. Therefore I am unable to calculate the " - f"unrealized sell value for your {op.coin} at evaluation " - "deadline. If you want to see your unrealized sell value " - "in the evaluation, please add a price by hand in the " - f"table {get_sorted_tablename(op.coin, config.FIAT)[0]} " - f"at {op.utc_time}; " - "or open an issue/PR to gather prices for your platform." - ) - sell_value_in_fiat = decimal.Decimal() - else: - raise + sell_value_in_fiat = self.get_sell_value(op, sc, ReportType) sell_report_entry = ReportType( sell_platform=op.platform, diff --git a/src/transaction.py b/src/transaction.py index 22e403dd..06b0730a 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -169,15 +169,16 @@ class Transaction(Operation): pass -class Sell(Transaction): - pass - - class Buy(Transaction): link: Optional[Sell] = None buying_cost: Optional[decimal.Decimal] = None +class Sell(Transaction): + link: Optional[Buy] = None + selling_value: Optional[decimal.Decimal] = None + + class CoinLendInterest(Transaction): pass From 96f8c7f78078b411c7023647d6afb52d84fc5145 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:16:21 +0200 Subject: [PATCH 115/141] UPDATE gain/loss of buy/transfer sheet always positiv --- src/transaction.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 06b0730a..1d89aa73 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -236,6 +236,7 @@ def partial(self, percent: decimal.Decimal) -> SoldCoin: class TaxReportEntry: event_type: ClassVar[str] = "virtual" allowed_missing_fields: ClassVar[list[str]] = [] + abs_gain_loss: ClassVar[bool] = False first_platform: Optional[str] = None second_platform: Optional[str] = None @@ -280,11 +281,14 @@ def _gain_in_fiat(self) -> Optional[decimal.Decimal]: and self._total_fee_in_fiat is None ): return None - return ( + gain_in_fiat = ( misc.cdecimal(self.first_value_in_fiat) - misc.cdecimal(self.second_value_in_fiat) - misc.cdecimal(self._total_fee_in_fiat) ) + if self.abs_gain_loss: + gain_in_fiat = abs(gain_in_fiat) + return gain_in_fiat taxable_gain_in_fiat: decimal.Decimal = dataclasses.field(init=False) @@ -584,6 +588,7 @@ def _labels(cls) -> list[str]: class BuyReportEntry(TaxReportEntry): event_type = "Kauf" + abs_gain_loss = True def __init__( self, @@ -615,12 +620,6 @@ def __init__( remark=remark, ) - @property - def _gain_in_fiat(self) -> Optional[decimal.Decimal]: - gain_in_fiat = super()._gain_in_fiat - assert isinstance(gain_in_fiat, decimal.Decimal) - return decimal.Decimal(-1) * gain_in_fiat - @classmethod def _labels(cls) -> list[str]: return [ @@ -773,6 +772,7 @@ class CommissionReportEntry(AirdropReportEntry): class TransferReportEntry(TaxReportEntry): event_type = "Ein-& Auszahlungen" + abs_gain_loss = True def __init__( self, @@ -821,9 +821,9 @@ def _labels(cls) -> list[str]: # "-", "-", - "Werbungskosten in EUR", + "-", # - "Gewinn/Verlust in EUR", + "Kosten in EUR", "-", "-", "Bemerkung", From 72194f9d44f855fbf67facbdc8ac39a5555696f7 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:16:43 +0200 Subject: [PATCH 116/141] CHANGE do not output values in xlsx file when column is "-" --- src/transaction.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/transaction.py b/src/transaction.py index 1d89aa73..935dcf59 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -427,12 +427,16 @@ def excel_field_and_width(cls) -> Iterator[tuple[dataclasses.Field, float, bool] yield field, width, hidden def excel_values(self) -> Iterator: - for f in self.field_names(): - if self.is_excel_label(f): - value = getattr(self, f) - if isinstance(value, datetime.datetime): - value = value.astimezone(config.LOCAL_TIMEZONE) - yield value + for field_name in self.field_names(): + if self.is_excel_label(field_name): + value = getattr(self, field_name) + label = self.get_excel_label(field_name) + if label == "-": + yield None + else: + if isinstance(value, datetime.datetime): + value = value.astimezone(config.LOCAL_TIMEZONE) + yield value # Bypass dataclass machinery, add a custom property function to a dataclass field. From 56edf6b57d15997c8e5929fb09c2cdb68b2ce083 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:16:59 +0200 Subject: [PATCH 117/141] CHANGE Sort sheets of xlsx file --- src/misc.py | 33 +++++++++++++++++++++++++++++++++ src/taxman.py | 2 +- src/transaction.py | 28 +++++++++++++++++++++------- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/misc.py b/src/misc.py index e6b2f1c1..e9f2261b 100644 --- a/src/misc.py +++ b/src/misc.py @@ -30,6 +30,7 @@ SupportsFloat, SupportsInt, Tuple, + Type, TypeVar, Union, cast, @@ -223,6 +224,38 @@ def group_by(lst: L, key: Union[str, list[str]]) -> dict[Any, L]: return dict(d) +T = TypeVar("T") + + +def sort_by_order_and_key( + order: list[Type[T]], + list_: list[T], + keys: Optional[list[str]] = None, +) -> list[T]: + """Sort a list by list of existing types and arbitrary keys/members. + + If the type is missing in order list. The entry will be placed first. + + Args: + order (list[Type[T]]): List with types in correct order. + operations (list[T]): List with entries to be sorted. + keys (list[str], optional): List of members which will be considered + when sorting. Defaults to None. + + Returns: + list[T]: Sorted entries by `order` and specific keys. + """ + + def key_function(op: T) -> tuple: + try: + idx = order.index(type(op)) + except ValueError: + idx = 0 + return tuple(([getattr(op, key) for key in keys] if keys else []) + [idx]) + + return sorted(list_, key=key_function) + + __delayed: dict[int, datetime.datetime] = {} diff --git a/src/taxman.py b/src/taxman.py index 55729481..3af2c4e3 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -767,7 +767,7 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: # Sheets per ReportType # for event_type, tax_report_entries in misc.group_by( - self.tax_report_entries, "event_type" + tr.sort_tax_report_entries(self.tax_report_entries), "event_type" ).items(): ReportType = type(tax_report_entries[0]) diff --git a/src/transaction.py b/src/transaction.py index 935dcf59..5aaf7a27 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -907,6 +907,22 @@ def __init__( ] operations_order = gain_operations + loss_operations +tax_report_entry_order = [ + BuyReportEntry, + SellReportEntry, + LendingInterestReportEntry, + StakingInterestReportEntry, + InterestReportEntry, + AirdropReportEntry, + CommissionReportEntry, + TransferReportEntry, + DepositReportEntry, + WithdrawalReportEntry, + LendingReportEntry, + StakingReportEntry, + UnrealizedSellReportEntry, +] + def sort_operations( operations: list[Operation], @@ -925,12 +941,10 @@ def sort_operations( Returns: list[Operation]: Sorted operations by `operations_order` and specific keys. """ + return misc.sort_by_order_and_key(operations_order, operations, keys=keys) - def key_function(op: Operation) -> tuple: - try: - idx = operations_order.index(type(op)) - except ValueError: - idx = 0 - return tuple(([getattr(op, key) for key in keys] if keys else []) + [idx]) - return sorted(operations, key=key_function) +def sort_tax_report_entries( + tax_report_entries: list[TaxReportEntry], +) -> list[TaxReportEntry]: + return misc.sort_by_order_and_key(tax_report_entry_order, tax_report_entries) From 64593cac2eabf31c3346471adc11ae436c7f16eb Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:28:00 +0200 Subject: [PATCH 118/141] RENAME CommissionReportEntry.entry_type --- src/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index 5aaf7a27..7fe933eb 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -771,7 +771,7 @@ def _labels(cls) -> list[str]: class CommissionReportEntry(AirdropReportEntry): - event_type = "Werbeprämien" + event_type = "Belohnungen-Bonus" class TransferReportEntry(TaxReportEntry): From 2041c87b916640061ff3ef9baa479d54785d7e3e Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:54:25 +0200 Subject: [PATCH 119/141] =?UTF-8?q?RENAME=20tr.Sell=20taxation=20type=20to?= =?UTF-8?q?=20`Eink=C3=BCnfte=20aus=20privaten=20Ver=C3=A4u=C3=9Ferungsges?= =?UTF-8?q?ch=C3=A4ften`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/taxman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index 3af2c4e3..400e876c 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -296,7 +296,7 @@ def _evaluate_sell( sell_value_in_fiat=sell_value_in_fiat, buy_cost_in_fiat=buy_cost_in_fiat, is_taxable=is_taxable, - taxation_type="Sonstige Einkünfte", + taxation_type="Einkünfte aus privaten Veräußerungsgeschäften", remark=op.remark, ) From 64acd14604db96544d712b7e3376fe752162ab14 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:54:47 +0200 Subject: [PATCH 120/141] UPDATE extend summary of export file with sell value/buy cost/... --- src/taxman.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 400e876c..3e4108e2 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -742,25 +742,70 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: # ws_summary = wb.add_worksheet("Zusammenfassung") ws_summary.write_row( - 0, 0, ["Einkunftsart", "steuerbarer Betrag in EUR"], header_format + 0, + 0, + [ + "Einkunftsart", + "Veräußerungserlös", + "Anschaffungskosten", + "Werbungskosten", + "steuerbarer Betrag in EUR", + ], + header_format, ) - ws_summary.set_row(0, 30) row = 1 for taxation_type, tax_report_entries in misc.group_by( self.tax_report_entries, "taxation_type" ).items(): if taxation_type is None: continue + first_value_in_fiat = None + second_value_in_fiat = None + total_fee_in_fiat = None + if taxation_type == "Einkünfte aus privaten Veräußerungsgeschäften": + first_value_in_fiat = misc.dsum( + misc.cdecimal(tre.first_value_in_fiat) + for tre in tax_report_entries + if tre.taxable_gain_in_fiat + and not isinstance(tre, tr.UnrealizedSellReportEntry) + ) + second_value_in_fiat = misc.dsum( + misc.cdecimal(tre.second_value_in_fiat) + for tre in tax_report_entries + if ( + not isinstance(tre, tr.UnrealizedSellReportEntry) + and tre.taxable_gain_in_fiat + ) + ) + total_fee_in_fiat = misc.dsum( + misc.cdecimal(tre.total_fee_in_fiat) + for tre in tax_report_entries + if ( + not isinstance(tre, tr.UnrealizedSellReportEntry) + and tre.taxable_gain_in_fiat + ) + ) taxable_gain = misc.dsum( tre.taxable_gain_in_fiat for tre in tax_report_entries if not isinstance(tre, tr.UnrealizedSellReportEntry) ) - ws_summary.write_row(row, 0, [taxation_type, taxable_gain]) + ws_summary.write_row( + row, + 0, + [ + taxation_type, + first_value_in_fiat, + second_value_in_fiat, + total_fee_in_fiat, + taxable_gain, + ], + ) row += 1 # Set column format and freeze first row. - ws_summary.set_column(0, 0, 35) - ws_summary.set_column(1, 1, 13, fiat_format) + ws_summary.set_row(0, 30) + ws_summary.set_column(0, 0, 43) + ws_summary.set_column(1, 4, 13, fiat_format) ws_summary.freeze_panes(1, 0) # From f076d8e31276daebbee2d89edb0f39338aae0521 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 8 May 2022 23:56:46 +0200 Subject: [PATCH 121/141] FIX mypy error --- src/misc.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/misc.py b/src/misc.py index e9f2261b..904e8dc6 100644 --- a/src/misc.py +++ b/src/misc.py @@ -342,9 +342,6 @@ def get_current_commit_hash(default: Optional[str] = None) -> str: return default -T = TypeVar("T") - - def not_none(v: Optional[T]) -> T: if v is None: raise ValueError() From 4c76bbc9d94a504f02c668842eb17f2b7aa1da6e Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 18:19:34 +0200 Subject: [PATCH 122/141] FIX typos Co-authored-by: Joshua Hoogstraat <34964599+jhoogstraat@users.noreply.github.com> --- src/book.py | 2 +- src/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index d1a9de34..9bf95c99 100644 --- a/src/book.py +++ b/src/book.py @@ -134,7 +134,7 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: # DeFi yield farming "Liquid Swap add": "CoinLend", "Liquid Swap remove": "CoinLendEnd", - "Launchpool Interest": "CoindLendInterest", + "Launchpool Interest": "CoinLendInterest", # "Super BNB Mining": "StakingInterest", "POS savings interest": "StakingInterest", diff --git a/src/main.py b/src/main.py index 692810ca..4de1a622 100644 --- a/src/main.py +++ b/src/main.py @@ -42,7 +42,7 @@ def main() -> None: # Merge identical operations together, which makes it easier to match # buy/sell to get prices from csv, match fees and reduces database access # (as long as there are only one buy/sell pair per time, - # might be problematic otherwhise). + # might be problematic otherwise). book.merge_identical_operations() # Resolve dependencies between withdrawals and deposits, which is # necessary to correctly fetch prices and to calculate p/l. From 93f5280a7042842dac54c6f98c336667d0ae6701 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 18:44:19 +0200 Subject: [PATCH 123/141] UPDATE sorting of binance operations --- src/book.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/book.py b/src/book.py index 7895fba0..9d1e8aa1 100644 --- a/src/book.py +++ b/src/book.py @@ -122,6 +122,7 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: operation_mapping = { "Distribution": "Airdrop", "Cash Voucher distribution": "Airdrop", + "Rewards Distribution": "Airdrop", # "Savings Interest": "CoinLendInterest", "Savings purchase": "CoinLend", @@ -134,6 +135,7 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: # DeFi yield farming "Liquid Swap add": "CoinLend", "Liquid Swap remove": "CoinLendEnd", + "Liquid Swap rewards": "CoinLendInterest", "Launchpool Interest": "CoinLendInterest", # "Super BNB Mining": "StakingInterest", @@ -142,9 +144,6 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: "POS savings redemption": "StakingEnd", # "Withdraw": "Withdrawal", - # - "Rewards Distribution": "Airdrop", - "Liquid Swap rewards": "CoinLendInterest", } with open(file_path, encoding="utf8") as f: From 12afc57b2d4456fa5c38bcd9bb51223e7a3e8e9d Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 18:49:52 +0200 Subject: [PATCH 124/141] RM TODO regarding increase of speculation period (#57) --- README.md | 2 +- src/taxman.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index baa99c12..6e7975c1 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ Zusammenfassung in meinen Worten: Zusammenfassung in meinen Worten: - Erhaltene Kryptowährung durch Coin Lending wird im Zeitpunkt des Zuflusses als Einkunft aus sonstigen Leistungen versteuert (Freigrenze 256 €). - Der Verkauf ist steuerbar. -- Coin Lending beeinflusst nicht die Haltefrist der verliehenen Coins. +- Coin Lending beeinflusst nicht die Haltefrist der verliehenen Coins (vgl. [Anwalt.de vom 29.04.2022](https://www.anwalt.de/rechtstipps/neuer-sachstand-bmf-schreiben-zu-kryptowaehrungen-und-10-jahres-frist-finanzamt-bitcoin-nft-token-200209.html) ### Staking diff --git a/src/taxman.py b/src/taxman.py index 3e4108e2..6e7871e3 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -280,7 +280,6 @@ def _evaluate_sell( fee_params = self._get_fee_param_dict(op, percent) buy_cost_in_fiat = self.get_buy_cost(sc) - # TODO Recognized increased speculation period for lended/staked coins? is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) sell_value_in_fiat = self.get_sell_value(op, sc, ReportType) @@ -384,7 +383,6 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # not the first and second # TODO mark them as not lended/etc. anymore, so they could be sold # again - # TODO lending/etc might increase the tax-free speculation period! # TODO Add Lending/Staking TaxReportEntry (duration of lend) # TODO maybe add total accumulated fees? # might be impossible to match CoinInterest with CoinLend periods From c3496ff8e50969338c970c59448c35c5b2a71f81 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 19:19:32 +0200 Subject: [PATCH 125/141] FIX make archive: check `xlsx` files instead of `csv` --- src/archive_evaluation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/archive_evaluation.py b/src/archive_evaluation.py index 7a6eb0c4..ebda9a11 100644 --- a/src/archive_evaluation.py +++ b/src/archive_evaluation.py @@ -41,9 +41,9 @@ def append_files(basedir: Path, filenames: list[str]) -> None: # Evaluation and log file log.debug("Archive latest evaluation and log file") -eval_regex = re.compile(str(TAX_YEAR) + r"\_rev\d{3}\.csv") +eval_regex = re.compile(str(TAX_YEAR) + r"\_rev\d{3}\.xlsx") evaluation = max((f for f in os.listdir(EXPORT_PATH) if eval_regex.match(f))) -log_file = evaluation.removesuffix(".csv") + ".log" +log_file = evaluation.removesuffix(".xlsx") + ".log" log.debug("Found: %s", ", ".join((evaluation, log_file))) append_files(EXPORT_PATH, [evaluation, log_file]) From 5f08e55f70fdf447db5f30671ce678e82fc31ab4 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 19:40:11 +0200 Subject: [PATCH 126/141] RM Buy from export file for now.. added TODO to add trades instead --- src/taxman.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 6e7871e3..971e16c6 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -397,19 +397,21 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: # For now we'll just add our bought coins to the balance. self.add_to_balance(op) + # TODO Only adding the Buys don't bring that much. We should add + # all trades instead (buy with buy.link) # Add to export for informational purpose. - if in_tax_year(op): - fee_params = self._get_fee_param_dict(op, decimal.Decimal(1)) - tax_report_entry = tr.BuyReportEntry( - platform=op.platform, - amount=op.change, - coin=op.coin, - utc_time=op.utc_time, - **fee_params, - buy_value_in_fiat=self.price_data.get_cost(op), - remark=op.remark, - ) - self.tax_report_entries.append(tax_report_entry) + # if in_tax_year(op): + # fee_params = self._get_fee_param_dict(op, decimal.Decimal(1)) + # tax_report_entry = tr.BuyReportEntry( + # platform=op.platform, + # amount=op.change, + # coin=op.coin, + # utc_time=op.utc_time, + # **fee_params, + # buy_value_in_fiat=self.price_data.get_cost(op), + # remark=op.remark, + # ) + # self.tax_report_entries.append(tax_report_entry) elif isinstance(op, tr.Sell): # Buys and sells always come in a pair. The selling/redeeming From 7bcbfcfa57b2d8f25b0f9672f577487917722712 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 19:40:33 +0200 Subject: [PATCH 127/141] UPDATE increase width of taxation_type column in export --- src/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transaction.py b/src/transaction.py index 7fe933eb..b2a6e6c9 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -408,7 +408,7 @@ def excel_field_and_width(cls) -> Iterator[tuple[dataclasses.Field, float, bool] if label == "-": width = 15.0 elif field.name == "taxation_type": - width = 35.0 + width = 43.0 elif field.name == "taxable_gain_in_fiat": width = 13.0 elif ( From 6ea53af94319eb4f7b69c505166c700d11990b88 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 20:22:48 +0200 Subject: [PATCH 128/141] UPDATE no fees in UnrealizedSellReportEntry, better labels; do not inherit UnrealizedSellReportEntry from SellReportEntry --- src/taxman.py | 25 +++++++++++++++----- src/transaction.py | 58 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 971e16c6..f5cb81bc 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -19,7 +19,7 @@ import datetime import decimal from pathlib import Path -from typing import Any, Optional, Type +from typing import Any, Optional, Type, Union import xlsxwriter @@ -180,14 +180,18 @@ def get_buy_cost(self, sc: tr.SoldCoin) -> decimal.Decimal: return buy_value + buying_fees def get_sell_value( - self, op: tr.Sell, sc: tr.SoldCoin, ReportType: Type[tr.SellReportEntry] + self, + op: tr.Sell, + sc: tr.SoldCoin, + ReportType: Union[Type[tr.SellReportEntry], Type[tr.UnrealizedSellReportEntry]], ) -> decimal.Decimal: """Calculate the sell value by determining the market price for the with that sell bought coins. Args: sc (tr.SoldCoin): The sold coin. - ReportType (Type[tr.SellReportEntry]) + ReportType (Union[Type[tr.SellReportEntry], + Type[tr.UnrealizedSellReportEntry]]) Returns: decimal.Decimal: The sell value. @@ -257,7 +261,9 @@ def _evaluate_sell( self, op: tr.Sell, sc: tr.SoldCoin, - ReportType: Type[tr.SellReportEntry] = tr.SellReportEntry, + ReportType: Union[ + Type[tr.SellReportEntry], Type[tr.UnrealizedSellReportEntry] + ] = tr.SellReportEntry, ) -> None: """Evaluate a (partial) sell operation. @@ -265,7 +271,8 @@ def _evaluate_sell( op (tr.Sell): The general sell operation. sc (tr.SoldCoin): The specific sold coins with their origin (sc.op). `sc.sold` can be a partial sell of `op.change`. - ReportType (Type[tr.SellReportEntry], optional): + ReportType (Union[Type[tr.SellReportEntry], + Type[tr.UnrealizedSellReportEntry]], optional): The type of the report entry. Defaults to tr.SellReportEntry. Raises: @@ -277,7 +284,13 @@ def _evaluate_sell( # Share the fees and sell_value proportionally to the coins sold. percent = sc.sold / op.change + # Ignore fees for UnrealizedSellReportEntry. fee_params = self._get_fee_param_dict(op, percent) + if ReportType is tr.UnrealizedSellReportEntry: + # Make sure, that the unrealized sell has no fees. + assert not any(v for v in fee_params.values()) + # Do not give fee parameters to ReportEntry object. + fee_params = {} buy_cost_in_fiat = self.get_buy_cost(sc) is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) @@ -291,7 +304,7 @@ def _evaluate_sell( coin=op.coin, sell_utc_time=op.utc_time, buy_utc_time=sc.op.utc_time, - **fee_params, + **fee_params, # type: ignore[call-arg] sell_value_in_fiat=sell_value_in_fiat, buy_cost_in_fiat=buy_cost_in_fiat, is_taxable=is_taxable, diff --git a/src/transaction.py b/src/transaction.py index b2a6e6c9..2c92e915 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -557,34 +557,62 @@ def _labels(cls) -> list[str]: ] -class UnrealizedSellReportEntry(SellReportEntry): - event_type = "Unrealizierter Gewinn-Verlust" +class UnrealizedSellReportEntry(TaxReportEntry): + event_type = "Bestände" + + def __init__( + self, + sell_platform: str, + buy_platform: str, + amount: decimal.Decimal, + coin: str, + sell_utc_time: datetime.datetime, + buy_utc_time: datetime.datetime, + sell_value_in_fiat: decimal.Decimal, + buy_cost_in_fiat: decimal.Decimal, + is_taxable: bool, + taxation_type: str, + remark: str, + ) -> None: + super().__init__( + first_platform=sell_platform, + second_platform=buy_platform, + amount=amount, + coin=coin, + first_utc_time=sell_utc_time, + second_utc_time=buy_utc_time, + first_value_in_fiat=sell_value_in_fiat, + second_value_in_fiat=buy_cost_in_fiat, + is_taxable=is_taxable, + taxation_type=taxation_type, + remark=remark, + ) @classmethod def _labels(cls) -> list[str]: return [ - "Virtueller Verkauf auf Börse", + "Bestand auf Börse zum Stichtag", "Erworben auf Börse", # "Anzahl", "Währung", # - "Virtuelles Verkaufsdatum", + "Unrealisiertes Verkaufsdatum", "Erwerbsdatum", # - "(1) Anzahl Transaktionsgebühr", - "(1) Währung Transaktionsgebühr", - "(1) Transaktionsgebühr in EUR", - "(2) Anzahl Transaktionsgebühr", - "(2) Währung Transaktionsgebühr", - "(2) Transaktionsgebühr in EUR", + "-", + "-", + "-", + "-", + "-", + "-", # - "Virtueller Veräußerungserlös in EUR", - "Virtuelle Anschaffungskosten in EUR", - "Virtuelle Werbungskosten in EUR", + "Unrealisierter Veräußerungserlös in EUR", + "Anschaffungskosten in EUR", + "-", # - "Virtueller Gewinn/Verlust in EUR", - "davon wären steuerbar", + "Unrealisierter Gewinn/Verlust in EUR", + "davon wären steuerbar in EUR", "Einkunftsart", "Bemerkung", ] From b536e41910be7a06a301a8d0d908df8b01c84d06 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 20:50:44 +0200 Subject: [PATCH 129/141] FIX valign in export --- src/taxman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index f5cb81bc..5c95211d 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -713,7 +713,7 @@ def export_evaluation_as_excel(self) -> Path: "bold": True, "border": 5, "align": "center", - "valign": "center", + "valign": "vcenter", "text_wrap": True, } ) From ce569144cb6f2e328198000a7867b48c7667ab5a Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 20:51:02 +0200 Subject: [PATCH 130/141] ADD unrealized sell information to summary page --- src/taxman.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 5c95211d..31f6cc0c 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -759,10 +759,10 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: 0, [ "Einkunftsart", - "Veräußerungserlös", - "Anschaffungskosten", - "Werbungskosten", - "steuerbarer Betrag in EUR", + "steuerbarer Veräußerungserlös in EUR", + "steuerbare Anschaffungskosten in EUR", + "steuerbare Werbungskosten in EUR", + "steuerbarer Gewinn/Verlust in EUR", ], header_format, ) @@ -815,10 +815,65 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ], ) row += 1 + row += 2 + ws_summary.merge_range(row, 0, row, 4, "Unrealisierte Einkünfte", header_format) + ws_summary.write_row( + row + 1, + 0, + [ + "Einkunftsart", + "Unrealisierter Veräußerungserlös in EUR", + "steuerbare Anschaffungskosten in EUR", + "Unrealisierter Gewinn/Verlust in EUR", + "davon wären steuerbar in EUR", + ], + header_format, + ) + taxation_type = "Einkünfte aus privaten Veräußerungsgeschäften" + unrealized_report_entries = [ + tre + for tre in self.tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ] + assert all( + taxation_type == tre.taxation_type for tre in unrealized_report_entries + ) + assert all(tre.gain_in_fiat is not None for tre in unrealized_report_entries) + first_value_in_fiat = misc.dsum( + misc.cdecimal(tre.first_value_in_fiat) + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + second_value_in_fiat = misc.dsum( + misc.cdecimal(tre.second_value_in_fiat) + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + total_gain_fiat = misc.dsum( + misc.cdecimal(tre.gain_in_fiat) + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + taxable_gain = misc.dsum( + tre.taxable_gain_in_fiat + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + ws_summary.write_row( + row + 2, + 0, + [ + taxation_type, + first_value_in_fiat, + second_value_in_fiat, + total_gain_fiat, + taxable_gain, + ], + ) # Set column format and freeze first row. - ws_summary.set_row(0, 30) ws_summary.set_column(0, 0, 43) - ws_summary.set_column(1, 4, 13, fiat_format) + ws_summary.set_column(1, 2, 18.29, fiat_format) + ws_summary.set_column(3, 4, 15.57, fiat_format) ws_summary.freeze_panes(1, 0) # From e46c2fb16c7d07bd178fe06b588d9a8b19dec4c9 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 21:38:47 +0200 Subject: [PATCH 131/141] CHANGE Add more information to general page in export --- src/taxman.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 31f6cc0c..b99db470 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -730,24 +730,47 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: # # General # + last_day = TAX_DEADLINE.date() + first_day = last_day.replace(month=1, day=1) + time_period = f"{first_day.strftime('%x')}–{last_day.strftime('%x')}" ws_general = wb.add_worksheet("Allgemein") - ws_general.merge_range(0, 0, 0, 1, "Allgemeine Daten", header_format) - ws_general.write_row(1, 0, ["Stichtag", TAX_DEADLINE.date()], date_format) + row = 0 + ws_general.merge_range(row, 0, 0, 1, "Allgemeine Daten", header_format) + row += 1 ws_general.write_row( - 2, + row, 0, ["Zeitraum des Steuerberichts", time_period], date_format + ) + row += 1 + ws_general.write_row( + row, 0, ["Verbrauchsfolgeverfahren", config.PRINCIPLE.name], date_format + ) + row += 1 + ws_general.write_row( + row, + 0, + ["Methode", "Multi-Depot" if config.MULTI_DEPOT else "Single-Depot"], + date_format, + ) + row += 1 + ws_general.write_row(row, 0, ["Alle Zeiten in", config.LOCAL_TIMEZONE_KEY]) + row += 1 + ws_general.write_row( + row, 0, ["Erstellt am", datetime.datetime.now(config.LOCAL_TIMEZONE)], datetime_format, ) + row += 1 ws_general.write_row( - 3, 0, ["Software", "CoinTaxman "] + row, 0, ["Software", "CoinTaxman "] ) + row += 1 commit_hash = misc.get_current_commit_hash(default="undetermined") - ws_general.write_row(4, 0, ["Version (Commit)", commit_hash]) - ws_general.write_row(5, 0, ["Alle Zeiten in", config.LOCAL_TIMEZONE_KEY]) + ws_general.write_row(row, 0, ["Version (Commit)", commit_hash]) + row += 1 # Set column format and freeze first row. - ws_general.set_column(0, 0, 17) - ws_general.set_column(1, 1, 20) + ws_general.set_column(0, 0, 26) + ws_general.set_column(1, 1, 21) ws_general.freeze_panes(1, 0) # From f42b0f9fe1c1bcfcbc9d9998ccc0d60c2ea1ba87 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 21:52:22 +0200 Subject: [PATCH 132/141] CHANGE Remarks of deposits/withdrawals changed, when link is missing --- src/book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/book.py b/src/book.py index c965b34b..dd3b5353 100644 --- a/src/book.py +++ b/src/book.py @@ -1436,7 +1436,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: ) ) for op in unmatched_deposits: - op.remarks.append("Einzahlung ohne zugehörige Auszahlung!") + op.remarks.append("Herkunft der Einzahlung unbekannt") if withdrawal_queue: log.warning( "Unable to match all withdrawals with deposits. " @@ -1450,7 +1450,7 @@ def is_match(withdrawal: tr.Withdrawal, deposit: tr.Deposit) -> bool: ) ) for op in withdrawal_queue: - op.remarks.append("Auszahlung ohne zugehörige Einzahlung!") + op.remarks.append("Ziel der Auszahlung unbekannt") log.info("Finished withdrawal/deposit matching") From 098439390b842ce86112575d833b0b5adf761121 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 21:53:16 +0200 Subject: [PATCH 133/141] CHANGE Adjust warning message when missing linked deposits get sold --- src/taxman.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index b99db470..a639fcc2 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -367,9 +367,11 @@ def evaluate_sell( f"You sold {sc.op.change} {sc.op.coin} which were deposited " f"from somewhere unknown onto {sc.op.platform} (see " f"{sc.op.file_path} {sc.op.line}). " - "A correct tax evaluation is not possible! " + "A correct tax evaluation might not be possible! " "For now, we assume that the coins were bought at " - "the timestamp of the deposit." + "the timestamp of the deposit. " + "If these coins get sold one year after this " + "the sell is not tax relevant and everything is fine." ) self._evaluate_sell(op, sc) From 3778a5354f7dd9cdd95e857e9aea590bbe1ed141 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 22:05:06 +0200 Subject: [PATCH 134/141] FIX Ignore error when not enough EUR in queue to pay fees --- src/balance_queue.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index b715906e..54878c02 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -256,17 +256,29 @@ def sanity_check(self) -> None: RuntimeError: Not all fees were paid. """ if self.buffer_fee: - log.error( + msg = ( f"Not enough {self.coin} in queue to pay left over fees: " f"missing {self.buffer_fee} {self.coin}.\n" "\tThis error occurs when you sold more coins than you have " "according to your account statements. Have you added every " "account statement, including these from the last years?\n" "\tThis error may also occur after deposits from unknown " - "sources. CoinTaxman requires the full transaction history to " - "evaluate taxation (when where these deposited coins bought?).\n" + "sources. " ) - raise RuntimeError + if self.coin == config.FIAT: + log.warning( + f"{msg}" + "Tracking of your home fiat is not important for tax " + f"evaluation but the {self.coin} in your portfolio at " + "deadline will be wrong." + ) + else: + log.error( + "{msg}" + "CoinTaxman requires the full transaction history to " + "evaluate taxation (when where these deposited coins bought?).\n" + ) + raise RuntimeError def remove_all(self) -> list[tr.SoldCoin]: sold_coins = [] From 9b54ee29d8830abfdfde8cf862fb26e6b297181f Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 22:16:23 +0200 Subject: [PATCH 135/141] FIX Catch error when determining sell value of unrealized sell. Only raise warning. --- src/taxman.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/taxman.py b/src/taxman.py index a639fcc2..16267243 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -295,7 +295,21 @@ def _evaluate_sell( is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) - sell_value_in_fiat = self.get_sell_value(op, sc, ReportType) + try: + sell_value_in_fiat = self.get_sell_value(op, sc, ReportType) + except Exception as e: + if ReportType is tr.UnrealizedSellReportEntry: + log.warning( + "Catched the following exception while trying to query an " + f"unrealized sell value for {sc.sold} {sc.op.coin} at deadline. " + "The sell value will be set to 0. " + "Your unrealized sell summary will be wrong and will not " + "be exported\n" + f"Catched exception: {e}" + ) + sell_value_in_fiat = decimal.Decimal() + else: + raise e sell_report_entry = ReportType( sell_platform=op.platform, From d5d58cdea83d949fc0bff803e7a4f9736339388c Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 22:23:45 +0200 Subject: [PATCH 136/141] CHANGE Ignore unrealized sell value in export when it's faulty --- src/taxman.py | 155 +++++++++++++++++++++++++------------------------- 1 file changed, 77 insertions(+), 78 deletions(-) diff --git a/src/taxman.py b/src/taxman.py index 16267243..a1adb8a0 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -59,6 +59,7 @@ def __init__(self, book: Book, price_data: PriceData) -> None: self.single_depot_portfolio: dict[ str, decimal.Decimal ] = collections.defaultdict(decimal.Decimal) + self.unrealized_sells_faulty = False # Determine used functions/classes depending on the config. country = config.COUNTRY.name @@ -183,7 +184,6 @@ def get_sell_value( self, op: tr.Sell, sc: tr.SoldCoin, - ReportType: Union[Type[tr.SellReportEntry], Type[tr.UnrealizedSellReportEntry]], ) -> decimal.Decimal: """Calculate the sell value by determining the market price for the with that sell bought coins. @@ -204,25 +204,7 @@ def get_sell_value( elif op.link: sell_value = self.price_data.get_partial_cost(op.link, percent) else: - try: - sell_value = self.price_data.get_partial_cost(op, percent) - except NotImplementedError: - # Do not raise an error when we are unable to calculate an - # unrealized sell value. - if ReportType is tr.UnrealizedSellReportEntry: - log.warning( - f"Gathering prices for platform {op.platform} is currently " - "not implemented. Therefore I am unable to calculate the " - f"unrealized sell value for your {op.coin} at evaluation " - "deadline. If you want to see your unrealized sell value " - "in the evaluation, please add a price by hand in the " - f"table {get_sorted_tablename(op.coin, config.FIAT)[0]} " - f"at {op.utc_time}; " - "or open an issue/PR to gather prices for your platform." - ) - sell_value = decimal.Decimal() - else: - raise + sell_value = self.price_data.get_partial_cost(op, percent) return sell_value @@ -296,18 +278,24 @@ def _evaluate_sell( is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) try: - sell_value_in_fiat = self.get_sell_value(op, sc, ReportType) + sell_value_in_fiat = self.get_sell_value(op, sc) except Exception as e: if ReportType is tr.UnrealizedSellReportEntry: log.warning( "Catched the following exception while trying to query an " - f"unrealized sell value for {sc.sold} {sc.op.coin} at deadline. " - "The sell value will be set to 0. " + f"unrealized sell value for {sc.sold} {sc.op.coin} at deadline " + f"on platform {sc.op.platform}. " + "If you want to see your unrealized sell value " + "in the evaluation, please add a price by hand in the " + f"table {get_sorted_tablename(op.coin, config.FIAT)[0]} " + f"at {op.utc_time}; " + "The sell value for this calculation will be set to 0. " "Your unrealized sell summary will be wrong and will not " - "be exported\n" + "be exported.\n" f"Catched exception: {e}" ) sell_value_in_fiat = decimal.Decimal() + self.unrealized_sells_faulty = True else: raise e @@ -855,60 +843,65 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ) row += 1 row += 2 - ws_summary.merge_range(row, 0, row, 4, "Unrealisierte Einkünfte", header_format) - ws_summary.write_row( - row + 1, - 0, - [ - "Einkunftsart", - "Unrealisierter Veräußerungserlös in EUR", - "steuerbare Anschaffungskosten in EUR", - "Unrealisierter Gewinn/Verlust in EUR", - "davon wären steuerbar in EUR", - ], - header_format, - ) - taxation_type = "Einkünfte aus privaten Veräußerungsgeschäften" - unrealized_report_entries = [ - tre - for tre in self.tax_report_entries - if isinstance(tre, tr.UnrealizedSellReportEntry) - ] - assert all( - taxation_type == tre.taxation_type for tre in unrealized_report_entries - ) - assert all(tre.gain_in_fiat is not None for tre in unrealized_report_entries) - first_value_in_fiat = misc.dsum( - misc.cdecimal(tre.first_value_in_fiat) - for tre in tax_report_entries - if isinstance(tre, tr.UnrealizedSellReportEntry) - ) - second_value_in_fiat = misc.dsum( - misc.cdecimal(tre.second_value_in_fiat) - for tre in tax_report_entries - if isinstance(tre, tr.UnrealizedSellReportEntry) - ) - total_gain_fiat = misc.dsum( - misc.cdecimal(tre.gain_in_fiat) - for tre in tax_report_entries - if isinstance(tre, tr.UnrealizedSellReportEntry) - ) - taxable_gain = misc.dsum( - tre.taxable_gain_in_fiat - for tre in tax_report_entries - if isinstance(tre, tr.UnrealizedSellReportEntry) - ) - ws_summary.write_row( - row + 2, - 0, - [ - taxation_type, - first_value_in_fiat, - second_value_in_fiat, - total_gain_fiat, - taxable_gain, - ], - ) + if not self.unrealized_sells_faulty: + ws_summary.merge_range( + row, 0, row, 4, "Unrealisierte Einkünfte", header_format + ) + ws_summary.write_row( + row + 1, + 0, + [ + "Einkunftsart", + "Unrealisierter Veräußerungserlös in EUR", + "steuerbare Anschaffungskosten in EUR", + "Unrealisierter Gewinn/Verlust in EUR", + "davon wären steuerbar in EUR", + ], + header_format, + ) + taxation_type = "Einkünfte aus privaten Veräußerungsgeschäften" + unrealized_report_entries = [ + tre + for tre in self.tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ] + assert all( + taxation_type == tre.taxation_type for tre in unrealized_report_entries + ) + assert all( + tre.gain_in_fiat is not None for tre in unrealized_report_entries + ) + first_value_in_fiat = misc.dsum( + misc.cdecimal(tre.first_value_in_fiat) + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + second_value_in_fiat = misc.dsum( + misc.cdecimal(tre.second_value_in_fiat) + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + total_gain_fiat = misc.dsum( + misc.cdecimal(tre.gain_in_fiat) + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + taxable_gain = misc.dsum( + tre.taxable_gain_in_fiat + for tre in tax_report_entries + if isinstance(tre, tr.UnrealizedSellReportEntry) + ) + ws_summary.write_row( + row + 2, + 0, + [ + taxation_type, + first_value_in_fiat, + second_value_in_fiat, + total_gain_fiat, + taxable_gain, + ], + ) # Set column format and freeze first row. ws_summary.set_column(0, 0, 43) ws_summary.set_column(1, 2, 18.29, fiat_format) @@ -923,6 +916,12 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ).items(): ReportType = type(tax_report_entries[0]) + if ( + self.unrealized_sells_faulty + and ReportType is tr.UnrealizedSellReportEntry + ): + continue + ws = wb.add_worksheet(event_type) # Header From 3d6c770aab1b9422675dc56c2a88e3c6243031d5 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 22:28:37 +0200 Subject: [PATCH 137/141] UPDATE Ignore buffer fee warning for config.FIAT --- src/balance_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/balance_queue.py b/src/balance_queue.py index 54878c02..8c0fcff1 100644 --- a/src/balance_queue.py +++ b/src/balance_queue.py @@ -234,7 +234,7 @@ def _remove_fee(self, fee: decimal.Decimal) -> None: fee: decimal.Decimal """ _, left_over_fee = self._remove(fee) - if left_over_fee: + if left_over_fee and self.coin != config.FIAT: log.warning( "Not enough coins in queue to remove fee. Buffer the fee for " "next adding time... " From 76c04b1991c42b519d622dba79ed179a77edf1b8 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 22:51:47 +0200 Subject: [PATCH 138/141] CHANGE Section "Taxation in Germany". updated according to new "BMF" writing --- README.md | 129 ++++++++---------------------------------------------- 1 file changed, 18 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 6e7975c1..4c1029c8 100644 --- a/README.md +++ b/README.md @@ -129,114 +129,21 @@ Feel free to commit details about the taxation in your country. ## Taxation in Germany -Meine Interpretation rund um die Besteuerung von Kryptowährung in Deutschland wird durch die Texte von den [Rechtsanwälten und Steuerberatern WINHELLER](https://www.winheller.com/) sehr gut artikuliert. - -Zusätzlich hat das Bundesministerium für Finanzen (BMF) am 17.06.2021 einen Entwurf über [Einzelfragen zur ertragssteuerrechtlichen Behandlung von virtuellen Währungen und von Token](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Einkommensteuer/2021-06-17-est-kryptowaehrungen.html) veröffentlicht. - -Beide Verweise geben schon einmal einen guten Einblick in die Versteuerung von Kryptowährung. -Viele Kleinigkeiten könnten jedoch noch steuerlich ungeklärt oder in einen Graubereich fallen. -Insofern ist es wichtig, sich über die genaue Besteuerung seines eigenen Sachverhaltes zu informieren oder (noch besser) einen Steuerberater diesbezüglich zu kontaktieren. - -Im Folgenden werde ich einen groben Überblick über die Besteuerungs-Methode in diesem Tool geben. -Genauere Informationen finden sich im Source-Code in `src\taxman.py`. - -An dieser Stelle sei explizit erwähnt, dass dies meine Interpretation ist. -Es ist weder sichergestellt, dass ich aktuell noch nach diesen praktiziere (falls ich das Repo in Zukunft nicht mehr aktiv pflege), noch ob diese Art der Versteuerung gesetzlich zulässig ist. -Meine Interpretation steht gerne zur Debatte. - -### Allgemein - -> Kryptowährungen sind kein gesetzliches Zahlungsmittel. Vielmehr werden sie – zumindest im Ertragsteuerrecht – als immaterielle Wirtschaftsgüter betrachtet. -> -> Wird der An- und Verkauf von Kryptowährungen als Privatperson unternommen, sind § 22 Nr. 2, § 23 Abs. 1 Nr. 2 EStG einschlägig. Es handelt sich hierbei um ein privates Veräußerungsgeschäft von „anderen Wirtschaftsgütern“. Gemäß § 23 Abs. 3 Satz 1 EStG ist der Gewinn oder Verlust der Unterschied zwischen Veräußerungspreis einerseits und den Anschaffungs- und Werbungskosten andererseits. Es muss also nur der Anschaffungspreis vom Veräußerungspreis abgezogen werden. Die Gebühren beim Handel auf den Börsen sind Werbungskosten und damit abzugsfähig. -> -> In § 23 Abs. 3 Satz 5 EStG ist zudem eine Freigrenze von 600 € vorgesehen, bis zu deren Erreichen alle privaten Veräußerungsgeschäfte des Veranlagungszeitraums steuerfrei bleiben. Wird die Grenze überschritten, muss allerdings der gesamte Betrag ab dem ersten Euro versteuert werden. Die Einkommensteuer fällt dabei nicht erst beim Umtausch von Kryptowährungen in Euro oder eine andere Fremdwährung an, sondern bereits bei einem Tausch in eine beliebige andere Kryptowährung oder auch beim Kauf von Waren oder Dienstleistungen mit einer solchen. Vergeht aber zwischen Anschaffung und Veräußerung mehr als ein Jahr (ggf. zehn Jahre nach § 23 Abs. 1 Nr. 2 Satz 4 EStG), greift die Haltefrist des § 23 Abs. 1 Nr. 2 Satz 1 EStG. In diesen Fällen ist der gesamte Veräußerungsgewinn nicht steuerbar. -> -> Zur Bestimmung der Anschaffungskosten und des Veräußerungsgewinns sowie zur Bestimmung der Einhaltung der Haltefrist wird in der Regel die sogenannte FIFO-Methode aus § 23 Abs. 1 Nr. 2 Satz 3 EStG herangezogen. Zwar schreibt das Gesetz diese First-In-First-Out-Methode nicht für Kryptowährungen vor, in der Praxis wird sie aber weitgehend angewendet. Es werden allerdings auch andere Meinungen vertreten und eine Berechnung nach der LIFO-Methode oder – zur Bestimmung der Anschaffungskosten – nach Durchschnittswerten vorgeschlagen. - -[Quelle](https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-kryprowaehrungen.html) -[Wörtlich zitiert vom 14.02.2021] - -Zusammenfassung in meinen Worten: -- Kryptowährung sind immaterielle Wirtschaftsgüter. -- Der Verkauf innerhalb der Spekulationsfirst gilt als privates Veräußerungsgeschäft und ist als Sonstiges Einkommen zu versteuern (Freigrenze 600 €). -- Jeder Tausch von Kryptowährung wird (wie ein Verkauf) versteuert, indem der getauschte Betrag virtuell in EUR umgewandelt wird. -- Gebühren zum Handel sind steuerlich abzugsfähig. -- Es kann einmalig entschieden werden, ob nach FIFO oder LIFO versteuert werden soll. - -Weitere Grundsätze, die oben nicht angesprochen wurden: -- Versteuerung kann getrennt nach Depots (Wallets, Exchanges, etc.) erfolgen (Multi-Depot-Methode) [cryptotax](https://cryptotax.io/fifo-oder-lifo-bitcoin-gewinnermittlung/). - -### Airdrops - -> Der Erhalt zusätzlicher Einheiten einer virtuellen Währung oder von Token kann zu -Einkünften aus einer Leistung im Sinne des § 22 Nummer 3 EStG führen. Beim Airdrop -werden Einheiten einer virtuellen Währung oder Token „unentgeltlich“ verteilt. In der Regel -handelt es sich dabei um eine Marketingmaßnahme. Allerdings müssen sich Kunden für die -Teilnahme am Airdrop anmelden und Daten von sich preisgeben. Als Belohnung erhalten -diese Kunden Einheiten einer virtuellen Währung oder Token zugeteilt. Hängt die Zuteilung -der Einheiten einer virtuellen Währung oder Token davon ab, dass der Steuerpflichtige Daten -von sich angibt, die über die Informationen hinausgehen, die für die schlichte technische Zuteilung/Bereitstellung erforderlich sind, liegt in der Datenüberlassung eine Leistung des -Steuerpflichtigen im Sinne des § 22 Nummer 3 EStG, für die er als Gegenleistung Einheiten -einer virtuellen Währung oder Token erhält. Davon ist im Zusammenhang mit einem Airdrop -jedenfalls dann auszugehen, wenn der Steuerpflichtige verpflichtet ist oder sich bereit -erklären muss, dem Emittenten als Gegenleistung für die Einheiten einer virtuellen Währung -oder Token personenbezogene Daten zur Verfügung zu stellen. -Die Einheiten der virtuellen Währung oder Token sind mit dem Marktkurs im Zeitpunkt des -Erwerbs anzusetzen (vgl. zur Ermittlung des Marktkurses Rz. 32). -Erfolgt keine Gegenleistung, kommt eine Schenkung in Betracht, für die die -schenkungssteuerrechtlichen Regelungen gelten. -Eine Leistung im Sinne des § 22 Nummer 3 EStG erbringt der Steuerpflichtige auch dann, -wenn er eigene Bilder/Fotos oder private Filme (Videos) auf einer Plattform hochlädt und -hierfür Einheiten einer virtuellen Währung oder Token erhält, sofern das Eigentum an den -Bildern/Fotos/Filmen beim Steuerpflichtigen verbleibt. - -[Quelle 79-80](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Einkommensteuer/2021-06-17-est-kryptowaehrungen.html) -[Wörtlich zitiert vom 04.05.2022] - -Zusammenfassung in meinen Worten: -- Falls man etwas gemacht hat, um die Airdrops zu erhalten (bspw. sich irgendwo angemeldet oder anderweitig Daten preisgegeben), handelt es sich um Einkünfte aus sonstigen Leistungen; ansonsten handelt es sich um eine Schenkung - -### Coin Lending - -> Handelt es sich bei den durch Krypto-Lending erhaltenen Zinserträgen um Einkünfte aus sonstigen Leistungen gem. § 22 Nr. 3 EStG, so gilt eine Freigrenze von 256 Euro. Beträge darüber werden mit dem perönlichen Einkommensteuersatz von 18 bis 45 % versteuert. Außerdem wäre die spätere Veräußerung gem. § 23 Abs. 1 Nr. 2 EStG der durch das Lending erlangten Kryptowährung mangels Anschaffungsvorgangs nicht steuerbar. -> -> In Deutschland ist die Besteuerung der durch das Krypto-Lending erhaltenen Zinsen jedoch nicht abschließend geklärt. Zum einem wird diskutiert, ob es sich dabei um Kapitaleinkünfte gem. § 20 Abs. 1 Nr. 7 EStG handelt, da es sich bei der Hingabe der Kryptowährung um ein klassisches, verzinsliches Darlehen handelt. Anderseits wird von Finanzämtern immer wieder angenommen, dass es sich bei den erzielten Zinserträgen durch Lending um Einkünfte aus sonstigen Leistungen gem. § 22 Nr. 3 EStG handelt. -> -> Die erhaltene Kryptowährung in Form von Zinsen ist im Zeitpunkt des Zuflusses zu bewerten. Es handele sich deshalb nicht um Kapitaleinkünfte, da die Hingabe der Kryptowährung gerade keine Hingabe von Kapital, sondern vielmehr eine Sachleistung darstelle. Begründet wird dies damit, dass sich eine Kapitalforderung auf eine Geldleistung beziehen muss, nicht aber auf eine Sachleistung, wie es bei Kryptowährungen der Fall ist. -> -> Kontrovers diskutiert wird auch, ob der Verleih einer Kryptowährung zu einer Verlängerung der Haltefrist nach § 23 Abs. 1 Nr. 2 Satz 4 EStG führt. Eine Verlängerung der Haltefrist tritt danach nur dann ein, wenn ein Wirtschaftsgut -> - nach dem 31.12.08 angeschafft wurde, -> - als Einkunftsquelle genutzt wird und -> - damit Einkünfte erzielt werden. -> Unter Nutzung als Einkunftsquelle ist zu verstehen, dass die betroffenen Wirtschaftsgüter eine eigenständige Erwerbsgrundlage bilden. Maßgeblich ist also die Frage, ob mit der Kryptowährung Einkünften erzielt werden. -> -> Beim Lending werden jedoch in der Regel keine Einkünfte aus dem Wirtschaftsgut (der Kryptowährung), sondern aus dem Verleihgeschäft erzielt (als Ertrag aus der Forderung). Weil in diesen Fällen kein Missbrauch vorliegt, kann es bei der Haltefrist von einem Jahr bleiben. Auch das Bayrische Landesamt für Steuern hat bestätigt, dass die erhaltenen Zinsen nicht Ausfluss des „anderen Wirtschaftsgutes Fremdwährungsguthaben“, sondern vielmehr Ausfluss der eigentlichen Kapitalforderungen sind. - -[Quelle](https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-lending.html) -[Wörtlich zitiert vom 14.02.2021] - -Zusammenfassung in meinen Worten: -- Erhaltene Kryptowährung durch Coin Lending wird im Zeitpunkt des Zuflusses als Einkunft aus sonstigen Leistungen versteuert (Freigrenze 256 €). -- Der Verkauf ist steuerbar. -- Coin Lending beeinflusst nicht die Haltefrist der verliehenen Coins (vgl. [Anwalt.de vom 29.04.2022](https://www.anwalt.de/rechtstipps/neuer-sachstand-bmf-schreiben-zu-kryptowaehrungen-und-10-jahres-frist-finanzamt-bitcoin-nft-token-200209.html) - -### Staking - -> Ebenso [wie beim Coin Lending] verhält es sich bei Kryptowährungen, die für Staking oder Masternodes genutzt werden. Nutzer müssen bei proof-of-stake-basierten Kryptowährungen oder beim Betreiben von Masternodes einen bestimmten Teil ihrer Kryptowährung der Verfügungsmacht entziehen und dem Netzwerk als Sicherheit bereitstellen. Die Sicherheit des Netzwerkes wird dadurch gewährleistet, dass regelwidriges Verhalten den dem Verlust der Sicherheitsleistung (Kryptowährung) zur Folge hat. Auch in diesen Fällen werden keine Einkünfte aus dem Wirtschaftsgut selbst, sondern für das Blockieren der Verfügungsmacht, also als Ertrag aus der Forderung, erzielt. Auch hier bleibt es bei der Haltefrist von einem Jahr. - -[Quelle](https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-von-staking.html) -[Wörtlich zitiert vom 19.02.2021] - -Zusammenfassung: -- siehe Coin Lending - -### Kommission (Referral System) - -Wenn man denkt, dass man den Steuerdschungel endlich durchquert hat, kommt Binance mit seinem Referral System daher. -Über das Werbungs-System erhält man einen prozentualen Anteil an den Trading-Gebühren der Geworbenen auf sein Konto gutgeschrieben. -(lebenslang, logischerweise in BTC.) -Es ist also keine typische Kunden-werben-Kunden-Prämie sondern eher eine Kommission und damit bin ich mir unsicher, wie das einzuordnen ist. - -Für das Erste handhabe ich es wie eine Kunden-werben-Kunden-Prämie in Form eines Sachwerts. -Sprich, die BTC werden zum Zeitpunkt des Erhalts in ihren EUR-Gegenwert umgerechnet und den Einkünften aus sonstigen Leistungen hinzugefügt. +Nach langer Unklarheit über die Besteuerung von Kryptowährung wurde am 10.05.2022 durch das Bundesministerium für Finanzen (BMF) [ein Schreiben](https://www.bundesfinanzministerium.de/Content/DE/Downloads/BMF_Schreiben/Steuerarten/Einkommensteuer/2022-05-09-einzelfragen-zur-ertragsteuerrechtlichen-behandlung-von-virtuellen-waehrungen-und-von-sonstigen-token.html) mit rechtsverbindlichen Vorgaben zur Versteuerung veröffentlicht. + +Die ursprünglich hier stehenden Vermutungen und Interpretationen meinerseits habe ich aus der aktuellen `README.md` entfernt. +Für Interessierte findet sich das in der Versionshistorie. +Dieses Tool richtet sich nach bestem Wissen nach dem BMF Schreiben. + +An dieser Stelle sei explizit erwähnt, dass ich trotzdem keine Haftung für die Anwendung dieses Programms übernehme. +Es ist weder sichergestellt, dass ich es aktuell noch nutze (falls ich das Repo in Zukunft nicht mehr aktiv pflege), noch ob die tatsächliche Umsetzung des Programms gesetzlich zulässig ist. +Issues und Pull Requests sind gerne gesehen. + +Weitere Besonderheiten die sich so nicht im BMF-Schreiben wiederfinden, sind im folgenden aufgelistet. + + +### Binance Referral Rewards (Referral System) + +Bei Binance gibt es die Möglichkeit, andere Personen über einen Link zu werben. +Bei jeder Transaktion der Person erhält man einen kleinen Anteil derer Gebühren als Reward gutgeschrieben. +Die Einkünfte durch diese Rewards werden durch CoinTaxman als Einkünfte aus sonstigen Leistungen ausgewiesen und damit wie eine übliche Kunden-werben-Kunden-Prämie erfasst. From 3dba427bc4f100d872c8ed7aff11dce4f511ac0f Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sat, 14 May 2022 23:02:20 +0200 Subject: [PATCH 139/141] MV calculation of `is_taxable` to `_evaluate_sell` --- src/config.py | 7 ------- src/taxman.py | 4 +++- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/config.py b/src/config.py index 6b1aca85..e6322790 100644 --- a/src/config.py +++ b/src/config.py @@ -15,14 +15,11 @@ # along with this program. If not, see . import configparser -import datetime as dt import locale import zoneinfo from os import environ from pathlib import Path -from dateutil.relativedelta import relativedelta - import core # Dir and file paths @@ -71,10 +68,6 @@ LOCAL_TIMEZONE_KEY = "MEZ" locale_str = "de_DE" - def IS_LONG_TERM(buy: dt.datetime, sell: dt.datetime) -> bool: - return buy + relativedelta(years=1) < sell - - else: raise NotImplementedError( f"Your country {COUNTRY} is currently not supported. " diff --git a/src/taxman.py b/src/taxman.py index a1adb8a0..34159ec1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -22,6 +22,7 @@ from typing import Any, Optional, Type, Union import xlsxwriter +from dateutil.relativedelta import relativedelta import balance_queue import config @@ -275,7 +276,8 @@ def _evaluate_sell( fee_params = {} buy_cost_in_fiat = self.get_buy_cost(sc) - is_taxable = not config.IS_LONG_TERM(sc.op.utc_time, op.utc_time) + # Taxable when sell is not more than one year after buy. + is_taxable = sc.op.utc_time + relativedelta(years=1) >= op.utc_time try: sell_value_in_fiat = self.get_sell_value(op, sc) From b03af45c4efd6317f9cf6f98fc5c43c384ba563d Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 15 May 2022 00:11:37 +0200 Subject: [PATCH 140/141] RM unused excel helper functions --- src/misc.py | 19 ------------------- src/taxman.py | 2 +- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/misc.py b/src/misc.py index 904e8dc6..3d94af12 100644 --- a/src/misc.py +++ b/src/misc.py @@ -346,22 +346,3 @@ def not_none(v: Optional[T]) -> T: if v is None: raise ValueError() return v - - -def column_num_to_string(n: int) -> str: - # References: https://stackoverflow.com/a/63013258/8979290 - n, rem = divmod(n - 1, 26) - char = chr(65 + rem) - if n: - return column_num_to_string(n) + char - else: - return char - - -def column_string_to_num(s: str) -> int: - # References: https://stackoverflow.com/a/63013258/8979290 - n = ord(s[-1]) - 64 - if s[:-1]: - return 26 * (column_string_to_num(s[:-1])) + n - else: - return n diff --git a/src/taxman.py b/src/taxman.py index 34159ec1..7319f598 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -931,7 +931,7 @@ def get_format(field: dataclasses.Field) -> Optional[xlsxwriter.format.Format]: ws.write_row(0, 0, labels, header_format) # Set height ws.set_row(0, 45) - ws.autofilter(f"A1:{misc.column_num_to_string(len(labels))}1") + ws.autofilter(0, 0, 0, len(labels) - 1) # Data for row, entry in enumerate(tax_report_entries, 1): From bb2dcfe23d963dab71875ee71ef8e3caeeb53ff1 Mon Sep 17 00:00:00 2001 From: Jeppy Date: Sun, 15 May 2022 00:17:37 +0200 Subject: [PATCH 141/141] UPDATE make sure that module `tzdata` is installed by importing it in config --- setup.cfg | 3 +++ src/config.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/setup.cfg b/setup.cfg index 5336fe78..9bb5a502 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,6 +12,9 @@ warn_unused_configs = True [mypy-xlsxwriter] ignore_missing_imports = True +[mypy-tzdata] +ignore_missing_imports = True + [flake8] exclude = *py*env*/ max_line_length = 88 diff --git a/src/config.py b/src/config.py index e6322790..39edf3b4 100644 --- a/src/config.py +++ b/src/config.py @@ -20,6 +20,9 @@ from os import environ from pathlib import Path +# Make sure, that module `tzdata` is installed. +import tzdata # noqa: F401 + import core # Dir and file paths