diff --git a/.harper-dictionary.txt b/.harper-dictionary.txt index 06e0b38e..1aaf1375 100644 --- a/.harper-dictionary.txt +++ b/.harper-dictionary.txt @@ -18,6 +18,7 @@ BGIF blackrock BrokerTransaction BSF +BST calc Center CG12920 diff --git a/cgt_calc/const.py b/cgt_calc/const.py index e45e3fc4..09a8f643 100644 --- a/cgt_calc/const.py +++ b/cgt_calc/const.py @@ -8,6 +8,7 @@ import os from pathlib import Path from typing import Final +from zoneinfo import ZoneInfo from dateutil.relativedelta import relativedelta @@ -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]] = { diff --git a/cgt_calc/parsers/trading212.py b/cgt_calc/parsers/trading212.py index 17db3d5a..99788d43 100644 --- a/cgt_calc/parsers/trading212.py +++ b/cgt_calc/parsers/trading212.py @@ -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 @@ -25,6 +25,7 @@ class Trading212Column(StrEnum): ACTION = "Action" TIME = "Time" + TIME_UTC = "Time (UTC)" ISIN = "ISIN" TICKER = "Ticker" NAME = "Name" @@ -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 { @@ -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) @@ -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 @@ -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 diff --git a/docs/brokers/trading212.md b/docs/brokers/trading212.md index fa6e8790..3dff3125 100644 --- a/docs/brokers/trading212.md +++ b/docs/brokers/trading212.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e325b410..a36e9f7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/trading212/data/2026/inputs/transactions.csv b/tests/trading212/data/2026/inputs/transactions.csv index 48e89b20..236a8a79 100644 --- a/tests/trading212/data/2026/inputs/transactions.csv +++ b/tests/trading212/data/2026/inputs/transactions.csv @@ -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,, diff --git a/tests/trading212/test_trading212.py b/tests/trading212/test_trading212.py index b4fb6ef8..9ba6abe5 100644 --- a/tests/trading212/test_trading212.py +++ b/tests/trading212/test_trading212.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, date, datetime from decimal import Decimal import logging from pathlib import Path @@ -16,6 +17,7 @@ Trading212Column, Trading212Parser, Trading212Transaction, + datetime_from_str, ) from tests.utils import build_cmd, report_path, stderr_alerts @@ -104,6 +106,11 @@ "Merchant category", ] +# Trading 212 renamed the Time column to spell out the zone. +HEADER_2026_UTC = [ + "Time (UTC)" if column == "Time" else column for column in HEADER_2026 +] + def _write_csv(path: Path, rows: list[list[str]]) -> None: path.write_text("\n".join(",".join(row) for row in rows) + "\n", encoding="utf-8") @@ -967,6 +974,275 @@ def test_read_trading212_transactions_unconvertible_fee_skips_price_check( assert "does not add up" not in caplog.text +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("2024-01-01 16:10:05", datetime(2024, 1, 1, 16, 10, 5, tzinfo=UTC)), + ( + "2024-01-01 16:10:05.175", + datetime(2024, 1, 1, 16, 10, 5, 175000, tzinfo=UTC), + ), + ("2024-01-01T16:10:05Z", datetime(2024, 1, 1, 16, 10, 5, tzinfo=UTC)), + ( + "2024-01-01 16:10:05.175+00:00", + datetime(2024, 1, 1, 16, 10, 5, 175000, tzinfo=UTC), + ), + ("2024-01-01 17:10:05+01:00", datetime(2024, 1, 1, 16, 10, 5, tzinfo=UTC)), + ], +) +def test_datetime_from_str(value: str, expected: datetime) -> None: + """Parse every timestamp shape the exports use, normalised to UTC.""" + + assert datetime_from_str(value) == expected + + +@pytest.mark.parametrize( + ("time_str", "expected"), + [ + # The tax year boundary always falls inside BST, so the last hour + # of 5 April in UTC already belongs to the next tax year. + ("2025-04-05 22:59:59+00:00", date(2025, 4, 5)), + ("2025-04-05 23:00:00+00:00", date(2025, 4, 6)), + ("2025-04-05 23:30:00", date(2025, 4, 6)), + # Outside summer time UK dates and UTC dates agree. + ("2026-01-15 23:30:00+00:00", date(2026, 1, 15)), + ("2026-11-01 23:30:00+00:00", date(2026, 11, 1)), + ], +) +def test_read_trading212_transactions_uses_uk_dates( + tmp_path: Path, time_str: str, expected: date +) -> None: + """Take the tax date from the UK calendar, not the UTC one.""" + + rows = [ + HEADER_2024, + _make_row( + HEADER_2024, + { + Trading212Column.ACTION: "Deposit", + Trading212Column.TIME: time_str, + Trading212Column.TOTAL: "100.00", + Trading212Column.CURRENCY_TOTAL: "GBP", + Trading212Column.TRANSACTION_ID: "deposit-boundary", + }, + ), + ] + folder = _prepare_file(tmp_path, rows) + + transactions = Trading212Parser().load_from_dir(folder) + + assert transactions[0].date == expected + + +def test_read_trading212_transactions_supports_time_utc_column( + tmp_path: Path, +) -> None: + """Parse an export whose Time column spells out the zone.""" + + rows = [ + HEADER_2026_UTC, + _make_row( + HEADER_2026_UTC, + { + Trading212Column.TIME_UTC: "2026-05-02 14:30:05.123", + Trading212Column.ACTION: "Market buy", + Trading212Column.ISIN: "US0000000200", + Trading212Column.TICKER: "ACME", + Trading212Column.NAME: "Acme Corp", + Trading212Column.NO_OF_SHARES: "10", + Trading212Column.PRICE_PER_SHARE: "150.00", + Trading212Column.CURRENCY_PRICE_PER_SHARE: "USD", + Trading212Column.EXCHANGE_RATE: "1.25", + Trading212Column.TOTAL: "1200.00", + Trading212Column.CURRENCY_TOTAL: "GBP", + Trading212Column.TRANSACTION_ID: "buy-utc", + }, + ), + ] + folder = _prepare_file(tmp_path, rows) + + transactions = Trading212Parser().load_from_dir(folder) + + assert len(transactions) == 1 + transaction = transactions[0] + assert isinstance(transaction, Trading212Transaction) + assert transaction.datetime == datetime(2026, 5, 2, 14, 30, 5, 123000, tzinfo=UTC) + assert transaction.amount == Decimal("-1200.00") + + +def test_read_trading212_transactions_mixes_time_column_spellings( + tmp_path: Path, +) -> None: + """Read old and new exports from the same directory.""" + + folder = tmp_path / "inputs" + folder.mkdir() + _write_csv( + folder / "old.csv", + [ + HEADER_2026, + _make_row( + HEADER_2026, + { + Trading212Column.ACTION: "Deposit", + Trading212Column.TIME: "2026-05-01 00:10:00.000", + Trading212Column.TOTAL: "100.00", + Trading212Column.CURRENCY_TOTAL: "GBP", + Trading212Column.TRANSACTION_ID: "deposit-old", + }, + ), + ], + ) + _write_csv( + folder / "new.csv", + [ + HEADER_2026_UTC, + _make_row( + HEADER_2026_UTC, + { + Trading212Column.ACTION: "Deposit", + Trading212Column.TIME_UTC: "2026-05-02 00:10:00.000", + Trading212Column.TOTAL: "200.00", + Trading212Column.CURRENCY_TOTAL: "GBP", + Trading212Column.TRANSACTION_ID: "deposit-new", + }, + ), + ], + ) + + transactions = Trading212Parser().load_from_dir(folder) + + assert [transaction.amount for transaction in transactions] == [ + Decimal("100.00"), + Decimal("200.00"), + ] + + +def test_read_trading212_transactions_missing_time_column(tmp_path: Path) -> None: + """Raise ParsingError when neither Time column is present.""" + + header = ["Action", "Total", "Currency (Total)", "ID"] + rows = [header, ["Deposit", "100.00", "GBP", "deposit-no-time"]] + folder = _prepare_file(tmp_path, rows) + + with pytest.raises(ParsingError) as exc: + Trading212Parser().load_from_dir(folder) + + message = str(exc.value) + assert "row 2" in message + assert "Missing Time or Time (UTC)" in message + + +def test_read_trading212_transactions_invalid_time(tmp_path: Path) -> None: + """Raise ParsingError when the timestamp cannot be parsed.""" + + rows = [ + HEADER_2024, + _make_row( + HEADER_2024, + { + Trading212Column.ACTION: "Deposit", + Trading212Column.TIME: "01/01/2024 10:00", + Trading212Column.TOTAL: "100.00", + Trading212Column.CURRENCY_TOTAL: "GBP", + Trading212Column.TRANSACTION_ID: "deposit-bad-time", + }, + ), + ] + folder = _prepare_file(tmp_path, rows) + + with pytest.raises(ParsingError) as exc: + Trading212Parser().load_from_dir(folder) + + message = str(exc.value) + assert "row 2" in message + assert "Invalid timestamp" in message + + +def _make_dividend_row( + total: str, overrides: Mapping[str | Trading212Column, str] | None = None +) -> list[str]: + """Build a GBP-account dividend of a USD stock with USD withholding tax.""" + row: dict[str | Trading212Column, str] = { + Trading212Column.ACTION: "Dividend (Ordinary)", + Trading212Column.TIME: "2024-06-01 12:00:00", + Trading212Column.ISIN: "US0000000036", + Trading212Column.TICKER: "BAZ", + Trading212Column.NAME: "Baz Corp", + Trading212Column.NO_OF_SHARES: "10", + Trading212Column.PRICE_PER_SHARE: "1.00", + Trading212Column.CURRENCY_PRICE_PER_SHARE: "USD", + Trading212Column.EXCHANGE_RATE: "1.25", + Trading212Column.TOTAL: total, + Trading212Column.CURRENCY_TOTAL: "GBP", + Trading212Column.WITHHOLDING_TAX: "1.50", + Trading212Column.CURRENCY_WITHHOLDING_TAX: "USD", + Trading212Column.TRANSACTION_ID: "dividend-withholding", + } + row.update(overrides or {}) + return _make_row(HEADER_2024, row) + + +def test_read_trading212_transactions_withholding_tax_price_consistent( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Stay quiet when only withholding tax separates the total from the price. + + Trading 212 reports the gross Price per Share but a Total net of + withholding tax, so the tax has to be added back before comparing them. + """ + + # 10 shares at $1.00 less $1.50 tax is $8.50, or GBP 6.80 at 1.25. + folder = _prepare_file(tmp_path, [HEADER_2024, _make_dividend_row("6.80")]) + + with caplog.at_level(logging.WARNING, logger="cgt_calc.parsers.trading212"): + transactions = Trading212Parser().load_from_dir(folder) + + assert "does not add up" not in caplog.text + assert transactions[0].amount == Decimal("6.80") + # Withholding tax is not a dealing cost. + assert transactions[0].fees == Decimal(0) + + +def test_read_trading212_transactions_withholding_tax_price_discrepancy( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Still warn when a dividend does not add up once tax is accounted for.""" + + folder = _prepare_file(tmp_path, [HEADER_2024, _make_dividend_row("6.00")]) + + with caplog.at_level(logging.WARNING, logger="cgt_calc.parsers.trading212"): + Trading212Parser().load_from_dir(folder) + + assert "does not add up" in caplog.text + + +@pytest.mark.parametrize("tax_currency", ["GBP", ""]) +def test_read_trading212_transactions_account_currency_withholding_tax( + tmp_path: Path, caplog: pytest.LogCaptureFixture, tax_currency: str +) -> None: + """Add back withholding tax charged in the account currency. + + Some exports leave the tax currency blank, which means the currency of + the transaction. + """ + + row = _make_dividend_row( + "8.50", + { + Trading212Column.CURRENCY_PRICE_PER_SHARE: "GBP", + Trading212Column.EXCHANGE_RATE: "", + Trading212Column.CURRENCY_WITHHOLDING_TAX: tax_currency, + }, + ) + folder = _prepare_file(tmp_path, [HEADER_2024, row]) + + with caplog.at_level(logging.WARNING, logger="cgt_calc.parsers.trading212"): + Trading212Parser().load_from_dir(folder) + + assert "does not add up" not in caplog.text + + def test_read_trading212_transactions_invalid_decimal(tmp_path: Path) -> None: """Raise ParsingError when a decimal value is invalid.""" diff --git a/uv.lock b/uv.lock index 7e7a4d42..d4ff963f 100644 --- a/uv.lock +++ b/uv.lock @@ -170,6 +170,7 @@ dependencies = [ { name = "pyrate-limiter" }, { name = "requests" }, { name = "shtab" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, { name = "xlrd" }, { name = "yfinance" }, ] @@ -211,6 +212,7 @@ requires-dist = [ { name = "pyrate-limiter", specifier = ">=4.0.0" }, { name = "requests", specifier = ">=2.27.1" }, { name = "shtab", specifier = ">=1.12.0" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'", specifier = ">=2025.2" }, { name = "xlrd", specifier = ">=2.0.2" }, { name = "yfinance", specifier = ">=1.6.0" }, ]