From 0609889461f4164dc43bfc62b3623b2c8d3baeca Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 5 Aug 2026 11:43:21 +0700 Subject: [PATCH] feat(stock): reduce cost basis when applying cash dividends Cash dividends are a return of capital: the payout still credits the VND balance, and now also lowers the position's remaining cost basis (floored at zero) so the ticker's unrealized P&L reflects dividends already received. Zero basis is now a valid open-position state; negative basis remains invalid. Share dividends are unchanged. --- README.md | 8 +++-- internal/modules/stock/dividend_callback.go | 8 +++-- internal/modules/stock/dividend_flow_test.go | 2 +- internal/modules/stock/handlers.go | 10 +++--- internal/modules/stock/handlers_test.go | 5 ++- internal/modules/stock/portfolio.go | 38 ++++++++++++++++---- internal/modules/stock/portfolio_test.go | 36 +++++++++++++++++-- internal/modules/stock/startup_test.go | 2 +- 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ae3a43f..a1677b2 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ command and its provider fallbacks are unchanged. Stock dividends are manual portfolio adjustments: -- `/stock_cash_dividend ` credits a positive whole-VND amount for each pre-event share held. Eg: `/stock_cash_dividend 1500 TCB`. +- `/stock_cash_dividend ` credits a positive whole-VND amount for each pre-event share held and lowers the position's cost basis by the total payout. Eg: `/stock_cash_dividend 1500 TCB`. - `/stock_share_dividend ` adds `floor(pre_event_shares × new / owned)` whole shares. Eg: `/stock_share_dividend 100:10 TCB`. The combined `/stock_dividend` shortcut was retired. Use the specialized cash @@ -111,8 +111,10 @@ store an `openedAt` lifecycle marker. Stock cash is stored directly as `vnd`; coin cash remains `usd`. Buys add their actual spend. Partial sells remove basis using the weighted-average method and report realized P&L; full sells remove the position and its basis. Stock share dividends add -shares without adding cost, which lowers the derived average price, while cash -dividends do not change position basis. +shares without adding cost, which lowers the derived average price. Cash +dividends credit the balance and reduce the position basis by the payout +(floored at zero, never negative) as a return of capital, so the ticker's +unrealized P&L includes dividends already received. `/stock_portfolio` and `/coin_portfolio` show compact aligned monospace tables with separate unrealized P&L amount and percentage columns for each priced diff --git a/internal/modules/stock/dividend_callback.go b/internal/modules/stock/dividend_callback.go index 866c1ff..4a5e4dc 100644 --- a/internal/modules/stock/dividend_callback.go +++ b/internal/modules/stock/dividend_callback.go @@ -167,12 +167,14 @@ func applySuggestedDividend(p *Portfolio, symbol string, record DividendRecord, if err != nil { return "", err } - if err := p.ApplyDividend(symbol, held, balance, now); err != nil { + baseBefore := p.Assets[symbol].Base + if err := p.ApplyCashDividend(symbol, total, balance, now); err != nil { return "", err } return "Applied cash dividend for " + symbol + ": " + FormatVND(float64(record.VNDPerShare)) + " × " + formatShareQuantity(held) + " = " + FormatVND(float64(total)) + - "\nBalance: " + FormatVND(balance), nil + "\nBalance: " + FormatVND(balance) + + "\nCost basis: " + formatVNDNumber(baseBefore) + " → " + FormatVND(p.Assets[symbol].Base), nil case DividendKindShares: ratio := shareRatio{owned: record.OwnedShares, new: record.NewShares, @@ -188,7 +190,7 @@ func applySuggestedDividend(p *Portfolio, symbol string, record DividendRecord, if err != nil { return "", err } - if err := p.ApplyDividend(symbol, finalHolding, p.VND, now); err != nil { + if err := p.ApplyShareDividend(symbol, finalHolding, now); err != nil { return "", err } return "Applied share dividend for " + symbol + " (" + ratio.raw + "): +" + diff --git a/internal/modules/stock/dividend_flow_test.go b/internal/modules/stock/dividend_flow_test.go index d25ebef..22a3578 100644 --- a/internal/modules/stock/dividend_flow_test.go +++ b/internal/modules/stock/dividend_flow_test.go @@ -289,7 +289,7 @@ func TestDividendCallbackUsesStoredEventAndCurrentHoldingOnce(t *testing.T) { t.Fatal(err) } p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli()) - if p.VND != 250_000 || !p.Dividends["TCB"][event.ProviderID].Processed { + if p.VND != 250_000 || p.Assets["TCB"].Base != 2_850_000 || !p.Dividends["TCB"][event.ProviderID].Processed { t.Fatalf("processed portfolio = %+v", p) } if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, action.MessageID, token)); err != nil { diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index c88bdfb..d53a39e 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -313,7 +313,8 @@ func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *mode if err != nil { return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") } - if err := p.ApplyDividend(symbol, held, balance, s.now().UnixMilli()); err != nil { + baseBefore := p.Assets[symbol].Base + if err := p.ApplyCashDividend(symbol, total, balance, s.now().UnixMilli()); err != nil { return chathelper.Reply(ctx, b, update.Message, "Could not apply this dividend. Try again later.") } if err := SavePortfolio(ctx, s.store, userID, p); err != nil { @@ -323,7 +324,8 @@ func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *mode return chathelper.Reply(ctx, b, update.Message, "Cash dividend: "+FormatVND(float64(vndPerShare))+" × "+formatShareQuantity(held)+" "+symbol+ " = "+FormatVND(float64(total))+ - "\nBalance: "+FormatVND(balance)) + "\nBalance: "+FormatVND(balance)+ + "\nCost basis: "+formatVNDNumber(baseBefore)+" → "+FormatVND(p.Assets[symbol].Base)) } func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *models.Update) error { @@ -376,7 +378,7 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod if err != nil { return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") } - if err := p.ApplyDividend(symbol, finalHolding, p.VND, s.now().UnixMilli()); err != nil { + if err := p.ApplyShareDividend(symbol, finalHolding, s.now().UnixMilli()); err != nil { return chathelper.Reply(ctx, b, update.Message, "Could not apply this dividend. Try again later.") } if err := SavePortfolio(ctx, s.store, userID, p); err != nil { @@ -445,7 +447,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda continue } val := float64(h.qty) * price - if !isPositiveFiniteCost(val) || !isPositiveFiniteCost(average) { + if !isPositiveFiniteCost(val) || !isNonNegativeFiniteCost(average) { missingPrice = true positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), "N/A", "N/A", "N/A", "N/A", "N/A"}) continue diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go index fbdbcc9..10eea58 100644 --- a/internal/modules/stock/handlers_test.go +++ b/internal/modules/stock/handlers_test.go @@ -255,9 +255,12 @@ func TestHandleCashDividendAllowsRepeatedManualAdjustments(t *testing.T) { if got, want := p.VND, float64(418000); got != want { t.Fatalf("balance = %v, want %v", got, want) } - if p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].OpenedAt != 100 { + // Each payout (1.500 × 139 = 208.500) is a return of capital: the basis + // drops from 4.170.000 through 3.961.500 to 3.753.000. + if p.Assets["TCB"].Base != 3_753_000 || p.Assets["TCB"].OpenedAt != 100 { t.Fatalf("cash dividend position: %+v", p.Assets["TCB"]) } + rb.AssertSentText(t, "Cost basis: 3.961.500 → 3.753.000 VND") } func TestHandleCashDividendRejectsInexactBalanceSum(t *testing.T) { diff --git a/internal/modules/stock/portfolio.go b/internal/modules/stock/portfolio.go index 47cd026..6bc6e0b 100644 --- a/internal/modules/stock/portfolio.go +++ b/internal/modules/stock/portfolio.go @@ -15,7 +15,8 @@ type Store = storage.DocStore[Portfolio] const CollectionName = "stock" // AssetPosition keeps the complete persisted state for one open stock ticker. -// Base is total remaining VND cost, not average price. +// Base is total remaining VND cost, not average price. Cash dividends return +// part of that cost, so Base can reach zero on a still-open position. type AssetPosition struct { Quantity int64 `json:"quantity" bson:"quantity"` Base float64 `json:"base" bson:"base"` @@ -108,7 +109,7 @@ func (p Portfolio) Validate() error { if err != nil || canonical != symbol { return fmt.Errorf("stock: invalid ticker %q", symbol) } - if position.Quantity <= 0 || !isPositiveFiniteCost(position.Base) || position.OpenedAt < 0 { + if position.Quantity <= 0 || !isNonNegativeFiniteCost(position.Base) || position.OpenedAt < 0 { return fmt.Errorf("stock: %s has invalid position", symbol) } } @@ -178,27 +179,52 @@ func (p *Portfolio) SellTicker(symbol string, quantity int64) (remaining int64, soldBase = position.Base * (float64(quantity) / float64(position.Quantity)) position.Quantity -= quantity position.Base -= soldBase - if !isPositiveFiniteCost(soldBase) || !isPositiveFiniteCost(position.Base) { + if !isNonNegativeFiniteCost(soldBase) || !isNonNegativeFiniteCost(position.Base) { return 0, 0, false, fmt.Errorf("stock: invalid remaining cost basis") } p.Assets[symbol] = position return position.Quantity, soldBase, true, nil } -func (p *Portfolio) ApplyDividend(symbol string, quantity int64, vnd float64, now int64) error { +// ApplyCashDividend credits the payout balance and treats the payout as a +// return of capital: the position's remaining cost basis drops by the same +// amount, floored at zero, so the ticker's unrealized P&L reflects dividends +// already received. Any payout beyond the remaining basis still lands in the +// balance; it just cannot push the basis negative. +func (p *Portfolio) ApplyCashDividend(symbol string, total int64, balance float64, now int64) error { position, ok := p.Assets[symbol] if !ok || position.Quantity <= 0 { return fmt.Errorf("stock: ticker position not found") } - if quantity < position.Quantity || !isPositiveFiniteCost(position.Base) || now <= 0 { + if total <= 0 || !isNonNegativeFiniteCost(position.Base) || now <= 0 { + return fmt.Errorf("stock: invalid dividend position") + } + position.Base = math.Max(0, position.Base-float64(total)) + p.Assets[symbol] = position + p.VND = balance + return nil +} + +// ApplyShareDividend grows the holding to quantity. The cost basis is +// unchanged: the same spent money now covers more shares, which lowers the +// derived average price. +func (p *Portfolio) ApplyShareDividend(symbol string, quantity int64, now int64) error { + position, ok := p.Assets[symbol] + if !ok || position.Quantity <= 0 { + return fmt.Errorf("stock: ticker position not found") + } + if quantity < position.Quantity || !isNonNegativeFiniteCost(position.Base) || now <= 0 { return fmt.Errorf("stock: invalid dividend position") } position.Quantity = quantity p.Assets[symbol] = position - p.VND = vnd return nil } func isPositiveFiniteCost(value float64) bool { return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0) } + +func isNonNegativeFiniteCost(value float64) bool { + return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/internal/modules/stock/portfolio_test.go b/internal/modules/stock/portfolio_test.go index 0054959..58ea1c2 100644 --- a/internal/modules/stock/portfolio_test.go +++ b/internal/modules/stock/portfolio_test.go @@ -68,14 +68,44 @@ func TestBuyPreservesOpenedAtAndSellUsesWeightedBasis(t *testing.T) { } } -func TestDividendDoesNotChangeLifecycleOrBase(t *testing.T) { +func TestCashDividendReducesBaseAndKeepsLifecycle(t *testing.T) { p := NewPortfolio(1) _ = p.BuyTicker("TCB", 100, 3_000_000, 10) - if err := p.ApplyDividend("TCB", 110, 500_000, 30); err != nil { + if err := p.ApplyCashDividend("TCB", 150_000, 650_000, 30); err != nil { t.Fatal(err) } position := p.Assets["TCB"] - if position.Quantity != 110 || position.Base != 3_000_000 || position.OpenedAt != 10 || p.VND != 500_000 { + if position.Quantity != 100 || position.Base != 2_850_000 || position.OpenedAt != 10 || p.VND != 650_000 { + t.Fatalf("portfolio=%+v", p) + } +} + +func TestCashDividendFloorsBaseAtZeroAndSellStillWorks(t *testing.T) { + p := NewPortfolio(1) + _ = p.BuyTicker("TCB", 100, 100_000, 10) + if err := p.ApplyCashDividend("TCB", 150_000, 150_000, 30); err != nil { + t.Fatal(err) + } + if position := p.Assets["TCB"]; position.Base != 0 || position.Quantity != 100 { + t.Fatalf("position=%+v", position) + } + if err := p.Validate(); err != nil { + t.Fatalf("zero-base position failed validation: %v", err) + } + remaining, soldBase, ok, err := p.SellTicker("TCB", 40) + if err != nil || !ok || remaining != 60 || soldBase != 0 { + t.Fatalf("remaining=%d soldBase=%v ok=%v err=%v", remaining, soldBase, ok, err) + } +} + +func TestShareDividendDoesNotChangeLifecycleOrBase(t *testing.T) { + p := NewPortfolio(1) + _ = p.BuyTicker("TCB", 100, 3_000_000, 10) + if err := p.ApplyShareDividend("TCB", 110, 30); err != nil { + t.Fatal(err) + } + position := p.Assets["TCB"] + if position.Quantity != 110 || position.Base != 3_000_000 || position.OpenedAt != 10 || p.VND != 0 { t.Fatalf("portfolio=%+v", p) } } diff --git a/internal/modules/stock/startup_test.go b/internal/modules/stock/startup_test.go index 4b15358..82f6781 100644 --- a/internal/modules/stock/startup_test.go +++ b/internal/modules/stock/startup_test.go @@ -150,7 +150,7 @@ func TestInitStoreDoesNotMarkFailedMigrationComplete(t *testing.T) { // its retired fields are removed. if err := storage.Typed[legacyDividendPortfolio](provider.Collection(CollectionName)).Put(ctx, "user:7", legacyDividendPortfolio{ Assets: map[string]legacyDividendAssetPosition{ - "TCB": {Quantity: 10, Base: 0, DividendCheckedAt: legacyCursor(123), OpenedAt: 99}, + "TCB": {Quantity: 10, Base: -1, DividendCheckedAt: legacyCursor(123), OpenedAt: 99}, }, }); err != nil { t.Fatal(err)