diff --git a/docs/journals/260722-1119-compact-portfolio-number-plan.md b/docs/journals/260722-1119-compact-portfolio-number-plan.md new file mode 100644 index 0000000..9043843 --- /dev/null +++ b/docs/journals/260722-1119-compact-portfolio-number-plan.md @@ -0,0 +1,39 @@ +--- +date: 2026-07-22 +component: stock-and-coin-portfolios +status: completed +--- + +# Compact Portfolio Number Plan + +## Context + +Full monetary values make the stock and coin Telegram portfolio position tables +wide and difficult to scan. + +## What Happened + +The compact format work is done. Automatic `k/M/B/T` suffixes now apply to +stock and coin position values with at most three trimmed fractional digits. +Stock keeps Vietnamese separators; coin keeps USD separators and `$`. Only the +position `Avg`, `Now`, `Value`, and `Unrealized P&L` fields were compacted. + +- [Brainstorm report](../../plans/reports/260722-1112-compact-portfolio-number-format.md) +- [Implementation plan](../../plans/260722-1114-compact-portfolio-numbers/plan.md) + +## Reflection + +Per-value scaling still beats fixed units per column or unit labels in headers. +It keeps the table readable without changing summaries or non-portfolio output. + +## Decisions + +- Compact only position `Avg`, `Now`, `Value`, and `Unrealized P&L` amounts. +- Keep percentages, summaries, `N/A`, and non-portfolio replies unchanged. +- Focused tests, full tests, `go vet`, and `golangci-lint` all passed. +- A pre-existing extreme-finite overflow in legacy full-value formatters was + observed separately and left as optional follow-up, not part of this change. + +## Next + +No further action required for this change set. diff --git a/internal/modules/coin/format.go b/internal/modules/coin/format.go index 97aa63d..8817849 100644 --- a/internal/modules/coin/format.go +++ b/internal/modules/coin/format.go @@ -24,6 +24,41 @@ func FormatUSD(n float64) string { return sign + "$" + groupDigits(strconv.FormatInt(whole, 10)) + "." + twoDigits(cents) } +// formatCompactUSD renders a position-table amount with at most three scaled +// fractional digits while preserving the module's dollar/sign convention. +func formatCompactUSD(n float64) string { + if math.IsNaN(n) || math.IsInf(n, 0) { + return "invalid USD" + } + // Use the full formatter's cent rounding at the base/k boundary so an + // amount displayed as $1,000.00 is promoted to $1k instead. + if math.Round(math.Abs(n)*100)/100 < 1_000 { + return FormatUSD(n) + } + + sign := "" + if n < 0 { + sign = "-" + n = -n + } + suffixes := [...]string{"k", "M", "B", "T"} + divisor := 1_000.0 + suffixIndex := 0 + for suffixIndex < len(suffixes)-1 && n >= divisor*1_000 { + divisor *= 1_000 + suffixIndex++ + } + + scaled := math.Round(n/divisor*1_000) / 1_000 + if scaled >= 1_000 && suffixIndex < len(suffixes)-1 { + divisor *= 1_000 + suffixIndex++ + scaled = math.Round(n/divisor*1_000) / 1_000 + } + amount := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(scaled, 'f', 3, 64), "0"), ".") + return sign + "$" + amount + suffixes[suffixIndex] +} + func FormatCoinQty(n float64) string { s := strconv.FormatFloat(n, 'f', 8, 64) s = strings.TrimRight(s, "0") @@ -35,6 +70,14 @@ func FormatCoinQty(n float64) string { } func FormatPnLUSD(currentValue, invested float64) string { + return formatPnLUSD(currentValue, invested, FormatUSD) +} + +func formatPortfolioPositionPnLUSD(currentValue, invested float64) string { + return formatPnLUSD(currentValue, invested, formatCompactUSD) +} + +func formatPnLUSD(currentValue, invested float64, formatAmount func(float64) string) string { diff := currentValue - invested pct := 0.0 if invested > 0 { @@ -44,7 +87,7 @@ func FormatPnLUSD(currentValue, invested float64) string { if diff >= 0 { sign = "+" } - return sign + FormatUSD(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)" + return sign + formatAmount(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)" } func groupDigits(s string) string { diff --git a/internal/modules/coin/format_test.go b/internal/modules/coin/format_test.go new file mode 100644 index 0000000..140ff57 --- /dev/null +++ b/internal/modules/coin/format_test.go @@ -0,0 +1,66 @@ +package coin + +import ( + "math" + "testing" +) + +func TestFormatCompactUSD(t *testing.T) { + tests := []struct { + in float64 + want string + }{ + {0, "$0.00"}, + {999.99, "$999.99"}, + {999.994, "$999.99"}, + {999.999, "$1k"}, + {1_000, "$1k"}, + {25_350, "$25.35k"}, + {25_351, "$25.351k"}, + {999_499, "$999.499k"}, + {999_999.4, "$999.999k"}, + {999_999.5, "$1M"}, + {1_000_000, "$1M"}, + {1_234_000, "$1.234M"}, + {126_000_000, "$126M"}, + {999_999_999.5, "$1B"}, + {1_250_000_000, "$1.25B"}, + {999_999_999_999.5, "$1T"}, + {1_000_000_000_000, "$1T"}, + {-25_350, "-$25.35k"}, + {-999.999, "-$1k"}, + {-1_250_000_000, "-$1.25B"}, + {math.NaN(), "invalid USD"}, + {math.Inf(1), "invalid USD"}, + } + for _, test := range tests { + if got := formatCompactUSD(test.in); got != test.want { + t.Errorf("formatCompactUSD(%v): got %q, want %q", test.in, got, test.want) + } + } +} + +func TestFormatPortfolioPositionPnLUSDUsesCompactAmountAndFullPercentage(t *testing.T) { + tests := []struct { + current float64 + invested float64 + want string + }{ + {1_250_000, 1_000_000, "+$250k (+25.00%)"}, + {750_000, 1_000_000, "-$250k (-25.00%)"}, + } + for _, test := range tests { + if got := formatPortfolioPositionPnLUSD(test.current, test.invested); got != test.want { + t.Errorf("formatPortfolioPositionPnLUSD(%v, %v): got %q, want %q", test.current, test.invested, got, test.want) + } + } +} + +func TestExportedUSDFormattersRemainFullPrecision(t *testing.T) { + if got, want := FormatUSD(1_234_567.89), "$1,234,567.89"; got != want { + t.Fatalf("FormatUSD: got %q, want %q", got, want) + } + if got, want := FormatPnLUSD(1_250_000, 1_000_000), "+$250,000.00 (+25.00%)"; got != want { + t.Fatalf("FormatPnLUSD: got %q, want %q", got, want) + } +} diff --git a/internal/modules/coin/handlers_test.go b/internal/modules/coin/handlers_test.go index e5f1c90..efad471 100644 --- a/internal/modules/coin/handlers_test.go +++ b/internal/modules/coin/handlers_test.go @@ -334,7 +334,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { t.Fatalf("handleStats: %v", err) } text := rb.LastSent().Text() - for _, want := range []string{"Coin Portfolio", "
", "BTC", "0.01", "P&L"} {
+ for _, want := range []string{"Coin Portfolio", "", "BTC", "0.01", "$50k", "$500.00", "+$0.00 (+0.00%)", "P&L"} {
if !strings.Contains(text, want) {
t.Fatalf("stats missing %q in %q", want, text)
}
@@ -345,11 +345,44 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
t.Fatalf("handleStats no price: %v", err)
}
rb.AssertSentText(t, "N/A")
+ rb.AssertSentText(t, "$50k")
if strings.Contains(rb.LastSent().Text(), "Account P&L: +") || strings.Contains(rb.LastSent().Text(), "Account P&L: -") {
t.Fatalf("partial prices must not show numeric account P&L: %q", rb.LastSent().Text())
}
}
+func TestStatsCompactsOnlyPositionMonetaryCells(t *testing.T) {
+ ctx := context.Background()
+ s := newTestState(map[string]CoinPrice{"BTC": {USD: 1_250, Source: "test"}}, nil)
+ p := NewPortfolio(1)
+ p.USD = 1_234
+ p.Meta.Invested = 2_001_234
+ p.Assets["BTC"] = AssetPosition{Quantity: 2_000, Base: 2_000_000}
+ if err := SavePortfolio(ctx, s.store, 7, p); err != nil {
+ t.Fatal(err)
+ }
+
+ rb := testutil.NewRecordingBot(t)
+ if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil {
+ t.Fatal(err)
+ }
+ text := rb.LastSent().Text()
+ for _, want := range []string{
+ "BTC",
+ "$1k",
+ "$1.25k",
+ "$2.5M",
+ "+$500k (+25.00%)",
+ "$1,234.00",
+ "$2,501,234.00",
+ "+$500,000.00 (+25.00%)",
+ } {
+ if !strings.Contains(text, want) {
+ t.Fatalf("portfolio missing %q in:\n%s", want, text)
+ }
+ }
+}
+
func TestStatsTreatsOverflowedValuationAsUnavailable(t *testing.T) {
ctx := context.Background()
s := newTestState(map[string]CoinPrice{"BTC": {USD: math.MaxFloat64, Source: "test"}}, nil)
diff --git a/internal/modules/coin/views.go b/internal/modules/coin/views.go
index 5dceb5d..3880671 100644
--- a/internal/modules/coin/views.go
+++ b/internal/modules/coin/views.go
@@ -48,15 +48,15 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
}
totalValue += value
totalBasis += basis
- positions = append(positions, []string{symbol, FormatCoinQty(held), FormatUSD(average), FormatUSD(price.USD), FormatUSD(value), FormatPnLUSD(value, basis)})
+ positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), formatCompactUSD(price.USD), formatCompactUSD(value), formatPortfolioPositionPnLUSD(value, basis)})
} else {
log.Error("coin_fetch_price", "symbol", symbol, "err", err)
missingPrice = true
- positions = append(positions, []string{symbol, FormatCoinQty(held), FormatUSD(average), "N/A", "N/A", "N/A"})
+ positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), "N/A", "N/A", "N/A"})
}
} else {
missingPrice = true
- positions = append(positions, []string{symbol, FormatCoinQty(held), FormatUSD(average), "N/A", "N/A", "N/A"})
+ positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), "N/A", "N/A", "N/A"})
}
}
var summary [][]string
diff --git a/internal/modules/stock/format.go b/internal/modules/stock/format.go
index a767472..37d7efe 100644
--- a/internal/modules/stock/format.go
+++ b/internal/modules/stock/format.go
@@ -18,10 +18,46 @@ func FormatVND(n float64) string {
// 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
+ return formatGroupedInteger(int64(math.Round(n)))
+}
+
+// formatCompactVND renders a position-table amount using the smallest suitable
+// financial suffix. Values are first rounded to the nearest VND, matching the
+// module's full VND formatter.
+func formatCompactVND(n float64) string {
+ rounded := math.Round(n)
+ abs := math.Abs(rounded)
+ if abs < 1_000 {
+ return formatVNDNumber(rounded)
+ }
+
+ suffixes := [...]string{"k", "M", "B", "T"}
+ divisor := 1_000.0
+ suffixIndex := 0
+ for suffixIndex < len(suffixes)-1 && abs >= divisor*1_000 {
+ divisor *= 1_000
+ suffixIndex++
+ }
+
+ scaled := math.Round(abs/divisor*1_000) / 1_000
+ if scaled >= 1_000 && suffixIndex < len(suffixes)-1 {
+ divisor *= 1_000
+ suffixIndex++
+ scaled = math.Round(abs/divisor*1_000) / 1_000
+ }
+
+ result := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(scaled, 'f', 3, 64), "0"), ".")
+ result = strings.Replace(result, ".", ",", 1)
if rounded < 0 {
+ result = "-" + result
+ }
+ return result + suffixes[suffixIndex]
+}
+
+func formatGroupedInteger(n int64) string {
+ abs := strconv.FormatInt(absInt64(n), 10)
+ var sb strings.Builder
+ if n < 0 {
sb.WriteByte('-')
}
for i := 0; i < len(abs); i++ {
@@ -72,6 +108,10 @@ func formatPortfolioPnL(currentValue, invested float64) string {
return formatPnL(currentValue, invested, formatVNDNumber)
}
+func formatPortfolioPositionPnL(currentValue, invested float64) string {
+ return formatPnL(currentValue, invested, formatCompactVND)
+}
+
func formatPnL(currentValue, invested float64, formatAmount func(float64) string) string {
diff := currentValue - invested
pct := 0.0
diff --git a/internal/modules/stock/format_test.go b/internal/modules/stock/format_test.go
index 5ccabba..dd5de51 100644
--- a/internal/modules/stock/format_test.go
+++ b/internal/modules/stock/format_test.go
@@ -43,6 +43,46 @@ func TestFormatStock(t *testing.T) {
}
}
+func TestFormatCompactVND(t *testing.T) {
+ cases := []struct {
+ in float64
+ want string
+ }{
+ {0, "0"},
+ {999, "999"},
+ {999.4, "999"},
+ {999.5, "1k"},
+ {1_000, "1k"},
+ {25_000, "25k"},
+ {25_350, "25,35k"},
+ {25_351, "25,351k"},
+ {999_499, "999,499k"},
+ {999_999.4, "999,999k"},
+ {999_999.5, "1M"},
+ {1_000_000, "1M"},
+ {1_234_000, "1,234M"},
+ {126_000_000, "126M"},
+ {999_999_999.5, "1B"},
+ {1_250_000_000, "1,25B"},
+ {999_999_999_999.5, "1T"},
+ {1_000_000_000_000, "1T"},
+ {-25_350, "-25,35k"},
+ {-1_250_000_000, "-1,25B"},
+ {25_350.5, "25,351k"},
+ }
+ for _, c := range cases {
+ if got := formatCompactVND(c.in); got != c.want {
+ t.Errorf("formatCompactVND(%v): got %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestFormatPortfolioPositionPnLUsesCompactAmountAndFullPercentage(t *testing.T) {
+ if got, want := formatPortfolioPositionPnL(1_250_000, 1_000_000), "+250k (+25.00%)"; got != want {
+ t.Fatalf("formatPortfolioPositionPnL: got %q, want %q", got, want)
+ }
+}
+
func TestFormatShareQuantity(t *testing.T) {
cases := []struct {
in int64
diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go
index 8b4d966..5738d98 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)), formatVNDNumber(average), "N/A", "N/A", "N/A"})
+ positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatCompactVND(average), "N/A", "N/A", "N/A"})
continue
}
val := float64(h.qty) * price
@@ -521,7 +521,7 @@ 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)), formatVNDNumber(average), formatVNDNumber(price), formatVNDNumber(val), formatPortfolioPnL(val, basis)})
+ positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatCompactVND(average), formatCompactVND(price), formatCompactVND(val), formatPortfolioPositionPnL(val, basis)})
}
}
var summary [][]string
diff --git a/internal/modules/stock/stats_test.go b/internal/modules/stock/stats_test.go
index e39f851..a5ebc28 100644
--- a/internal/modules/stock/stats_test.go
+++ b/internal/modules/stock/stats_test.go
@@ -71,7 +71,10 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
"",
"Ticker",
"MWG",
- "126.000.000",
+ "60k",
+ "70k",
+ "126M",
+ "+18M (+16.67%)",
"Cash",
"Total value",
"530.335.000",
@@ -92,6 +95,90 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
}
}
+func TestHandleStats_UnavailablePriceKeepsCompactAverage(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
+ priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[]}`))
+ }))
+ t.Cleanup(priceSrv.Close)
+
+ store := newStockStore()
+ p := NewPortfolio(now.UnixMilli())
+ p.VND = 1_000_000
+ p.Meta.Invested = 3_535_000
+ if err := p.BuyTicker("TCB", 100, 2_535_000, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := SavePortfolio(ctx, store, 7, p); err != nil {
+ t.Fatal(err)
+ }
+
+ s := &state{
+ store: store,
+ prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL},
+ nowFn: func() time.Time { return now },
+ }
+ rb := testutil.NewRecordingBot(t)
+ if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
+ t.Fatal(err)
+ }
+
+ text := rb.LastSent().Text()
+ for _, want := range []string{"TCB", "25,35k", "N/A", "Priced value (partial)", "Account P&L", "Unavailable"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("portfolio missing %q in:\n%s", want, text)
+ }
+ }
+ if got := strings.Count(text, "N/A"); got != 3 {
+ t.Fatalf("missing-price position has %d N/A cells, want 3:\n%s", got, text)
+ }
+}
+
+func TestHandleStats_OverflowedValuationKeepsMonetaryCellsUnavailable(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
+ priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"stockSymbol":"FPT","matchedPrice":1.7976931348623157e308}]}`))
+ }))
+ t.Cleanup(priceSrv.Close)
+
+ store := newStockStore()
+ p := NewPortfolio(now.UnixMilli())
+ p.Meta.Invested = 2_000
+ if err := p.BuyTicker("FPT", 2, 2_000, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := SavePortfolio(ctx, store, 7, p); err != nil {
+ t.Fatal(err)
+ }
+
+ s := &state{
+ store: store,
+ prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL},
+ nowFn: func() time.Time { return now },
+ }
+ rb := testutil.NewRecordingBot(t)
+ if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
+ t.Fatal(err)
+ }
+
+ text := rb.LastSent().Text()
+ for _, want := range []string{"FPT", "Account P&L", "Unavailable"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("overflow portfolio missing %q in:\n%s", want, text)
+ }
+ }
+ if got := strings.Count(text, "N/A"); got != 4 {
+ t.Fatalf("overflowed position has %d N/A monetary cells, want 4:\n%s", got, text)
+ }
+ if strings.Contains(text, "1k") {
+ t.Fatalf("overflowed position exposed its average instead of N/A:\n%s", text)
+ }
+}
+
func TestStockPortfolioReplyStaysWithinTelegramBudget(t *testing.T) {
positions := make([]string, 200)
for i := range positions {
diff --git a/plans/260722-1114-compact-portfolio-numbers/phase-01-define-compact-formatters.md b/plans/260722-1114-compact-portfolio-numbers/phase-01-define-compact-formatters.md
new file mode 100644
index 0000000..f3dcd64
--- /dev/null
+++ b/plans/260722-1114-compact-portfolio-numbers/phase-01-define-compact-formatters.md
@@ -0,0 +1,62 @@
+---
+phase: 1
+title: Define Compact Formatters
+status: completed
+priority: P1
+dependencies: []
+effort: small
+---
+
+# Phase 1: Define Compact Formatters
+
+## Overview
+
+Define private compact monetary formatters for stock VND and coin USD while
+preserving the modules' exported formatting contracts.
+
+## Requirements
+
+- Functional: select base, `k`, `M`, `B`, or `T` from absolute magnitude.
+- Functional: show at most three fractional digits and trim trailing zeroes.
+- Functional: promote after rounding rollover, so `999.9999k` becomes `1M`.
+- Functional: preserve negative signs and module-native separators.
+- Non-functional: keep `FormatVND`, `FormatPnL`, `FormatUSD`, and
+ `FormatPnLUSD` output unchanged outside position tables.
+
+## Architecture
+
+Each module owns a private compact formatter because stock uses dot grouping
+plus comma decimals, while coin uses `$`, comma grouping, and dot decimals.
+Both follow the same magnitude table: base `< 10^3`, then powers of 1,000
+through `T`. P&L wrappers reuse existing percentage calculation behavior while
+substituting only the monetary formatter.
+
+## Related Code Files
+
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/format.go`
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/format_test.go`
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/coin/format.go`
+- Create: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/coin/format_test.go`
+
+## Implementation Steps
+
+1. Replace the preliminary stock `k`-only helper with automatic suffix selection.
+2. Preserve stock grouping, decimal comma, sign, and nearest-VND input rounding.
+3. Add the equivalent USD helper with `$` and English separators.
+4. Add private compact P&L wrappers that retain signed two-decimal percentages.
+5. Test base/`k`/`M`/`B`/`T` boundaries, trimmed fractions, negatives, zero,
+ rounding rollover, and unchanged exported formatter results.
+
+## Success Criteria
+
+- [x] Stock examples include `999`, `1k`, `25,35k`, `126M`, and `1,25B`.
+- [x] Coin examples include `$999.99`, `$1k`, `$50k`, and `$1.234M`.
+- [x] Boundary rollover never emits `1000k`, `1000M`, or `1000B`.
+- [x] Exported stock and coin formatters retain their current test outputs.
+
+## Risk Assessment
+
+Main risks: threshold off-by-one errors, locale separator drift, negative-sign
+duplication, and rounding into the next suffix. Table-driven boundary tests
+mitigate all four. No security or data-protection impact; formatting is local
+and receives already-validated numeric values.
diff --git a/plans/260722-1114-compact-portfolio-numbers/phase-02-integrate-portfolio-renderers.md b/plans/260722-1114-compact-portfolio-numbers/phase-02-integrate-portfolio-renderers.md
new file mode 100644
index 0000000..50145f0
--- /dev/null
+++ b/plans/260722-1114-compact-portfolio-numbers/phase-02-integrate-portfolio-renderers.md
@@ -0,0 +1,64 @@
+---
+phase: 2
+title: Integrate Portfolio Renderers
+status: completed
+priority: P1
+dependencies:
+ - 1
+effort: small
+---
+
+# Phase 2: Integrate Portfolio Renderers
+
+## Overview
+
+Wire the approved compact formatters into the four monetary position columns
+of both portfolio renderers and lock down user-visible output.
+
+## Requirements
+
+- Functional: compact `Avg`, `Now`, `Value`, and position `Unrealized P&L`.
+- Functional: keep `Qty`, percentages, title currency markers, and `N/A` intact.
+- Functional: keep stock and coin summary tables at their existing full formats.
+- Non-functional: do not change quote fetching, valuation, sorting, Telegram
+ HTML escaping, reply truncation, or dividend notification behavior.
+
+## Architecture
+
+Only the position-row construction changes. Stock calls its VND compact helper;
+coin calls its USD compact helper. Summary row construction continues using the
+existing full-value formatters, isolating the behavior to the requested cells.
+
+## Related Code Files
+
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/handlers.go`
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/stats_test.go`
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/coin/views.go`
+- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/coin/handlers_test.go`
+- Inspect: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/coin/views_reply_budget_test.go`
+
+## Implementation Steps
+
+1. Replace stock position monetary cells with automatic compact formatting,
+ including the missing-price branch's available average.
+2. Apply compact USD formatting to the equivalent coin position cells.
+3. Keep both summary slices unchanged and verify full values remain present.
+4. Strengthen renderer assertions for magnitude suffixes, P&L percentages,
+ currency conventions, and the absence of compaction in summaries.
+5. Confirm partial-price paths still emit `N/A` and unavailable Account P&L.
+
+## Success Criteria
+
+- [x] Stock position cells select `k/M/B/T` independently and use VND separators.
+- [x] Coin position cells select `k/M/B/T` independently and retain `$`.
+- [x] Position P&L amounts compact; percentages remain unchanged.
+- [x] Summary values and non-portfolio replies remain byte-for-byte compatible.
+- [x] Missing and overflowed quotes retain current `N/A` behavior.
+- [x] Telegram reply-budget tests remain valid or improve due to shorter cells.
+
+## Risk Assessment
+
+Main regression risk is accidentally compacting summary or standalone output.
+Use private position-only wrappers and assertions that check both compact rows
+and full summary values. No authorization, concurrency, persistence, or network
+boundary changes.
diff --git a/plans/260722-1114-compact-portfolio-numbers/phase-03-verify-formatting-contracts.md b/plans/260722-1114-compact-portfolio-numbers/phase-03-verify-formatting-contracts.md
new file mode 100644
index 0000000..5613df8
--- /dev/null
+++ b/plans/260722-1114-compact-portfolio-numbers/phase-03-verify-formatting-contracts.md
@@ -0,0 +1,63 @@
+---
+phase: 3
+title: Verify Formatting Contracts
+status: completed
+priority: P1
+dependencies:
+ - 2
+effort: small
+---
+
+# Phase 3: Verify Formatting Contracts
+
+## Overview
+
+Verify formatter correctness, both portfolio flows, and repository-wide quality
+without expanding the feature scope.
+
+## Requirements
+
+- Functional: every approved example and boundary has automated coverage.
+- Non-functional: changed Go files are formatted; tests, vet, and lint pass.
+- Non-functional: public functions, commands, schemas, and persistence remain
+ unchanged.
+
+## Architecture
+
+Verification proceeds from deterministic formatter tests to renderer tests,
+then the full repository gates. A final diff review maps each changed call site
+to the requested four columns and checks that unrelated formatting is untouched.
+
+## Related Code Files
+
+- Inspect: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock`
+- Inspect: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/coin`
+- Inspect: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/README.md`
+- Inspect: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/docs`
+
+## Implementation Steps
+
+1. Run `gofmt` on every changed Go file and `git diff --check`.
+2. Run `go test ./internal/modules/stock ./internal/modules/coin`.
+3. Run `go test ./...` and `go vet ./...`.
+4. Run `golangci-lint run` when available, matching the repository CI gate.
+5. Review the final diff for scope, exported-contract stability, secrets, and
+ accidental summary or standalone-message changes.
+6. Decide whether README/docs need an evergreen update; avoid documentation
+ churn when tests and the approved report sufficiently capture presentation.
+
+## Success Criteria
+
+- [x] All focused and repository-wide tests pass.
+- [x] `go vet ./...` passes.
+- [x] `golangci-lint run` passes when installed.
+- [x] `git diff --check` is clean.
+- [x] Review finds no changes to commands, storage, migrations, APIs, or quotes.
+- [x] Final portfolio examples match the approved brainstorm report.
+
+## Risk Assessment
+
+The main risk is incomplete branch coverage for partial or overflowed prices.
+Run existing partial/overflow tests and add only focused assertions if a gap is
+found. Rollback is a localized renderer/formatter revert; no data migration is
+needed. Security impact is none beyond the standard staged-diff secret scan.
diff --git a/plans/260722-1114-compact-portfolio-numbers/plan.md b/plans/260722-1114-compact-portfolio-numbers/plan.md
new file mode 100644
index 0000000..7b31172
--- /dev/null
+++ b/plans/260722-1114-compact-portfolio-numbers/plan.md
@@ -0,0 +1,50 @@
+---
+title: Compact Portfolio Number Formatting
+description: >-
+ Compact stock and coin portfolio position values with automatic k/M/B/T
+ financial suffixes.
+status: completed
+priority: P2
+branch: main
+tags:
+ - refactor
+ - stock
+ - coin
+ - telegram
+blockedBy: []
+blocks: []
+created: '2026-07-22T04:14:02.795Z'
+createdBy: 'ck:plan'
+source: skill
+---
+
+# Compact Portfolio Number Formatting
+
+## Overview
+
+Replace wide monetary values in stock and coin position tables with automatic
+`k/M/B/T` formatting. Apply only to `Avg`, `Now`, `Value`, and position-level
+`Unrealized P&L`; preserve summary tables and standalone replies.
+
+Source: [approved brainstorm report](../reports/260722-1112-compact-portfolio-number-format.md).
+
+## Phases
+
+| Phase | Name | Status |
+|-------|------|--------|
+| 1 | [Define Compact Formatters](./phase-01-define-compact-formatters.md) | Completed |
+| 2 | [Integrate Portfolio Renderers](./phase-02-integrate-portfolio-renderers.md) | Completed |
+| 3 | [Verify Formatting Contracts](./phase-03-verify-formatting-contracts.md) | Completed |
+
+## Dependencies
+
+- No cross-plan dependencies.
+- Implementation revised the preliminary stock-only `k` work into the approved
+ automatic formatter and added matching coin support.
+- Go toolchain plus optional `golangci-lint` for verification.
+
+## Boundaries
+
+- No command, storage, API, migration, price-fetching, or P&L calculation changes.
+- No compact formatting in summary tables or non-portfolio messages.
+- No shared cross-module abstraction unless duplication creates real complexity.
diff --git a/plans/reports/260722-1112-compact-portfolio-number-format.md b/plans/reports/260722-1112-compact-portfolio-number-format.md
new file mode 100644
index 0000000..996649d
--- /dev/null
+++ b/plans/reports/260722-1112-compact-portfolio-number-format.md
@@ -0,0 +1,90 @@
+---
+title: Compact portfolio number format
+status: approved
+created: 2026-07-22
+tags: [stock, coin, formatting, telegram]
+---
+
+# Compact Portfolio Number Format
+
+## Summary
+
+Stock and coin position tables need shorter monetary cells without hiding each
+value's magnitude. Use automatic per-cell financial suffixes for the `Avg`,
+`Now`, `Value`, and position-level `Unrealized P&L` columns.
+
+## Problem
+
+Full monetary values make Telegram's monospace portfolio tables wide and harder
+to scan. A single fixed scale fails because stock and coin prices and position
+values span several orders of magnitude.
+
+Evidence is direct user feedback on the rendered portfolio. Success means the
+four position columns become visibly narrower while their currencies,
+magnitudes, signs, and P&L percentages remain understandable.
+
+## Requirements
+
+- Scale absolute values automatically: base below 1,000, then `k`, `M`, `B`,
+ and `T` at successive powers of 1,000.
+- Show at most three fractional digits and trim trailing zeroes.
+- Preserve each module's convention: stock uses dot grouping and comma decimal;
+ coin uses `$`, comma grouping, and dot decimal.
+- Keep signs and P&L percentages unchanged.
+- Apply compact formatting only to position-table `Avg`, `Now`, `Value`, and
+ `Unrealized P&L` monetary amounts.
+- Keep summary tables and standalone trade, price, top-up, and dividend replies
+ unchanged.
+- Keep `N/A` behavior unchanged when a price or valuation is unavailable.
+
+## Evaluated Approaches
+
+### Automatic magnitude per value — selected
+
+Examples: stock `25,35k`, `126M`, `1,25B`; coin `$950`, `$50k`, `$1.234M`.
+Each value carries its scale, so mixed rows remain unambiguous. Three fractional
+digits balance width and display precision.
+
+### Fixed unit per column
+
+Predictable alignment, but produces awkward small values such as `0,125M` and
+cannot fit the full range of coin prices cleanly.
+
+### Unit in headers
+
+Produces the narrowest cells, but one header scale cannot represent mixed
+magnitudes without exceptions and ambiguity.
+
+## Decision
+
+Use the common financial suffix set `k/M/B/T`. Do not use strict SI `G` for
+billion because it is unfamiliar in financial displays. Do not use Vietnamese
+`tr/tỷ` because the requested stock-and-coin style should remain uniform and
+compact.
+
+## Implementation Considerations
+
+- Keep compact helpers private to their modules so exported formatting
+ contracts remain stable.
+- Select a suffix from the absolute value, then preserve the original sign.
+- Round to at most three scaled fractional digits before trimming zeroes.
+- Add boundary tests around 1,000, 1M, 1B, 1T, negatives, and rounding rollover.
+- Update renderer tests for both fully priced and unavailable-price paths.
+
+## Success Criteria
+
+- `999` remains unscaled; `1,000` becomes `1k`; `1,000,000` becomes `1M`.
+- Stock examples use `25,35k` and `126M`.
+- Coin examples use `$50k` and `$1.234M`.
+- Position P&L amounts compact while percentages remain exact to two decimals.
+- Summary and non-portfolio messages retain current output.
+- Focused tests, full tests, vet, and lint pass.
+
+## Next Steps
+
+Create an implementation plan, revise the preliminary stock-only formatter,
+add the coin formatter and renderer integration, then verify both modules.
+
+## Unresolved Questions
+
+None.