From 90eb2cc9cb3cde51ff65b81fcb9f6a6ec1930056 Mon Sep 17 00:00:00 2001 From: przemyslawbialon Date: Sun, 26 Apr 2026 16:45:40 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Binance=20Spot=20export=20format=20chang?= =?UTF-8?q?e=20(UTC=5FTime=E2=86=92Time,=20YY=20year)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binance renamed the time column ("UTC_Time" → "Time"), added a leading "User ID" column, and shortened timestamps from 4-digit to 2-digit year in recent Spot exports. The parser now reads row["time"] and uses "%y-%m-%d %H:%M:%S". Legacy exports need to be regenerated from the Binance portal — back-compat would require a heuristic fallback that isn't worth the surface area. Also: - First unit tests for the Binance parser (there were none before) — cover deposit-skip, convert-pair grouping, transaction-triple fee aggregation, and the YY vs YYYY guard. - Anonymized 7-row fixture (User ID 999999999, round amounts). - CliRunner test for `pit38 import binance` for parity with revolut-stock and ibi-capital. Noted as out-of-scope for this fix: FiatValue.currency is populated with the raw "PLN" string instead of Currency.ZLOTY (pre-existing quirk). Tests assert on components so they're honest about the drift without endorsing it. --- pit38/plugins/crypto/binance/csv.py | 8 +- .../fixtures/binance_transaction_history.csv | 7 ++ tests/e2e/test_cli_e2e.py | 15 ++++ tests/test_binance_csv.py | 73 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/fixtures/binance_transaction_history.csv create mode 100644 tests/test_binance_csv.py diff --git a/pit38/plugins/crypto/binance/csv.py b/pit38/plugins/crypto/binance/csv.py index 8042241..a38fdb8 100644 --- a/pit38/plugins/crypto/binance/csv.py +++ b/pit38/plugins/crypto/binance/csv.py @@ -18,8 +18,14 @@ class BinanceOperationType(Enum): class BinanceTransaction: + # Binance Spot export switched headers in 2025 ("UTC_Time" → "Time"), added a + # leading "User ID" column, and shortened the year in timestamps (2024 → 24). + # We parse the current format; legacy exports need regeneration from the + # Binance portal rather than client-side back-compat here. + _TIME_FORMAT = "%y-%m-%d %H:%M:%S" + def __init__(self, row: dict): - self.utc_time = datetime.strptime(row["utc_time"], "%Y-%m-%d %H:%M:%S") + self.utc_time = datetime.strptime(row["time"], self._TIME_FORMAT) self.operation = row["operation"] self.coin = row["coin"] self.change = float(row["change"]) diff --git a/tests/e2e/fixtures/binance_transaction_history.csv b/tests/e2e/fixtures/binance_transaction_history.csv new file mode 100644 index 0000000..7a42476 --- /dev/null +++ b/tests/e2e/fixtures/binance_transaction_history.csv @@ -0,0 +1,7 @@ +User ID,Time,Account,Operation,Coin,Change,Remark +999999999,24-01-10 10:00:00,Spot,Deposit,PLN,1000, +999999999,24-01-15 12:00:00,Spot,Binance Convert,BTC,0.002, +999999999,24-01-15 12:00:00,Spot,Binance Convert,PLN,-500, +999999999,24-01-20 14:30:00,Spot,Transaction Buy,BTC,0.001, +999999999,24-01-20 14:30:00,Spot,Transaction Fee,BTC,-0.000001, +999999999,24-01-20 14:30:00,Spot,Transaction Spend,PLN,-200.0, diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py index 6c9aa61..2c8cc47 100644 --- a/tests/e2e/test_cli_e2e.py +++ b/tests/e2e/test_cli_e2e.py @@ -69,6 +69,21 @@ def test_import_revolut_stock(self): self.assertIn("Saved", result.output) self.assertTrue(pathlib.Path("output.csv").exists()) + def test_import_binance(self): + runner = CliRunner() + csv_path = str(FIXTURES / "binance_transaction_history.csv") + + with runner.isolated_filesystem(): + result = runner.invoke(main, [ + "import", "binance", + "-i", csv_path, + "-o", "output.csv", + "-ll", "ERROR", + ]) + self.assertEqual(result.exit_code, 0, msg=result.output) + self.assertIn("Saved 2 transactions", result.output) + self.assertTrue(pathlib.Path("output.csv").exists()) + class TestImportIbiCapitalCLI(TestCase): """CLI integration for ``pit38 import ibi-capital``. diff --git a/tests/test_binance_csv.py b/tests/test_binance_csv.py new file mode 100644 index 0000000..a51559c --- /dev/null +++ b/tests/test_binance_csv.py @@ -0,0 +1,73 @@ +"""Unit tests for the Binance Spot export parser. + +Covers the post-2025 format where the time column was renamed +``UTC_Time`` → ``Time`` and the year in timestamps shortened (2024 → 24). +""" +import pathlib +from unittest import TestCase + +import pendulum + +from pit38.domain.transactions import Action, AssetValue +from pit38.plugins.crypto.binance.csv import BinanceTransactionProcessor + +FIXTURE = pathlib.Path(__file__).parent / "e2e" / "fixtures" / "binance_transaction_history.csv" + + +class TestBinanceCsvReader(TestCase): + def test_reads_fixture_without_crashing(self): + transactions = BinanceTransactionProcessor().read(str(FIXTURE)) + self.assertEqual(len(transactions), 2) + + def test_deposit_rows_are_skipped(self): + # Deposit rows top up the Spot wallet with fiat but carry no + # buy/sell intent — they must not leak into the output. Our + # fixture has 1 Deposit; parsed output should never contain + # a PLN-denominated asset leg. + transactions = BinanceTransactionProcessor().read(str(FIXTURE)) + + self.assertTrue(all(tx.asset.asset_name in {"BTC", "ETH"} for tx in transactions)) + + def test_convert_pair_becomes_single_buy(self): + transactions = BinanceTransactionProcessor().read(str(FIXTURE)) + converts = [t for t in transactions if t.date == pendulum.datetime(2024, 1, 15, 12, 0, 0)] + + self.assertEqual(len(converts), 1) + self.assertEqual(converts[0].action, Action.BUY) + self.assertEqual(converts[0].asset, AssetValue(0.002, "BTC")) + # The current Binance parser stores the raw coin string ("PLN") + # on FiatValue.currency rather than Currency.ZLOTY — pre-existing + # quirk that's outside the scope of this format-change fix. We + # assert on components so the test stays honest about observed + # behavior without silently endorsing the string-vs-enum drift. + self.assertEqual(converts[0].fiat_value.amount, 500.0) + self.assertEqual(str(converts[0].fiat_value.currency), "PLN") + + def test_transaction_triple_aggregates_fee_into_asset(self): + # Transaction Buy/Fee/Spend triples: the asset received is + # buy_amount + |fee_amount| (the fee is a crypto-denominated + # deduction from the buy, so the cost basis in PLN covers both). + transactions = BinanceTransactionProcessor().read(str(FIXTURE)) + triples = [t for t in transactions if t.date == pendulum.datetime(2024, 1, 20, 14, 30, 0)] + + self.assertEqual(len(triples), 1) + tx = triples[0] + self.assertEqual(tx.action, Action.BUY) + # 0.001 buy + 0.000001 fee = 0.001001 BTC + self.assertAlmostEqual(tx.asset.amount, 0.001001, places=6) + self.assertEqual(tx.asset.asset_name, "BTC") + self.assertEqual(tx.fiat_value.amount, 200.0) + self.assertEqual(str(tx.fiat_value.currency), "PLN") + + def test_output_sorted_by_date(self): + transactions = BinanceTransactionProcessor().read(str(FIXTURE)) + dates = [t.date for t in transactions] + self.assertEqual(dates, sorted(dates)) + + def test_yy_year_parsing(self): + # Guard against accidental revert to "%Y-%m-%d" — that would + # parse "24-01-15" as year 24 AD and silently produce garbage + # dates, which FIFO would then match wrongly. + transactions = BinanceTransactionProcessor().read(str(FIXTURE)) + for tx in transactions: + self.assertGreaterEqual(tx.date.year, 2000)