Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .harper-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ BGIF
blackrock
BrokerTransaction
BSF
BST
calc
Center
CG12920
Expand Down
6 changes: 6 additions & 0 deletions cgt_calc/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
from pathlib import Path
from typing import Final
from zoneinfo import ZoneInfo

from dateutil.relativedelta import relativedelta

Expand Down Expand Up @@ -103,6 +104,11 @@ class RuntimeMode(Enum):
BED_AND_BREAKFAST_DAYS: Final = 30

UK_CURRENCY: Final = "GBP"

# Tax dates are UK calendar days, so timestamped transactions are read
# in UK time (GMT in winter, BST in summer) and not in UTC.
UK_TIMEZONE: Final = ZoneInfo("Europe/London")

ERI_TAX_DATE_DELTA: Final = relativedelta(months=6)

TICKER_RENAMES: Final[dict[str, str]] = {
Expand Down
66 changes: 54 additions & 12 deletions cgt_calc/parsers/trading212.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

import csv
from dataclasses import dataclass
from datetime import datetime
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, ClassVar, Final, TextIO, override

from cgt_calc.const import TICKER_RENAMES
from cgt_calc.const import TICKER_RENAMES, UK_TIMEZONE
from cgt_calc.exceptions import ParsingError, UnexpectedColumnCountError
from cgt_calc.model import ActionType, BrokerTransaction, CurrencyCode, Isin

Expand All @@ -25,6 +25,7 @@ class Trading212Column(StrEnum):

ACTION = "Action"
TIME = "Time"
TIME_UTC = "Time (UTC)"
ISIN = "ISIN"
TICKER = "Ticker"
NAME = "Name"
Expand Down Expand Up @@ -160,6 +161,23 @@ def decimal_or_none(
raise ValueError(f"Invalid decimal in {column.value}: {value!r}") from err


def datetime_from_str(value: str) -> datetime:
"""Convert a timestamp to an aware UTC datetime.

Exports have used whole seconds and milliseconds, and the column
renamed to "Time (UTC)" may spell the zone out. Every export states
its times in UTC, so a value without a zone is read as UTC too.
"""

try:
parsed = datetime.fromisoformat(value)
except ValueError as err:
raise ValueError(f"Invalid timestamp: {value!r}") from err
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)


def action_from_str(label: str, file: Path) -> ActionType:
"""Convert label to ActionType."""
if label in {
Expand Down Expand Up @@ -235,10 +253,17 @@ def __init__(self, header: list[str], row_raw: list[str], file: Path) -> None:
for column, value in zip(header, row_raw, strict=False)
}

time_str = row[Trading212Column.TIME]
time_format = "%Y-%m-%d %H:%M:%S.%f" if "." in time_str else "%Y-%m-%d %H:%M:%S"
self.datetime = datetime.strptime(time_str, time_format)
date = self.datetime.date()
# Older exports call the column "Time", newer ones "Time (UTC)".
time_str = row.get(Trading212Column.TIME) or row.get(Trading212Column.TIME_UTC)
if not time_str:
raise ValueError(
f"Missing {Trading212Column.TIME.value} "
f"or {Trading212Column.TIME_UTC.value}"
)
self.datetime = datetime_from_str(time_str)
# The instant is kept in UTC for ordering, but the date that drives
# the tax year and the matching rules is the UK one.
date = self.datetime.astimezone(UK_TIMEZONE).date()
self.raw_action = row[Trading212Column.ACTION]
action = action_from_str(self.raw_action, file)

Expand Down Expand Up @@ -302,7 +327,9 @@ def __init__(self, header: list[str], row_raw: list[str], file: Path) -> None:
and (self.currency_foreign == "GBP" or self.exchange_rate is not None)
):
exchange_rate = self.exchange_rate or Decimal(1)
check_fees = self._checkable_fees(fees, foreign_fees, exchange_rate)
check_fees = self._checkable_fees(
row, fees, foreign_fees, currency, exchange_rate
)
if check_fees is not None:
check_price = abs(amount + check_fees) / quantity
calculated_price_foreign = check_price * exchange_rate
Expand Down Expand Up @@ -338,18 +365,33 @@ def __init__(self, header: list[str], row_raw: list[str], file: Path) -> None:

def _checkable_fees(
self,
row: dict[Trading212Column, str],
fees: Decimal,
foreign_fees: dict[CurrencyCode, Decimal],
currency: CurrencyCode,
exchange_rate: Decimal,
) -> Decimal | None:
"""Total fees for the price consistency check.
"""Total amounts to add back for the price consistency check.

Foreign fees in the instrument currency are converted with the
export's own exchange rate. Returns None when a fee is in some
other currency, which the export alone cannot convert.
Withholding tax is deducted from a dividend Total while the Price
per Share stays gross, so it is added back here. It is not a
dealing cost and never reaches the reported fees.

Foreign amounts in the instrument currency are converted with the
export's own exchange rate. Returns None when one is in some other
currency, which the export alone cannot convert.
"""
total = fees
for fee_currency, fee_amount in foreign_fees.items():
foreign = dict(foreign_fees)
withholding_tax = decimal_or_none(row, Trading212Column.WITHHOLDING_TAX)
if withholding_tax:
tax_currency = row.get(Trading212Column.CURRENCY_WITHHOLDING_TAX) or None
if tax_currency is None or tax_currency == currency:
total += withholding_tax
else:
tax_code = CurrencyCode(tax_currency)
foreign[tax_code] = foreign.get(tax_code, Decimal(0)) + withholding_tax
for fee_currency, fee_amount in foreign.items():
if fee_currency != self.currency_foreign:
return None
total += fee_amount / exchange_rate
Expand Down
12 changes: 10 additions & 2 deletions docs/brokers/trading212.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,18 @@ The Trading 212 parser currently handles:
| Corporate actions | Transactions labelled `Stock Split` or `Spin off` |
| Costs and taxes | Transaction, regulatory and currency-conversion fees; stamp duty, stamp duty reserve tax and French transaction tax, including costs charged in a foreign currency |

### Dates and time zones

Trading 212 timestamps every transaction in UTC. cgt-calc converts each one to UK time, GMT in
winter and BST in summer, before taking the date. The tax year boundary and the same-day and 30-day
matching rules all run on UK calendar days, and the boundary always falls inside BST, so a
transaction stamped after 23:00 UTC on 5 April belongs to the following tax year.

### Known limitations

- Dividends are recorded at the CSV `Total`; the `Withholding tax` column is not used and does not
appear separately in the report.
- Dividends are recorded at the CSV `Total`, which is net of withholding tax. The `Withholding tax`
column is only used to check the export for consistency and does not appear separately in the
report.
- Share transfers between accounts or brokers, labelled `Transfer in` or `Transfer out`, are not
supported.
- Split transactions labelled `Stock split open` or `Stock split close` are not supported. Only the
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ dependencies = [
"openpyxl>=3.1.5",
"xlrd>=2.0.2",
"shtab>=1.12.0",
# zoneinfo needs a tz database where the OS does not ship one.
"tzdata>=2025.2; sys_platform == 'win32' or sys_platform == 'emscripten'",
]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion tests/trading212/data/2026/inputs/transactions.csv
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Action,Time,ISIN,Ticker,Name,Notes,ID,No. of shares,Price / share,Currency (Price / share),Exchange rate,Result,Currency (Result),Total,Currency (Total),Withholding tax,Currency (Withholding tax),Stamp duty reserve tax,Currency (Stamp duty reserve tax),French transaction tax,Currency (French transaction tax),Transaction fee,Finra fee,Currency conversion from amount,Currency (Currency conversion from amount),Currency conversion to amount,Currency (Currency conversion to amount),Currency conversion fee,Currency (Currency conversion fee),Currency (Transaction fee),Currency (Finra fee),Merchant name,Merchant category
Action,Time (UTC),ISIN,Ticker,Name,Notes,ID,No. of shares,Price / share,Currency (Price / share),Exchange rate,Result,Currency (Result),Total,Currency (Total),Withholding tax,Currency (Withholding tax),Stamp duty reserve tax,Currency (Stamp duty reserve tax),French transaction tax,Currency (French transaction tax),Transaction fee,Finra fee,Currency conversion from amount,Currency (Currency conversion from amount),Currency conversion to amount,Currency (Currency conversion to amount),Currency conversion fee,Currency (Currency conversion fee),Currency (Transaction fee),Currency (Finra fee),Merchant name,Merchant category
Deposit,2024-05-01 00:10:00.000,,,,,xxxxxxxxxxxx01,,,,,,,10000.00,GBP,,,,,,,,,,,,,,,,,,
Market buy,2024-05-02 14:30:05.123,US0000000200,ACME,Acme Corp,,xxxxxxxxxxxx02,10.0000000000,150.00,USD,1.25,,,1201.80,GBP,,,,,,,,,,,,,1.80,GBP,,,,
Limit sell,2024-06-27 15:45:10.456,US0000000200,ACME,Acme Corp,,xxxxxxxxxxxx03,10.0000000000,160.00,USD,1.28,46.29,GBP,1248.09,GBP,,,,,,,,0.05,,,,,1.87,GBP,,USD,,
Expand Down
Loading
Loading