Skip to content

Commit ccf8667

Browse files
authored
Work with the new IBKR 2022 data format (#243)
* Work with the new IBKR 2022 data format
1 parent 2f31590 commit ccf8667

6 files changed

Lines changed: 69 additions & 52 deletions

File tree

ibkr_report/definitions.py

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from dataclasses import dataclass
55
from decimal import Decimal
6-
from enum import Enum, IntEnum, unique
6+
from enum import Enum, unique
77
from typing import Dict
88

99

@@ -41,19 +41,6 @@ def _strtobool(val: str) -> bool:
4141
MAX_HTTP_RETRIES = 5
4242
SAVED_RATES_FILE = "official_ecb_exchange_rates-{0}.json.xz"
4343

44-
_SINGLE_ACCOUNT = (
45-
"Trades,Header,DataDiscriminator,Asset Category,Currency,Symbol,Date/Time,Exchange,"
46-
"Quantity,T. Price,Proceeds,Comm/Fee,Basis,Realized P/L,Code"
47-
).split(",")
48-
_MULTI_ACCOUNT = (
49-
"Trades,Header,DataDiscriminator,Asset Category,Currency,Account,Symbol,Date/Time,Exchange,"
50-
"Quantity,T. Price,Proceeds,Comm/Fee,Basis,Realized P/L,Code"
51-
).split(",")
52-
OFFSET_DICT = {
53-
tuple(_SINGLE_ACCOUNT): 0,
54-
tuple(_MULTI_ACCOUNT): len(_MULTI_ACCOUNT) - len(_SINGLE_ACCOUNT),
55-
}
56-
FIELD_COUNT = len(_SINGLE_ACCOUNT)
5744
DATE_FORMAT = "%Y-%m-%d"
5845
TIME_FORMAT = " %H:%M:%S"
5946
DATE_STR_FORMATS = (
@@ -70,24 +57,24 @@ class StrEnum(str, Enum):
7057

7158

7259
@unique
73-
class Field(IntEnum):
60+
class Field(StrEnum):
7461
"""CSV indices."""
7562

76-
TRADES = 0
77-
HEADER = 1
78-
DATA_DISCRIMINATOR = 2
79-
ASSET_CATEGORY = 3
80-
CURRENCY = 4
81-
SYMBOL = 5
82-
DATE_TIME = 6
83-
EXCHANGE = 7
84-
QUANTITY = 8
85-
TRANSACTION_PRICE = 9
86-
PROCEEDS = 10
87-
COMMISSION_AND_FEES = 11
88-
BASIS = 12
89-
REALIZED_PL = 13
90-
CODE = 14
63+
TRADES = "Trades"
64+
HEADER = "Header"
65+
DATA_DISCRIMINATOR = "DataDiscriminator"
66+
ASSET_CATEGORY = "Asset Category"
67+
CURRENCY = "Currency"
68+
SYMBOL = "Symbol"
69+
DATE_TIME = "Date/Time"
70+
EXCHANGE = "Exchange"
71+
QUANTITY = "Quantity"
72+
TRANSACTION_PRICE = "T. Price"
73+
PROCEEDS = "Proceeds"
74+
COMMISSION_AND_FEES = "Comm/Fee"
75+
BASIS = "Basis"
76+
REALIZED_PL = "Realized P/L"
77+
CODE = "Code"
9178

9279

9380
@unique
@@ -130,7 +117,7 @@ class ReportOptions:
130117

131118
report_currency: str
132119
deemed_acquisition_cost: bool
133-
offset: int
120+
fields: dict
134121

135122

136123
@dataclass

ibkr_report/report.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010

1111
from ibkr_report.definitions import (
1212
CURRENCY,
13-
FIELD_COUNT,
14-
OFFSET_DICT,
1513
USE_DEEMED_ACQUISITION_COST,
1614
AssetCategory,
1715
DataDiscriminator,
@@ -67,7 +65,7 @@ def __init__(
6765
self.options = ReportOptions(
6866
report_currency=report_currency.upper(),
6967
deemed_acquisition_cost=use_deemed_acquisition_cost,
70-
offset=0,
68+
fields={},
7169
)
7270
self.rates = ExchangeRates()
7371
if file:
@@ -85,32 +83,47 @@ def add_trades(self, file: Iterable[bytes]) -> None:
8583
def is_stock_or_options_trade(self, items: Tuple[str, ...]) -> bool:
8684
"""Checks whether the current row is part of a trade or not."""
8785
if (
88-
len(items) == FIELD_COUNT + self.options.offset
89-
and items[Field.TRADES] == FieldValue.TRADES
90-
and items[Field.HEADER] == FieldValue.HEADER
91-
and items[Field.DATA_DISCRIMINATOR]
86+
all(
87+
item in self.options.fields
88+
for item in [
89+
Field.TRADES,
90+
Field.HEADER,
91+
Field.DATA_DISCRIMINATOR,
92+
Field.ASSET_CATEGORY,
93+
]
94+
)
95+
and items[self.options.fields[Field.TRADES]] == FieldValue.TRADES
96+
and items[self.options.fields[Field.HEADER]] == FieldValue.HEADER
97+
and items[self.options.fields[Field.DATA_DISCRIMINATOR]]
9298
in (DataDiscriminator.TRADE, DataDiscriminator.CLOSED_LOT)
93-
and items[Field.ASSET_CATEGORY]
99+
and items[self.options.fields[Field.ASSET_CATEGORY]]
94100
in (AssetCategory.STOCKS, AssetCategory.OPTIONS)
95101
):
96102
return True
97103
return False
98104

99105
def _handle_one_line(self, items: Tuple[str, ...]) -> None:
100-
offset = OFFSET_DICT.get(items)
101-
if offset is not None:
102-
self.options.offset = offset
106+
if len(items) > 2 and items[0] == Field.TRADES and items[1] == Field.HEADER:
107+
self.options.fields = {}
103108
self._trade = None
109+
for index, item in enumerate(items):
110+
self.options.fields[item] = index
104111
return
105112
if self.is_stock_or_options_trade(items):
106113
self._handle_trade(items)
107114

108115
def _handle_trade(self, items: Tuple[str, ...]) -> None:
109116
"""Parses prices, gains, and losses from trades."""
110-
if items[Field.DATA_DISCRIMINATOR] == DataDiscriminator.TRADE:
117+
if (
118+
items[self.options.fields[Field.DATA_DISCRIMINATOR]]
119+
== DataDiscriminator.TRADE
120+
):
111121
self._trade = Trade(items, self.options, self.rates)
112122
self.prices += self._trade.total_selling_price
113-
if items[Field.DATA_DISCRIMINATOR] == DataDiscriminator.CLOSED_LOT:
123+
if (
124+
items[self.options.fields[Field.DATA_DISCRIMINATOR]]
125+
== DataDiscriminator.CLOSED_LOT
126+
):
114127
if not self._trade:
115128
raise ValueError("Tried to close a lot without trades.")
116129
details = self._trade.details_from_closed_lot(items)

ibkr_report/trade.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ def __init__(
4040
self.rates = rates
4141
self.data = self._row_data(items)
4242

43-
fee = decimal_cleanup(items[Field.COMMISSION_AND_FEES + self.options.offset])
43+
fee = decimal_cleanup(items[self.options.fields[Field.COMMISSION_AND_FEES]])
4444
self.fee = fee / self.data.rate
4545

4646
# Sold stocks have a negative value in the "Quantity" column
4747
if self.data.quantity < Decimal(0):
48-
proceeds = decimal_cleanup(items[Field.PROCEEDS + self.options.offset])
48+
proceeds = decimal_cleanup(items[self.options.fields[Field.PROCEEDS]])
4949
self.total_selling_price = proceeds / self.data.rate
5050
log.debug(
5151
'Trade: "%s" "%s" %.2f',
@@ -70,7 +70,11 @@ def details_from_closed_lot(self, items: Tuple[str, ...]) -> TradeDetails:
7070
unit_sell_price, unit_buy_price = unit_buy_price, unit_sell_price
7171

7272
# One option represents 100 shares of the underlying stock
73-
multiplier = 100 if items[Field.ASSET_CATEGORY] == AssetCategory.OPTIONS else 1
73+
multiplier = (
74+
100
75+
if items[self.options.fields[Field.ASSET_CATEGORY]] == AssetCategory.OPTIONS
76+
else 1
77+
)
7478
lot_sell_price = abs(lot_data.quantity) * unit_sell_price * multiplier
7579
lot_buy_price = abs(lot_data.quantity) * unit_buy_price * multiplier
7680
lot_fee = lot_data.quantity * self.fee / self.data.quantity
@@ -102,16 +106,16 @@ def details_from_closed_lot(self, items: Tuple[str, ...]) -> TradeDetails:
102106
)
103107

104108
def _row_data(self, items: Tuple[str, ...]) -> RowData:
105-
symbol = items[Field.SYMBOL + self.options.offset]
106-
date_str = items[Field.DATE_TIME + self.options.offset]
109+
symbol = items[self.options.fields[Field.SYMBOL]]
110+
date_str = items[self.options.fields[Field.DATE_TIME]]
107111
rate = self.rates.get_rate(
108112
currency_from=self.options.report_currency,
109-
currency_to=items[Field.CURRENCY],
113+
currency_to=items[self.options.fields[Field.CURRENCY]],
110114
date_str=date_str,
111115
)
112-
original_price_per_share = items[Field.TRANSACTION_PRICE + self.options.offset]
116+
original_price_per_share = items[self.options.fields[Field.TRANSACTION_PRICE]]
113117
price_per_share = decimal_cleanup(original_price_per_share) / rate
114-
quantity = decimal_cleanup(items[Field.QUANTITY + self.options.offset])
118+
quantity = decimal_cleanup(items[self.options.fields[Field.QUANTITY]])
115119
return RowData(symbol, date_str, rate, price_per_share, quantity)
116120

117121
def _validate_lot(self, lot_data: RowData) -> None:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Trades,Header,DataDiscriminator,Asset Category,Currency,Symbol,Date/Time,Exchange,Quantity,T. Price,C. Price,Proceeds,Comm/Fee,Basis,Realized P/L,MTM P/L,Code
2+
Trades,Data,Order,Equity and Index Options,USD,SPY 18MAR22 440.0 P,"2022-03-01, 11:02:15",-,-2,14.66,16.7975,2932,-1.4176132,-2483.2827,447.299686,-427.5,C
3+
Trades,Data,Trade,Equity and Index Options,USD,SPY 18MAR22 440.0 P,"2022-03-01, 11:02:15",MERCURY,-2,14.66,16.7975,2932,-1.4176132,-2483.2827,447.299686,-427.5,C
4+
Trades,Data,ClosedLot,Equity and Index Options,USD,SPY 18MAR22 440.0 P,2021-12-14,,2,12.4164135,,,,2483.2827,447.299686,,ST
5+
Trades,SubTotal,,Equity and Index Options,USD,SPY 18MAR22 440.0 P,,,-2,,,2932,-1.4176132,-2483.2827,447.299686,-427.5,

tests/test-data/eurofxref-hist.zip

11.1 KB
Binary file not shown.

tests/test_report.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ def test_report_currency_eur_lowercase(self):
3939
self.assertEqual(round(report.gains, 2), Decimal("5964.76"))
4040
self.assertEqual(round(report.losses, 2), Decimal("0.00"))
4141

42+
def test_report_ibkr_2022_format(self):
43+
report = Report()
44+
with open("tests/test-data/data_single_account_2022.csv", "rb") as file:
45+
report.add_trades(file)
46+
self.assertEqual(round(report.prices, 2), Decimal("2626.77"))
47+
self.assertEqual(round(report.gains, 2), Decimal("429.65"))
48+
self.assertEqual(round(report.losses, 2), Decimal("0.00"))
49+
4250

4351
if __name__ == "__main__":
4452
unittest.main()

0 commit comments

Comments
 (0)