diff --git a/docs/journals/260722-1048-stock-portfolio-currency-title.md b/docs/journals/260722-1048-stock-portfolio-currency-title.md new file mode 100644 index 0000000..eecfa65 --- /dev/null +++ b/docs/journals/260722-1048-stock-portfolio-currency-title.md @@ -0,0 +1,32 @@ +--- +date: 2026-07-22 +component: internal/modules/stock +status: completed +--- + +# Stock Portfolio Currency Title + +## Context + +The stock portfolio repeated `VND` in every monetary table cell. + +## What Happened + +The renderer now declares `VND` once in the `Stock Portfolio (VND)` title, +labels the balance row `Cash`, and uses suffix-free monetary values in the +portfolio table. Standalone stock replies retain their explicit `VND` suffix. + +## Reflection + +A portfolio-specific display helper kept this presentation change from altering +the exported formatters used by trades, prices, and dividends. + +## Decisions + +- Keep command, storage, and exported formatting contracts unchanged. +- Cover the title, cash label, suffix-free values, and single currency marker in + the portfolio regression test. + +## Next + +No follow-up required. Focused and full tests, vet, and lint passed. diff --git a/internal/modules/stock/format.go b/internal/modules/stock/format.go index c9b692f..a767472 100644 --- a/internal/modules/stock/format.go +++ b/internal/modules/stock/format.go @@ -12,6 +12,12 @@ import ( // and a "VND" suffix (e.g. 15000000 -> "15.000.000 VND"). Manual to avoid // locale-dependent formatting. func FormatVND(n float64) string { + return formatVNDNumber(n) + " VND" +} + +// formatVNDNumber renders a VND amount without its currency suffix. Portfolio +// tables use this because their title declares the currency once. +func formatVNDNumber(n float64) string { rounded := int64(math.Round(n)) abs := strconv.FormatInt(absInt64(rounded), 10) var sb strings.Builder @@ -24,7 +30,6 @@ func FormatVND(n float64) string { } sb.WriteByte(abs[i]) } - sb.WriteString(" VND") return sb.String() } @@ -60,6 +65,14 @@ func formatShareQuantity(n int64) string { // "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero // the percentage is reported as 0.00 to avoid division-by-zero. func FormatPnL(currentValue, invested float64) string { + return formatPnL(currentValue, invested, FormatVND) +} + +func formatPortfolioPnL(currentValue, invested float64) string { + return formatPnL(currentValue, invested, formatVNDNumber) +} + +func formatPnL(currentValue, invested float64, formatAmount func(float64) string) string { diff := currentValue - invested pct := 0.0 if invested > 0 { @@ -69,7 +82,7 @@ func FormatPnL(currentValue, invested float64) string { if diff >= 0 { sign = "+" } - return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)" + return sign + formatAmount(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)" } func absInt64(n int64) int64 { diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index cd53d2e..8b4d966 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -510,7 +510,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda average := basis / float64(h.qty) if !isPositiveFiniteCost(price) { missingPrice = true - positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), FormatVND(average), "N/A", "N/A", "N/A"}) + positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatVNDNumber(average), "N/A", "N/A", "N/A"}) continue } val := float64(h.qty) * price @@ -521,28 +521,28 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda } totalValue += val totalBasis += basis - positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), FormatVND(average), FormatVND(price), FormatVND(val), FormatPnL(val, basis)}) + positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatVNDNumber(average), formatVNDNumber(price), formatVNDNumber(val), formatPortfolioPnL(val, basis)}) } } var summary [][]string if missingPrice { summary = [][]string{ - {"VND", FormatVND(p.VND)}, - {"Priced value (partial)", FormatVND(totalValue)}, - {"Unrealized P&L (priced)", FormatPnL(totalValue-p.VND, totalBasis)}, - {"Invested", FormatVND(p.Meta.Invested)}, + {"Cash", formatVNDNumber(p.VND)}, + {"Priced value (partial)", formatVNDNumber(totalValue)}, + {"Unrealized P&L (priced)", formatPortfolioPnL(totalValue-p.VND, totalBasis)}, + {"Invested", formatVNDNumber(p.Meta.Invested)}, {"Account P&L", "Unavailable"}, } } else { summary = [][]string{ - {"VND", FormatVND(p.VND)}, - {"Total value", FormatVND(totalValue)}, - {"Invested", FormatVND(p.Meta.Invested)}, - {"Unrealized P&L", FormatPnL(totalValue-p.VND, totalBasis)}, - {"Account P&L", FormatPnL(totalValue, p.Meta.Invested)}, + {"Cash", formatVNDNumber(p.VND)}, + {"Total value", formatVNDNumber(totalValue)}, + {"Invested", formatVNDNumber(p.Meta.Invested)}, + {"Unrealized P&L", formatPortfolioPnL(totalValue-p.VND, totalBasis)}, + {"Account P&L", formatPortfolioPnL(totalValue, p.Meta.Invested)}, } } - if err := chathelper.ReplyHTML(ctx, b, update.Message, portfolioTableReply("Stock Portfolio", positions, summary)); err != nil { + if err := chathelper.ReplyHTML(ctx, b, update.Message, portfolioTableReply("Stock Portfolio (VND)", positions, summary)); err != nil { return err } return s.notifyDividendEvents(ctx, b, update.Message, userID, p, checkedThrough) diff --git a/internal/modules/stock/stats_test.go b/internal/modules/stock/stats_test.go index 545cc89..e39f851 100644 --- a/internal/modules/stock/stats_test.go +++ b/internal/modules/stock/stats_test.go @@ -67,16 +67,18 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) { text := rb.LastSent().Text() for _, want := range []string{ + "Stock Portfolio (VND)", "
",
 		"Ticker",
 		"MWG",
-		"126.000.000 VND",
+		"126.000.000",
+		"Cash",
 		"Total value",
-		"530.335.000 VND",
+		"530.335.000",
 		"Unrealized P&L",
-		"+85.000.000 VND (+19.19%)",
+		"+85.000.000 (+19.19%)",
 		"Account P&L",
-		"-469.665.000 VND (-46.97%)",
+		"-469.665.000 (-46.97%)",
 	} {
 		if !strings.Contains(text, want) {
 			t.Fatalf("stats missing %q in:\n%s", want, text)
@@ -85,6 +87,9 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
 	if strings.Contains(text, "N/A") {
 		t.Fatalf("stats rendered missing prices:\n%s", text)
 	}
+	if strings.Count(text, "VND") != 1 {
+		t.Fatalf("stats should declare VND only in the title:\n%s", text)
+	}
 }
 
 func TestStockPortfolioReplyStaysWithinTelegramBudget(t *testing.T) {