forked from rotki/rotki
/
events.py
892 lines (805 loc) · 36.3 KB
/
events.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
import logging
from typing import Dict, Optional, Tuple
from rotkehlchen.accounting.structures import DefiEvent
from rotkehlchen.assets.asset import Asset
from rotkehlchen.constants import BTC_BCH_FORK_TS, ETH_DAO_FORK_TS, ZERO
from rotkehlchen.constants.assets import A_BCH, A_BTC, A_ETC, A_ETH
from rotkehlchen.csv_exporter import CSVExporter
from rotkehlchen.errors import NoPriceForGivenTimestamp, PriceQueryUnsupportedAsset
from rotkehlchen.exchanges.data_structures import BuyEvent, Events, MarginPosition, SellEvent
from rotkehlchen.fval import FVal
from rotkehlchen.history import PriceHistorian
from rotkehlchen.logging import RotkehlchenLogsAdapter
from rotkehlchen.typing import Fee, Location, Timestamp
from rotkehlchen.utils.misc import taxable_gain_for_sell, timestamp_to_date, ts_now
logger = logging.getLogger(__name__)
log = RotkehlchenLogsAdapter(logger)
class TaxableEvents():
def __init__(self, csv_exporter: CSVExporter, profit_currency: Asset) -> None:
self.events: Dict[Asset, Events] = {}
self.csv_exporter = csv_exporter
self.profit_currency = profit_currency
# If this flag is True when your asset is being forcefully sold as a
# loan/margin settlement then profit/loss is also calculated before the entire
# amount is taken as a loss
self.count_profit_for_settlements = False
self._taxfree_after_period: Optional[int] = None
self._include_crypto2crypto: Optional[bool] = None
def reset(self, start_ts: Timestamp, end_ts: Timestamp) -> None:
self.events = {}
self.query_start_ts = start_ts
self.query_end_ts = end_ts
self.general_trade_profit_loss = ZERO
self.taxable_trade_profit_loss = ZERO
self.loan_profit = ZERO
self.defi_profit_loss = ZERO
self.settlement_losses = ZERO
self.margin_positions_profit_loss = ZERO
self.defi_profit_loss = ZERO
@property
def include_crypto2crypto(self) -> Optional[bool]:
return self._include_crypto2crypto
@include_crypto2crypto.setter
def include_crypto2crypto(self, value: Optional[bool]) -> None:
self._include_crypto2crypto = value
@property
def taxfree_after_period(self) -> Optional[int]:
return self._taxfree_after_period
@taxfree_after_period.setter
def taxfree_after_period(self, value: Optional[int]) -> None:
is_valid = isinstance(value, int) or value is None
assert is_valid, 'set taxfree_after_period should only get int or None'
self._taxfree_after_period = value
def calculate_asset_details(self) -> Dict[Asset, Tuple[FVal, FVal]]:
""" Calculates what amount of all assets has been untouched for a year and
is hence tax-free and also the average buy price for each asset"""
self.details: Dict[Asset, Tuple[FVal, FVal]] = {}
now = ts_now()
for asset, events in self.events.items():
tax_free_amount_left = ZERO
amount_sum = ZERO
average = ZERO
for buy_event in events.buys:
if self.taxfree_after_period is not None:
if buy_event.timestamp + self.taxfree_after_period < now:
tax_free_amount_left += buy_event.amount
amount_sum += buy_event.amount
average += buy_event.amount * buy_event.rate
if amount_sum == ZERO:
self.details[asset] = (ZERO, ZERO)
else:
self.details[asset] = (tax_free_amount_left, average / amount_sum)
return self.details
def get_rate_in_profit_currency(self, asset: Asset, timestamp: Timestamp) -> FVal:
"""Get the profit_currency price of asset in the given timestamp
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from the price oracle
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
if asset == self.profit_currency:
rate = FVal(1)
else:
rate = PriceHistorian().query_historical_price(
from_asset=asset,
to_asset=self.profit_currency,
timestamp=timestamp,
)
return rate
def reduce_asset_amount(self, asset: Asset, amount: FVal) -> bool:
"""Searches all buy events for asset and reduces them by amount
Returns True if enough buy events to reduce the asset by amount were
found and False otherwise.
"""
# No need to do anything if amount is to be reduced by zero
if amount == ZERO:
return True
if asset not in self.events or len(self.events[asset].buys) == 0:
return False
remaining_amount_from_last_buy = FVal('-1')
remaining_amount = amount
for idx, buy_event in enumerate(self.events[asset].buys):
if remaining_amount < buy_event.amount:
stop_index = idx
remaining_amount_from_last_buy = buy_event.amount - remaining_amount
# stop iterating since we found all buys to satisfy reduction
break
else:
remaining_amount -= buy_event.amount
if idx == len(self.events[asset].buys) - 1:
stop_index = idx + 1
# Otherwise, delete all the used up buys from the list
del self.events[asset].buys[:stop_index]
# and modify the amount of the buy where we stopped if there is one
if remaining_amount_from_last_buy != FVal('-1'):
self.events[asset].buys[0].amount = remaining_amount_from_last_buy
elif remaining_amount != ZERO:
return False
return True
def handle_prefork_asset_buys(
self,
location: Location,
bought_asset: Asset,
bought_amount: FVal,
paid_with_asset: Asset,
trade_rate: FVal,
fee_in_profit_currency: Fee,
fee_currency: Asset,
timestamp: Timestamp,
) -> None:
# TODO: Should fee also be taken into account here?
if bought_asset == 'ETH' and timestamp < ETH_DAO_FORK_TS:
self.add_buy(
location=location,
bought_asset=A_ETC,
bought_amount=bought_amount,
paid_with_asset=paid_with_asset,
trade_rate=trade_rate,
fee_in_profit_currency=fee_in_profit_currency,
fee_currency=fee_currency,
timestamp=timestamp,
is_virtual=True,
)
if bought_asset == 'BTC' and timestamp < BTC_BCH_FORK_TS:
# Acquiring BTC before the BCH fork provides equal amount of BCH
self.add_buy(
location=location,
bought_asset=A_BCH,
bought_amount=bought_amount,
paid_with_asset=paid_with_asset,
trade_rate=trade_rate,
fee_in_profit_currency=fee_in_profit_currency,
fee_currency=fee_currency,
timestamp=timestamp,
is_virtual=True,
)
def handle_prefork_asset_sells(
self, sold_asset: Asset,
sold_amount: FVal,
timestamp: Timestamp,
) -> None:
if sold_asset == A_ETH and timestamp < ETH_DAO_FORK_TS:
if not self.reduce_asset_amount(asset=A_ETC, amount=sold_amount):
log.critical(
'No documented buy found for ETC (ETH equivalent) before {}'.format(
timestamp_to_date(timestamp, formatstr='%d/%m/%Y %H:%M:%S'),
),
)
if sold_asset == A_BTC and timestamp < BTC_BCH_FORK_TS:
if not self.reduce_asset_amount(asset=A_BCH, amount=sold_amount):
log.critical(
'No documented buy found for BCH (BTC equivalent) before {}'.format(
timestamp_to_date(timestamp, formatstr='%d/%m/%Y %H:%M:%S'),
),
)
def add_buy_and_corresponding_sell(
self,
location: Location,
bought_asset: Asset,
bought_amount: FVal,
paid_with_asset: Asset,
trade_rate: FVal,
fee_in_profit_currency: Fee,
fee_currency: Asset,
timestamp: Timestamp,
) -> None:
"""
Account for the given buy and the corresponding sell if it's a crypto to crypto
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from cryptocompare
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
self.add_buy(
location=location,
bought_asset=bought_asset,
bought_amount=bought_amount,
paid_with_asset=paid_with_asset,
trade_rate=trade_rate,
fee_in_profit_currency=fee_in_profit_currency,
fee_currency=fee_currency,
timestamp=timestamp,
is_virtual=False,
)
if paid_with_asset.is_fiat() or not self.include_crypto2crypto:
return
# else you are also selling some other asset to buy the bought asset
log.debug(
f'Buying {bought_asset} with {paid_with_asset} also introduces a virtual sell event',
)
try:
bought_asset_rate_in_profit_currency = self.get_rate_in_profit_currency(
bought_asset,
timestamp,
)
except (NoPriceForGivenTimestamp, PriceQueryUnsupportedAsset):
bought_asset_rate_in_profit_currency = FVal(-1)
if bought_asset_rate_in_profit_currency != FVal(-1):
# The asset bought does not have a price yet
# Can happen for Token sales, presales e.t.c.
with_bought_asset_gain = bought_asset_rate_in_profit_currency * bought_amount
receiving_asset = bought_asset
receiving_amount = bought_amount
rate_in_profit_currency = bought_asset_rate_in_profit_currency / trade_rate
gain_in_profit_currency = with_bought_asset_gain
sold_amount = trade_rate * bought_amount
sold_asset_rate_in_profit_currency = self.get_rate_in_profit_currency(
paid_with_asset,
timestamp,
)
with_sold_asset_gain = sold_asset_rate_in_profit_currency * sold_amount
# Consider as value of the sell what would give the least profit
if (bought_asset_rate_in_profit_currency == -1 or
with_sold_asset_gain < with_bought_asset_gain):
receiving_asset = self.profit_currency
receiving_amount = with_sold_asset_gain
trade_rate = sold_asset_rate_in_profit_currency
rate_in_profit_currency = sold_asset_rate_in_profit_currency
gain_in_profit_currency = with_sold_asset_gain
self.add_sell(
location=location,
selling_asset=paid_with_asset,
selling_amount=sold_amount,
receiving_asset=receiving_asset,
receiving_amount=receiving_amount,
trade_rate=trade_rate,
rate_in_profit_currency=rate_in_profit_currency,
gain_in_profit_currency=gain_in_profit_currency,
total_fee_in_profit_currency=fee_in_profit_currency,
timestamp=timestamp,
is_virtual=True,
)
def add_buy(
self,
location: Location,
bought_asset: Asset,
bought_amount: FVal,
paid_with_asset: Asset,
trade_rate: FVal,
fee_in_profit_currency: Fee,
fee_currency: Asset,
timestamp: Timestamp,
is_virtual: bool = False,
) -> None:
"""
Account for the given buy
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from all price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from cryptocompare
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
skip_trade = (
not self.include_crypto2crypto and
not bought_asset.is_fiat() and
not paid_with_asset.is_fiat()
)
if skip_trade:
return
paid_with_asset_rate = self.get_rate_in_profit_currency(paid_with_asset, timestamp)
buy_rate = paid_with_asset_rate * trade_rate
self.handle_prefork_asset_buys(
location=location,
bought_asset=bought_asset,
bought_amount=bought_amount,
paid_with_asset=paid_with_asset,
trade_rate=trade_rate,
fee_in_profit_currency=fee_in_profit_currency,
fee_currency=fee_currency,
timestamp=timestamp,
)
if bought_asset not in self.events:
self.events[bought_asset] = Events([], [])
gross_cost = bought_amount * buy_rate
cost_in_profit_currency = gross_cost + fee_in_profit_currency
self.events[bought_asset].buys.append(
BuyEvent(
amount=bought_amount,
timestamp=timestamp,
rate=buy_rate,
fee_rate=fee_in_profit_currency / bought_amount,
),
)
log.debug(
'Buy Event',
sensitive_log=True,
location=str(location),
bought_amount=bought_amount,
bought_asset=bought_asset,
paid_with_asset=paid_with_asset,
rate=trade_rate,
rate_in_profit_currency=buy_rate,
profit_currency=self.profit_currency,
timestamp=timestamp,
)
if timestamp >= self.query_start_ts:
self.csv_exporter.add_buy(
location=location,
bought_asset=bought_asset,
rate=buy_rate,
fee_cost=fee_in_profit_currency,
amount=bought_amount,
cost=cost_in_profit_currency,
paid_with_asset=paid_with_asset,
paid_with_asset_rate=paid_with_asset_rate,
timestamp=timestamp,
is_virtual=is_virtual,
)
def add_sell_and_corresponding_buy(
self,
location: Location,
selling_asset: Asset,
selling_amount: FVal,
receiving_asset: Asset,
receiving_amount: FVal,
gain_in_profit_currency: FVal,
total_fee_in_profit_currency: Fee,
trade_rate: FVal,
rate_in_profit_currency: FVal,
timestamp: Timestamp,
) -> None:
"""
Account for the given sell and the corresponding buy if it's a crypto to crypto
Args:
selling_asset (str): The ticker representation of the asset we sell.
selling_amount (FVal): The amount of `selling_asset` for sale.
receiving_asset (str): The ticker representation of the asset we receive
in exchange for `selling_asset`.
receiving_amount (FVal): The amount of `receiving_asset` we receive.
gain_in_profit_currency (FVal): This is the amount of `profit_currency` equivalent
we receive after doing this trade. Fees are not counted
in this.
total_fee_in_profit_currency (FVal): This is the amount of `profit_currency` equivalent
we pay in fees after doing this trade.
trade_rate (FVal): How much does 1 unit of `receiving_asset` cost in `selling_asset`
rate_in_profit_currency (FVal): The equivalent of `trade_rate` in `profit_currency`
timestamp (int): The timestamp for the trade
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from cryptocompare
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
self.add_sell(
location=location,
selling_asset=selling_asset,
selling_amount=selling_amount,
receiving_asset=receiving_asset,
receiving_amount=receiving_amount,
gain_in_profit_currency=gain_in_profit_currency,
total_fee_in_profit_currency=total_fee_in_profit_currency,
trade_rate=trade_rate,
rate_in_profit_currency=rate_in_profit_currency,
timestamp=timestamp,
is_virtual=False,
)
if receiving_asset.is_fiat() or not self.include_crypto2crypto:
return
log.debug(
f'Selling {selling_asset} for {receiving_asset} also introduces a virtual buy event',
)
# else then you are also buying some other asset through your sell
self.add_buy(
location=location,
bought_asset=receiving_asset,
bought_amount=receiving_amount,
paid_with_asset=selling_asset,
trade_rate=1 / trade_rate,
fee_in_profit_currency=total_fee_in_profit_currency,
fee_currency=receiving_asset, # does not matter
timestamp=timestamp,
is_virtual=True,
)
def add_sell(
self,
location: Location,
selling_asset: Asset,
selling_amount: FVal,
receiving_asset: Optional[Asset],
receiving_amount: Optional[FVal],
gain_in_profit_currency: FVal,
total_fee_in_profit_currency: Fee,
trade_rate: FVal,
rate_in_profit_currency: FVal,
timestamp: Timestamp,
loan_settlement: bool = False,
is_virtual: bool = False,
) -> None:
"""Account for the given sell action
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from cryptocompare
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
skip_trade = (
not self.include_crypto2crypto and
not selling_asset.is_fiat() and
receiving_asset and not receiving_asset.is_fiat()
)
if skip_trade:
return
if selling_asset not in self.events:
self.events[selling_asset] = Events([], [])
self.events[selling_asset].sells.append(
SellEvent(
amount=selling_amount,
timestamp=timestamp,
rate=rate_in_profit_currency,
fee_rate=total_fee_in_profit_currency / selling_amount,
gain=gain_in_profit_currency,
),
)
self.handle_prefork_asset_sells(selling_asset, selling_amount, timestamp)
if loan_settlement:
log.debug(
'Loan Settlement Selling Event',
sensitive_log=True,
selling_amount=selling_amount,
selling_asset=selling_asset,
gain_in_profit_currency=gain_in_profit_currency,
profit_currency=self.profit_currency,
timestamp=timestamp,
)
else:
log.debug(
'Selling Event',
sensitive_log=True,
selling_amount=selling_amount,
selling_asset=selling_asset,
receiving_amount=receiving_amount,
receiving_asset=receiving_asset,
rate=trade_rate,
rate_in_profit_currency=rate_in_profit_currency,
profit_currency=self.profit_currency,
gain_in_profit_currency=gain_in_profit_currency,
fee_in_profit_currency=total_fee_in_profit_currency,
timestamp=timestamp,
)
# now search the buys for `paid_with_asset` and calculate profit/loss
(
taxable_amount,
taxable_bought_cost,
taxfree_bought_cost,
) = self.search_buys_calculate_profit(
selling_amount, selling_asset, timestamp,
)
general_profit_loss = ZERO
taxable_profit_loss = ZERO
# If we don't include crypto2crypto and we sell for crypto, stop here
if receiving_asset and not receiving_asset.is_fiat() and not self.include_crypto2crypto:
return
# calculate profit/loss
if not loan_settlement or (loan_settlement and self.count_profit_for_settlements):
taxable_gain = taxable_gain_for_sell(
taxable_amount=taxable_amount,
rate_in_profit_currency=rate_in_profit_currency,
total_fee_in_profit_currency=total_fee_in_profit_currency,
selling_amount=selling_amount,
)
general_profit_loss = gain_in_profit_currency - (
taxfree_bought_cost +
taxable_bought_cost +
total_fee_in_profit_currency
)
taxable_profit_loss = taxable_gain - taxable_bought_cost
# should never happen, should be stopped at the main loop
assert timestamp <= self.query_end_ts, (
"Trade time > query_end_ts found in adding to sell event"
)
# count profit/losses if we are inside the query period
if timestamp >= self.query_start_ts:
if loan_settlement:
# If it's a loan settlement we are charged both the fee and the gain
settlement_loss = gain_in_profit_currency + total_fee_in_profit_currency
expected = rate_in_profit_currency * selling_amount + total_fee_in_profit_currency
msg = (
f'Expected settlement loss mismatch. rate_in_profit_currency'
f' ({rate_in_profit_currency}) * selling_amount'
f' ({selling_amount}) + total_fee_in_profit_currency'
f' ({total_fee_in_profit_currency}) != settlement_loss '
f'({settlement_loss})'
)
assert expected == settlement_loss, msg
self.settlement_losses += settlement_loss
log.debug(
'Loan Settlement Loss',
sensitive_log=True,
settlement_loss=settlement_loss,
profit_currency=self.profit_currency,
)
else:
log.debug(
"After Sell Profit/Loss",
sensitive_log=True,
taxable_profit_loss=taxable_profit_loss,
general_profit_loss=general_profit_loss,
profit_currency=self.profit_currency,
)
self.general_trade_profit_loss += general_profit_loss
self.taxable_trade_profit_loss += taxable_profit_loss
if loan_settlement:
self.csv_exporter.add_loan_settlement(
location=location,
asset=selling_asset,
amount=selling_amount,
rate_in_profit_currency=rate_in_profit_currency,
total_fee_in_profit_currency=total_fee_in_profit_currency,
timestamp=timestamp,
)
else:
assert receiving_asset, 'Here receiving asset should have a value'
self.csv_exporter.add_sell(
location=location,
selling_asset=selling_asset,
rate_in_profit_currency=rate_in_profit_currency,
total_fee_in_profit_currency=total_fee_in_profit_currency,
gain_in_profit_currency=gain_in_profit_currency,
selling_amount=selling_amount,
receiving_asset=receiving_asset,
receiving_amount=receiving_amount,
receiving_asset_rate_in_profit_currency=self.get_rate_in_profit_currency(
receiving_asset,
timestamp,
),
taxable_amount=taxable_amount,
taxable_bought_cost=taxable_bought_cost,
timestamp=timestamp,
is_virtual=is_virtual,
)
def search_buys_calculate_profit(
self,
selling_amount: FVal,
selling_asset: Asset,
timestamp: Timestamp,
) -> Tuple[FVal, FVal, FVal]:
"""
When selling `selling_amount` of `selling_asset` at `timestamp` this function
calculates using the first-in-first-out rule the corresponding buy/s from
which to do profit calculation. Also applies the one year rule after which
a sell is not taxable in Germany.
Returns a tuple of 3 values:
- `taxable_amount`: The amount out of `selling_amount` that is taxable,
calculated from the 1 year rule.
- `taxable_bought_cost`: How much it cost in `profit_currency` to buy
the `taxable_amount`
- `taxfree_bought_cost`: How much it cost in `profit_currency` to buy
the taxfree_amount (selling_amount - taxable_amount)
"""
remaining_sold_amount = selling_amount
stop_index = -1
taxfree_bought_cost = ZERO
taxable_bought_cost = ZERO
taxable_amount = ZERO
taxfree_amount = ZERO
remaining_amount_from_last_buy = FVal('-1')
for idx, buy_event in enumerate(self.events[selling_asset].buys):
if self.taxfree_after_period is None:
at_taxfree_period = False
else:
at_taxfree_period = (
buy_event.timestamp + self.taxfree_after_period < timestamp
)
if remaining_sold_amount < buy_event.amount:
stop_index = idx
buying_cost = remaining_sold_amount.fma(
buy_event.rate,
(buy_event.fee_rate * remaining_sold_amount),
)
if at_taxfree_period:
taxfree_amount += remaining_sold_amount
taxfree_bought_cost += buying_cost
else:
taxable_amount += remaining_sold_amount
taxable_bought_cost += buying_cost
remaining_amount_from_last_buy = buy_event.amount - remaining_sold_amount
log.debug(
'Sell uses up part of historical buy',
sensitive_log=True,
tax_status='TAX-FREE' if at_taxfree_period else 'TAXABLE',
used_amount=remaining_sold_amount,
from_amount=buy_event.amount,
asset=selling_asset,
trade_buy_rate=buy_event.rate,
profit_currency=self.profit_currency,
trade_timestamp=buy_event.timestamp,
)
# stop iterating since we found all buys to satisfy this sell
break
else:
buying_cost = buy_event.amount.fma(
buy_event.rate,
(buy_event.fee_rate * buy_event.amount),
)
remaining_sold_amount -= buy_event.amount
if at_taxfree_period:
taxfree_amount += buy_event.amount
taxfree_bought_cost += buying_cost
else:
taxable_amount += buy_event.amount
taxable_bought_cost += buying_cost
log.debug(
'Sell uses up entire historical buy',
sensitive_log=True,
tax_status='TAX-FREE' if at_taxfree_period else 'TAXABLE',
bought_amount=buy_event.amount,
asset=selling_asset,
trade_buy_rate=buy_event.rate,
profit_currency=self.profit_currency,
trade_timestamp=buy_event.timestamp,
)
# If the sell used up the last historical buy
if idx == len(self.events[selling_asset].buys) - 1:
stop_index = idx + 1
if len(self.events[selling_asset].buys) == 0:
log.critical(
'No documented buy found for "{}" before {}'.format(
selling_asset,
timestamp_to_date(timestamp, formatstr='%d/%m/%Y %H:%M:%S'),
),
)
# That means we had no documented buy for that asset. This is not good
# because we can't prove a corresponding buy and as such we are burdened
# calculating the entire sell as profit which needs to be taxed
return selling_amount, ZERO, ZERO
# Otherwise, delete all the used up buys from the list
del self.events[selling_asset].buys[:stop_index]
# and modify the amount of the buy where we stopped if there is one
if remaining_amount_from_last_buy != FVal('-1'):
self.events[selling_asset].buys[0].amount = remaining_amount_from_last_buy
elif remaining_sold_amount != ZERO:
# if we still have sold amount but no buys to satisfy it then we only
# found buys to partially satisfy the sell
adjusted_amount = selling_amount - taxfree_amount
log.critical(
'Not enough documented buys found for "{}" before {}.'
'Only found buys for {} {}'.format(
selling_asset,
timestamp_to_date(timestamp, formatstr='%d/%m/%Y %H:%M:%S'),
taxable_amount + taxfree_amount,
selling_asset,
),
)
return adjusted_amount, taxable_bought_cost, taxfree_bought_cost
return taxable_amount, taxable_bought_cost, taxfree_bought_cost
def add_loan_gain(
self,
location: Location,
gained_asset: Asset,
gained_amount: FVal,
fee_in_asset: Fee,
lent_amount: FVal,
open_time: Timestamp,
close_time: Timestamp,
) -> None:
"""Account for gains from the given loan
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from the external service.
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
timestamp = close_time
rate = self.get_rate_in_profit_currency(gained_asset, timestamp)
if gained_asset not in self.events:
self.events[gained_asset] = Events([], [])
net_gain_amount = gained_amount - fee_in_asset
gain_in_profit_currency = net_gain_amount * rate
assert gain_in_profit_currency > 0, "Loan profit is negative. Should never happen"
self.events[gained_asset].buys.append(
BuyEvent(
amount=net_gain_amount,
timestamp=timestamp,
rate=rate,
fee_rate=ZERO,
),
)
# count profits if we are inside the query period
if timestamp >= self.query_start_ts:
log.debug(
'Accounting for loan profit',
sensitive_log=True,
location=location,
gained_asset=gained_asset,
gained_amount=gained_amount,
gain_in_profit_currency=gain_in_profit_currency,
lent_amount=lent_amount,
open_time=open_time,
close_time=close_time,
)
self.loan_profit += gain_in_profit_currency
self.csv_exporter.add_loan_profit(
location=location,
gained_asset=gained_asset,
gained_amount=gained_amount,
gain_in_profit_currency=gain_in_profit_currency,
lent_amount=lent_amount,
open_time=open_time,
close_time=close_time,
)
def add_margin_position(self, margin: MarginPosition) -> None:
"""Account for the given margin position
May raise:
- PriceQueryUnsupportedAsset if from/to asset is missing from price oracles
- NoPriceForGivenTimestamp if we can't find a price for the asset in the given
timestamp from the external service.
- RemoteError if there is a problem reaching the price oracle server
or with reading the response returned by the server
"""
if margin.pl_currency not in self.events:
self.events[margin.pl_currency] = Events([], [])
if margin.fee_currency not in self.events:
self.events[margin.fee_currency] = Events([], [])
pl_currency_rate = self.get_rate_in_profit_currency(margin.pl_currency, margin.close_time)
fee_currency_rate = self.get_rate_in_profit_currency(margin.pl_currency, margin.close_time)
net_gain_loss_in_profit_currency = (
margin.profit_loss * pl_currency_rate - margin.fee * fee_currency_rate
)
# Add or remove to the pl_currency asset
if margin.profit_loss > 0:
self.events[margin.pl_currency].buys.append(
BuyEvent(
amount=margin.profit_loss,
timestamp=margin.close_time,
rate=pl_currency_rate,
fee_rate=ZERO,
),
)
elif margin.profit_loss < 0:
result = self.reduce_asset_amount(
asset=margin.pl_currency,
amount=-margin.profit_loss,
)
if not result:
log.critical(
f'No documented buy found for {margin.pl_currency} before '
f'{timestamp_to_date(margin.close_time, formatstr="%d/%m/%Y %H:%M:%S")}',
)
# Reduce the fee_currency asset
result = self.reduce_asset_amount(asset=margin.fee_currency, amount=margin.fee)
if not result:
log.critical(
f'No documented buy found for {margin.fee_currency} before '
f'{timestamp_to_date(margin.close_time, formatstr="%d/%m/%Y %H:%M:%S")}',
)
# count profit/loss if we are inside the query period
if margin.close_time >= self.query_start_ts:
self.margin_positions_profit_loss += net_gain_loss_in_profit_currency
log.debug(
'Accounting for margin position',
sensitive_log=True,
notes=margin.notes,
gain_loss_asset=margin.pl_currency,
gain_loss_amount=margin.profit_loss,
net_gain_loss_in_profit_currency=net_gain_loss_in_profit_currency,
timestamp=margin.close_time,
)
self.csv_exporter.add_margin_position(
location=margin.location,
margin_notes=margin.notes,
gain_loss_asset=margin.pl_currency,
gain_loss_amount=margin.profit_loss,
gain_loss_in_profit_currency=net_gain_loss_in_profit_currency,
timestamp=margin.close_time,
)
def add_defi_event(self, event: DefiEvent) -> None:
log.debug(
'Accounting for DeFi event',
sensitive_log=True,
event=event,
)
rate = self.get_rate_in_profit_currency(event.asset, event.timestamp)
profit_loss = event.amount * rate
if event.is_profitable():
self.defi_profit_loss += profit_loss
else:
self.defi_profit_loss -= profit_loss
self.csv_exporter.add_defi_event(event=event, profit_loss_in_profit_currency=profit_loss)