diff options
-rw-r--r-- | CHANGELOG.md | 69 | ||||
-rw-r--r-- | cointop/actions.go | 16 | ||||
-rw-r--r-- | cointop/chart.go | 11 | ||||
-rw-r--r-- | cointop/coin.go | 6 | ||||
-rw-r--r-- | cointop/cointop.go | 15 | ||||
-rw-r--r-- | cointop/config.go | 134 | ||||
-rw-r--r-- | cointop/conversion.go | 21 | ||||
-rw-r--r-- | cointop/debug.go | 9 | ||||
-rw-r--r-- | cointop/default_shortcuts.go | 5 | ||||
-rw-r--r-- | cointop/favorites.go | 2 | ||||
-rw-r--r-- | cointop/keybindings.go | 8 | ||||
-rw-r--r-- | cointop/list.go | 16 | ||||
-rw-r--r-- | cointop/marketbar.go | 12 | ||||
-rw-r--r-- | cointop/navigation.go | 18 | ||||
-rw-r--r-- | cointop/portfolio.go | 258 | ||||
-rw-r--r-- | cointop/sort.go | 10 | ||||
-rw-r--r-- | cointop/table_header.go | 25 | ||||
-rw-r--r-- | docs/content/config.md | 3 | ||||
-rw-r--r-- | docs/content/faq.md | 39 | ||||
-rw-r--r-- | pkg/api/impl/coingecko/coingecko.go | 64 | ||||
-rw-r--r-- | pkg/api/impl/coinmarketcap/coinmarketcap.go | 8 | ||||
-rw-r--r-- | pkg/api/interface.go | 1 | ||||
-rw-r--r-- | pkg/api/vendors/coingecko/v3/v3.go | 12 | ||||
-rw-r--r-- | pkg/humanize/humanize.go | 5 | ||||
-rw-r--r-- | pkg/pathutil/pathutil.go | 2 | ||||
-rw-r--r-- | pkg/termui/linechart.go | 2 | ||||
-rw-r--r-- | pkg/ui/ui.go | 6 |
27 files changed, 641 insertions, 136 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7b173..e3c587b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.9] - 2021-10-12 +### Added +- Chart x-axis date labels +- Configurable favorite character +- Configurable chart width +- Save chart height + +### Changed +- Renamed organization `miguelmota` → `cointop-sh` + +### Fixed +- Global chart currency +- Chart resampling and interpolation +- Chart time periods +- Use preferred cache directory +- Currency symbol width + ## [1.6.8] - 2021-09-13 ### Fixed - Hide holdings amount when using command hide flag @@ -55,50 +72,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Config option to keep row focus on sort ## [1.6.1] - 2021-02-12 +### Added +- Multiple coin support in price command + ### Fixed - Chart data interpolation - CoinMarketCap graph data endpoint +## [1.6.0] - 2021-02-12 ### Added -- Multiple coin support in price command +- Configurable table columns +- Basic price alerts -## [1.6.0] - 2021-02-12 ### Fixed - Coin chart lookup - Dynamic column widths +## [1.5.5] - 2020-11-15 ### Added -- Configurable table columns -- Basic price alerts +- Currency convesion option to holdings command +- Sort by percent holdings shortcut -## [1.5.5] - 2020-11-15 ### Fixed - Termux cache directory - Open command on Windows +## [1.5.4] - 2020-08-24 ### Added -- Currency convesion option to holdings command -- Sort by percent holdings shortcut +- Colorschemes directory flag -## [1.5.4] - 2020-08-24 ### Fixed - Rank order for low market cap coins -### Added -- Colorschemes directory flag - ## [1.5.3] - 2020-08-14 ### Fixed - Build error ## [1.5.2] - 2020-08-13 -### Fixed -- `XDG_CONFIG_HOME` config path - ### Added - Holdings command with sorting and filter options - Bitcoin dominance command +### Fixed +- `XDG_CONFIG_HOME` config path + ## [1.5.1] - 2020-08-05 ### Fixed - Version typo @@ -124,24 +141,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Increase number of page results from CoinGecko ## [1.4.5] - 2020-02-18 -### Fixed -- Convert to chosen currency for market data - ### Added - VND currency conversion +### Fixed +- Convert to chosen currency for market data + ## [1.4.4] - 2019-12-31 ### Fixed - Flathub app release version ## [1.4.3] - 2019-12-29 +### Added +- Tab keybinding + ### Fixed - Chart update bug fixes - Marketbar currency bug fixes -### Added -- Tab keybinding - ## [1.4.2] - 2019-12-29 ### Fixed - Fix keybinding issue on FreeBSD @@ -209,25 +226,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Release archive to contain latest source code ## [1.1.4] - 2019-04-21 -### Changed -- CoinMarketCap legacy V2 API to Pro V1 API - ### Added - Config option to use CoinMarketCap Pro V1 API KEY +### Changed +- CoinMarketCap legacy V2 API to Pro V1 API + ## [1.1.3] - 2019-02-25 ### Fixed - Vendor dependencies ## [1.1.2] - 2018-12-30 -### Fixed -- Paginate CoinMarketCap V1 API responses due to their backward-incompatible update - ### Added - `-clean` flag to clean cache - `-reset` flag to clean cache and delete config - `-config` flag to use a different specified config file +### Fixed +- Paginate CoinMarketCap V1 API responses due to their backward-incompatible update + ## [1.1.1] - 2018-12-26 ### Changed - Use go modules instead of dep diff --git a/cointop/actions.go b/cointop/actions.go index 53f098b..a05ac57 100644 --- a/cointop/actions.go +++ b/cointop/actions.go @@ -4,6 +4,7 @@ package cointop func ActionsMap() map[string]bool { return map[string]bool{ "first_page": true, + "move_to_first_page_first_row": true, "help": true, "toggle_show_help": true, "close_help": true, @@ -56,6 +57,21 @@ func ActionsMap() map[string]bool { "toggle_show_portfolio": true, "enlarge_chart": true, "shorten_chart": true, + "toggle_chart_fullscreen": true, + "scroll_right": true, + "show_portfolio_edit_menu": true, + "sort_column_percent_holdings": true, + "toggle_portfolio_balances": true, + "scroll_left": true, + "save": true, + "toggle_table_fullscreen": true, + "toggle_price_alerts": true, + "move_down_or_next_page": true, + "show_price_alert_add_menu": true, + "sort_column_balance": true, + "sort_column_cost": true, + "sort_column_pnl": true, + "sort_column_pnl_percent": true, } } diff --git a/cointop/chart.go b/cointop/chart.go index dcca3f4..98ff63c 100644 --- a/cointop/chart.go +++ b/cointop/chart.go @@ -315,10 +315,15 @@ func (ct *Cointop) PortfolioChart() error { // Scale Portfolio Balances to hide value if ct.State.hidePortfolioBalances { - var lastPrice = data[len(data)-1] - if lastPrice > 0.0 { + scalePrice := 0.0 + for _, price := range data { + if price > scalePrice { + scalePrice = price + } + } + if scalePrice > 0.0 { for i, price := range data { - data[i] = 100 * price / lastPrice + data[i] = 100 * price / scalePrice } } } diff --git a/cointop/coin.go b/cointop/coin.go index ef8f4fd..861f7cb 100644 --- a/cointop/coin.go +++ b/cointop/coin.go @@ -23,8 +23,10 @@ type Coin struct { // for favorites Favorite bool // for portfolio - Holdings float64 - Balance float64 + Holdings float64 + Balance float64 + BuyPrice float64 + BuyCurrency string } // AllCoins returns a slice of all the coins diff --git a/cointop/cointop.go b/cointop/cointop.go index 4b15410..d4579ed 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -92,6 +92,7 @@ type State struct { tableCompactNotation bool favoritesCompactNotation bool portfolioCompactNotation bool + enableMouse bool } // Cointop cointop @@ -125,8 +126,10 @@ type Cointop struct { // PortfolioEntry is portfolio entry type PortfolioEntry struct { - Coin string - Holdings float64 + Coin string + Holdings float64 + BuyPrice float64 + BuyCurrency string } // Portfolio is portfolio structure @@ -187,6 +190,9 @@ var DefaultChartRange = "1Y" // DefaultCompactNotation ... var DefaultCompactNotation = false +// DefaultEnableMouse ... +var DefaultEnableMouse = true + // DefaultMaxChartWidth ... var DefaultMaxChartWidth = 175 @@ -200,7 +206,7 @@ var DefaultSortBy = "rank" var DefaultPerPage = uint(100) // DefaultMaxPages ... -var DefaultMaxPages = uint(35) +var DefaultMaxPages = uint(10) // DefaultColorscheme ... var DefaultColorscheme = "cointop" @@ -296,6 +302,7 @@ func NewCointop(config *Config) (*Cointop, error) { SoundEnabled: true, }, compactNotation: DefaultCompactNotation, + enableMouse: DefaultEnableMouse, tableCompactNotation: DefaultCompactNotation, favoritesCompactNotation: DefaultCompactNotation, portfolioCompactNotation: DefaultCompactNotation, @@ -488,7 +495,7 @@ func (ct *Cointop) Run() error { defer ui.Close() ui.SetInputEsc(true) - ui.SetMouse(true) + ui.SetMouse(ct.State.enableMouse) ui.SetHighlight(true) ui.SetManagerFunc(ct.layout) if err := ct.SetKeybindings(); err != nil { diff --git a/cointop/config.go b/cointop/config.go index 58c467e..40b7af3 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -49,6 +49,7 @@ type ConfigFileConfig struct { RefreshRate interface{} `toml:"refresh_rate"` CacheDir interface{} `toml:"cache_dir"` CompactNotation interface{} `toml:"compact_notation"` + EnableMouse interface{} `toml:"enable_mouse"` Table map[string]interface{} `toml:"table"` Chart map[string]interface{} `toml:"chart"` } @@ -72,6 +73,7 @@ func (ct *Cointop) SetupConfig() error { ct.loadRefreshRateFromConfig, ct.loadCacheDirFromConfig, ct.loadCompactNotationFromConfig, + ct.loadEnableMouseFromConfig, ct.loadPriceAlertsFromConfig, ct.loadPortfolioFromConfig, } @@ -227,9 +229,12 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { if !ok || entry.Coin == "" { continue } - amount := strconv.FormatFloat(entry.Holdings, 'f', -1, 64) - coinName := entry.Coin - tuple := []string{coinName, amount} + tuple := []string{ + entry.Coin, + strconv.FormatFloat(entry.Holdings, 'f', -1, 64), + strconv.FormatFloat(entry.BuyPrice, 'f', -1, 64), + entry.BuyCurrency, + } holdingsIfc = append(holdingsIfc, tuple) } sort.Slice(holdingsIfc, func(i, j int) bool { @@ -289,6 +294,7 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { Table: tableMapIfc, Chart: chartMapIfc, CompactNotation: ct.State.compactNotation, + EnableMouse: ct.State.enableMouse, } var b bytes.Buffer @@ -366,14 +372,44 @@ func (ct *Cointop) loadTableColumnsFromConfig() error { // LoadShortcutsFromConfig loads keyboard shortcuts from config file to struct func (ct *Cointop) loadShortcutsFromConfig() error { log.Debug("loadShortcutsFromConfig()") + + // Load the shortcut config into a key:action map (filtering to actions that exist). Keep track of actions. + config := make(map[string]string) + actions := make(map[string]bool) for k, ifc := range ct.config.Shortcuts { if v, ok := ifc.(string); ok { if !ct.ActionExists(v) { + log.Debugf("Shortcut '%s'=>%s is not a valid action", k, v) continue } - ct.State.shortcutKeys[k] = v + config[k] = v + actions[v] = true } } + + // Count how many keys are configured per action. + actionCount := make(map[string]int) + for _, action := range ct.State.shortcutKeys { + actionCount[action] += 1 + } + + // merge defaults into the loaded config - if the key is not defined, and the action is not found, add it + for key, action := range ct.State.shortcutKeys { + if _, ok := config[key]; ok { + // k is already in the config - ignore it + } else if _, ok := actions[action]; ok { + if actionCount[action] == 1 { + // action is already in the config - ignore it + } else { + // there are multiple bindings, add them anyway + config[key] = action // add action + } + } else { + config[key] = action // add action + } + } + ct.State.shortcutKeys = config + return nil } @@ -476,6 +512,16 @@ func (ct *Cointop) loadCompactNotationFromConfig() error { return nil } +// loadCompactNotationFromConfig loads compact-notation setting from config file to struct +func (ct *Cointop) loadEnableMouseFromConfig() error { + log.Debug("loadEnableMouseFromConfig()") + if enableMouse, ok := ct.config.EnableMouse.(bool); ok { + ct.State.enableMouse = enableMouse + } + + return nil +} + // LoadAPIChoiceFromConfig loads API choices from config file to struct func (ct *Cointop) loadAPIChoiceFromConfig() error { log.Debug("loadAPIKeysFromConfig()") @@ -554,33 +600,7 @@ func (ct *Cointop) loadPortfolioFromConfig() error { } } } else if key == "holdings" { - holdingsIfc, ok := valueIfc.([]interface{}) - if !ok { - continue - } - - for _, itemIfc := range holdingsIfc { - tupleIfc, ok := itemIfc.([]interface{}) - if !ok { - continue - } - if len(tupleIfc) > 2 { - continue - } - name, ok := tupleIfc[0].(string) - if !ok { - continue - } - - holdings, err := ct.InterfaceToFloat64(tupleIfc[1]) - if err != nil { - return nil - } - - if err := ct.SetPortfolioEntry(name, holdings); err != nil { - return err - } - } + // Defer until the end to work around premature-save issue } else if key == "compact_notation" { ct.State.portfolioCompactNotation = valueIfc.(bool) } else { @@ -590,12 +610,62 @@ func (ct *Cointop) loadPortfolioFromConfig() error { return err } - if err := ct.SetPortfolioEntry(key, holdings); err != nil { + if err := ct.SetPortfolioEntry(key, holdings, 0.0, ""); err != nil { return err } } } + // Process holdings last because it causes a ct.Save() + if valueIfc, ok := ct.config.Portfolio["holdings"]; ok { + if holdingsIfc, ok := valueIfc.([]interface{}); ok { + ct.loadPortfolioHoldingsFromConfig(holdingsIfc) + } + } + + return nil +} + +func (ct *Cointop) loadPortfolioHoldingsFromConfig(holdingsIfc []interface{}) error { + for _, itemIfc := range holdingsIfc { + tupleIfc, ok := itemIfc.([]interface{}) + if !ok { + continue + } + if len(tupleIfc) > 4 { + continue + } + name, ok := tupleIfc[0].(string) + if !ok { + continue // was not a string + } + + holdings, err := ct.InterfaceToFloat64(tupleIfc[1]) + if err != nil { + return err // was not a float64 + } + + buyPrice := 0.0 + if len(tupleIfc) >= 3 { + if buyPrice, err = ct.InterfaceToFloat64(tupleIfc[2]); err != nil { + return err + } + } + + buyCurrency := "" + if len(tupleIfc) >= 4 { + if parseCurrency, ok := tupleIfc[3].(string); !ok { + return err // was not a string + } else { + buyCurrency = parseCurrency + } + } + + // Watch out - this calls ct.Save() which may save a half-loaded configuration + if err := ct.SetPortfolioEntry(name, holdings, buyPrice, buyCurrency); err != nil { + return err + } + } return nil } diff --git a/cointop/conversion.go b/cointop/conversion.go index 7094ad7..bbe2a2e 100644 --- a/cointop/conversion.go +++ b/cointop/conversion.go @@ -12,7 +12,7 @@ import ( log "github.com/sirupsen/logrus" ) -// FiatCurrencyNames is a mpa of currency symbols to names. +// FiatCurrencyNames is a map of currency symbols to names. // Keep these in alphabetical order. var FiatCurrencyNames = map[string]string{ "AUD": "Australian Dollar", @@ -241,7 +241,7 @@ func (ct *Cointop) SetCurrencyConverstionFn(convert string) func() error { if err := ct.Save(); err != nil { return err } - + go ct.UpdateCurrentPageCoins() go ct.RefreshAll() return nil } @@ -301,3 +301,20 @@ func CurrencySymbol(currency string) string { return "?" } + +// Convert converts an amount to another currency type +func (ct *Cointop) Convert(convertFrom, convertTo string, amount float64) (float64, error) { + convertFrom = strings.ToLower(convertFrom) + convertTo = strings.ToLower(convertTo) + + if convertFrom == convertTo { + return amount, nil + } + + rate, err := ct.api.GetExchangeRate(convertFrom, convertTo, true) + if err != nil { + return 0, err + } + + return rate * amount, nil +} diff --git a/cointop/debug.go b/cointop/debug.go index aab5dee..631168c 100644 --- a/cointop/debug.go +++ b/cointop/debug.go @@ -1,13 +1,22 @@ package cointop import ( + "fmt" "os" + "github.com/cointop-sh/cointop/pkg/pathutil" log "github.com/sirupsen/logrus" ) func (ct *Cointop) initlog() { filename := "/tmp/cointop.log" + debugFile := os.Getenv("DEBUG_FILE") + if debugFile != "" { + filename = pathutil.NormalizePath(debugFile) + if filename != debugFile && os.Getenv("DEBUG") != "" { + fmt.Printf("Writing debug log to %s\n", filename) + } + } f, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) if err != nil { panic(err) diff --git a/cointop/default_shortcuts.go b/cointop/default_shortcuts.go index 8dc8ff1..e18988d 100644 --- a/cointop/default_shortcuts.go +++ b/cointop/default_shortcuts.go @@ -36,7 +36,7 @@ func DefaultShortcuts() map[string]string { "alt+right": "sort_right_column", "F1": "help", "F5": "refresh", - "0": "first_page", + "0": "move_to_first_page_first_row", "1": "sort_column_1h_change", "2": "sort_column_24h_change", "3": "sort_column_30d_change", @@ -85,5 +85,8 @@ func DefaultShortcuts() map[string]string { "<": "scroll_left", "+": "show_price_alert_add_menu", "\\\\": "toggle_table_fullscreen", + "!": "sort_column_cost", + "@": "sort_column_pnl", + "#": "sort_column_pnl_percent", } } diff --git a/cointop/favorites.go b/cointop/favorites.go index a2907b6..2aa4815 100644 --- a/cointop/favorites.go +++ b/cointop/favorites.go @@ -64,7 +64,7 @@ func (ct *Cointop) GetFavoritesSlice() []*Coin { } } - sort.Slice(sliced, func(i, j int) bool { + sort.SliceStable(sliced, func(i, j int) bool { return sliced[i].MarketCap > sliced[j].MarketCap }) diff --git a/cointop/keybindings.go b/cointop/keybindings.go index 2f0b6b3..b220901 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -218,6 +218,8 @@ func (ct *Cointop) SetKeybindingAction(shortcutKey string, action string) error view = "help" case "first_page": fn = ct.Keyfn(ct.FirstPage) + case "move_to_first_page_first_row": + fn = ct.Keyfn(ct.NavigateToFirstPageFirstRow) case "sort_column_1h_change": fn = ct.Sortfn("1h_change", true) case "sort_column_24h_change": @@ -323,6 +325,12 @@ func (ct *Cointop) SetKeybindingAction(shortcutKey string, action string) error fn = ct.Keyfn(ct.CursorDownOrNextPage) case "move_up_or_previous_page": fn = ct.Keyfn(ct.CursorUpOrPreviousPage) + case "sort_column_cost": + fn = ct.Sortfn("cost", true) + case "sort_column_pnl": + fn = ct.Sortfn("pnl", true) + case "sort_column_pnl_percent": + fn = ct.Sortfn("pnl_percent", true) default: fn = ct.Keyfn(ct.Noop) } diff --git a/cointop/list.go b/cointop/list.go index 94d28cd..ff66e6a 100644 --- a/cointop/list.go +++ b/cointop/list.go @@ -46,6 +46,22 @@ func (ct *Cointop) UpdateCoins() error { return nil } +// UpdateCurrentPageCoins updates all the coins in the current page +func (ct *Cointop) UpdateCurrentPageCoins() error { + log.Debugf("UpdateCurrentPageCoins(%d)", len(ct.State.coins)) + currentPageCoins := make([]string, len(ct.State.coins)) + for i, entry := range ct.State.coins { + currentPageCoins[i] = entry.Name + } + + coins, err := ct.api.GetCoinDataBatch(currentPageCoins, ct.State.currencyConversion) + if err != nil { + return err + } + go ct.processCoins(coins) + return nil +} + // ProcessCoinsMap processes coins map func (ct *Cointop) processCoinsMap(coinsMap map[string]types.Coin) { log.Debug("ProcessCoinsMap()") diff --git a/cointop/marketbar.go b/cointop/marketbar.go index 86b5dbf..bfaeb2c 100644 --- a/cointop/marketbar.go +++ b/cointop/marketbar.go @@ -78,10 +78,9 @@ func (ct *Cointop) UpdateMarketbar() error { chartInfo := "" if !ct.State.hideChart { chartInfo = fmt.Sprintf( - "[ Chart: %s %s %s ] ", + "[ Chart: %s %s ] ", charttitle, timeframe, - ct.State.currencyConversion, ) } @@ -92,8 +91,9 @@ func (ct *Cointop) UpdateMarketbar() error { } content = fmt.Sprintf( - "%sTotal Portfolio Value: %s • 24H: %s", + "%sTotal Portfolio Value %s: %s • 24H: %s", chartInfo, + ct.State.currencyConversion, ct.colorscheme.MarketBarLabelActive(totalstr), percentChange24Hstr, ) @@ -142,10 +142,9 @@ func (ct *Cointop) UpdateMarketbar() error { chartInfo := "" if !ct.State.hideChart { chartInfo = fmt.Sprintf( - "[ Chart: %s %s %s] ", + "[ Chart: %s %s] ", ct.colorscheme.MarketBarLabelActive(chartname), timeframe, - ct.State.currencyConversion, ) } @@ -166,8 +165,9 @@ func (ct *Cointop) UpdateMarketbar() error { } content = fmt.Sprintf( - "%sGlobal ▶ Market Cap: %s %s 24H Volume: %s %s BTC Dominance: %.2f%%", + "%sGlobal %s ▶ Market Cap: %s %s 24H Volume: %s %s BTC Dominance: %.2f%%", chartInfo, + ct.State.currencyConversion, fmt.Sprintf("%s%s", ct.CurrencySymbol(), marketCapStr), separator1, fmt.Sprintf("%s%s", ct.CurrencySymbol(), volumeStr), diff --git a/cointop/navigation.go b/cointop/navigation.go index abbd2be..e5b4b2e 100644 --- a/cointop/navigation.go +++ b/cointop/navigation.go @@ -309,6 +309,13 @@ func (ct *Cointop) PrevPageTop() error { return nil } +// NavigateToFirstPageFirstRow navigates to the first row on the first page +func (ct *Cointop) NavigateToFirstPageFirstRow() error { + log.Debug("TopCoin()") + ct.GoToGlobalIndex(0) + return nil +} + // FirstPage navigates to the first page func (ct *Cointop) FirstPage() error { log.Debug("FirstPage()") @@ -409,13 +416,18 @@ func (ct *Cointop) GoToPageRowIndex(idx int) error { // GoToGlobalIndex navigates to the selected row index of all page rows func (ct *Cointop) GoToGlobalIndex(idx int) error { - log.Debug("GoToGlobalIndex()") + log.Debugf("GoToGlobalIndex(%d)", idx) + target := ct.State.allCoins[idx] l := ct.TableRowsLen() atpage := idx / l ct.SetPage(atpage) - rowIndex := idx % l - ct.HighlightRow(rowIndex) ct.UpdateTable() + // Look for the coin in the current page + for i, coin := range ct.State.coins { + if coin == target { + ct.HighlightRow(i) + } + } return nil } diff --git a/cointop/portfolio.go b/cointop/portfolio.go index d27f32f..f6613fb 100644 --- a/cointop/portfolio.go +++ b/cointop/portfolio.go @@ -35,6 +35,10 @@ var SupportedPortfolioTableHeaders = []string{ "1y_change", "percent_holdings", "last_updated", + "cost_price", + "cost", + "pnl", + "pnl_percent", } // DefaultPortfolioTableHeaders are the default portfolio table header columns @@ -49,12 +53,23 @@ var DefaultPortfolioTableHeaders = []string{ "24h_change", "7d_change", "percent_holdings", + "cost_price", + "cost", + "pnl", + "pnl_percent", "last_updated", } // HiddenBalanceChars are the characters to show when hidding balances var HiddenBalanceChars = "********" +var costColumns = map[string]bool{ + "cost_price": true, + "cost": true, + "pnl": true, + "pnl_percent": true, +} + // ValidPortfolioTableHeader returns the portfolio table headers func (ct *Cointop) ValidPortfolioTableHeader(name string) bool { for _, v := range SupportedPortfolioTableHeaders { @@ -80,6 +95,25 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { headers := ct.GetPortfolioTableHeaders() ct.ClearSyncMap(&ct.State.tableColumnWidths) ct.ClearSyncMap(&ct.State.tableColumnAlignLeft) + + displayCostColumns := false + for _, coin := range ct.State.coins { + if coin.BuyPrice > 0 && coin.BuyCurrency != "" { + displayCostColumns = true + break + } + } + + if !displayCostColumns { + filtered := make([]string, 0) + for _, header := range headers { + if _, ok := costColumns[header]; !ok { + filtered = append(filtered, header) + } + } + headers = filtered + } + for _, coin := range ct.State.coins { leftMargin := 1 rightMargin := 1 @@ -301,6 +335,117 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { Color: ct.colorscheme.TableRow, Text: lastUpdated, }) + case "cost_price": + text := fmt.Sprintf("%s %s", coin.BuyCurrency, ct.FormatPrice(coin.BuyPrice)) + if coin.BuyPrice == 0.0 || coin.BuyCurrency == "" { + text = "" + } + if ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + } + symbolPadding := 1 + ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding) + ct.SetTableColumnAlignLeft(header, false) + rowCells = append(rowCells, + &table.RowCell{ + LeftMargin: leftMargin, + RightMargin: rightMargin, + LeftAlign: false, + Color: ct.colorscheme.TableRow, + Text: text, + }) + case "cost": + cost := 0.0 + if coin.BuyPrice > 0 && coin.BuyCurrency != "" { + costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice) + if err == nil { + cost = costPrice * coin.Holdings + } + } + text := humanize.FixedMonetaryf(cost, 2) + if coin.BuyPrice == 0.0 { + text = "" + } + if ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + } + + symbolPadding := 1 + ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding) + ct.SetTableColumnAlignLeft(header, false) + rowCells = append(rowCells, + &table.RowCell{ + LeftMargin: leftMargin, + RightMargin: rightMargin, + LeftAlign: false, + Color: ct.colorscheme.TableColumnPrice, + Text: text, + }) + case "pnl": + text := "" + colorProfit := ct.colorscheme.TableColumnChange + if coin.BuyPrice > 0 && coin.BuyCurrency != "" { + costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice) + if err == nil { + profit := (coin.Price - costPrice) * coin.Holdings + text = humanize.FixedMonetaryf(profit, 2) + if profit > 0 { + colorProfit = ct.colorscheme.TableColumnChangeUp + } else if profit < 0 { + colorProfit = ct.colorscheme.TableColumnChangeDown + } + } else { + text = "?" + } + } + if ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + colorProfit = ct.colorscheme.TableColumnChange + } + + symbolPadding := 1 + ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding) + ct.SetTableColumnAlignLeft(header, false) + rowCells = append(rowCells, + &table.RowCell{ + LeftMargin: leftMargin, + RightMargin: rightMargin, + LeftAlign: false, + Color: colorProfit, + Text: text, + }) + case "pnl_percent": + profitPercent := 0.0 + if coin.BuyPrice > 0 && coin.BuyCurrency != "" { + costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice) + if err == nil { + profitPercent = 100 * (coin.Price/costPrice - 1) + } + } + colorProfit := ct.colorscheme.TableColumnChange + if profitPercent > 0 { + colorProfit = ct.colorscheme.TableColumnChangeUp + } else if profitPercent < 0 { + colorProfit = ct.colorscheme.TableColumnChangeDown + } + text := fmt.Sprintf("%.2f%%", profitPercent) + if coin.BuyPrice == 0.0 { + text = "" + } + if ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + |