From acd8af949dbefe81b5fffb590db88e502a6d8962 Mon Sep 17 00:00:00 2001 From: Simon Roberts Date: Thu, 21 Oct 2021 07:55:06 +1100 Subject: [PATCH 1/5] Add configuration for enable_mouse --- cointop/cointop.go | 7 ++++++- cointop/config.go | 13 +++++++++++++ pkg/ui/ui.go | 6 +++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cointop/cointop.go b/cointop/cointop.go index 4b15410..b655dcb 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 @@ -187,6 +188,9 @@ var DefaultChartRange = "1Y" // DefaultCompactNotation ... var DefaultCompactNotation = false +// DefaultEnableMouse ... +var DefaultEnableMouse = true + // DefaultMaxChartWidth ... var DefaultMaxChartWidth = 175 @@ -296,6 +300,7 @@ func NewCointop(config *Config) (*Cointop, error) { SoundEnabled: true, }, compactNotation: DefaultCompactNotation, + enableMouse: DefaultEnableMouse, tableCompactNotation: DefaultCompactNotation, favoritesCompactNotation: DefaultCompactNotation, portfolioCompactNotation: DefaultCompactNotation, @@ -488,7 +493,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 aa81a49..a16d14f 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, } @@ -289,6 +291,7 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) { Table: tableMapIfc, Chart: chartMapIfc, CompactNotation: ct.State.compactNotation, + EnableMouse: ct.State.enableMouse, } var b bytes.Buffer @@ -506,6 +509,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()") diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index 3366bef..6ea2487 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -38,12 +38,12 @@ func (ui *UI) SetBgColor(bgColor gocui.Attribute) { // SetInputEsc enables the escape key func (ui *UI) SetInputEsc(enabled bool) { - ui.g.InputEsc = true + ui.g.InputEsc = enabled } // SetMouse enables the mouse func (ui *UI) SetMouse(enabled bool) { - ui.g.Mouse = true + ui.g.Mouse = enabled } // SetCursor enables the input field cursor @@ -53,7 +53,7 @@ func (ui *UI) SetCursor(enabled bool) { // SetHighlight enables the highlight active state func (ui *UI) SetHighlight(enabled bool) { - ui.g.Highlight = true + ui.g.Highlight = enabled } // SetManagerFunc sets the function to call for rendering UI From b5b68833f5e6713ec8bf4394f0a8315ec8268b6a Mon Sep 17 00:00:00 2001 From: Simon Roberts Date: Sun, 24 Oct 2021 12:08:58 +1100 Subject: [PATCH 2/5] Add support for purchase price/currency to portfolio (#243) * Add support for declaring a BuyPrice and BuyCurrency in portfolio. eg: ["Algorand", "125.4", "0.8", "USD"] Add optional (default off) columns to portfolio: "buy_price", "buy_currency", "profit", "profit_percent" Note: there is no UI for entering this yet. --- cointop/actions.go | 3 + cointop/coin.go | 6 +- cointop/cointop.go | 6 +- cointop/config.go | 91 +++++++++----- cointop/conversion.go | 19 ++- cointop/default_shortcuts.go | 3 + cointop/keybindings.go | 6 + cointop/portfolio.go | 133 +++++++++++++++++++- cointop/sort.go | 8 ++ cointop/table_header.go | 25 +++- docs/content/faq.md | 25 +++- pkg/api/impl/coingecko/coingecko.go | 64 +++++++--- pkg/api/impl/coinmarketcap/coinmarketcap.go | 8 ++ pkg/api/interface.go | 1 + pkg/humanize/humanize.go | 5 + 15 files changed, 342 insertions(+), 61 deletions(-) diff --git a/cointop/actions.go b/cointop/actions.go index 3b7c567..a05ac57 100644 --- a/cointop/actions.go +++ b/cointop/actions.go @@ -69,6 +69,9 @@ func ActionsMap() map[string]bool { "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/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 b655dcb..4050175 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -126,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 diff --git a/cointop/config.go b/cointop/config.go index a16d14f..066d326 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -229,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 { @@ -597,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 { @@ -633,12 +610,64 @@ 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 parsePrice, err := ct.InterfaceToFloat64(tupleIfc[2]); err != nil { + return err + } else { + buyPrice = parsePrice + } + } + + 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..4bcc18a 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", @@ -301,3 +301,20 @@ func CurrencySymbol(currency string) string { return "?" } + +func (ct *Cointop) Convert(convertFrom string, convertTo string, amount float64) (float64, error) { + convertFrom = strings.ToLower(convertFrom) + convertTo = strings.ToLower(convertTo) + + var rate float64 + if convertFrom == convertTo { + rate = 1.0 + } else { + crate, err := ct.api.GetExchangeRate(convertFrom, convertTo, true) + if err != nil { + return 0, err + } + rate = crate + } + return rate * amount, nil +} diff --git a/cointop/default_shortcuts.go b/cointop/default_shortcuts.go index 261885d..e18988d 100644 --- a/cointop/default_shortcuts.go +++ b/cointop/default_shortcuts.go @@ -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/keybindings.go b/cointop/keybindings.go index 5e6b865..04af301 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -325,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("profit", true) + case "sort_column_pnl_percent": + fn = ct.Sortfn("profit_percent", true) default: fn = ct.Keyfn(ct.Noop) } diff --git a/cointop/portfolio.go b/cointop/portfolio.go index 3848bec..3b3be51 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", + "profit", + "profit_percent", } // DefaultPortfolioTableHeaders are the default portfolio table header columns @@ -301,6 +305,118 @@ 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 ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + } + if coin.BuyPrice == 0.0 || coin.BuyCurrency == "" { + text = "" + } + 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 := ct.FormatPrice(cost) + text := humanize.FixedMonetaryf(cost, 2) + if ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + } + if coin.BuyPrice == 0.0 { + text = "" + } + + 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 "profit": + 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 "profit_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 ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + colorProfit = ct.colorscheme.TableColumnChange + } + if coin.BuyPrice == 0.0 { + text = "" + } + ct.SetTableColumnWidthFromString(header, text) + ct.SetTableColumnAlignLeft(header, false) + rowCells = append(rowCells, + &table.RowCell{ + LeftMargin: leftMargin, + RightMargin: rightMargin, + LeftAlign: false, + Color: colorProfit, + Text: text, + }) } } @@ -456,8 +572,12 @@ func (ct *Cointop) SetPortfolioHoldings() error { } shouldDelete := holdings == 0 + // TODO: add fields to form, parse here + buyPrice := 0.0 + buyCurrency := "" + idx := ct.GetPortfolioCoinIndex(coin) - if err := ct.SetPortfolioEntry(coin.Name, holdings); err != nil { + if err := ct.SetPortfolioEntry(coin.Name, holdings, buyPrice, buyCurrency); err != nil { return err } @@ -503,7 +623,7 @@ func (ct *Cointop) PortfolioEntry(c *Coin) (*PortfolioEntry, bool) { } // SetPortfolioEntry sets a portfolio entry -func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64) error { +func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64, buyPrice float64, buyCurrency string) error { log.Debug("SetPortfolioEntry()") ic, _ := ct.State.allCoinsSlugMap.Load(strings.ToLower(coin)) c, _ := ic.(*Coin) @@ -511,8 +631,10 @@ func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64) error { if isNew { key := strings.ToLower(coin) ct.State.portfolio.Entries[key] = &PortfolioEntry{ - Coin: coin, - Holdings: holdings, + Coin: coin, + Holdings: holdings, + BuyPrice: buyPrice, + BuyCurrency: buyCurrency, } } else { p.Holdings = holdings @@ -564,6 +686,8 @@ func (ct *Cointop) GetPortfolioSlice() []*Coin { continue } coin.Holdings = p.Holdings + coin.BuyPrice = p.BuyPrice + coin.BuyCurrency = p.BuyCurrency balance := coin.Price * p.Holdings balancestr := fmt.Sprintf("%.2f", balance) if ct.State.currencyConversion == "ETH" || ct.State.currencyConversion == "BTC" { @@ -688,6 +812,7 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error { records := make([][]string, len(holdings)) symbol := ct.CurrencySymbol() + // TODO: buy_price, buy_currency, profit, profit_percent, etc headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings"} if len(filterCols) > 0 { for _, col := range filterCols { diff --git a/cointop/sort.go b/cointop/sort.go index 1ff5816..fd596be 100644 --- a/cointop/sort.go +++ b/cointop/sort.go @@ -68,6 +68,14 @@ func (ct *Cointop) Sort(sortBy string, desc bool, list []*Coin, renderHeaders bo return a.AvailableSupply < b.AvailableSupply case "last_updated": return a.LastUpdated < b.LastUpdated + case "cost_price": + return a.BuyPrice < b.BuyPrice + case "cost": + return (a.BuyPrice * a.Holdings) < (b.BuyPrice * b.Holdings) // TODO: convert? + case "profit": + return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice) + case "profit_percent": + return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice) default: return a.Rank < b.Rank } diff --git a/cointop/table_header.go b/cointop/table_header.go index 7c9df10..c526c47 100644 --- a/cointop/table_header.go +++ b/cointop/table_header.go @@ -126,6 +126,26 @@ var HeaderColumns = map[string]*HeaderColumn{ Label: "last [u]pdated", PlainLabel: "last updated", }, + "cost_price": { + Slug: "cost_price", + Label: "cost price", + PlainLabel: "cost price", + }, + "cost": { + Slug: "cost", + Label: "cost[!]", + PlainLabel: "cost", + }, + "profit": { + Slug: "profit", + Label: "PNL[@]", + PlainLabel: "PNL", + }, + "profit_percent": { + Slug: "profit_percent", + Label: "PNL%[#]", + PlainLabel: "PNL%", + }, } // GetLabel fetch the label to use for the heading (depends on configuration) @@ -211,7 +231,7 @@ func (ct *Cointop) UpdateTableHeader() error { } leftAlign := ct.GetTableColumnAlignLeft(col) switch col { - case "price", "balance": + case "price", "balance", "profit", "cost": label = fmt.Sprintf("%s%s", ct.CurrencySymbol(), label) } if leftAlign { @@ -265,6 +285,9 @@ func (ct *Cointop) SetTableColumnWidth(header string, width int) { prev = prevIfc.(int) } else { hc := HeaderColumns[header] + if hc == nil { + log.Warnf("SetTableColumnWidth(%s) not found", header) + } prev = utf8.RuneCountInString(ct.GetLabel(hc)) + 1 switch header { case "price", "balance": diff --git a/docs/content/faq.md b/docs/content/faq.md index 7a2aa64..9da8352 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -184,6 +184,29 @@ draft: false Your portfolio is autosaved after you edit holdings. You can also press ctrl+s to manually save your portfolio holdings to the config file. +## How do I include buy/cost price in my portfolio? + + Currently there is no UI for this. If you want to include the cost of your coins in the Portfolio screen, you will need to edit your config.toml + + Each coin consists of four values: coin name, coin amount, cost-price, cost-currency. + + For example, the following configuration includes 100 ALGO at USD1.95 each; and 0.1 BTC at AUD50100.83 each. + + ```toml + holdings = [["Algorand", "100", "1.95", "USD"], ["Bitcoin", "0.1", "50100.83", "AUD"]] + ``` + + With this configuration, four new columns are useful: + + - `cost_price` the price and currency that the coins were purchased at + - `cost` the cost (in the current currency) of the coins + - `profit` the PNL of the coins (current value vs original cost) + - `profit_percent` the PNL of the coins as a fraction of the original cost + + With the holdings above, and the currency set to GBP (British Pounds) cointop will look something like this: + + ![Screen Shot 2021-10-22 at 8 41 21 am](https://user-images.githubusercontent.com/122371/138361142-8e1f32b5-ca24-471d-a628-06968f07c65f.png) + ## How do I hide my portfolio balances (private mode)? You can run cointop with the `--hide-portfolio-balances` flag to hide portfolio balances or use the keyboard shortcut Ctrl+space on the portfolio page to toggle hide/show. @@ -505,4 +528,4 @@ draft: false DEBUG=1 DEBUG_HTTP=1 cointop ``` - If you set environment variable `DEBUG_FILE` you can explicitly provide a logfile location, rather than `/tmp/cointop.log` \ No newline at end of file + If you set environment variable `DEBUG_FILE` you can explicitly provide a logfile location, rather than `/tmp/cointop.log` diff --git a/pkg/api/impl/coingecko/coingecko.go b/pkg/api/impl/coingecko/coingecko.go index 612cb68..2b9553a 100644 --- a/pkg/api/impl/coingecko/coingecko.go +++ b/pkg/api/impl/coingecko/coingecko.go @@ -12,6 +12,7 @@ import ( apitypes "github.com/cointop-sh/cointop/pkg/api/types" "github.com/cointop-sh/cointop/pkg/api/util" gecko "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3" + "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types" geckoTypes "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types" ) @@ -33,6 +34,7 @@ type Service struct { maxResultsPerPage uint maxPages uint cacheMap sync.Map + cachedRates *types.ExchangeRatesItem } // NewCoinGecko new service @@ -146,6 +148,45 @@ func (s *Service) GetCoinGraphData(convert, symbol, name string, start, end int6 return ret, nil } +// GetCachedExchangeRates returns an indefinitely cached set of exchange rates +func (s *Service) GetExchangeRates(cached bool) (*types.ExchangeRatesItem, error) { + if s.cachedRates == nil || !cached { + rates, err := s.client.ExchangeRates() + if err != nil { + return nil, err + } + s.cachedRates = rates + } + return s.cachedRates, nil +} + +// GetExchangeRate gets the current excange rate between two currencies +func (s *Service) GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) { + convertFrom = strings.ToLower(convertFrom) + convertTo = strings.ToLower(convertTo) + if convertFrom == convertTo { + return 1.0, nil + } + rates, err := s.GetExchangeRates(cached) + if err != nil { + return 0, err + } + if rates == nil { + return 0, fmt.Errorf("expected rates, received nil") + } + // Combined rate is convertFrom->BTC->convertTo + fromRate, found := (*rates)[convertFrom] + if !found { + return 0, fmt.Errorf("unsupported currency conversion: %s", convertFrom) + } + toRate, found := (*rates)[convertTo] + if !found { + return 0, fmt.Errorf("unsupported currency conversion: %s", convertTo) + } + rate := toRate.Value / fromRate.Value + return rate, nil +} + // GetGlobalMarketGraphData gets global market graph data func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int64) (apitypes.MarketGraph, error) { days := strconv.Itoa(util.CalcDays(start, end)) @@ -160,25 +201,10 @@ func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int6 } // This API does not appear to support vs_currency and only returns USD, so use ExchangeRates to convert - rate := 1.0 - if convertTo != "usd" { - rates, err := s.client.ExchangeRates() - if err != nil { - return ret, err - } - if rates == nil { - return ret, fmt.Errorf("expected rates, received nil") - } - // Combined rate is USD->BTC->other - btcRate, found := (*rates)[convertTo] - if !found { - return ret, fmt.Errorf("unsupported currency conversion: %s", convertTo) - } - usdRate, found := (*rates)["usd"] - if !found { - return ret, fmt.Errorf("unsupported currency conversion: usd") - } - rate = btcRate.Value / usdRate.Value + // TODO: watch out - this is not cached, so we hit the backend every time! + rate, err := s.GetExchangeRate("usd", convertTo, true) + if err != nil { + return ret, err } var marketCapUSD [][]float64 diff --git a/pkg/api/impl/coinmarketcap/coinmarketcap.go b/pkg/api/impl/coinmarketcap/coinmarketcap.go index a8408c6..e3a15fe 100644 --- a/pkg/api/impl/coinmarketcap/coinmarketcap.go +++ b/pkg/api/impl/coinmarketcap/coinmarketcap.go @@ -430,3 +430,11 @@ func getChartInterval(start, end int64) string { } return interval } + +// GetExchangeRate gets the current excange rate between two currencies +func (s *Service) GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) { + if convertFrom == convertTo { + return 1.0, nil + } + return 0, fmt.Errorf("unsupported currency conversion: %s => %s", convertFrom, convertTo) +} diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 0b406c3..294c16c 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -16,4 +16,5 @@ type Interface interface { CoinLink(name string) string SupportedCurrencies() []string Price(name string, convert string) (float64, error) + GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) // I don't love this caching } diff --git a/pkg/humanize/humanize.go b/pkg/humanize/humanize.go index 975a1d1..d48f941 100644 --- a/pkg/humanize/humanize.go +++ b/pkg/humanize/humanize.go @@ -34,6 +34,11 @@ func Monetaryf(value float64, precision int) string { return f(value, precision, "LC_MONETARY", false) } +// FixedMonetaryf produces a fixed-precision monetary-value string. See Monetaryf. +func FixedMonetaryf(value float64, precision int) string { + return f(value, precision, "LC_MONETARY", true) +} + // borrowed from go-locale/util.go func splitLocale(locale string) (string, string) { // Remove the encoding, if present From 0e956d63582315bb5df836db462105a7a6751441 Mon Sep 17 00:00:00 2001 From: Miguel Mota Date: Sat, 23 Oct 2021 03:52:49 -0700 Subject: [PATCH 3/5] portfolio: clean up fixes #243 --- cointop/config.go | 4 +- cointop/conversion.go | 18 ++++----- cointop/keybindings.go | 4 +- cointop/portfolio.go | 42 +++++++++++++++++---- cointop/sort.go | 4 +- cointop/table_header.go | 16 ++++---- docs/content/faq.md | 16 ++++---- pkg/api/impl/coingecko/coingecko.go | 2 +- pkg/api/impl/coinmarketcap/coinmarketcap.go | 2 +- pkg/api/interface.go | 2 +- 10 files changed, 68 insertions(+), 42 deletions(-) diff --git a/cointop/config.go b/cointop/config.go index 066d326..40b7af3 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -647,10 +647,8 @@ func (ct *Cointop) loadPortfolioHoldingsFromConfig(holdingsIfc []interface{}) er buyPrice := 0.0 if len(tupleIfc) >= 3 { - if parsePrice, err := ct.InterfaceToFloat64(tupleIfc[2]); err != nil { + if buyPrice, err = ct.InterfaceToFloat64(tupleIfc[2]); err != nil { return err - } else { - buyPrice = parsePrice } } diff --git a/cointop/conversion.go b/cointop/conversion.go index 4bcc18a..51756c7 100644 --- a/cointop/conversion.go +++ b/cointop/conversion.go @@ -302,19 +302,19 @@ func CurrencySymbol(currency string) string { return "?" } -func (ct *Cointop) Convert(convertFrom string, convertTo string, amount float64) (float64, error) { +// 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) - var rate float64 if convertFrom == convertTo { - rate = 1.0 - } else { - crate, err := ct.api.GetExchangeRate(convertFrom, convertTo, true) - if err != nil { - return 0, err - } - rate = crate + 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/keybindings.go b/cointop/keybindings.go index 04af301..b220901 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -328,9 +328,9 @@ func (ct *Cointop) SetKeybindingAction(shortcutKey string, action string) error case "sort_column_cost": fn = ct.Sortfn("cost", true) case "sort_column_pnl": - fn = ct.Sortfn("profit", true) + fn = ct.Sortfn("pnl", true) case "sort_column_pnl_percent": - fn = ct.Sortfn("profit_percent", true) + fn = ct.Sortfn("pnl_percent", true) default: fn = ct.Keyfn(ct.Noop) } diff --git a/cointop/portfolio.go b/cointop/portfolio.go index 3b3be51..61641fd 100644 --- a/cointop/portfolio.go +++ b/cointop/portfolio.go @@ -37,8 +37,8 @@ var SupportedPortfolioTableHeaders = []string{ "last_updated", "cost_price", "cost", - "profit", - "profit_percent", + "pnl", + "pnl_percent", } // DefaultPortfolioTableHeaders are the default portfolio table header columns @@ -53,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 { @@ -84,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 @@ -332,7 +362,6 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { cost = costPrice * coin.Holdings } } - // text := ct.FormatPrice(cost) text := humanize.FixedMonetaryf(cost, 2) if ct.State.hidePortfolioBalances { text = HiddenBalanceChars @@ -352,7 +381,7 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { Color: ct.colorscheme.TableColumnPrice, Text: text, }) - case "profit": + case "pnl": text := "" colorProfit := ct.colorscheme.TableColumnChange if coin.BuyPrice > 0 && coin.BuyCurrency != "" { @@ -385,7 +414,7 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { Color: colorProfit, Text: text, }) - case "profit_percent": + case "pnl_percent": profitPercent := 0.0 if coin.BuyPrice > 0 && coin.BuyCurrency != "" { costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice) @@ -812,8 +841,7 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error { records := make([][]string, len(holdings)) symbol := ct.CurrencySymbol() - // TODO: buy_price, buy_currency, profit, profit_percent, etc - headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings"} + headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings", "buy_price", "buy_currency", "pnl", "pnl_percent"} if len(filterCols) > 0 { for _, col := range filterCols { valid := false diff --git a/cointop/sort.go b/cointop/sort.go index fd596be..f45b2f7 100644 --- a/cointop/sort.go +++ b/cointop/sort.go @@ -72,9 +72,9 @@ func (ct *Cointop) Sort(sortBy string, desc bool, list []*Coin, renderHeaders bo return a.BuyPrice < b.BuyPrice case "cost": return (a.BuyPrice * a.Holdings) < (b.BuyPrice * b.Holdings) // TODO: convert? - case "profit": + case "pnl": return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice) - case "profit_percent": + case "pnl_percent": return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice) default: return a.Rank < b.Rank diff --git a/cointop/table_header.go b/cointop/table_header.go index c526c47..cc71b24 100644 --- a/cointop/table_header.go +++ b/cointop/table_header.go @@ -133,17 +133,17 @@ var HeaderColumns = map[string]*HeaderColumn{ }, "cost": { Slug: "cost", - Label: "cost[!]", + Label: "[!]cost", PlainLabel: "cost", }, - "profit": { - Slug: "profit", - Label: "PNL[@]", + "pnl": { + Slug: "pnl", + Label: "[@]PNL", PlainLabel: "PNL", }, - "profit_percent": { - Slug: "profit_percent", - Label: "PNL%[#]", + "pnl_percent": { + Slug: "pnl_percent", + Label: "[#]PNL%", PlainLabel: "PNL%", }, } @@ -231,7 +231,7 @@ func (ct *Cointop) UpdateTableHeader() error { } leftAlign := ct.GetTableColumnAlignLeft(col) switch col { - case "price", "balance", "profit", "cost": + case "price", "balance", "pnl", "cost": label = fmt.Sprintf("%s%s", ct.CurrencySymbol(), label) } if leftAlign { diff --git a/docs/content/faq.md b/docs/content/faq.md index 9da8352..cbe8547 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -186,11 +186,11 @@ draft: false ## How do I include buy/cost price in my portfolio? - Currently there is no UI for this. If you want to include the cost of your coins in the Portfolio screen, you will need to edit your config.toml + Currently there is no UI for this. If you want to include the cost of your coins in the Portfolio screen, you will need to edit your config.toml - Each coin consists of four values: coin name, coin amount, cost-price, cost-currency. + Each coin consists of four values: coin name, coin amount, cost-price, cost-currency. - For example, the following configuration includes 100 ALGO at USD1.95 each; and 0.1 BTC at AUD50100.83 each. + For example, the following configuration includes 100 ALGO at USD1.95 each; and 0.1 BTC at AUD50100.83 each. ```toml holdings = [["Algorand", "100", "1.95", "USD"], ["Bitcoin", "0.1", "50100.83", "AUD"]] @@ -200,12 +200,12 @@ draft: false - `cost_price` the price and currency that the coins were purchased at - `cost` the cost (in the current currency) of the coins - - `profit` the PNL of the coins (current value vs original cost) - - `profit_percent` the PNL of the coins as a fraction of the original cost + - `pnl` the PNL of the coins (current value vs original cost) + - `pnl_percent` the PNL of the coins as a fraction of the original cost With the holdings above, and the currency set to GBP (British Pounds) cointop will look something like this: - - ![Screen Shot 2021-10-22 at 8 41 21 am](https://user-images.githubusercontent.com/122371/138361142-8e1f32b5-ca24-471d-a628-06968f07c65f.png) + + ![portfolio profit and loss](https://user-images.githubusercontent.com/122371/138361142-8e1f32b5-ca24-471d-a628-06968f07c65f.png) ## How do I hide my portfolio balances (private mode)? @@ -520,7 +520,7 @@ draft: false ## How can I get more information when something is going wrong? Cointop creates a logfile at `/tmp/cointop.log`. Normally nothing is written to this, but if you set the environment variable - `DEBUG=1` cointop will write a lot of output describing its operation. Furthermore, if you also set `DEBUG_HTTP=1` it will + `DEBUG=1` cointop will write a lot of output describing its operation. Furthermore, if you also set `DEBUG_HTTP=1` it will emit lots about every HTTP request that cointop makes to coingecko (backend). Developers may ask for this information to help diagnose any problems you may experience. diff --git a/pkg/api/impl/coingecko/coingecko.go b/pkg/api/impl/coingecko/coingecko.go index 2b9553a..5046ecf 100644 --- a/pkg/api/impl/coingecko/coingecko.go +++ b/pkg/api/impl/coingecko/coingecko.go @@ -161,7 +161,7 @@ func (s *Service) GetExchangeRates(cached bool) (*types.ExchangeRatesItem, error } // GetExchangeRate gets the current excange rate between two currencies -func (s *Service) GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) { +func (s *Service) GetExchangeRate(convertFrom, convertTo string, cached bool) (float64, error) { convertFrom = strings.ToLower(convertFrom) convertTo = strings.ToLower(convertTo) if convertFrom == convertTo { diff --git a/pkg/api/impl/coinmarketcap/coinmarketcap.go b/pkg/api/impl/coinmarketcap/coinmarketcap.go index e3a15fe..71a7b73 100644 --- a/pkg/api/impl/coinmarketcap/coinmarketcap.go +++ b/pkg/api/impl/coinmarketcap/coinmarketcap.go @@ -432,7 +432,7 @@ func getChartInterval(start, end int64) string { } // GetExchangeRate gets the current excange rate between two currencies -func (s *Service) GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) { +func (s *Service) GetExchangeRate(convertFrom, convertTo string, cached bool) (float64, error) { if convertFrom == convertTo { return 1.0, nil } diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 294c16c..55ffcf2 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -16,5 +16,5 @@ type Interface interface { CoinLink(name string) string SupportedCurrencies() []string Price(name string, convert string) (float64, error) - GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) // I don't love this caching + GetExchangeRate(convertFrom, convertTo string, cached bool) (float64, error) // I don't love this caching } From e99d46b424ea5ff4e3c71b254f762343683067c1 Mon Sep 17 00:00:00 2001 From: Miguel Mota Date: Sat, 23 Oct 2021 04:00:43 -0700 Subject: [PATCH 4/5] portfolio: Fix cost/pnl hidden value if empty text. #243 --- cointop/portfolio.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cointop/portfolio.go b/cointop/portfolio.go index 61641fd..ac6de20 100644 --- a/cointop/portfolio.go +++ b/cointop/portfolio.go @@ -337,12 +337,12 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { }) case "cost_price": text := fmt.Sprintf("%s %s", coin.BuyCurrency, ct.FormatPrice(coin.BuyPrice)) - if ct.State.hidePortfolioBalances { - text = HiddenBalanceChars - } 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) @@ -363,12 +363,12 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { } } text := humanize.FixedMonetaryf(cost, 2) - if ct.State.hidePortfolioBalances { - text = HiddenBalanceChars - } if coin.BuyPrice == 0.0 { text = "" } + if ct.State.hidePortfolioBalances { + text = HiddenBalanceChars + } symbolPadding := 1 ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding) @@ -429,13 +429,13 @@ func (ct *Cointop) GetPortfolioTable() *table.Table { colorProfit = ct.colorscheme.TableColumnChangeDown } text := fmt.Sprintf("%.2f%%", profitPercent) + if coin.BuyPrice == 0.0 { + text = "" + } if ct.State.hidePortfolioBalances { text = HiddenBalanceChars colorProfit = ct.colorscheme.TableColumnChange } - if coin.BuyPrice == 0.0 { - text = "" - } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, From 2acbb39496584015059522932b97bbe20bb520e6 Mon Sep 17 00:00:00 2001 From: Simon Roberts Date: Sun, 24 Oct 2021 13:24:39 +1100 Subject: [PATCH 5/5] Fill in cost_price, cost, pnl, pnl_percent in "cointop holdings" output --- cointop/portfolio.go | 66 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/cointop/portfolio.go b/cointop/portfolio.go index ac6de20..f6613fb 100644 --- a/cointop/portfolio.go +++ b/cointop/portfolio.go @@ -841,7 +841,7 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error { records := make([][]string, len(holdings)) symbol := ct.CurrencySymbol() - headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings", "buy_price", "buy_currency", "pnl", "pnl_percent"} + headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings", "cost_price", "cost", "pnl", "pnl_percent"} if len(filterCols) > 0 { for _, col := range filterCols { valid := false @@ -938,6 +938,70 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error { if hideBalances { item[i] = HiddenBalanceChars } + case "cost_price": + if entry.BuyPrice > 0 && entry.BuyCurrency != "" { + if humanReadable { + item[i] = fmt.Sprintf("%s %s", entry.BuyCurrency, ct.FormatPrice(entry.BuyPrice)) + } else { + item[i] = fmt.Sprintf("%s %s", entry.BuyCurrency, strconv.FormatFloat(entry.BuyPrice, 'f', -1, 64)) + } + } + if hideBalances { + item[i] = HiddenBalanceChars + } + case "cost": + if entry.BuyPrice > 0 && entry.BuyCurrency != "" { + costPrice, err := ct.Convert(entry.BuyCurrency, ct.State.currencyConversion, entry.BuyPrice) + if err == nil { + cost := costPrice * entry.Holdings + if humanReadable { + item[i] = fmt.Sprintf("%s%s", symbol, humanize.FixedMonetaryf(cost, 2)) + } else { + item[i] = strconv.FormatFloat(cost, 'f', -1, 64) + } + } else { + item[i] = "?" // error + } + } + if hideBalances { + item[i] = HiddenBalanceChars + } + case "pnl": + if entry.BuyPrice > 0 && entry.BuyCurrency != "" { + costPrice, err := ct.Convert(entry.BuyCurrency, ct.State.currencyConversion, entry.BuyPrice) + if err == nil { + profit := (entry.Price - costPrice) * entry.Holdings + if humanReadable { + // TODO: if <0 "£-3.71" should be "-£3.71"? + item[i] = fmt.Sprintf("%s%s", symbol, humanize.FixedMonetaryf(profit, 2)) + } else { + item[i] = strconv.FormatFloat(profit, 'f', -1, 64) + } + } else { + item[i] = "?" // error + } + } + if hideBalances { + item[i] = HiddenBalanceChars + } + case "pnl_percent": + if entry.BuyPrice > 0 && entry.BuyCurrency != "" { + costPrice, err := ct.Convert(entry.BuyCurrency, ct.State.currencyConversion, entry.BuyPrice) + if err == nil { + profitPercent := 100 * (entry.Price/costPrice - 1) + if humanReadable { + item[i] = fmt.Sprintf("%s%%", humanize.Numericf(profitPercent, 2)) + } else { + item[i] = fmt.Sprintf("%.2f", profitPercent) + } + + } else { + item[i] = "?" // error + } + } + if hideBalances { + item[i] = HiddenBalanceChars + } } } records[i] = item