diff --git a/cointop/coins_table.go b/cointop/coins_table.go index e8ac01f..79dff1b 100644 --- a/cointop/coins_table.go +++ b/cointop/coins_table.go @@ -136,6 +136,9 @@ func (ct *Cointop) GetCoinsTable() *table.Table { }) case "24h_volume": text := humanize.Monetaryf(coin.Volume24H, 0) + if ct.IsActiveTableCompactNotation() { + text = humanize.ScaleNumericf(coin.Volume24H, 3) + } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, @@ -243,6 +246,9 @@ func (ct *Cointop) GetCoinsTable() *table.Table { }) case "market_cap": text := humanize.Monetaryf(coin.MarketCap, 0) + if ct.IsActiveTableCompactNotation() { + text = humanize.ScaleNumericf(coin.MarketCap, 3) + } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, @@ -255,6 +261,9 @@ func (ct *Cointop) GetCoinsTable() *table.Table { }) case "total_supply": text := humanize.Numericf(coin.TotalSupply, 0) + if ct.IsActiveTableCompactNotation() { + text = humanize.ScaleNumericf(coin.TotalSupply, 3) + } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, @@ -267,6 +276,9 @@ func (ct *Cointop) GetCoinsTable() *table.Table { }) case "available_supply": text := humanize.Numericf(coin.AvailableSupply, 0) + if ct.IsActiveTableCompactNotation() { + text = humanize.ScaleNumericf(coin.AvailableSupply, 3) + } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, diff --git a/cointop/cointop.go b/cointop/cointop.go index fa54c78..ca3afec 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -89,6 +89,11 @@ type State struct { priceAlerts *PriceAlerts priceAlertEditID string priceAlertNewID string + + compactNotation bool + tableCompactNotation bool + favoritesCompactNotation bool + portfolioCompactNotation bool } // Cointop cointop @@ -181,6 +186,9 @@ var DefaultCurrency = "USD" // DefaultChartRange ... var DefaultChartRange = "1Y" +// DefaultCompactNotation ... +var DefaultCompactNotation = false + // DefaultMaxChartWidth ... var DefaultMaxChartWidth = 175 @@ -291,6 +299,10 @@ func NewCointop(config *Config) (*Cointop, error) { Entries: make([]*PriceAlert, 0), SoundEnabled: true, }, + compactNotation: DefaultCompactNotation, + tableCompactNotation: DefaultCompactNotation, + favoritesCompactNotation: DefaultCompactNotation, + portfolioCompactNotation: DefaultCompactNotation, }, Views: &Views{ Chart: NewChartView(), diff --git a/cointop/config.go b/cointop/config.go index 02cc058..dbe1f94 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -48,6 +48,7 @@ type ConfigFileConfig struct { Colorscheme interface{} `toml:"colorscheme"` RefreshRate interface{} `toml:"refresh_rate"` CacheDir interface{} `toml:"cache_dir"` + CompactNotation interface{} `toml:"compact_notation"` Table map[string]interface{} `toml:"table"` Chart map[string]interface{} `toml:"chart"` } @@ -70,6 +71,7 @@ func (ct *Cointop) SetupConfig() error { ct.loadColorschemeFromConfig, ct.loadRefreshRateFromConfig, ct.loadCacheDirFromConfig, + ct.loadCompactNotationFromConfig, ct.loadPriceAlertsFromConfig, ct.loadPortfolioFromConfig, } @@ -215,10 +217,11 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { var favoritesBySymbolIfc []interface{} favoritesMapIfc := map[string]interface{}{ // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility. - "symbols": favoritesBySymbolIfc, - "names": favoritesIfc, - "columns": ct.State.favoritesTableColumns, - "character": ct.State.favoriteChar, + "symbols": favoritesBySymbolIfc, + "names": favoritesIfc, + "columns": ct.State.favoritesTableColumns, + "character": ct.State.favoriteChar, + "compact_notation": ct.State.favoritesCompactNotation, } var holdingsIfc [][]string @@ -236,8 +239,9 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { return holdingsIfc[i][0] < holdingsIfc[j][0] }) portfolioIfc := map[string]interface{}{ - "holdings": holdingsIfc, - "columns": ct.State.portfolioTableColumns, + "holdings": holdingsIfc, + "columns": ct.State.portfolioTableColumns, + "compact_notation": ct.State.portfolioCompactNotation, } cmcIfc := map[string]interface{}{ @@ -264,6 +268,7 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { tableMapIfc := map[string]interface{}{ "columns": ct.State.coinsTableColumns, "keep_row_focus_on_sort": ct.State.keepRowFocusOnSort, + "compact_notation": ct.State.tableCompactNotation, } chartMapIfc := map[string]interface{}{ @@ -286,6 +291,7 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { CacheDir: ct.State.cacheDir, Table: tableMapIfc, Chart: chartMapIfc, + CompactNotation: ct.State.compactNotation, } var b bytes.Buffer @@ -310,6 +316,11 @@ func (ct *Cointop) loadTableConfig() error { if ok { ct.State.keepRowFocusOnSort = keepRowFocusOnSortIfc.(bool) } + + if compactNotation, ok := ct.config.Table["compact_notation"]; ok { + ct.State.tableCompactNotation = compactNotation.(bool) + } + return nil } @@ -458,6 +469,16 @@ func (ct *Cointop) loadCacheDirFromConfig() error { return nil } +// loadCompactNotationFromConfig loads compact-notation setting from config file to struct +func (ct *Cointop) loadCompactNotationFromConfig() error { + log.Debug("loadCompactNotationFromConfig()") + if compactNotation, ok := ct.config.CompactNotation.(bool); ok { + ct.State.compactNotation = compactNotation + } + + return nil +} + // LoadAPIChoiceFromConfig loads API choices from config file to struct func (ct *Cointop) loadAPIChoiceFromConfig() error { log.Debug("loadAPIKeysFromConfig()") @@ -480,6 +501,8 @@ func (ct *Cointop) loadFavoritesFromConfig() error { } ct.State.favoriteChar = favoriteChar } + } else if k == "compact_notation" { + ct.State.favoritesCompactNotation = valueIfc.(bool) } ifcs, ok := valueIfc.([]interface{}) if !ok { @@ -568,6 +591,8 @@ func (ct *Cointop) loadPortfolioFromConfig() error { return err } } + } else if key == "compact_notation" { + ct.State.portfolioCompactNotation = valueIfc.(bool) } else { // Backward compatibility < v1.6.0 holdings, err := ct.InterfaceToFloat64(valueIfc) diff --git a/cointop/marketbar.go b/cointop/marketbar.go index d43478a..a381573 100644 --- a/cointop/marketbar.go +++ b/cointop/marketbar.go @@ -40,6 +40,9 @@ func (ct *Cointop) UpdateMarketbar() error { total = math.Round(total*1e2) / 1e2 totalstr = humanize.Monetaryf(total, 2) } + if ct.State.compactNotation { + totalstr = humanize.ScaleNumericf(total, 3) + } timeframe := ct.State.selectedChartRange chartname := ct.SelectedCoinName() @@ -153,12 +156,19 @@ func (ct *Cointop) UpdateMarketbar() error { separator2 = "\n" + offset } + marketCapStr := humanize.Monetaryf(market.TotalMarketCapUSD, 0) + volumeStr := humanize.Monetaryf(market.Total24HVolumeUSD, 0) + if ct.State.compactNotation { + marketCapStr = humanize.ScaleNumericf(market.TotalMarketCapUSD, 3) + volumeStr = humanize.ScaleNumericf(market.Total24HVolumeUSD, 3) + } + content = fmt.Sprintf( "%sGlobal ▶ Market Cap: %s %s 24H Volume: %s %s BTC Dominance: %.2f%%", chartInfo, - fmt.Sprintf("%s%s", ct.CurrencySymbol(), humanize.Monetaryf(market.TotalMarketCapUSD, 0)), + fmt.Sprintf("%s%s", ct.CurrencySymbol(), marketCapStr), separator1, - fmt.Sprintf("%s%s", ct.CurrencySymbol(), humanize.Monetaryf(market.Total24HVolumeUSD, 0)), + fmt.Sprintf("%s%s", ct.CurrencySymbol(), volumeStr), separator2, market.BitcoinPercentageOfMarketCap, ) diff --git a/cointop/table_header.go b/cointop/table_header.go index ded019e..ffd222f 100644 --- a/cointop/table_header.go +++ b/cointop/table_header.go @@ -21,6 +21,7 @@ var ArrowDown = "▼" type HeaderColumn struct { Slug string Label string + ShortLabel string // only columns with a ShortLabel can be scaled? PlainLabel string } @@ -69,11 +70,13 @@ var HeaderColumns = map[string]*HeaderColumn{ "market_cap": { Slug: "market_cap", Label: "[m]arket cap", + ShortLabel: "[m]cap", PlainLabel: "market cap", }, "24h_volume": { Slug: "24h_volume", Label: "24H [v]olume", + ShortLabel: "24[v]", PlainLabel: "24H volume", }, "1h_change": { @@ -104,11 +107,13 @@ var HeaderColumns = map[string]*HeaderColumn{ "total_supply": { Slug: "total_supply", Label: "[t]otal supply", + ShortLabel: "[t]ot", PlainLabel: "total supply", }, "available_supply": { Slug: "available_supply", Label: "[a]vailable supply", + ShortLabel: "[a]vl", PlainLabel: "available supply", }, "percent_holdings": { @@ -123,6 +128,15 @@ var HeaderColumns = map[string]*HeaderColumn{ }, } +// GetLabel fetch the label to use for the heading (depends on configuration) +func (ct *Cointop) GetLabel(h *HeaderColumn) string { + // TODO: technically this should support nosort + if ct.IsActiveTableCompactNotation() && h.ShortLabel != "" { + return h.ShortLabel + } + return h.Label +} + // TableHeaderView is structure for table header view type TableHeaderView = ui.View @@ -145,6 +159,22 @@ func (ct *Cointop) GetActiveTableHeaders() []string { return cols } +// GetActiveTableHeaders returns the list of active table headers +func (ct *Cointop) IsActiveTableCompactNotation() bool { + var compact bool + switch ct.State.selectedView { + case PortfolioView: + compact = ct.State.portfolioCompactNotation + case CoinsView: + compact = ct.State.tableCompactNotation + case FavoritesView: + compact = ct.State.favoritesCompactNotation + default: + compact = ct.State.tableCompactNotation + } + return compact +} + // UpdateTableHeader renders the table header func (ct *Cointop) UpdateTableHeader() error { log.Debug("UpdateTableHeader()") @@ -175,7 +205,7 @@ func (ct *Cointop) UpdateTableHeader() error { } } } - label := hc.Label + label := ct.GetLabel(hc) if noSort { label = hc.PlainLabel } @@ -240,7 +270,7 @@ func (ct *Cointop) SetTableColumnWidth(header string, width int) { prev = prevIfc.(int) } else { hc := HeaderColumns[header] - prev = utf8.RuneCountInString(hc.Label) + 1 + prev = utf8.RuneCountInString(ct.GetLabel(hc)) + 1 switch header { case "price", "balance": prev++ diff --git a/docs/content/faq.md b/docs/content/faq.md index 00fe0f2..8a700a2 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -357,6 +357,12 @@ draft: false Supported columns relating to price change are `1h_change`, `24h_change`, `7d_change`, `30d_change`, `1y_change` +## How can I use K (thousand), M (million), B (billion), T (trillion) suffixes for shorter numbers? + + There is a setting at the top-level of the configuration file called `compact_notation=true` which changes the marketbar values `market cap`, `volume` and `portfolio total value`. + + The same setting can be applied at in the `[table]` section to impact the `24h_volume`, `market_cap`, `total_supply`, `available_supply` columns in the main coin view; and in the `[favorites]` section to change the same columns. The setting also changes the column names to be shorter. + ## How can use a different config file other than the default? Run cointop with the `--config` flag, eg `cointop --config="/path/to/config.toml"`, to use the specified file as the config. diff --git a/pkg/humanize/humanize.go b/pkg/humanize/humanize.go index 61a0660..1d45354 100644 --- a/pkg/humanize/humanize.go +++ b/pkg/humanize/humanize.go @@ -2,6 +2,7 @@ package humanize import ( "fmt" + "math" "os" "strconv" "strings" @@ -12,7 +13,7 @@ import ( // Numericf produces a string from of the given number with give fixed precision // in base 10 with thousands separators after every three orders of magnitude -// using a thousands and decimal spearator according to LC_NUMERIC; defaulting "en". +// using thousands and decimal separator according to LC_NUMERIC; defaulting "en". // // e.g. Numericf(834142.32, 2) -> "834,142.32" func Numericf(value float64, precision int) string { @@ -21,16 +22,16 @@ func Numericf(value float64, precision int) string { // Monetaryf produces a string from of the given number give minimum precision // in base 10 with thousands separators after every three orders of magnitude -// using thousands and decimal spearator according to LC_MONETARY; defaulting "en". +// using thousands and decimal separator according to LC_MONETARY; defaulting "en". // // e.g. Monetaryf(834142.3256, 2) -> "834,142.3256" func Monetaryf(value float64, precision int) string { return f(value, precision, "LC_MONETARY", false) } -// f formats given value v, with d decimal places using thousands and decimal +// f formats given value, with precision decimal places using thousands and decimal // separator according to language found in given locale environment variable e. -// If r is true the decimal places are fixed to the given d otherwise d is the +// If fixed is true the decimal places are fixed to the given precision otherwise d is the // minimum of decimal places until the first 0. func f(value float64, precision int, envvar string, fixed bool) string { parts := strings.Split(strconv.FormatFloat(value, 'f', -1, 64), ".") @@ -51,3 +52,47 @@ func f(value float64, precision int, envvar string, fixed bool) string { format := fmt.Sprintf("%%.%df", precision) return message.NewPrinter(lang).Sprintf(format, value) } + +// Scale returns a scaled-down version of value and a suffix to add (M,B,etc.) +func Scale(value float64) (float64, string) { + type scalingUnit struct { + value float64 + suffix string + } + + // quadrillion, quintrillion, sextillion, septillion, octillion, nonillion, and decillion + var scales = [...]scalingUnit{ + {value: 1e12, suffix: "T"}, + {value: 1e9, suffix: "B"}, + {value: 1e6, suffix: "M"}, + {value: 1e3, suffix: "K"}, + } + + for _, scale := range scales { + if math.Abs(value) > scale.value { + return value / scale.value, scale.suffix + } + } + return value, "" +} + +// ScaleNumericf scales a large number down using a suffix, then formats it with the +// prescribed number of significant digits. +func ScaleNumericf(value float64, digits int) string { + value, suffix := Scale(value) + + // Round the scaled value to a certain number of significant figures + var s string + if math.Abs(value) < 1 { + s = Numericf(value, digits) + } else { + numDigits := len(fmt.Sprintf("%.0f", math.Abs(value))) + if numDigits >= digits { + s = Numericf(value, 0) + } else { + s = Numericf(value, digits-numDigits) + } + } + + return s + suffix +} diff --git a/pkg/humanize/humanize_test.go b/pkg/humanize/humanize_test.go index e7c3de8..4051382 100644 --- a/pkg/humanize/humanize_test.go +++ b/pkg/humanize/humanize_test.go @@ -1,6 +1,7 @@ package humanize import ( + "fmt" "testing" ) @@ -10,3 +11,43 @@ func TestMonetary(t *testing.T) { t.FailNow() } } + +func TestScale(t *testing.T) { + scaleTests := map[float64]string{ + 5.54 * 1e12: "5.5T", + 4.44 * 1e9: "4.4B", + 3.34 * 1e6: "3.3M", + 2.24 * 1e3: "2.2K", + 1.1: "1.1", + 0.06: "0.1", + 0.04: "0.0", + -5.54 * 1e12: "-5.5T", + } + + for value, expected := range scaleTests { + volScale, volSuffix := Scale(value) + result := fmt.Sprintf("%.1f%s", volScale, volSuffix) + if result != expected { + t.Fatalf("Expected %f to scale to '%s' but got '%s'\n", value, expected, result) + } + } +} + +func TestScaleNumeric(t *testing.T) { + scaleTests := map[float64]string{ + 5.54 * 1e12: "5.5T", + 4.44 * 1e9: "4.4B", + 3.34 * 1e6: "3.3M", + 2.24 * 1e3: "2.2K", + 1.1: "1.1", + 0.0611: "0.06", + -5.5432 * 1e12: "-5.5T", + } + + for value, expected := range scaleTests { + result := ScaleNumericf(value, 2) + if result != expected { + t.Fatalf("Expected %f to scale to '%s' but got '%s'\n", value, expected, result) + } + } +}