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)