def _parseFidelityTransaction(t: _FidelityTransaction) -> Optional[Activity]: if t.action == "DIVIDEND RECEIVED": return CashPayment( date=_parseFidelityTransactionDate(t.date), instrument=Stock(t.symbol, currency=Currency[t.currency]), proceeds=Cash(currency=Currency[t.currency], quantity=Decimal(t.amount)), ) elif t.action == "INTEREST EARNED": return CashPayment( date=_parseFidelityTransactionDate(t.date), instrument=None, proceeds=Cash(currency=Currency[t.currency], quantity=Decimal(t.amount)), ) flags = None # TODO: Handle 'OPENING TRANSACTION' and 'CLOSING TRANSACTION' text for options transactions if t.action.startswith("YOU BOUGHT"): flags = TradeFlags.OPEN elif t.action.startswith("YOU SOLD"): flags = TradeFlags.CLOSE elif t.action.startswith("REINVESTMENT"): flags = TradeFlags.OPEN | TradeFlags.DRIP if not flags: return None return _forceParseFidelityTransaction(t, flags=flags)
def test_currencyInterest(self) -> None: # IBKR interest accruals don't have dates associated with them, so # they'll be tagged with the last day in the period being looked at. ts = self.activityByDate[date(2019, 3, 1)] # BASE_SUMMARY should be excluded self.assertEqual(len(ts), 2) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=None, proceeds=Cash(currency=Currency.AUD, quantity=Decimal("-4.29")), ), ) self.assertEqual( ts[1], CashPayment( date=ts[1].date, instrument=None, proceeds=Cash(currency=Currency.USD, quantity=Decimal("2.26")), ), )
def _parseVanguardTransaction(t: _VanguardTransaction) -> Optional[Activity]: if t.transactionType == "Dividend": return CashPayment( date=_parseVanguardTransactionDate(t.tradeDate), instrument=Stock(t.symbol if t.symbol else t.investmentName, currency=Currency.USD), proceeds=Cash(currency=Currency.USD, quantity=Decimal(t.netAmount)), ) validTransactionTypes = set([ "Buy", "Sell", "Reinvestment", "Corp Action (Redemption)", "Transfer (outgoing)", ]) if t.transactionType not in validTransactionTypes: return None flagsByTransactionType = { "Buy": TradeFlags.OPEN, "Sell": TradeFlags.CLOSE, "Reinvestment": TradeFlags.OPEN | TradeFlags.DRIP, "Corp Action (Redemption)": TradeFlags.CLOSE, "Transfer (outgoing)": TradeFlags.CLOSE, } return _forceParseVanguardTransaction( t, flags=flagsByTransactionType[t.transactionType])
def test_cashInterest(self) -> None: ts = self.activityByDate[date(2017, 8, 31)] self.assertEqual(len(ts), 1) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=None, proceeds=helpers.cashUSD(Decimal("0.02")), ), )
def test_dividendPayment(self) -> None: ts = self.activityByDate[date(2017, 11, 9)] self.assertEqual(len(ts), 5) self.assertEqual( ts[2], CashPayment( date=ts[2].date, instrument=Stock("ROBO", Currency.USD), proceeds=helpers.cashUSD(Decimal("6.78")), ), )
def test_stockLoanInterest(self) -> None: ts = self.activityByDate[date(2019, 1, 1)] self.assertEqual(len(ts), 1) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=Stock("TSLA", Currency.USD, exchange="NASDAQ"), proceeds=helpers.cashUSD(Decimal("0.01")), ), )
def test_cashDividend(self) -> None: ts = self.activityByDate[date(2018, 3, 6)] self.assertEqual(len(ts), 1) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=Stock("VGLT", Currency.USD), proceeds=helpers.cashUSD(Decimal("12.85")), ), )
def test_dividendReinvested(self) -> None: ts = self.activityByDate[date(2017, 3, 28)] self.assertEqual(len(ts), 1) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=Stock("VOO", Currency.USD), proceeds=helpers.cashUSD(Decimal("22.95")), ), )
def test_postedAndPaid(self) -> None: ts = self.activityByDate[date(2019, 2, 14)] self.assertEqual(len(ts), 1) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=Stock("AAPL", Currency.USD, exchange="NASDAQ"), proceeds=helpers.cashUSD(Decimal("23.36")), ), ) self.assertNotIn(date(2019, 2, 7), self.activityByDate) self.assertNotIn(date(2019, 2, 8), self.activityByDate)
def _parseStockLoanFee(entry: _IBSLBFee) -> Optional[Activity]: # We don't see accrual reversals here, because it rolls up into total interest accounting, so use the accrual postings instead. codes = entry.code.split(";") if "Po" not in codes: return None proceeds = Cash(currency=Currency[entry.currency], quantity=Decimal(entry.netLendFee)) return CashPayment( date=_parseIBDate(entry.valueDate), instrument=_parseInstrument(entry), proceeds=proceeds, )
def _parseChangeInDividendAccrual( entry: _IBChangeInDividendAccrual) -> Optional[Activity]: codes = entry.code.split(";") if "Re" not in codes: return None # IB "reverses" dividend postings when they're paid out, so they all appear as debits. proceeds = Cash(currency=Currency[entry.currency], quantity=-Decimal(entry.netAmount)) return CashPayment( date=_parseIBDate(entry.payDate), instrument=_parseInstrument(entry), proceeds=proceeds, )
def test_bondInterest(self) -> None: ts = self.activityByDate[date(2019, 1, 17)] self.assertEqual(len(ts), 1) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=Bond("BA 3 3/4 02/17/19", Currency.USD, validateSymbol=False), proceeds=helpers.cashUSD(Decimal("18.75")), ), ) self.assertNotIn(date(2019, 1, 15), self.activityByDate) self.assertNotIn(date(2019, 1, 14), self.activityByDate)
def _parseCurrencyInterestAccrual( entry: _IBInterestAccrualsCurrency) -> Optional[Activity]: # This entry includes forex translation, which we don't want. if entry.currency == "BASE_SUMMARY": return None # An accrual gets "reversed" when it is credited/debited. Because the # reversal refers to the balance of interest, accrual reversal > 0 means # that the cash account was debited, while accrual reversal < 0 means the # cash account was credited with the interest. proceeds = Cash(currency=Currency[entry.currency], quantity=-Decimal(entry.accrualReversal)) # Using `toDate` here since there are no dates attached to the actual # accruals. return CashPayment(date=_parseIBDate(entry.toDate), instrument=None, proceeds=proceeds)
def test_reinvestShares(self) -> None: ts = self.activityByDate[date(2017, 2, 4)] self.assertEqual(len(ts), 4) self.assertEqual( ts[0], CashPayment( date=ts[0].date, instrument=Stock("VWO", Currency.USD), proceeds=helpers.cashUSD(Decimal("29.35")), ), ) self.assertEqual( ts[1], Trade( date=ts[1].date, instrument=Stock("VWO", Currency.USD), quantity=Decimal("0.123"), amount=Cash(currency=Currency.USD, quantity=Decimal("-20.15")), fees=Cash(currency=Currency.USD, quantity=Decimal("0.00")), flags=TradeFlags.OPEN | TradeFlags.DRIP, ), ) self.assertEqual( ts[3], Trade( date=ts[3].date, instrument=Stock("VOO", Currency.USD), quantity=Decimal("0.321"), amount=Cash(currency=Currency.USD, quantity=Decimal("-17.48")), fees=Cash(currency=Currency.USD, quantity=Decimal("0.00")), flags=TradeFlags.OPEN | TradeFlags.DRIP, ), )
def _parseSchwabTransaction( t: _SchwabTransaction, otherTransactionsThisDate: Iterable[_SchwabTransaction] ) -> Optional[Activity]: dividendActions = {"Cash Dividend", "Reinvest Dividend", "Non-Qualified Div"} if t.action in dividendActions: return CashPayment( date=_parseSchwabTransactionDate(t.date), instrument=Stock(t.symbol, currency=Currency.USD), proceeds=Cash(currency=Currency.USD, quantity=_schwabDecimal(t.amount)), ) interestActions = {"Credit Interest", "Margin Interest"} if t.action in interestActions: return CashPayment( date=_parseSchwabTransactionDate(t.date), instrument=None, proceeds=Cash(currency=Currency.USD, quantity=_schwabDecimal(t.amount)), ) # Bond redemptions are split into two entries, for some reason. if t.action == "Full Redemption Adj": redemption = next( ( r for r in otherTransactionsThisDate if r.symbol == t.symbol and r.action == "Full Redemption" ), None, ) if not redemption: raise ValueError( f'Expected to find "Full Redemption" action on same date as {t}' ) quantity = Decimal(redemption.quantity) amount = _schwabDecimal(t.amount) return Trade( date=_parseSchwabTransactionDate(t.date), instrument=Bond(t.symbol, currency=Currency.USD), quantity=quantity, amount=Cash(currency=Currency.USD, quantity=amount), fees=Cash(currency=Currency.USD, quantity=Decimal(0)), # TODO: Do we want a new TradeFlag? flags=TradeFlags.CLOSE | TradeFlags.EXPIRED, ) if t.action == "Full Redemption": adj = next( ( r for r in otherTransactionsThisDate if r.symbol == t.symbol and r.action == "Full Redemption Adj" ), None, ) if not adj: raise ValueError( f'Expected to find "Full Redemption Adj" action on same date as {t}' ) # Will process on the adjustment entry return None ignoredActions = { "Wire Funds", "Wire Funds Received", "MoneyLink Transfer", "MoneyLink Deposit", "Long Term Cap Gain Reinvest", "ATM Withdrawal", "Schwab ATM Rebate", "Service Fee", "Journal", "Misc Cash Entry", "Security Transfer", } if t.action in ignoredActions: return None flagsByAction = { "Buy": TradeFlags.OPEN, "Sell Short": TradeFlags.OPEN, "Buy to Open": TradeFlags.OPEN, "Sell to Open": TradeFlags.OPEN, "Reinvest Shares": TradeFlags.OPEN | TradeFlags.DRIP, "Sell": TradeFlags.CLOSE, "Buy to Close": TradeFlags.CLOSE, "Sell to Close": TradeFlags.CLOSE, "Assigned": TradeFlags.CLOSE | TradeFlags.ASSIGNED_OR_EXERCISED, "Exchange or Exercise": TradeFlags.CLOSE | TradeFlags.ASSIGNED_OR_EXERCISED, "Expired": TradeFlags.CLOSE | TradeFlags.EXPIRED, } if not t.action in flagsByAction: raise ValueError(f'Unexpected Schwab action "{t.action}" in transaction {t}') return _forceParseSchwabTransaction(t, flags=flagsByAction[t.action])