mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-04 06:20:01 +00:00
feat: format compact portfolio numbers
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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&L"} {
|
||||
for _, want := range []string{"Coin Portfolio", "<pre>", "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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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&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 {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user