feat: format compact portfolio numbers

This commit is contained in:
2026-07-22 12:27:52 +07:00
parent a1f0a682fc
commit b6f6361c94
14 changed files with 688 additions and 11 deletions
@@ -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.
+44 -1
View File
@@ -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 {
+66
View File
@@ -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)
}
}
+34 -1
View File
@@ -334,7 +334,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
t.Fatalf("handleStats: %v", err)
}
text := rb.LastSent().Text()
for _, want := range []string{"Coin Portfolio", "<pre>", "BTC", "0.01", "P&amp;L"} {
for _, want := range []string{"Coin Portfolio", "<pre>", "BTC", "0.01", "$50k", "$500.00", "+$0.00 (+0.00%)", "P&amp;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)
+3 -3
View File
@@ -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
+43 -3
View File
@@ -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
+40
View File
@@ -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
+2 -2
View File
@@ -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
+88 -1
View File
@@ -71,7 +71,10 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
"<pre>",
"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&amp;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&amp;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 {
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.