Repository: KapJI/capital-gains-calculator

Issue:
schwab_equity_award_json parser can introduce rounding errors in share quantity
The schwab_equity_award_json parser can introduce rounding errors when selling an integer number of shares, which can trigger asserts later.

The workaround in https://github.com/KapJI/capital-gains-calculator/blob/main/cgt_calc/parsers/schwab_equity_award_json.py#L232-L282 for dealing with missing decimal numbers after the decimal point in some early Schwab exports can introduce rounding errors in the quantity of shares being sold, which can later trigger asserts (e.g. the one that checks if you're selling more shares than you have).

The problematic line is https://github.com/KapJI/capital-gains-calculator/blob/main/cgt_calc/parsers/schwab_equity_award_json.py#L282.

I'll try to come up with a repro and will update this bug accordingly. (EDIT: done, see #575)

I think a simple fix is to check if you can reproduce the sale total `$` number from the transaction using the per share price, quantity, fees and rounding.

Discussion:

Solution patch:
diff --git a/cgt_calc/parsers/schwab_equity_award_json.py b/cgt_calc/parsers/schwab_equity_award_json.py
index cf5f5f9..8e910c0 100644
--- a/cgt_calc/parsers/schwab_equity_award_json.py
+++ b/cgt_calc/parsers/schwab_equity_award_json.py
@@ -196,7 +196,8 @@ class SchwabTransaction(BrokerTransaction):
                 f"Schwab Equity Award JSON only supports GOOG stock but found {symbol}",
             )
         quantity = _decimal_from_number_or_str(row, names.quantity)
-        amount = _decimal_from_number_or_str(row, names.amount)
+        initially_parsed_amount = _decimal_from_number_or_str(row, names.amount)
+        amount = initially_parsed_amount
         fees = _decimal_from_number_or_str(row, names.fees)
         if row[names.action] == "Deposit":
             if len(row[names.transac_details]) != 1:
@@ -279,7 +280,19 @@ class SchwabTransaction(BrokerTransaction):
                                 " prices",
                             )
 
-                    quantity = (amount + fees) / price
+                    # Check if the transaction was lacking decimals. Sometimes
+                    # we did just sell an integer number of shares.
+                    # If we unconditionally perform the quantity update step
+                    # here, small roundings that were necessary to determine
+                    # the initially_parsed_amount on the broker side can cause
+                    # tiny decimal deviations in the quantity, which can then
+                    # cause errors later on, e.g. triggering assertions about
+                    # selling more than was available.
+                    if (
+                        round_decimal(quantity * price - fees, 2)
+                        != initially_parsed_amount
+                    ):
+                        quantity = (amount + fees) / price
 
         else:
             raise ParsingError(


Test patch:
diff --git a/tests/test_data/schwab_equity_award_v2_rounding.json b/tests/test_data/schwab_equity_award_v2_rounding.json
new file mode 100644
index 0000000..058fd07
--- /dev/null
+++ b/tests/test_data/schwab_equity_award_v2_rounding.json
@@ -0,0 +1,499 @@
+{
+    "FromDate": "01/01/2020",
+    "ToDate": "01/06/2020",
+    "Transactions": [
+        {
+            "Date": "01/15/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/14/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/14/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/13/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/13/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/12/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/12/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/11/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/11/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/10/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/10/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/09/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/09/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/08/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/08/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/07/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/07/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/06/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/06/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/05/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/05/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/04/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/04/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/03/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "01/03/2020",
+            "Action": "Deposit",
+            "Symbol": "GOOG",
+            "Quantity": "7",
+            "Description": "RS",
+            "FeesAndCommissions": null,
+            "DisbursementElection": null,
+            "Amount": null,
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "AwardDate": "01/01/2020",
+                        "AwardId": "A111",
+                        "VestDate": "01/02/2020",
+                        "VestFairMarketValue": "$123.45"
+                    }
+                }
+            ]
+        },
+        {
+            "Date": "04/24/2020",
+            "Action": "Sale",
+            "Symbol": "GOOG",
+            "Quantity": "91",
+            "Description": "Share Sale",
+            "FeesAndCommissions": "$0.49",
+            "DisbursementElection": "Journal",
+            "Amount": "$9,326.42",
+            "TransactionDetails": [
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/02/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/03/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/04/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/05/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/06/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/07/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/08/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/09/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/10/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/11/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/12/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/13/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                },
+                {
+                    "Details": {
+                        "Type": "RS",
+                        "Shares": "7",
+                        "SalePrice": "$102.4935",
+                        "SubscriptionDate": "",
+                        "SubscriptionFairMarketValue": "",
+                        "PurchaseDate": "",
+                        "PurchasePrice": "",
+                        "PurchaseFairMarketValue": "",
+                        "DispositionType": null,
+                        "GrantId": "A111",
+                        "VestDate": "01/14/2020",
+                        "VestFairMarketValue": "$123.41",
+                        "GrossProceeds": "$717.45"
+                    }
+                }
+            ]
+        }
+    ]
+}
diff --git a/tests/test_schwab_equity_award_json.py b/tests/test_schwab_equity_award_json.py
index b8b9abd..934f5c1 100644
--- a/tests/test_schwab_equity_award_json.py
+++ b/tests/test_schwab_equity_award_json.py
@@ -128,3 +128,34 @@ def test_schwab_transaction_v2() -> None:
     assert transactions[4].fees == Decimal("0")
     assert transactions[4].currency == "USD"
     assert transactions[4].broker == "Charles Schwab"
+
+
+def test_schwab_transaction_v2_rounding() -> None:
+    """Test read_schwab_equity_award_json_transactions() on v2_rounding data.
+
+    This tests 13 vesting events with 7 shares each, which are then sold.
+    """
+    transactions = schwab_equity_award_json.read_schwab_equity_award_json_transactions(
+        "tests/test_data/schwab_equity_award_v2_rounding.json"
+    )
+
+    assert transactions[0].date == datetime.date(2020, 4, 24)
+    assert transactions[0].action == ActionType.SELL
+    assert transactions[0].symbol == "GOOG"
+    assert transactions[0].quantity == Decimal("91")
+    assert transactions[0].price == Decimal("102.4935")
+    assert transactions[0].fees == Decimal("0.49")
+    assert transactions[0].currency == "USD"
+    assert transactions[0].broker == "Charles Schwab"
+
+    num_vested = Decimal("0")
+    for transaction_id in range(1, len(transactions)):
+        assert transactions[transaction_id].date == (
+            datetime.date(2020, 1, 1) + datetime.timedelta(days=transaction_id)
+        )
+        assert transactions[transaction_id].action == ActionType.STOCK_ACTIVITY
+        assert transactions[transaction_id].quantity == Decimal("7")
+        num_vested += transactions[transaction_id].quantity or Decimal("0")
+        assert transactions[transaction_id].price == Decimal("123.45")
+        assert transactions[transaction_id].fees == Decimal("0")
+    assert num_vested == transactions[0].quantity