diff --git a/cgt_calc/main.py b/cgt_calc/main.py index 0b027f0e..81c6303f 100755 --- a/cgt_calc/main.py +++ b/cgt_calc/main.py @@ -31,6 +31,7 @@ from .model import ( ActionType, BrokerTransaction, + CalcuationType, CalculationEntry, CalculationLog, CapitalGainsReport, @@ -57,6 +58,55 @@ def get_amount_or_fail(transaction: BrokerTransaction) -> Decimal: return amount +# Amount difference is caused by rounding errors in the price. +# Schwab rounds down the price to 4 decimal places +# so that the error in amount can be more than $0.01. +# Fox example: +# 500 shares of FOO sold at $100.00016 with $1.23 fees. +# "01/01/2024,"Sell","FOO","FOO","500","$100.0001","$1.23","$49,998.85" +# calculated_amount = 500 * 100.0001 - 1.23 = 49998.82 +# amount_on_record = 49998.85 vs calculated_amount = 49998.82 +def _approx_equal_price_rounding( + amount_on_record: Decimal, + quantity_on_record: Decimal, + price_on_record: Decimal, + fees_on_record: Decimal, + calcuationType: CalcuationType, +) -> bool: + calculated_amount = Decimal(0) + calculated_price = Decimal(0) + if calcuationType is CalcuationType.ACQUISITION: + calculated_amount = Decimal(-1) * ( + quantity_on_record * price_on_record + fees_on_record + ) + calculated_price = ( + Decimal(-1) * amount_on_record - fees_on_record + ) / quantity_on_record + elif calcuationType is CalcuationType.DISPOSAL: + calculated_amount = quantity_on_record * price_on_record - fees_on_record + calculated_price = (amount_on_record + fees_on_record) / quantity_on_record + if abs(calculated_price - price_on_record) < Decimal("0.0001"): + LOGGER.debug( + "Price calculated_price %.6f vs price_on_record %s in an acceptable range", + calculated_price, + price_on_record, + ) + return True + LOGGER.debug( + "Price error in calculated_price %.6f vs price_on_record %s", + calculated_price, + price_on_record, + ) + accptable_amount = _approx_equal(amount_on_record, calculated_amount) + LOGGER.debug( + "Amount amount_on_record %.6f vs calculated_amount %s in %s", + calculated_price, + price_on_record, + "acceptable range" if accptable_amount else "error", + ) + return accptable_amount + + # It is not clear how Schwab or other brokers round the dollar value, # so assume the values are equal if they are within $0.01. def _approx_equal(val_a: Decimal, val_b: Decimal) -> bool: @@ -131,7 +181,13 @@ def add_acquisition( amount = get_amount_or_fail(transaction) calculated_amount = quantity * price + transaction.fees - if not _approx_equal(amount, -calculated_amount): + if not _approx_equal_price_rounding( + amount, + quantity, + price, + transaction.fees, + CalcuationType.ACQUISITION, + ): raise CalculatedAmountDiscrepancyError(transaction, -calculated_amount) amount = -amount @@ -265,7 +321,13 @@ def add_disposal( if price is None: raise PriceMissingError(transaction) calculated_amount = quantity * price - transaction.fees - if not _approx_equal(amount, calculated_amount): + if not _approx_equal_price_rounding( + amount, + quantity, + price, + transaction.fees, + CalcuationType.DISPOSAL, + ): raise CalculatedAmountDiscrepancyError(transaction, calculated_amount) add_to_list( self.disposal_list, diff --git a/cgt_calc/model.py b/cgt_calc/model.py index d079d0a7..eca14f3f 100644 --- a/cgt_calc/model.py +++ b/cgt_calc/model.py @@ -67,6 +67,13 @@ class ActionType(Enum): CASH_MERGER = 16 +class CalcuationType(Enum): + """Calculation type enumeration.""" + + ACQUISITION = 1 + DISPOSAL = 2 + + @dataclass class BrokerTransaction: """Broken transaction data.""" diff --git a/tests/test_data/schwab/expected_output.txt b/tests/test_data/schwab/expected_output.txt new file mode 100644 index 00000000..1244867c --- /dev/null +++ b/tests/test_data/schwab/expected_output.txt @@ -0,0 +1,29 @@ +WARNING: No schwab award file provided +INFO: No schwab Equity Award JSON file provided +INFO: No trading212 folder provided +INFO: No mssb folder provided +INFO: No sharesight file provided +INFO: No raw file provided +First pass completed +Final portfolio: +Final balance: + Charles Schwab: 1019826.86 (USD) +Dividends: £0.00 +Dividend taxes: £0.00 +Interest: £0.00 +Disposal proceeds: £248079.51 + + +Second pass completed +Portfolio at the end of 2023/2024 tax year: +For tax year 2023/2024: +Number of disposals: 3 +Disposal proceeds: £248079.51 +Allowable costs: £232737.33 +Capital gain: £15342.18 +Capital loss: £0.00 +Total capital gain: £15342.18 +Taxable capital gain: £9342.18 + +Generate calculations report +All done! diff --git a/tests/test_data/schwab/schwab_transactions-2023.csv b/tests/test_data/schwab/schwab_transactions-2023.csv new file mode 100644 index 00000000..1d0de6bb --- /dev/null +++ b/tests/test_data/schwab/schwab_transactions-2023.csv @@ -0,0 +1,8 @@ +"Date","Action","Symbol","Description","Quantity","Price","Fees & Comm","Amount" +"03/01/2022","MoneyLink Transfer","","Tfr BANK","","","","$1000000.00" +"10/09/2023","Buy","BAR","BAR CORP","300","$135.0075","$1.34","-$40503.61" +"10/24/2023","Sell","BAR","BAR CORP","300","$140.35","$1.34","$42103.66" +"12/01/2023","Buy","FOO","FOO CORP","500","$90.1234","","-$45061.70" +"01/08/2024","Sell","FOO","FOO CORP","500","$100.0001","$1.23","$49998.85" +"02/06/2024","Buy","QUX","QUX CORP","1300","$160.1275","$1.12","-$208166.93" +"02/26/2024","Sell","QUX","QUX CORP","1300","$170.3521","$1.2","$221456.59" \ No newline at end of file diff --git a/tests/test_schwab_2023.py b/tests/test_schwab_2023.py new file mode 100644 index 00000000..5872c91d --- /dev/null +++ b/tests/test_schwab_2023.py @@ -0,0 +1,29 @@ +"""Test Schwab.""" + +from pathlib import Path +import subprocess +import sys + + +def test_run_with_schwab_example_files() -> None: + """Runs the script and verifies it doesn't fail.""" + cmd = [ + sys.executable, + "-m", + "cgt_calc.main", + "--year", + "2023", + "--schwab", + "tests/test_data/schwab/schwab_transactions-2023.csv", + "--no-pdflatex", + ] + result = subprocess.run(cmd, check=True, capture_output=True) + assert result.stderr == b"", "Run with example files generated errors" + expected_file = Path("tests") / "test_data" / "schwab" / "expected_output.txt" + expected = expected_file.read_text() + cmd_str = " ".join([param if param else "''" for param in cmd]) + assert result.stdout.decode("utf-8") == expected, ( + "Run with example files generated unexpected outputs, " + "if you added new features update the test with:\n" + f"{cmd_str} > {expected_file}" + )