mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-04 04:18:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 + "): +" +
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user