From 8c67d263cf4c6bb874a4ac5ca9c44d119366816c Mon Sep 17 00:00:00 2001 From: wullxz Date: Sun, 12 May 2024 01:02:11 +0200 Subject: [PATCH 1/3] Fixed changed bitpanda fileformat; added handling for some transfers; small cfg fix * cfg fix: The de_DE locale doesn't work on my system. I've changed it to try different locales in order ("de_DE", "de_DE.utf-8") * general: Created AirdropGift (non-taxed) and AirdropIncome (taxed) as subclasses of Airdrop. AirdropGifts are always non-taxed and AirdropIncome are always taxed, no matter the setting `ALL_AIRDROPS_ARE_GIFTS` in the config. These subclasses can be used if it is save to say that a record is to be taxed or not. * bitpanda general: Current csv exports have an additional field that tripped up the parsing. Added the field `_tax_fiat` to the header definition. TODO: check if the contained information is relevant. * bitpanda airdrop types: Implemented AirdropGift for BEST and ETHW drops (see below). * bitpanda stocks: Ignore records where the asset type starts with "Stock" (for now) * bitpanda BEST transfer: BEST is bitpandas own coin and is rewarded for activity and holding a portfolio. These are classified as AirdropGift (non-taxable). * bitpanda ETHW transfer: ETHW is the old ETH chain. If a user held ETH with bitpanda when the fork to PoS happened, they received ETHW some time in September 2022. Classify that as AirdropGift according to the first reasoning described [here](https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-hardforks-ledger-splits.html) * bitpanda staking: Implemented handling of staking for `transfer(stake)` and `transfer(unstake)` operation types. --- config.ini | 3 ++ src/book.py | 72 ++++++++++++++++++++++++++++++++++++++++------ src/config.py | 9 ++++-- src/taxman.py | 6 ++++ src/transaction.py | 11 ++++++- 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/config.ini b/config.ini index 2a115a6..f2cbb33 100644 --- a/config.ini +++ b/config.ini @@ -28,4 +28,7 @@ LOG_LEVEL = DEBUG # 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. +# Some airdrops can be classified as gifts (Schenkung) or income (Eink├╝nfte) +# relatively savely. For those, there is a flag to signal either type. +# See the AirdropGift and AirdropIncome classes in transaction.py and their usage in book.py ALL_AIRDROPS_ARE_GIFTS = True diff --git a/src/book.py b/src/book.py index 9ac9521..dac6d0d 100644 --- a/src/book.py +++ b/src/book.py @@ -1017,6 +1017,11 @@ def _read_bitpanda(self, file_path: Path) -> None: "withdrawal": "Withdrawal", "buy": "Buy", "sell": "Sell", + "reward": "StakingInterest", + "staking": "Staking", + "staking_end": "StakingEnd", + "airdrop_gift": "AirdropGift", + "airdrop_income": "AirdropIncome", } with open(file_path, encoding="utf8") as f: @@ -1041,6 +1046,7 @@ def _read_bitpanda(self, file_path: Path) -> None: "Fee asset", "Spread", "Spread Currency", + "Tax Fiat", ]: try: line = next(reader) @@ -1052,7 +1058,7 @@ def _read_bitpanda(self, file_path: Path) -> None: _tx_id, csv_utc_time, operation, - _inout, + inout, amount_fiat, fiat, amount_asset, @@ -1065,9 +1071,15 @@ def _read_bitpanda(self, file_path: Path) -> None: fee_currency, _spread, _spread_currency, + _tax_fiat, ) in reader: row = reader.line_num + # Skip stocks for now! + # TODO: Handle Stocks? + if asset_class.startswith("Stock"): + continue + # make RFC3339 timestamp ISO 8601 parseable if csv_utc_time[-1] == "Z": csv_utc_time = csv_utc_time[:-1] + "+00:00" @@ -1079,23 +1091,66 @@ def _read_bitpanda(self, file_path: Path) -> None: # CocaCola transfer, which I don't want to track. Would need to # be implemented if need be. if operation == "transfer": - log.warning( - f"'Transfer' operations are not " - f"implemented, skipping row {row} of file {file_path}" - ) - continue + if asset == "BEST" and asset_class == "Cryptocurrency": + # BEST is awarded for trading activity and holding a portfolio at bitpanda + # The BEST awards are listed as "transfer" but must be processed as Airdrop (non-taxable) + operation = "airdrop_gift" + elif ( + inout == "incoming" + and asset == "ETHW" + and asset_class == "Cryptocurrency" + and utc_time.year == 2022 + and utc_time.month == 9 + ): + # In September 2022 the ETH blockchain switched from proof of work to + # proof of stake. This bore the potential for a hardfork and continuation + # of the original PoW chain albeit with a drastically reduced hashrate. + # Bitpanda considered listing the resulting token if there was still value + # in trading the ETH token on the PoW fork and considered distributing airdrops + # in that case. The resulting token would be traded using the ETHW handle. + # See: https://blog.bitpanda.com/en/ethereum-merge-everything-you-need-know + # + # German law regarding this case is not entirely clear + # (see https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-hardforks-ledger-splits.html). + # TODO: This should actually copy the history from the original ETH history. + log.warning( + f"Airdrop of {asset} is likely a result of Ethereums switch " + f"to PoS in September 2022. The legal status of taxation of fork " + f"airdrops is unclear in Germany (at least). Also, the original " + f"history should be copied, which is NOT YET IMPLEMENTED. " + f"See https://blog.bitpanda.com/en/ethereum-merge-everything-you-need-know " + f"for more information. " + f"Please open an issue or PR if you know how to resolve this. " + f"In row {row} in file {file_path}." + ) + operation = "airdrop_gift" + else: + log.warning( + f"'Transfer' operations are not " + f"implemented, skipping row {row} of file {file_path}" + ) + continue + + # remap tansfer(stake) + if operation == "transfer(stake)": + if inout == "incoming": + operation = "staking" + if operation == "transfer(unstake)": + if inout == "outgoing": + operation = "staking_end" # fail for unknown ops try: operation = operation_mapping[operation] except KeyError: log.error( - f"Unsupported operation '{operation}' " + f"Unsupported operation '{operation}' for asset {asset} " f"in row {row} of file {file_path}" ) raise RuntimeError - if operation in ["Deposit", "Withdrawal"]: + # Handling Airdrops the same as Deposits and Withdrawals here. Otherwise, balance doesn't add up. + if operation in ["Deposit", "Withdrawal", "Airdrop", "AirdropGift", "AirdropIncome"]: if asset_class == "Fiat": change = misc.force_decimal(amount_fiat) if fiat != asset: @@ -1418,6 +1473,7 @@ def detect_exchange(self, file_path: Path) -> Optional[str]: "Fee asset", "Spread", "Spread Currency", + "Tax Fiat", ], "custom_eur": [ "Type", diff --git a/src/config.py b/src/config.py index 39edf3b..5992416 100644 --- a/src/config.py +++ b/src/config.py @@ -69,7 +69,7 @@ PRINCIPLE = core.Principle.FIFO LOCAL_TIMEZONE = zoneinfo.ZoneInfo("CET") LOCAL_TIMEZONE_KEY = "MEZ" - locale_str = "de_DE" + locale_str = ["de_DE", "de_DE.utf8"] # try multiple german locales in order else: raise NotImplementedError( @@ -79,4 +79,9 @@ # Program specific constants. FIAT = FIAT_CLASS.name # Convert to string. -locale.setlocale(locale.LC_ALL, locale_str) +for loc in locale_str: + try: + locale.setlocale(locale.LC_ALL, loc) + except: + continue + break diff --git a/src/taxman.py b/src/taxman.py index 3691e34..a41d5c1 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -486,6 +486,12 @@ def _evaluate_taxation_GERMANY(self, op: tr.Operation) -> None: taxation_type = "Schenkung" else: taxation_type = "Einkünfte aus sonstigen Leistungen" + + # If taxation_type is actually set, it should overwrite the general setting. + # This can happen by using the subclasses AirdropGift (not taxed) and AirdropIncome (taxed) + if op.taxation_type: + taxation_type = op.taxation_type + report_entry = tr.AirdropReportEntry( platform=op.platform, amount=op.change, diff --git a/src/transaction.py b/src/transaction.py index 8845a64..4e4693a 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -190,8 +190,17 @@ class StakingInterest(Transaction): class Airdrop(Transaction): - pass + taxation_type: Optional[str] = None + +class AirdropGift(Airdrop): + """AirdropGift is used for gifts that are non-taxable""" + + taxation_type: Optional[str] = "Schenkung" + +class AirdropIncome(Airdrop): + """AirdropIncome is used for income that is taxable""" + taxation_type: Optional[str] = "Einkünfte aus sonstigen Leistungen" class Commission(Transaction): pass From 5eca87ef759557d7a336d41f5ed13893bfc0e762 Mon Sep 17 00:00:00 2001 From: wullxz Date: Sun, 12 May 2024 20:44:55 +0200 Subject: [PATCH 2/3] Bitpanda: fixes for unobtainable prices for ETHW and BEST Background info: Bitpanda Pro, which is used to obtain historical prices for (crypto-)assets is now ONE TRADING and a separate company to which Bitpanda only holds a minority stake. After that split, BEST can't be used anymore on ONE TRADING and historical BEST price data isn't available anymore. Also, ETHW, which stands for the Proof of Work branch of the ETH chain, isn't available on ONE TRADING. This means, that we need to use the prices we have from the csv file as best as we can. For normal transactions, that's possible by using the "Asset market price" column of the export but for fee withdrawals from the BEST wallet, no price was actually associated with the withdrawal transaction. For now, I'm using an asset price of 0 because I don't know how to fix this otherwise. For the normal transactions, a new property has been added to the Operation class (`exported_price`) to be able to carry the asset price for normal transactions into the processing classes like PriceData and use it there if a proper price can't be obtained (see `get_cost` method in `PriceData` in price_data.py). Source: https://support.bitpanda.com/hc/en-us/articles/9374684386332-Why-is-Bitpanda-Pro-evolving-to-become-One-Trading * general: Added new data field `exported_price` to `Operation` class * bitpanda general: For ETHW and BEST, the `exported_price` will be used because the ONE TRADING API doesn't return anything. * bitpanda general fix: The `change` calculation used a change from an earlier data record for some operation types, because the `change` variable was not touched (for Airdrops of any kind and Staking operations of any kind). The missing operations are now added in `_read_bitpanda` in book.py and an Exception has been added for anything else not included in my change. --- src/book.py | 26 ++++++++++++++++++++++---- src/price_data.py | 31 ++++++++++++++++++++++++++----- src/transaction.py | 5 +++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/book.py b/src/book.py index dac6d0d..4b9d3cb 100644 --- a/src/book.py +++ b/src/book.py @@ -59,6 +59,7 @@ def create_operation( coin: str, row: int, file_path: Path, + exported_price: Optional[decimal.Decimal] = None, remark: Optional[str] = None, ) -> tr.Operation: @@ -77,7 +78,7 @@ def create_operation( if remark: kwargs["remarks"] = [remark] - op = Op(utc_time, platform, change, coin, [row], file_path, **kwargs) + op = Op(utc_time, platform, change, coin, [row], file_path, None, exported_price, **kwargs) assert isinstance(op, tr.Operation) return op @@ -99,6 +100,7 @@ def append_operation( coin: str, row: int, file_path: Path, + exported_price: Optional[decimal.Decimal] = None, remark: Optional[str] = None, ) -> None: # Discard operations after the `TAX_YEAR`. @@ -112,6 +114,7 @@ def append_operation( coin, row, file_path, + exported_price, remark=remark, ) @@ -263,7 +266,7 @@ def _read_binance(self, file_path: Path, version: int = 1) -> None: ) self.append_operation( - operation, utc_time, platform, change, coin, row, file_path, remark + operation, utc_time, platform, change, coin, row, file_path, None, remark ) def _read_binance_v2(self, file_path: Path) -> None: @@ -1063,7 +1066,7 @@ def _read_bitpanda(self, file_path: Path) -> None: fiat, amount_asset, asset, - _asset_price, + asset_price, asset_price_currency, asset_class, _product_id, @@ -1084,6 +1087,11 @@ def _read_bitpanda(self, file_path: Path) -> None: if csv_utc_time[-1] == "Z": csv_utc_time = csv_utc_time[:-1] + "+00:00" + if asset_price != "-": + exported_price = misc.force_decimal(asset_price) + else: + exported_price = None + # timezone information is already taken care of with this utc_time = datetime.datetime.fromisoformat(csv_utc_time) @@ -1183,6 +1191,13 @@ def _read_bitpanda(self, file_path: Path) -> None: # Calculated price price_calc = change_fiat / change set_price_db(platform, asset, config.FIAT, utc_time, price_calc) + elif operation in ["Staking", "StakingEnd", "StakingInterest"]: + change = misc.force_decimal(amount_asset) + else: + # If something slips through the if/elifs above, the change will be wrong! + # That's why we have to raise an exception here! + log.error(f"Failed to appropriately handle operation '{operation}' for {platform}!") + raise RuntimeError if change < 0: log.error( @@ -1191,8 +1206,11 @@ def _read_bitpanda(self, file_path: Path) -> None: ) raise RuntimeError + # Asset price is added to operation as 'exported_price' because some asset prices + # can't be checked anymore (like BEST and ETHW, which are both not available using + # ONE TRADINGs (ex Bitpanda Pro) candlebars API. self.append_operation( - operation, utc_time, platform, change, asset, row, file_path + operation, utc_time, platform, change, asset, row, file_path, exported_price ) # add buy / sell operation for fiat currency diff --git a/src/price_data.py b/src/price_data.py index 6880eeb..99c0cf4 100644 --- a/src/price_data.py +++ b/src/price_data.py @@ -281,8 +281,11 @@ def _get_price_coinbase_pro( def _get_price_bitpanda( self, base_asset: str, utc_time: datetime.datetime, quote_asset: str ) -> decimal.Decimal: + # TODO: Do we want to get historic price data from ONE TRADING (ex Bitpanda Pro) or do we want something else? return self._get_price_bitpanda_pro(base_asset, utc_time, quote_asset) + # Bitpanda Pro is now ONE TRADING. + # TODO: Handle something different? @misc.delayed def _get_price_bitpanda_pro( self, base_asset: str, utc_time: datetime.datetime, quote_asset: str @@ -304,7 +307,7 @@ def _get_price_bitpanda_pro( """ baseurl = ( - f"https://api.exchange.bitpanda.com/public/v1/" + f"https://api.onetrading.com/fast/v1/" f"candlesticks/{base_asset}_{quote_asset}" ) @@ -341,18 +344,18 @@ def _get_price_bitpanda_pro( } if num_offset: log.debug( - f"Calling Bitpanda API for {base_asset} / {quote_asset} price " + f"Calling ONE TRADING (ex Bitpanda Pro) API for {base_asset} / {quote_asset} price " f"for {t} minute timeframe ending at {end} " f"(includes {window_offset} minutes offset)" ) else: log.debug( - f"Calling Bitpanda API for {base_asset} / {quote_asset} price " + f"Calling ONE TRADING (ex Bitpanda Pro) API for {base_asset} / {quote_asset} price " f"for {t} minute timeframe ending at {end}" ) r = requests.get(baseurl, params=params) - assert r.status_code == 200, "No valid response from Bitpanda API" + assert r.status_code == 200, f"No valid response from ONE TRADING (ex Bitpanda Pro) API\nError: {r.json()['error']}" data = r.json() # exit loop if data is valid @@ -604,7 +607,25 @@ def get_cost( reference_coin: str = config.FIAT, ) -> decimal.Decimal: 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 op.coin in ["ETHW", "BEST"] and op.platform == "bitpanda": + # ETHW and BEST are not available via ONE TRADING (ex Bitpanda Pro) API + # => use the price from the exported data. + if op.exported_price is not None: + return op.exported_price + if op.coin == "BEST" and isinstance(op, tr.Fee): + # Fees paid with BEST don't have a value given in the exported data. + # The value also can't be queried from the ONE TRADING (ex Bitpanda Pro) API (anymore) + log.warning( + f"Can't get price for '{type(op).__name__}' of {op.coin} on platform {op.platform} for operation 'Withdrawal' anymore.\n" + f"A withdrawal of BEST on bitpanda is likely a deduction of fees. For now we'll assume a value of 0.\n" + f"For accurately calculating fees, this needs to be fixed. PRs welcome!\n" + f"(row {op.line} in {op.file_path}" + ) + return 0 + raise RuntimeError(f"Can't get price for '{type(op).__name__}' of {op.coin} on platform {op.platform} (row {op.line} in {op.file_path})!") + else: + price = self.get_price(op.platform, op.coin, op.utc_time, reference_coin) + if isinstance(op_sc, tr.Operation): return price * op_sc.change if isinstance(op_sc, tr.SoldCoin): diff --git a/src/transaction.py b/src/transaction.py index 4e4693a..d66c705 100644 --- a/src/transaction.py +++ b/src/transaction.py @@ -46,6 +46,7 @@ class Operation: line: list[int] file_path: Path fees: "Optional[list[Fee]]" = None + exported_price: "Optional[decimal.Decimal]" = None # can hold the price from the exported data (csv) remarks: list[str] = dataclasses.field(default_factory=list) @property @@ -89,6 +90,10 @@ def validate_types(self) -> bool: assert actual_value is None continue + if field.name == "exported_price": + assert (actual_value is None or isinstance(actual_value, decimal.Decimal)) + continue + actual_type = typing.get_origin(field.type) or field.type if isinstance(actual_type, typing._SpecialForm): From 46b8abe3562e379c44a053e8584e48280307d870 Mon Sep 17 00:00:00 2001 From: wullxz Date: Mon, 13 May 2024 20:59:47 +0200 Subject: [PATCH 3/3] Fixes in bitpanda price fetching; more special case handling * bitpanda general: Bitpanda Pro, whose API we use to fetch prices for bitpanda transactions, is now One Trading. As a result, their API is available under a different address and with a slightly different response. This commit contains the fixes for both, the address and the parsing/usage of the result. * bitpanda general: Since One Trading doesn't offer the same coins as Bitpanda anymore, some coin/fiat pairs aren't available there (like BEST/EUR, ETHW/EUR, LTC/EUR and others). To differentiate between a "market" not being available and other errors, we raise a ValueError in case the "market" is not available. I chose ValueError because catching LookupError also catches errors with indices in lists, which should be thrown. * bitpanda general: The ValueError is caught in price_data.py in the `get_cost` method. In the last commit, I added a new field `exported_price` to the `Operation` data class, which is used in the exception handling to use the price from the csv export (it's better than nothing). For BEST, there is sadly no price available if the BEST transaction is a Withdrawal (Fee). For now, we assume a value of 0 in that case (I don't know how to fix this otherwise). * bitpanda LUNC airdrop: I added special handling for the LUNC airdrop that happened in May 2022 because of a blockchain crash and subsequent fork. Sadly, the price can't be retrieved using the API and the airdrop didn't have a price associated. CoinTaxman should throw an exception because it can't fetch a price and it should also say which line the airdrop is in. I used that to edit my csv export and input a ridiculously small price since the price was really low anyway. * bitpanda staking rewards: Sometime before 2022/6/14, bitpanda used "transfer" for staking rewards. Incoming crypto "transfers" before that date, that aren't BEST, are therefore classified as (staking-)reward. --- config.ini | 2 +- src/book.py | 44 ++++++++++++++++++++++++++++++++++++- src/price_data.py | 56 +++++++++++++++++++++++++++++++---------------- src/taxman.py | 8 +++---- 4 files changed, 85 insertions(+), 25 deletions(-) diff --git a/config.ini b/config.ini index f2cbb33..add9db1 100644 --- a/config.ini +++ b/config.ini @@ -28,7 +28,7 @@ LOG_LEVEL = DEBUG # 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. -# Some airdrops can be classified as gifts (Schenkung) or income (Eink├╝nfte) +# Some airdrops can be classified as gifts (Schenkung) or income (Einkünfte) # relatively savely. For those, there is a flag to signal either type. # See the AirdropGift and AirdropIncome classes in transaction.py and their usage in book.py ALL_AIRDROPS_ARE_GIFTS = True diff --git a/src/book.py b/src/book.py index 4b9d3cb..11a0aea 100644 --- a/src/book.py +++ b/src/book.py @@ -1099,7 +1099,7 @@ def _read_bitpanda(self, file_path: Path) -> None: # CocaCola transfer, which I don't want to track. Would need to # be implemented if need be. if operation == "transfer": - if asset == "BEST" and asset_class == "Cryptocurrency": + if asset == "BEST" and asset_class == "Cryptocurrency" and inout == "incoming": # BEST is awarded for trading activity and holding a portfolio at bitpanda # The BEST awards are listed as "transfer" but must be processed as Airdrop (non-taxable) operation = "airdrop_gift" @@ -1132,6 +1132,48 @@ def _read_bitpanda(self, file_path: Path) -> None: f"In row {row} in file {file_path}." ) operation = "airdrop_gift" + elif ( + inout == "incoming" + and asset == "LUNC" + and asset_class == "Fiat" + and utc_time.year == 2022 + and utc_time.month == 5 + ): + # In May 2022 the Terra (LUNA) blockchain crashed. In response, a new chain + # Terra 2.0 (LUNA) was created. The new old chain is still tradeable as + # Terra Classic (LUNC) and holders of LUNA before the crash received their + # LUNC tokens as airdrop. This also applied to LUNA tokens held through + # bitpanda crypto indices. + # Source for bitpanda LUNC airdrop: + # https://support.bitpanda.com/hc/en-us/articles/4995318011292-Terra-2-0-LUNA-Airdrop + # + # The German law regarding this case is not entirely clear: + # https://www.winheller.com/bankrecht-finanzrecht/bitcointrading/bitcoinundsteuer/besteuerung-hardforks-ledger-splits.html + # TODO: This should actually copy the history from the original LUNA history. + log.warning( + f"WARNING: Airdrop of {asset} is a result of the fork of the " + f"LUNA blockchain in May 2022. The legal status of " + f"taxation of hardfork results is not clear in German law. " + f"Also, the date of procurement should be set to the date(s) " + f"of procurement of the original coins, essentially copying " + f"the history of the original chain, which is NOT YET IMPLEMENTED. " + f"See https://support.bitpanda.com/hc/en-us/articles/4995318011292-Terra-2-0-LUNA-Airdrop " + f"for more information. " + f"Please open an issue or PR if you know how to resolve this. " + f"In row {row} in file {file_path}." + ) + # Rewrite this asset_class because "Fiat" clearly wrong. + asset_class = "Cryptocurrency" + operation = "airdrop_gift" + elif ( + inout == "incoming" + and asset_class == "Cryptocurrency" + and asset != "BEST" + and utc_time < datetime.datetime(2022, 6, 14, 0, 0, 0, 0, utc_time.tzinfo) + ): + # Bitpanda tagged incoming staking rewards as incoming transfer until June 14 2022 + # or a few days before that date. After that, staking rewards are correctly tagged as "reward". + operation = "reward" else: log.warning( f"'Transfer' operations are not " diff --git a/src/price_data.py b/src/price_data.py index 99c0cf4..de459d6 100644 --- a/src/price_data.py +++ b/src/price_data.py @@ -355,8 +355,10 @@ def _get_price_bitpanda_pro( ) r = requests.get(baseurl, params=params) - assert r.status_code == 200, f"No valid response from ONE TRADING (ex Bitpanda Pro) API\nError: {r.json()['error']}" data = r.json() + if r.status_code == 400 and data["error"] == f"The requested market {base_asset}_{quote_asset} is not available.": + raise ValueError(data["error"]) + assert r.status_code == 200, f"No valid response from ONE TRADING (ex Bitpanda Pro) API\nError: {r.json()['error']}" # exit loop if data is valid if data: @@ -380,11 +382,12 @@ def _get_price_bitpanda_pro( raise RuntimeError # this should never be triggered, but just in case assert received data - assert data, f"No valid price data for {base_asset} / {quote_asset} at {end}" + assert data["candlesticks"], f"No valid price data for {base_asset} / {quote_asset} at {end}" + data = data["candlesticks"] - # simply take the average of the latest data element - high = misc.force_decimal(data[-1]["high"]) - low = misc.force_decimal(data[-1]["low"]) + # simply take the average of the first data element + high = misc.force_decimal(data[0]["high"]) + low = misc.force_decimal(data[0]["low"]) # if spread is greater than 3% if (high - low) / high > 0.03: @@ -607,24 +610,39 @@ def get_cost( reference_coin: str = config.FIAT, ) -> decimal.Decimal: op = op_sc if isinstance(op_sc, tr.Operation) else op_sc.op - if op.coin in ["ETHW", "BEST"] and op.platform == "bitpanda": - # ETHW and BEST are not available via ONE TRADING (ex Bitpanda Pro) API - # => use the price from the exported data. - if op.exported_price is not None: - return op.exported_price - if op.coin == "BEST" and isinstance(op, tr.Fee): + try: + price = self.get_price(op.platform, op.coin, op.utc_time, reference_coin) + except ValueError as e: + log.warning( + f"The API didn't provide a valid response. Using the price from the csv file if possible.\n" + f"\t\tCoin: {op.coin} | Op: {type(op).__name__} | Platform: {op.platform} | Row: {op.line} | File: {op.file_path}\n" + f"\t\tCaught exception: {e}" + ) + if op.platform == "bitpanda": + # LUNC, ETHW, BEST and maybe more are not available via ONE TRADING (ex Bitpanda Pro) API + # => use the price from the exported data. + if op.exported_price is not None: + price = op.exported_price + # Fees paid with BEST don't have a value given in the exported data. # The value also can't be queried from the ONE TRADING (ex Bitpanda Pro) API (anymore) + if op.coin == "BEST" and isinstance(op, tr.Fee): + log.warning( + f"Can't get price for '{type(op).__name__}' of {op.coin} on platform {op.platform} anymore.\n" + f"A withdrawal of BEST on bitpanda is likely a deduction of fees. For now we'll assume a value of 0.\n" + f"For accurately calculating fees, this needs to be fixed. PRs welcome!\n" + f"(row {op.line} in {op.file_path}" + ) + return 0 + else: log.warning( - f"Can't get price for '{type(op).__name__}' of {op.coin} on platform {op.platform} for operation 'Withdrawal' anymore.\n" - f"A withdrawal of BEST on bitpanda is likely a deduction of fees. For now we'll assume a value of 0.\n" - f"For accurately calculating fees, this needs to be fixed. PRs welcome!\n" - f"(row {op.line} in {op.file_path}" + f"Could not get any price info for {type(op).__name__} {op.coin} on {op.platform}! " + f"Row: {op.line} | File: {op.file_path}" ) - return 0 - raise RuntimeError(f"Can't get price for '{type(op).__name__}' of {op.coin} on platform {op.platform} (row {op.line} in {op.file_path})!") - else: - price = self.get_price(op.platform, op.coin, op.utc_time, reference_coin) + raise RuntimeError(e) + + # This may fail if an exchange is queried for a non existant coin/fiat pair and the operation doesn't include an exported price. + assert price, f"Could not get a price for asset {op.coin} at {op.utc_time}" if isinstance(op_sc, tr.Operation): return price * op_sc.change diff --git a/src/taxman.py b/src/taxman.py index a41d5c1..11719aa 100644 --- a/src/taxman.py +++ b/src/taxman.py @@ -261,8 +261,8 @@ def _evaluate_sell( Raises: NotImplementedError: When there are more than two different fee coins. """ - assert op.coin == sc.op.coin - assert op.change >= sc.sold + assert op.coin == sc.op.coin, f"Error evaluating op.coin==sc.op.coin:\n\t\t{op}\n\t\t{sc}" + assert op.change >= sc.sold, f"Error evaluating op.change >=sc.sold:\n\t\t{op}\n\t\t{sc}" # Share the fees and sell_value proportionally to the coins sold. percent = sc.sold / op.change @@ -284,7 +284,7 @@ def _evaluate_sell( except Exception as e: if ReportType is tr.UnrealizedSellReportEntry: log.warning( - "Catched the following exception while trying to query an " + "Caught the following exception while trying to query an " 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 " @@ -294,7 +294,7 @@ def _evaluate_sell( "The sell value for this calculation will be set to 0. " "Your unrealized sell summary will be wrong and will not " "be exported.\n" - f"Catched exception: {e}" + f"Caught exception: {type(e).__name__}: {e}" ) sell_value_in_fiat = decimal.Decimal() self.unrealized_sells_faulty = True