mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-24 08:30:16 +00:00
fix(portfolio): tighten mobile column formatting
This commit is contained in:
@@ -25,15 +25,15 @@ func FormatUSD(n float64) string {
|
||||
}
|
||||
|
||||
// formatCompactUSD renders a position-table amount with at most three scaled
|
||||
// fractional digits while preserving the module's dollar/sign convention.
|
||||
// fractional digits. The coin portfolio makes USD implicit and omits "$".
|
||||
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.
|
||||
// amount displayed as 1,000.00 is promoted to 1k instead.
|
||||
if math.Round(math.Abs(n)*100)/100 < 1_000 {
|
||||
return FormatUSD(n)
|
||||
return strings.Replace(FormatUSD(n), "$", "", 1)
|
||||
}
|
||||
|
||||
sign := ""
|
||||
@@ -56,7 +56,7 @@ func formatCompactUSD(n float64) string {
|
||||
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]
|
||||
return sign + amount + suffixes[suffixIndex]
|
||||
}
|
||||
|
||||
func FormatCoinQty(n float64) string {
|
||||
@@ -73,11 +73,16 @@ func FormatPnLUSD(currentValue, invested float64) string {
|
||||
return formatPnLUSD(currentValue, invested, FormatUSD)
|
||||
}
|
||||
|
||||
func formatPortfolioPositionPnLUSD(currentValue, invested float64) string {
|
||||
return formatPnLUSD(currentValue, invested, formatCompactUSD)
|
||||
func formatPortfolioPositionPnLUSD(currentValue, invested float64) (string, string) {
|
||||
return formatPnLUSDParts(currentValue, invested, formatCompactUSD)
|
||||
}
|
||||
|
||||
func formatPnLUSD(currentValue, invested float64, formatAmount func(float64) string) string {
|
||||
amount, percentage := formatPnLUSDParts(currentValue, invested, formatAmount)
|
||||
return amount + " (" + percentage + ")"
|
||||
}
|
||||
|
||||
func formatPnLUSDParts(currentValue, invested float64, formatAmount func(float64) string) (string, string) {
|
||||
diff := currentValue - invested
|
||||
pct := 0.0
|
||||
if invested > 0 {
|
||||
@@ -87,7 +92,7 @@ func formatPnLUSD(currentValue, invested float64, formatAmount func(float64) str
|
||||
if diff >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
return sign + formatAmount(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
|
||||
return sign + formatAmount(diff), sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%"
|
||||
}
|
||||
|
||||
func groupDigits(s string) string {
|
||||
|
||||
@@ -10,26 +10,26 @@ func TestFormatCompactUSD(t *testing.T) {
|
||||
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"},
|
||||
{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"},
|
||||
}
|
||||
@@ -40,18 +40,20 @@ func TestFormatCompactUSD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPortfolioPositionPnLUSDUsesCompactAmountAndFullPercentage(t *testing.T) {
|
||||
func TestFormatPortfolioPositionPnLUSDSplitsAmountAndPercentage(t *testing.T) {
|
||||
tests := []struct {
|
||||
current float64
|
||||
invested float64
|
||||
want string
|
||||
amount string
|
||||
percent string
|
||||
}{
|
||||
{1_250_000, 1_000_000, "+$250k (+25.00%)"},
|
||||
{750_000, 1_000_000, "-$250k (-25.00%)"},
|
||||
{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)
|
||||
amount, percent := formatPortfolioPositionPnLUSD(test.current, test.invested)
|
||||
if amount != test.amount || percent != test.percent {
|
||||
t.Errorf("formatPortfolioPositionPnLUSD(%v, %v): got (%q, %q), want (%q, %q)", test.current, test.invested, amount, percent, test.amount, test.percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "$50k", "$500.00", "+$0.00 (+0.00%)", "P&L"} {
|
||||
for _, want := range []string{"Coin Portfolio", "<pre>", "Sym", "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,7 +345,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
|
||||
t.Fatalf("handleStats no price: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "N/A")
|
||||
rb.AssertSentText(t, "$50k")
|
||||
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())
|
||||
}
|
||||
@@ -369,10 +369,11 @@ func TestStatsCompactsOnlyPositionMonetaryCells(t *testing.T) {
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{
|
||||
"BTC",
|
||||
"$1k",
|
||||
"$1.25k",
|
||||
"$2.5M",
|
||||
"+$500k (+25.00%)",
|
||||
"1k",
|
||||
"1.25k",
|
||||
"2.5M",
|
||||
"+500k",
|
||||
"+25.00%",
|
||||
"$1,234.00",
|
||||
"$2,501,234.00",
|
||||
"+$500,000.00 (+25.00%)",
|
||||
|
||||
@@ -43,20 +43,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
value := held * price.USD
|
||||
if !isPositiveFinite(value) || !isPositiveFinite(average) {
|
||||
missingPrice = true
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), "N/A", "N/A", "N/A", "N/A"})
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), "N/A", "N/A", "N/A", "N/A", "N/A"})
|
||||
continue
|
||||
}
|
||||
totalValue += value
|
||||
totalBasis += basis
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), formatCompactUSD(price.USD), formatCompactUSD(value), formatPortfolioPositionPnLUSD(value, basis)})
|
||||
pnlAmount, pnlPercentage := formatPortfolioPositionPnLUSD(value, basis)
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), formatCompactUSD(price.USD), formatCompactUSD(value), pnlAmount, pnlPercentage})
|
||||
} else {
|
||||
log.Error("coin_fetch_price", "symbol", symbol, "err", err)
|
||||
missingPrice = true
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), "N/A", "N/A", "N/A"})
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), "N/A", "N/A", "N/A", "N/A"})
|
||||
}
|
||||
} else {
|
||||
missingPrice = true
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), "N/A", "N/A", "N/A"})
|
||||
positions = append(positions, []string{symbol, FormatCoinQty(held), formatCompactUSD(average), "N/A", "N/A", "N/A", "N/A"})
|
||||
}
|
||||
}
|
||||
var summary [][]string
|
||||
@@ -101,7 +102,7 @@ func portfolioTableReply(title string, positions, summary [][]string) string {
|
||||
rows = append(rows, []string{"… " + strconv.Itoa(omitted) + " omitted"})
|
||||
}
|
||||
reply := "<b>" + title + "</b>\n" +
|
||||
chathelper.MonospaceTable([]string{"Ticker", "Qty", "Avg", "Now", "Value", "Unrealized P&L"}, rows) + "\n" +
|
||||
chathelper.MonospaceTable([]string{"Sym", "Qty", "Avg", "Now", "Val", "P&L", "%"}, rows) + "\n" +
|
||||
chathelper.MonospaceTable([]string{"Metric", "Value"}, summary)
|
||||
if len(reply) <= portfolioReplyLimit || len(positions) == 0 {
|
||||
return reply
|
||||
|
||||
@@ -15,8 +15,8 @@ func FormatVND(n float64) string {
|
||||
return formatVNDNumber(n) + " VND"
|
||||
}
|
||||
|
||||
// formatVNDNumber renders a VND amount without its currency suffix. Portfolio
|
||||
// tables use this because their title declares the currency once.
|
||||
// formatVNDNumber renders a VND amount without its currency suffix. Stock
|
||||
// portfolio summaries use VND as their implicit currency.
|
||||
func formatVNDNumber(n float64) string {
|
||||
return formatGroupedInteger(int64(math.Round(n)))
|
||||
}
|
||||
@@ -54,6 +54,14 @@ func formatCompactVND(n float64) string {
|
||||
return result + suffixes[suffixIndex]
|
||||
}
|
||||
|
||||
// formatThousandVND renders a VND amount in thousands without a currency
|
||||
// suffix. Stock portfolio Avg and Now columns declare this unit by convention.
|
||||
func formatThousandVND(n float64) string {
|
||||
scaled := math.Round(n) / 1_000
|
||||
result := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(scaled, 'f', 3, 64), "0"), ".")
|
||||
return strings.Replace(result, ".", ",", 1)
|
||||
}
|
||||
|
||||
func formatGroupedInteger(n int64) string {
|
||||
abs := strconv.FormatInt(absInt64(n), 10)
|
||||
var sb strings.Builder
|
||||
@@ -108,11 +116,16 @@ func formatPortfolioPnL(currentValue, invested float64) string {
|
||||
return formatPnL(currentValue, invested, formatVNDNumber)
|
||||
}
|
||||
|
||||
func formatPortfolioPositionPnL(currentValue, invested float64) string {
|
||||
return formatPnL(currentValue, invested, formatCompactVND)
|
||||
func formatPortfolioPositionPnL(currentValue, invested float64) (string, string) {
|
||||
return formatPnLParts(currentValue, invested, formatCompactVND)
|
||||
}
|
||||
|
||||
func formatPnL(currentValue, invested float64, formatAmount func(float64) string) string {
|
||||
amount, percentage := formatPnLParts(currentValue, invested, formatAmount)
|
||||
return amount + " (" + percentage + ")"
|
||||
}
|
||||
|
||||
func formatPnLParts(currentValue, invested float64, formatAmount func(float64) string) (string, string) {
|
||||
diff := currentValue - invested
|
||||
pct := 0.0
|
||||
if invested > 0 {
|
||||
@@ -122,7 +135,7 @@ func formatPnL(currentValue, invested float64, formatAmount func(float64) string
|
||||
if diff >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
return sign + formatAmount(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
|
||||
return sign + formatAmount(diff), sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%"
|
||||
}
|
||||
|
||||
func absInt64(n int64) int64 {
|
||||
|
||||
@@ -77,9 +77,29 @@ func TestFormatCompactVND(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestFormatThousandVND(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{999, "0,999"},
|
||||
{1_000, "1"},
|
||||
{25_350, "25,35"},
|
||||
{1_250_000, "1250"},
|
||||
{-25_350, "-25,35"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatThousandVND(c.in); got != c.want {
|
||||
t.Errorf("formatThousandVND(%v): got %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPortfolioPositionPnLSplitsAmountAndPercentage(t *testing.T) {
|
||||
amount, percentage := formatPortfolioPositionPnL(1_250_000, 1_000_000)
|
||||
if amount != "+250k" || percentage != "+25.00%" {
|
||||
t.Fatalf("formatPortfolioPositionPnL: got (%q, %q)", amount, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -510,18 +510,19 @@ 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)), formatCompactVND(average), "N/A", "N/A", "N/A"})
|
||||
positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatThousandVND(average), "N/A", "N/A", "N/A", "N/A"})
|
||||
continue
|
||||
}
|
||||
val := float64(h.qty) * price
|
||||
if !isPositiveFiniteCost(val) || !isPositiveFiniteCost(average) {
|
||||
missingPrice = true
|
||||
positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), "N/A", "N/A", "N/A", "N/A"})
|
||||
positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), "N/A", "N/A", "N/A", "N/A", "N/A"})
|
||||
continue
|
||||
}
|
||||
totalValue += val
|
||||
totalBasis += basis
|
||||
positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatCompactVND(average), formatCompactVND(price), formatCompactVND(val), formatPortfolioPositionPnL(val, basis)})
|
||||
pnlAmount, pnlPercentage := formatPortfolioPositionPnL(val, basis)
|
||||
positions = append(positions, []string{h.symbol, FormatStock(float64(h.qty)), formatThousandVND(average), formatThousandVND(price), formatCompactVND(val), pnlAmount, pnlPercentage})
|
||||
}
|
||||
}
|
||||
var summary [][]string
|
||||
@@ -542,7 +543,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
{"Account P&L", formatPortfolioPnL(totalValue, p.Meta.Invested)},
|
||||
}
|
||||
}
|
||||
if err := chathelper.ReplyHTML(ctx, b, update.Message, portfolioTableReply("Stock Portfolio (VND)", positions, summary)); err != nil {
|
||||
if err := chathelper.ReplyHTML(ctx, b, update.Message, portfolioTableReply("Stock Portfolio", positions, summary)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.notifyDividendEvents(ctx, b, update.Message, userID, p, checkedThrough)
|
||||
@@ -558,7 +559,7 @@ func portfolioTableReply(title string, positions, summary [][]string) string {
|
||||
rows = append(rows, []string{"… " + strconv.Itoa(omitted) + " omitted"})
|
||||
}
|
||||
reply := "<b>" + title + "</b>\n" +
|
||||
chathelper.MonospaceTable([]string{"Ticker", "Qty", "Avg", "Now", "Value", "Unrealized P&L"}, rows) + "\n" +
|
||||
chathelper.MonospaceTable([]string{"Sym", "Qty", "Avg", "Now", "Val", "P&L", "%"}, rows) + "\n" +
|
||||
chathelper.MonospaceTable([]string{"Metric", "Value"}, summary)
|
||||
if len(reply) <= portfolioReplyLimit || len(positions) == 0 {
|
||||
return reply
|
||||
|
||||
@@ -67,18 +67,19 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
|
||||
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{
|
||||
"Stock Portfolio (VND)",
|
||||
"Stock Portfolio",
|
||||
"<pre>",
|
||||
"Ticker",
|
||||
"Sym",
|
||||
"MWG",
|
||||
"60k",
|
||||
"70k",
|
||||
"60",
|
||||
"70",
|
||||
"126M",
|
||||
"+18M (+16.67%)",
|
||||
"+18M",
|
||||
"+16.67%",
|
||||
"Cash",
|
||||
"Total value",
|
||||
"530.335.000",
|
||||
"Unrealized P&L",
|
||||
"P&L",
|
||||
"+85.000.000 (+19.19%)",
|
||||
"Account P&L",
|
||||
"-469.665.000 (-46.97%)",
|
||||
@@ -90,8 +91,8 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
|
||||
if strings.Contains(text, "N/A") {
|
||||
t.Fatalf("stats rendered missing prices:\n%s", text)
|
||||
}
|
||||
if strings.Count(text, "VND") != 1 {
|
||||
t.Fatalf("stats should declare VND only in the title:\n%s", text)
|
||||
if strings.Contains(text, "VND") {
|
||||
t.Fatalf("stock portfolio should use implicit VND:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,13 +127,13 @@ func TestHandleStats_UnavailablePriceKeepsCompactAverage(t *testing.T) {
|
||||
}
|
||||
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"TCB", "25,35k", "N/A", "Priced value (partial)", "Account P&L", "Unavailable"} {
|
||||
for _, want := range []string{"TCB", "25,35", "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)
|
||||
if got := strings.Count(text, "N/A"); got != 4 {
|
||||
t.Fatalf("missing-price position has %d N/A cells, want 4:\n%s", got, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,8 +172,8 @@ func TestHandleStats_OverflowedValuationKeepsMonetaryCellsUnavailable(t *testing
|
||||
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 got := strings.Count(text, "N/A"); got != 5 {
|
||||
t.Fatalf("overflowed position has %d N/A monetary cells, want 5:\n%s", got, text)
|
||||
}
|
||||
if strings.Contains(text, "1k") {
|
||||
t.Fatalf("overflowed position exposed its average instead of N/A:\n%s", text)
|
||||
|
||||
Reference in New Issue
Block a user