From bb8fb77bbce8d64490337e5b1d4ce9236abb8e50 Mon Sep 17 00:00:00 2001 From: Gerben Jacobs Date: Wed, 16 Oct 2024 00:07:03 +0200 Subject: [PATCH] Handle events related to T212 Card + Withdrawals --- README.md | 1 + cmd/aggregator/main.go | 24 +++++++++----- models.go | 17 +++++----- trading212/aggregate.go | 26 +++++++++++---- trading212/aggregate_test.go | 40 ++++++++++++++++++----- trading212/collect_test.go | 4 +-- trading212/export.go | 62 ++++++++++++++++++++++++------------ 7 files changed, 120 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 24fe91c..531410e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ The default currency is set to EUR, but you can use the dropdown to change it to ## Changelog +- v0.2.7 - 2024-10-15 - Handle events related to T212 Card + Withdrawals - v0.2.6 - 2024-10-05 - Added "Renames" config for renamed/delisted stocks - v0.2.5 - 2024-02-08 - Added "Lending interest" field - v0.2.4 - 2023-08-01 - Fix stock splits for stocks that are untouched + Update dependencies diff --git a/cmd/aggregator/main.go b/cmd/aggregator/main.go index adf6025..32628b0 100644 --- a/cmd/aggregator/main.go +++ b/cmd/aggregator/main.go @@ -4,6 +4,7 @@ import ( "encoding/json" "flag" "fmt" + "math" "os" "strings" @@ -82,14 +83,16 @@ func main() { stocks, totals := trading212.Aggregate(cfg.Splits, cfg.Renames, events) log.WithFields(logrus.Fields{ - "deposits": totals.Deposits, - "invested": totals.Invested, - "realized": totals.Realized, - "dividends": totals.Dividends, - "fees": totals.Fees, - "taxes": totals.Taxes, - "cash": totals.Cash, - "interest": totals.Interest, + "deposits": totals.Deposits, + "invested": totals.Invested, + "realized": totals.Realized, + "realized-with-costs": ceilFloat(totals.Realized-totals.Fees-totals.Taxes, 2), + "dividends": totals.Dividends, + "fees": totals.Fees, + "taxes": totals.Taxes, + "cash": totals.Cash, + "interest": totals.Interest, + "withdrawals": totals.Withdrawals, }).Info("Completed aggregation.") // write output @@ -208,3 +211,8 @@ func writeOutputJSON(cfg fin.Config, outputName string, output interface{}) stri return fn } + +func ceilFloat(f float64, precision int) float64 { + d := math.Pow(10, float64(precision)) + return math.Ceil(f*d) / d +} diff --git a/models.go b/models.go index 07a5b4d..c0fbdc8 100644 --- a/models.go +++ b/models.go @@ -18,12 +18,13 @@ type Aggregate struct { } type Totals struct { - Deposits float64 // the money you deposited - Invested float64 // the money you have invested, minus fees - Realized float64 // gains you have realized by selling - Dividends float64 // amount of money you received from dividends - Fees float64 // fees you paid - Cash float64 // cash left in your portfolio - Taxes float64 // taxes withheld from dividends - Interest float64 // interest you received on cash + Deposits float64 // the money you deposited + Invested float64 // the money you have invested, minus fees + Realized float64 // gains you have realized by selling + Dividends float64 // amount of money you received from dividends + Fees float64 // fees you paid + Withdrawals float64 // costs that are taken away from your cash i.e. Card debit + Cash float64 // cash left in your portfolio + Taxes float64 // taxes withheld from dividends + Interest float64 // interest you received on cash, lent shares or card cashback } diff --git a/trading212/aggregate.go b/trading212/aggregate.go index 550f585..43dff82 100644 --- a/trading212/aggregate.go +++ b/trading212/aggregate.go @@ -14,21 +14,32 @@ func Aggregate(splits []fin.Splits, renames map[string]string, events []TradeEve var stockNames []string var totals fin.Totals for _, e := range events { - // skip currency conversions - if e.Action == "Currency conversion" { + // skip every event we don't deal with + if e.IsSkippable() { continue } - // handle deposits - if e.Action == "Deposit" { + // handle deposits or additions + if e.Action == "Deposit" || e.Action == "Spending cashback" { totals.Deposits += e.Total + totals.Withdrawals -= e.DepositFee continue } - // handle interest - if e.Action == "Interest on cash" || e.Action == "Lending interest" { + if e.IsInterest() { totals.Interest += e.Total continue } + // handle money withdrawl + if e.IsMoneyWithdrawal() { + // we subtract the total, because it's stored as a negative number + totals.Withdrawals -= e.Total + continue + } + + // if no action matches, but our symbol is empty, we continue too + if e.TickerSymbol == "" { + continue + } // handle renamed stock symbols symbol := e.TickerSymbol @@ -114,7 +125,7 @@ func Aggregate(splits []fin.Splits, renames map[string]string, events []TradeEve // calculate cash left over in portfolio moneyGained := totals.Deposits + totals.Realized + totals.Dividends - moneySpent := totals.Invested + totals.Fees + moneySpent := totals.Invested + totals.Fees + totals.Withdrawals totals.Cash = moneyGained - moneySpent // format money values to 2 decimals @@ -135,6 +146,7 @@ func Aggregate(splits []fin.Splits, renames map[string]string, events []TradeEve totals.Fees = floorFloat(totals.Fees, 2) totals.Cash = floorFloat(totals.Cash, 2) totals.Taxes = floorFloat(totals.Taxes, 2) + totals.Withdrawals = floorFloat(totals.Withdrawals, 2) // sort and collate aggregates sort.Strings(stockNames) diff --git a/trading212/aggregate_test.go b/trading212/aggregate_test.go index 43ed46c..8ccd97e 100644 --- a/trading212/aggregate_test.go +++ b/trading212/aggregate_test.go @@ -32,13 +32,14 @@ func TestAggregate(t *testing.T) { {Symbol: "TSLA", Name: "Tesla", ShareCount: 0.076654, AvgPrice: 713.94, PriceCurrency: "USD", ShareCost: 54.72, ShareCostLocal: 46.67, ShareResult: 0, TotalDividend: 0, Fees: 0.07, Final: -0.08, LastUpdate: time.Date(2021, 8, 9, 18, 31, 41, 0, time.UTC)}, }, totals: &fin.Totals{ - Deposits: 2000, - Invested: 353.2, - Realized: 2.61, - Dividends: 0.11, - Fees: 0.34, - Cash: 1649.17, - Taxes: 0.02, + Deposits: 2000, + Invested: 353.2, + Realized: 2.61, + Dividends: 0.11, + Fees: 0.34, + Cash: 1650.58, + Taxes: 0.02, + Withdrawals: -1.4, }, }, { @@ -61,6 +62,21 @@ func TestAggregate(t *testing.T) { {Symbol: "FB", ShareCount: 0, AvgPrice: 0, ShareCost: 0, ShareCostLocal: 0, ShareResult: 100, TotalDividend: 0, Fees: 0, Final: 100, LastUpdate: time.Date(2022, 9, 27, 13, 19, 13, 0, time.UTC)}, }, }, + { + name: "Test operations with T212 card", + events: []TradeEvent{ + {Action: "New card cost", Time: DateTime{Time: time.Date(2021, 9, 27, 13, 19, 13, 0, time.UTC)}, Total: -4.95, TotalCurrency: "EUR", ID: "EOF1"}, + {Action: "Spending cashback", Time: DateTime{Time: time.Date(2022, 9, 27, 13, 19, 13, 0, time.UTC)}, Total: 0.22, TotalCurrency: "EUR", ID: "EOF2"}, + {Action: "Deposit", Time: DateTime{Time: time.Date(2022, 9, 27, 13, 19, 13, 0, time.UTC)}, Total: 100, TotalCurrency: "EUR", ID: "EOF3"}, + {Action: "Card debit", Time: DateTime{Time: time.Date(2022, 9, 27, 13, 19, 13, 0, time.UTC)}, Total: -15, TotalCurrency: "EUR", ID: "EOF4"}, + }, + want: []fin.Aggregate{}, + totals: &fin.Totals{ + Deposits: 100.22, + Cash: 80.27, + Withdrawals: 19.95, // New card cost + Card debit + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -73,9 +89,17 @@ func TestAggregate(t *testing.T) { if tt.totals != nil { if !reflect.DeepEqual(totals, *tt.totals) { - t.Errorf("totals are a mismatch \n%#v\n%#v", totals, tt.totals) + t.Errorf("totals are a mismatch \n%#v\n%#v", totals, *tt.totals) } } }) } } + +/* + trading212.TradeEvent{Action:"Deposit", Time:time.Date(2021, time.August, 9, 15, 25, 29, 0, time.UTC), ISIN:"", TickerSymbol:"", TickerName:"", ShareCount:0, SharePrice:0, ShareCurrency:"", ExchangeRate:"", ChargeAmount:1000, DepositFee:0, Result:0, ResultCurrency:"", Total:1000, TotalCurrency:"", Tax:0, TaxCurrency:"", StampDuty:0, StampDutyTax:0, Notes:"Transaction ID: xxx", ID:"d0ca160f-f407-4b9b-bb36-xxx", FXFee:0, FRFee:0, FinraFee:0, MerchantName:"", MerchantCategory:""} + trading212.TradeEvent{Action:"Deposit", Time:time.Date(2021, time.August, 9, 15, 25, 29, 0, time.UTC), ISIN:"", TickerSymbol:"", TickerName:"", ShareCount:0, SharePrice:0, ShareCurrency:"", ExchangeRate:"", ChargeAmount:0, DepositFee:0, Result:0, ResultCurrency:"", Total:1000, TotalCurrency:"", Tax:0, TaxCurrency:"", StampDuty:0, StampDutyTax:0, Notes:"Transaction ID: xxx", ID:"d0ca160f-f407-4b9b-bb36-xxx", FXFee:0, FRFee:0, FinraFee:0, MerchantName:"", MerchantCategory:""} + + trading212.TradeEvent{Action:"Deposit", Time:time.Date(2021, time.September, 7, 13, 43, 10, 0, time.UTC), ISIN:"", TickerSymbol:"", TickerName:"", ShareCount:0, SharePrice:0, ShareCurrency:"", ExchangeRate:"", ChargeAmount:1001.4, DepositFee:1.4, Result:0, ResultCurrency:"", Total:1000, TotalCurrency:"", Tax:0, TaxCurrency:"", StampDuty:0, StampDutyTax:0, Notes:"Transaction ID: xxx", ID:"3e8f5274-1c62-46d6-baf4-xxx", FXFee:0, FRFee:0, FinraFee:0, MerchantName:"", MerchantCategory:""} + trading212.TradeEvent{Action:"Deposit", Time:time.Date(2021, time.September, 7, 13, 43, 10, 0, time.UTC), ISIN:"", TickerSymbol:"", TickerName:"", ShareCount:0, SharePrice:0, ShareCurrency:"", ExchangeRate:"", ChargeAmount:1000, DepositFee:0, Result:0, ResultCurrency:"", Total:1000, TotalCurrency:"", Tax:0, TaxCurrency:"", StampDuty:0, StampDutyTax:0, Notes:"Transaction ID: xxx", ID:"3e8f5274-1c62-46d6-baf4-xxx", FXFee:0, FRFee:0, FinraFee:0, MerchantName:"", MerchantCategory:""} +*/ diff --git a/trading212/collect_test.go b/trading212/collect_test.go index fff2873..6242fbb 100644 --- a/trading212/collect_test.go +++ b/trading212/collect_test.go @@ -9,12 +9,12 @@ import ( // defaultTestDataEvents is a list of TradeEvents that matches testdata/tradign212.csv // this can be used by several tests var defaultTestDataEvents = []TradeEvent{ - {Action: "Deposit", Time: DateTime{Time: time.Date(2021, 8, 9, 15, 25, 29, 0, time.UTC)}, Total: 1000, Notes: "Transaction ID: xxx", ID: "d0ca160f-f407-4b9b-bb36-xxx"}, + {Action: "Deposit", Time: DateTime{Time: time.Date(2021, 8, 9, 15, 25, 29, 0, time.UTC)}, ChargeAmount: 1000, Total: 1000, Notes: "Transaction ID: xxx", ID: "d0ca160f-f407-4b9b-bb36-xxx"}, {Action: "Market buy", Time: DateTime{Time: time.Date(2021, 8, 9, 18, 31, 41, 0, time.UTC)}, ISIN: "US30303M1027", TickerSymbol: "FB", TickerName: "Meta Platforms", ShareCount: 0.0863914000, SharePrice: 362, ShareCurrency: "USD", ExchangeRate: "1.17437", Total: 26.67, ID: "EOF1", FXFee: 0.04}, {Action: "Market buy", Time: DateTime{Time: time.Date(2021, 8, 9, 18, 31, 41, 0, time.UTC)}, ISIN: "US88160R1014", TickerSymbol: "TSLA", TickerName: "Tesla", ShareCount: 0.0766547000, SharePrice: 713.93, ShareCurrency: "USD", ExchangeRate: "1.17437", Total: 46.67, ID: "EOF2", FXFee: 0.07}, {Action: "Market buy", Time: DateTime{Time: time.Date(2021, 8, 9, 18, 31, 41, 0, time.UTC)}, ISIN: "US5949181045", TickerSymbol: "MSFT", TickerName: "Microsoft", ShareCount: 0.2709950000, SharePrice: 288.53, ShareCurrency: "USD", ExchangeRate: "1.17437", Total: 66.68, ID: "EOF3", FXFee: 0.10}, {Action: "Market sell", Time: DateTime{Time: time.Date(2021, 8, 30, 13, 30, 3, 0, time.UTC)}, ISIN: "US5949181045", TickerSymbol: "MSFT", TickerName: "Microsoft", ShareCount: 0.2709950000, SharePrice: 301.14, ShareCurrency: "USD", ExchangeRate: "1.17951", Result: 2.61, Total: 69.09, ID: "EOF4", FXFee: 0.10}, - {Action: "Deposit", Time: DateTime{Time: time.Date(2021, 9, 7, 13, 43, 10, 0, time.UTC)}, Total: 1000, Notes: "Transaction ID: xxx", ID: "3e8f5274-1c62-46d6-baf4-xxx"}, + {Action: "Deposit", Time: DateTime{Time: time.Date(2021, 9, 7, 13, 43, 10, 0, time.UTC)}, ChargeAmount: 1001.4, DepositFee: 1.4, Total: 1000, Notes: "Transaction ID: xxx", ID: "3e8f5274-1c62-46d6-baf4-xxx"}, {Action: "Market buy", Time: DateTime{Time: time.Date(2021, 9, 27, 13, 19, 13, 0, time.UTC)}, ISIN: "US02079K1079", TickerSymbol: "ABEC", TickerName: "Google", ShareCount: 0.0041253700, SharePrice: 2424.00, ShareCurrency: "EUR", ExchangeRate: "1.00000", Total: 10.00, ID: "EOF5"}, {Action: "Dividend (Ordinary)", Time: DateTime{Time: time.Date(2021, 9, 30, 11, 15, 32, 0, time.UTC)}, ISIN: "US5949181045", TickerSymbol: "MSFT", TickerName: "Microsoft", ShareCount: 0.2709950000, SharePrice: 0.48, ShareCurrency: "USD", ExchangeRate: "Not available", Total: 0.11, Tax: 0.02, TaxCurrency: "USD"}, {Action: "Market buy", Time: DateTime{Time: time.Date(2022, 3, 7, 16, 10, 26, 0, time.UTC)}, ISIN: "FR0000120578", TickerSymbol: "SAN", TickerName: "Sanofi", ShareCount: 0.1117960000, SharePrice: 89.18, ShareCurrency: "EUR", ExchangeRate: "1.00000", Total: 10.00, ID: "EOF6", FRFee: 0.03}, diff --git a/trading212/export.go b/trading212/export.go index 9d90501..28c5049 100644 --- a/trading212/export.go +++ b/trading212/export.go @@ -20,30 +20,50 @@ func (e *TradeEvent) IsDividend() bool { } func (e *TradeEvent) Fees() float64 { - return e.FXFee + e.FRFee + e.StampDuty + e.StampDutyTax + e.FinraFee + return e.FXFee + e.FRFee + e.StampDuty + e.StampDutyTax + e.FinraFee + e.DepositFee +} + +func (e *TradeEvent) IsSkippable() bool { + return e.Action == "Currency conversion" +} + +// IsInterest marks if an event is considered the addition of interest +func (e *TradeEvent) IsInterest() bool { + return e.Action == "Interest on cash" || + e.Action == "Lending interest" +} + +func (e *TradeEvent) IsMoneyWithdrawal() bool { + return e.Action == "Card debit" || e.Action == "New card cost" } type TradeEvent struct { - Action string `csv:"Action"` - Time DateTime `csv:"Time"` - ISIN string `csv:"ISIN"` - TickerSymbol string `csv:"Ticker"` - TickerName string `csv:"Name"` - ShareCount float64 `csv:"No. of shares,omitempty"` - SharePrice float64 `csv:"Price / share,omitempty"` - ShareCurrency string `csv:"Currency (Price / share)"` - ExchangeRate string `csv:"Exchange rate,omitempty"` - Result float64 `csv:"Result,omitempty"` // gain or loss - Total float64 `csv:"Total,omitempty"` // total money gained - Tax float64 `csv:"Withholding tax,omitempty"` - TaxCurrency string `csv:"Currency (Withholding tax)"` - StampDuty float64 `csv:"Stamp duty,omitempty"` - StampDutyTax float64 `csv:"Stamp duty reserve tax,omitempty"` - Notes string `csv:"Notes"` - ID string `csv:"ID"` - FXFee float64 `csv:"Currency conversion fee,omitempty"` // foreign exchange fee - FRFee float64 `csv:"French transaction tax,omitempty"` - FinraFee float64 `csv:"Finra fee,omitempty"` + Action string `csv:"Action"` + Time DateTime `csv:"Time"` + ISIN string `csv:"ISIN"` + TickerSymbol string `csv:"Ticker"` + TickerName string `csv:"Name"` + ShareCount float64 `csv:"No. of shares,omitempty"` + SharePrice float64 `csv:"Price / share,omitempty"` + ShareCurrency string `csv:"Currency (Price / share)"` + ExchangeRate string `csv:"Exchange rate,omitempty"` + ChargeAmount float64 `csv:"Charge amount,omitempty"` + DepositFee float64 `csv:"Deposit fee,omitempty"` + Result float64 `csv:"Result,omitempty"` // gain or loss + ResultCurrency string `csv:"Currency (Result)"` + Total float64 `csv:"Total,omitempty"` // total money gained + TotalCurrency string `csv:"Currency (Total)"` + Tax float64 `csv:"Withholding tax,omitempty"` + TaxCurrency string `csv:"Currency (Withholding tax)"` + StampDuty float64 `csv:"Stamp duty,omitempty"` + StampDutyTax float64 `csv:"Stamp duty reserve tax,omitempty"` + Notes string `csv:"Notes"` + ID string `csv:"ID"` + FXFee float64 `csv:"Currency conversion fee,omitempty"` // foreign exchange fee + FRFee float64 `csv:"French transaction tax,omitempty"` + FinraFee float64 `csv:"Finra fee,omitempty"` + MerchantName string `csv:"Merchant name,omitempty"` + MerchantCategory string `csv:"Merchant category,omitempty"` } type DateTime struct {