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