From 7ed739271a1113f1abe0fca27d132cd86396f273 Mon Sep 17 00:00:00 2001 From: Casper da Costa-Luis Date: Tue, 25 Aug 2026 16:06:59 +0100 Subject: [PATCH 1/3] fix trading212 import --- cgt_calc/parsers/trading212.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/cgt_calc/parsers/trading212.py b/cgt_calc/parsers/trading212.py index 17db3d5a..83986e96 100644 --- a/cgt_calc/parsers/trading212.py +++ b/cgt_calc/parsers/trading212.py @@ -24,7 +24,7 @@ class Trading212Column(StrEnum): """Columns exported in the Trading 212 transaction CSV.""" ACTION = "Action" - TIME = "Time" + TIME = "Time (UTC)" ISIN = "ISIN" TICKER = "Ticker" NAME = "Name" @@ -236,8 +236,7 @@ def __init__(self, header: list[str], row_raw: list[str], file: Path) -> None: } 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) + self.datetime = datetime.strptime(time_str[:19], "%Y-%m-%d %H:%M:%S") date = self.datetime.date() self.raw_action = row[Trading212Column.ACTION] action = action_from_str(self.raw_action, file) @@ -304,9 +303,16 @@ def __init__(self, header: list[str], row_raw: list[str], file: Path) -> None: exchange_rate = self.exchange_rate or Decimal(1) check_fees = self._checkable_fees(fees, foreign_fees, exchange_rate) if check_fees is not None: - check_price = abs(amount + check_fees) / quantity - calculated_price_foreign = check_price * exchange_rate - discrepancy = self.price_foreign - calculated_price_foreign + if action == ActionType.DIVIDEND: + discrepancy = ( + self.price_foreign * quantity * exchange_rate + - amount + - check_fees + ) + else: + check_price = abs(amount + check_fees) / quantity + calculated_price_foreign = check_price * exchange_rate + discrepancy = self.price_foreign - calculated_price_foreign if abs(discrepancy) > Decimal("0.015"): LOGGER.warning( "The Price per Share for this transaction after converting " From db5eea6402bbb2bf580a1ebd28dfc458da015902 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Tue, 25 Aug 2026 19:46:42 +0100 Subject: [PATCH 2/3] Accept both Time columns and use withholding tax in the price check Recent exports renamed the Time column to "Time (UTC)", but older exports still use "Time" and a directory is expected to hold several years of them, so accept both spellings. Parse timestamps with fromisoformat so whole seconds, milliseconds and a zone designator all work, and an unparsable value still raises a ParsingError with the row instead of being silently truncated. The spurious dividend price warning comes from withholding tax: the Total is net of it while the Price per Share stays gross. Add the tax back for the consistency check only. It is not a dealing cost, so it never reaches the reported fees. --- cgt_calc/parsers/trading212.py | 74 ++++-- docs/brokers/trading212.md | 5 +- .../data/2026/inputs/transactions.csv | 2 +- tests/trading212/test_trading212.py | 232 ++++++++++++++++++ 4 files changed, 290 insertions(+), 23 deletions(-) diff --git a/cgt_calc/parsers/trading212.py b/cgt_calc/parsers/trading212.py index 83986e96..d379867c 100644 --- a/cgt_calc/parsers/trading212.py +++ b/cgt_calc/parsers/trading212.py @@ -4,7 +4,7 @@ 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 @@ -24,7 +24,8 @@ class Trading212Column(StrEnum): """Columns exported in the Trading 212 transaction CSV.""" ACTION = "Action" - TIME = "Time (UTC)" + 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 datetime. + + Exports have used whole seconds and milliseconds, and the column + renamed to "Time (UTC)" may spell the zone out. Zone-aware values are + converted to UTC and made naive so every transaction stays comparable. + """ + + 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 + return parsed.astimezone(UTC).replace(tzinfo=None) + + def action_from_str(label: str, file: Path) -> ActionType: """Convert label to ActionType.""" if label in { @@ -235,8 +253,14 @@ 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] - self.datetime = datetime.strptime(time_str[:19], "%Y-%m-%d %H:%M:%S") + # 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) date = self.datetime.date() self.raw_action = row[Trading212Column.ACTION] action = action_from_str(self.raw_action, file) @@ -301,18 +325,13 @@ 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: - if action == ActionType.DIVIDEND: - discrepancy = ( - self.price_foreign * quantity * exchange_rate - - amount - - check_fees - ) - else: - check_price = abs(amount + check_fees) / quantity - calculated_price_foreign = check_price * exchange_rate - discrepancy = self.price_foreign - calculated_price_foreign + check_price = abs(amount + check_fees) / quantity + calculated_price_foreign = check_price * exchange_rate + discrepancy = self.price_foreign - calculated_price_foreign if abs(discrepancy) > Decimal("0.015"): LOGGER.warning( "The Price per Share for this transaction after converting " @@ -344,18 +363,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. + + 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 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. + 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..052dd9d3 100644 --- a/docs/brokers/trading212.md +++ b/docs/brokers/trading212.md @@ -69,8 +69,9 @@ The Trading 212 parser currently handles: ### 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/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..d743158b 100644 --- a/tests/trading212/test_trading212.py +++ b/tests/trading212/test_trading212.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import 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,231 @@ 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)), + ("2024-01-01 16:10:05.175", datetime(2024, 1, 1, 16, 10, 5, 175000)), + ("2024-01-01T16:10:05Z", datetime(2024, 1, 1, 16, 10, 5)), + ("2024-01-01 16:10:05.175+00:00", datetime(2024, 1, 1, 16, 10, 5, 175000)), + ("2024-01-01 17:10:05+01:00", datetime(2024, 1, 1, 16, 10, 5)), + ], +) +def test_datetime_from_str(value: str, expected: datetime) -> None: + """Parse every timestamp shape the exports use, normalised to naive UTC.""" + + assert datetime_from_str(value) == 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) + 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.""" From b43633bf6c6c1443fb673205bb2e2c9eed992c5f Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Tue, 25 Aug 2026 21:20:24 +0100 Subject: [PATCH 3/3] Read Trading 212 dates in UK time The tax year boundary and the same-day and 30-day matching rules run on UK calendar days, and 5 April always falls inside BST. Taking the date straight from the UTC instant put anything stamped after 23:00 UTC on 5 April in the previous tax year. Keep the instant in UTC for ordering and derive the date in Europe/London. zoneinfo needs tzdata where the OS ships no tz database. --- .harper-dictionary.txt | 1 + cgt_calc/const.py | 6 +++ cgt_calc/parsers/trading212.py | 16 ++++---- docs/brokers/trading212.md | 7 ++++ pyproject.toml | 2 + tests/trading212/test_trading212.py | 60 +++++++++++++++++++++++++---- uv.lock | 2 + 7 files changed, 79 insertions(+), 15 deletions(-) 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 d379867c..99788d43 100644 --- a/cgt_calc/parsers/trading212.py +++ b/cgt_calc/parsers/trading212.py @@ -10,7 +10,7 @@ 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 @@ -162,11 +162,11 @@ def decimal_or_none( def datetime_from_str(value: str) -> datetime: - """Convert a timestamp to 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. Zone-aware values are - converted to UTC and made naive so every transaction stays comparable. + 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: @@ -174,8 +174,8 @@ def datetime_from_str(value: str) -> datetime: except ValueError as err: raise ValueError(f"Invalid timestamp: {value!r}") from err if parsed.tzinfo is None: - return parsed - return parsed.astimezone(UTC).replace(tzinfo=None) + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) def action_from_str(label: str, file: Path) -> ActionType: @@ -261,7 +261,9 @@ def __init__(self, header: list[str], row_raw: list[str], file: Path) -> None: f"or {Trading212Column.TIME_UTC.value}" ) self.datetime = datetime_from_str(time_str) - date = self.datetime.date() + # 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) diff --git a/docs/brokers/trading212.md b/docs/brokers/trading212.md index 052dd9d3..3dff3125 100644 --- a/docs/brokers/trading212.md +++ b/docs/brokers/trading212.md @@ -67,6 +67,13 @@ 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`, which is net of withholding tax. The `Withholding tax` 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/test_trading212.py b/tests/trading212/test_trading212.py index d743158b..9ba6abe5 100644 --- a/tests/trading212/test_trading212.py +++ b/tests/trading212/test_trading212.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, date, datetime from decimal import Decimal import logging from pathlib import Path @@ -977,19 +977,63 @@ def test_read_trading212_transactions_unconvertible_fee_skips_price_check( @pytest.mark.parametrize( ("value", "expected"), [ - ("2024-01-01 16:10:05", datetime(2024, 1, 1, 16, 10, 5)), - ("2024-01-01 16:10:05.175", datetime(2024, 1, 1, 16, 10, 5, 175000)), - ("2024-01-01T16:10:05Z", datetime(2024, 1, 1, 16, 10, 5)), - ("2024-01-01 16:10:05.175+00:00", datetime(2024, 1, 1, 16, 10, 5, 175000)), - ("2024-01-01 17:10:05+01:00", datetime(2024, 1, 1, 16, 10, 5)), + ("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 naive UTC.""" + """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: @@ -1022,7 +1066,7 @@ def test_read_trading212_transactions_supports_time_utc_column( assert len(transactions) == 1 transaction = transactions[0] assert isinstance(transaction, Trading212Transaction) - assert transaction.datetime == datetime(2026, 5, 2, 14, 30, 5, 123000) + assert transaction.datetime == datetime(2026, 5, 2, 14, 30, 5, 123000, tzinfo=UTC) assert transaction.amount == Decimal("-1200.00") 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" }, ]