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.
This commit is contained in:
2026-08-05 11:43:21 +07:00
parent 81303e5fe6
commit 0609889461
8 changed files with 87 additions and 22 deletions
+5 -3
View File
@@ -61,7 +61,7 @@ command and its provider fallbacks are unchanged.
Stock dividends are manual portfolio adjustments:
- `/stock_cash_dividend <vnd_per_share> <ticker>` credits a positive whole-VND amount for each pre-event share held. Eg: `/stock_cash_dividend 1500 TCB`.
- `/stock_cash_dividend <vnd_per_share> <ticker>` 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 <ratio(owned:new)> <ticker>` 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
+5 -3
View File
@@ -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 + "): +" +
+1 -1
View File
@@ -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 {
+6 -4
View File
@@ -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
+4 -1
View File
@@ -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) {
+32 -6
View File
@@ -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)
}
+33 -3
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)