package cointop import ( "encoding/csv" "encoding/json" "fmt" "math" "os" "sort" "strconv" "strings" "time" "unicode/utf8" "github.com/cointop-sh/cointop/pkg/asciitable" "github.com/cointop-sh/cointop/pkg/eval" "github.com/cointop-sh/cointop/pkg/humanize" "github.com/cointop-sh/cointop/pkg/pad" "github.com/cointop-sh/cointop/pkg/table" log "github.com/sirupsen/logrus" ) // SupportedPortfolioTableHeaders are all the supported portfolio table header columns var SupportedPortfolioTableHeaders = []string{ "rank", "name", "symbol", "price", "holdings", "balance", "1h_change", "24h_change", "7d_change", "30d_change", "1y_change", "percent_holdings", "last_updated", "cost_price", "cost", "pnl", "pnl_percent", } // DefaultPortfolioTableHeaders are the default portfolio table header columns var DefaultPortfolioTableHeaders = []string{ "rank", "name", "symbol", "price", "holdings", "balance", "1h_change", "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 { if v == name { return true } } return false } // GetPortfolioTableHeaders returns the portfolio table headers func (ct *Cointop) GetPortfolioTableHeaders() []string { return ct.State.portfolioTableColumns } // GetPortfolioTable returns the table for displaying portfolio holdings func (ct *Cointop) GetPortfolioTable() *table.Table { total := ct.GetPortfolioTotal() maxX := ct.Width() t := table.NewTable().SetWidth(maxX) var rows [][]*table.RowCell 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 var rowCells []*table.RowCell for _, header := range headers { switch header { case "rank": star := ct.colorscheme.TableRow(" ") if coin.Favorite { star = ct.colorscheme.TableRowFavorite(ct.State.favoriteChar) } rank := fmt.Sprintf("%s%v", star, ct.colorscheme.TableRow(fmt.Sprintf("%6v ", coin.Rank))) ct.SetTableColumnWidth(header, 8) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: ct.colorscheme.Default, Text: rank, }) case "name": name := TruncateString(coin.Name, 18) namecolor := ct.colorscheme.TableRow if coin.Favorite { namecolor = ct.colorscheme.TableRowFavorite } ct.SetTableColumnWidthFromString(header, name) ct.SetTableColumnAlignLeft(header, true) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: true, Color: namecolor, Text: name, }) case "symbol": symbol := TruncateString(coin.Symbol, 6) ct.SetTableColumnWidthFromString(header, symbol) ct.SetTableColumnAlignLeft(header, true) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: true, Color: ct.colorscheme.TableRow, Text: symbol, }) case "price": text := ct.FormatPrice(coin.Price) 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 "holdings": text := strconv.FormatFloat(coin.Holdings, 'f', -1, 64) if ct.State.hidePortfolioBalances { text = HiddenBalanceChars } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: ct.colorscheme.TableRow, Text: text, }) case "balance": text := humanize.Monetaryf(coin.Balance, 2) if ct.State.hidePortfolioBalances { text = HiddenBalanceChars } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) colorBalance := ct.colorscheme.TableColumnPrice rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: colorBalance, Text: text, }) case "1h_change": color1h := ct.colorscheme.TableColumnChange if coin.PercentChange1H > 0 { color1h = ct.colorscheme.TableColumnChangeUp } if coin.PercentChange1H < 0 { color1h = ct.colorscheme.TableColumnChangeDown } text := fmt.Sprintf("%.2f%%", coin.PercentChange1H) ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: color1h, Text: text, }) case "24h_change": color24h := ct.colorscheme.TableColumnChange if coin.PercentChange24H > 0 { color24h = ct.colorscheme.TableColumnChangeUp } if coin.PercentChange24H < 0 { color24h = ct.colorscheme.TableColumnChangeDown } text := fmt.Sprintf("%.2f%%", coin.PercentChange24H) ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: color24h, Text: text, }) case "7d_change": color7d := ct.colorscheme.TableColumnChange if coin.PercentChange7D > 0 { color7d = ct.colorscheme.TableColumnChangeUp } if coin.PercentChange7D < 0 { color7d = ct.colorscheme.TableColumnChangeDown } text := fmt.Sprintf("%.2f%%", coin.PercentChange7D) ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: color7d, Text: text, }) case "30d_change": color30d := ct.colorscheme.TableColumnChange if coin.PercentChange30D > 0 { color30d = ct.colorscheme.TableColumnChangeUp } if coin.PercentChange30D < 0 { color30d = ct.colorscheme.TableColumnChangeDown } text := fmt.Sprintf("%.2f%%", coin.PercentChange30D) ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: color30d, Text: text, }) case "1y_change": color1y := ct.colorscheme.TableColumnChange if coin.PercentChange1Y > 0 { color1y = ct.colorscheme.TableColumnChangeUp } if coin.PercentChange1Y < 0 { color1y = ct.colorscheme.TableColumnChangeDown } text := fmt.Sprintf("%.2f%%", coin.PercentChange1Y) ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: color1y, Text: text, }) case "percent_holdings": percentHoldings := (coin.Balance / total) * 1e2 if math.IsNaN(percentHoldings) { percentHoldings = 0 } text := fmt.Sprintf("%.2f%%", percentHoldings) if ct.State.hidePortfolioBalances { text = HiddenBalanceChars } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: ct.colorscheme.TableRow, Text: text, }) case "last_updated": unix, _ := strconv.ParseInt(coin.LastUpdated, 10, 64) lastUpdated := humanize.FormatTime(time.Unix(unix, 0), "15:04:05 Jan 02") ct.SetTableColumnWidthFromString(header, lastUpdated) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, 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 colorProfit = ct.colorscheme.TableColumnChange } ct.SetTableColumnWidthFromString(header, text) ct.SetTableColumnAlignLeft(header, false) rowCells = append(rowCells, &table.RowCell{ LeftMargin: leftMargin, RightMargin: rightMargin, LeftAlign: false, Color: colorProfit, Text: text, }) } } rows = append(rows, rowCells) } for _, row := range rows { for i, header := range headers { row[i].Width = ct.GetTableColumnWidth(header) } t.AddRowCells(row...) } return t } // TogglePortfolio toggles the portfolio view func (ct *Cointop) TogglePortfolio() error { log.Debug("TogglePortfolio()") ct.ToggleSelectedView(PortfolioView) go ct.UpdateChart() go ct.UpdateTable() return nil } // ToggleShowPortfolio shows the portfolio view func (ct *Cointop) ToggleShowPortfolio() error { log.Debug("ToggleShowPortfolio()") ct.SetSelectedView(PortfolioView) go ct.UpdateChart() go ct.UpdateTable() return nil } // TogglePortfolioUpdateMenu toggles the portfolio update menu func (ct *Cointop) TogglePortfolioUpdateMenu() error { log.Debug("TogglePortfolioUpdateMenu()") if ct.IsPriceAlertsVisible() { return ct.ShowPriceAlertsUpdateMenu() } ct.State.portfolioUpdateMenuVisible = !ct.State.portfolioUpdateMenuVisible if ct.State.portfolioUpdateMenuVisible { return ct.ShowPortfolioUpdateMenu() } return ct.HidePortfolioUpdateMenu() } // CoinHoldings returns portfolio coin holdings func (ct *Cointop) CoinHoldings(coin *Coin) float64 { entry, _ := ct.PortfolioEntry(coin) return entry.Holdings } // UpdatePortfolioUpdateMenu updates the portfolio update menu view func (ct *Cointop) UpdatePortfolioUpdateMenu() error { log.Debug("UpdatePortfolioUpdateMenu()") coin := ct.HighlightedRowCoin() exists := ct.PortfolioEntryExists(coin) value := strconv.FormatFloat(ct.CoinHoldings(coin), 'f', -1, 64) log.Debugf("UpdatePortfolioUpdateMenu() holdings %v", value) var mode string var current string var submitText string if exists { mode = "Edit" current = fmt.Sprintf("(current %s %s)", value, coin.Symbol) submitText = "Set" } else { mode = "Add" submitText = "Add" } header := ct.colorscheme.MenuHeader(fmt.Sprintf(" %s Portfolio Entry %s\n\n", mode, pad.Left("[q] close ", ct.Width()-25, " "))) label := fmt.Sprintf(" Enter holdings for %s %s", ct.colorscheme.MenuLabel(coin.Name), current) content := fmt.Sprintf("%s\n%s\n\n%s%s\n\n\n [Enter] %s [ESC] Cancel", header, label, strings.Repeat(" ", 29), coin.Symbol, submitText) ct.UpdateUI(func() error { ct.Views.Menu.SetFrame(true) ct.Views.Menu.Update(content) ct.Views.Input.Write(value) ct.Views.Input.SetCursor(utf8.RuneCountInString(value), 0) return nil }) return nil } // ShowPortfolioUpdateMenu shows the portfolio update menu func (ct *Cointop) ShowPortfolioUpdateMenu() error { log.Debug("ShowPortfolioUpdateMenu()") // TODO: separation of concerns if ct.IsPriceAlertsVisible() { return ct.ShowPriceAlertsUpdateMenu() } coin := ct.HighlightedRowCoin() if coin == nil { ct.TogglePortfolio() return nil } ct.State.portfolioUpdateMenuVisible = true ct.UpdatePortfolioUpdateMenu() ct.ui.SetCursor(true) ct.SetActiveView(ct.Views.Menu.Name()) ct.g.SetViewOnTop(ct.Views.Input.Name()) ct.g.SetCurrentView(ct.Views.Input.Name()) return nil } // HidePortfolioUpdateMenu hides the portfolio update menu func (ct *Cointop) HidePortfolioUpdateMenu() error { log.Debug("HidePortfolioUpdateMenu()") ct.State.portfolioUpdateMenuVisible = false ct.ui.SetViewOnBottom(ct.Views.Menu) ct.ui.SetViewOnBottom(ct.Views.Input) ct.ui.SetCursor(false) ct.SetActiveView(ct.Views.Table.Name()) ct.UpdateUI(func() error { ct.Views.Menu.SetFrame(false) ct.Views.Menu.Update("") ct.Views.Input.Update("") return nil }) return nil } // SetPortfolioHoldings sets portfolio entry holdings from inputed value func (ct *Cointop) SetPortfolioHoldings() error { log.Debug("SetPortfolioHoldings()") defer ct.HidePortfolioUpdateMenu() coin := ct.HighlightedRowCoin() if coin == nil { return nil } // read input field b := make([]byte, 100) n, err := ct.Views.Input.Read(b) if err != nil { return err } if n == 0 { return nil } input := string(b[:n]) holdings, err := eval.EvaluateExpressionToFloat64(input, coin) if err != nil { // leave value as is if expression can't be evaluated return nil } 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, buyPrice, buyCurrency); err != nil { return err } if shouldDelete { if err := ct.RemovePortfolioEntry(coin.Name); err != nil { return err } ct.UpdateTable() if idx > 0 { idx -= 1 } } else { ct.UpdateTable() ct.ToggleShowPortfolio() idx = ct.GetPortfolioCoinIndex(coin) } ct.HighlightRow(idx) return nil } // PortfolioEntry returns a portfolio entry func (ct *Cointop) PortfolioEntry(c *Coin) (*PortfolioEntry, bool) { //log.Debug("PortfolioEntry()") // too many if c == nil { return &PortfolioEntry{}, true } var p *PortfolioEntry var isNew bool var ok bool key := strings.ToLower(c.Name) if p, ok = ct.State.portfolio.Entries[key]; !ok { p = &PortfolioEntry{ Coin: c.Name, Holdings: 0, } isNew = true } return p, isNew } // SetPortfolioEntry sets a portfolio entry 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) p, isNew := ct.PortfolioEntry(c) if isNew { key := strings.ToLower(coin) ct.State.portfolio.Entries[key] = &PortfolioEntry{ Coin: coin, Holdings: holdings, BuyPrice: buyPrice, BuyCurrency: buyCurrency, } } else { p.Holdings = holdings } if err := ct.Save(); err != nil { return err } return nil } // RemovePortfolioEntry removes a portfolio entry func (ct *Cointop) RemovePortfolioEntry(coin string) error { log.Debug("RemovePortfolioEntry()") delete(ct.State.portfolio.Entries, strings.ToLower(coin)) if err := ct.Save(); err != nil { return err } return nil } // PortfolioEntryExists returns true if portfolio entry exists func (ct *Cointop) PortfolioEntryExists(c *Coin) bool { log.Debug("PortfolioEntryExists()") _, isNew := ct.PortfolioEntry(c) return !isNew } // PortfolioEntriesCount returns the count of portfolio entries func (ct *Cointop) PortfolioEntriesCount() int { log.Debug("PortfolioEntriesCount()") return len(ct.State.portfolio.Entries) } // GetPortfolioSlice returns portfolio entries as a slice func (ct *Cointop) GetPortfolioSlice() []*Coin { log.Debug("GetPortfolioSlice()") var sliced []*Coin if ct.PortfolioEntriesCount() == 0 { return sliced } for _, p := range ct.State.portfolio.Entries { coinIfc, _ := ct.State.allCoinsSlugMap.Load(p.Coin) coin, ok := coinIfc.(*Coin) if !ok { log.Errorf("Could not find coin %s", p.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" { balancestr = fmt.Sprintf("%.5f", balance) } balance, _ = strconv.ParseFloat(balancestr, 64) coin.Balance = balance sliced = append(sliced, coin) } sort.SliceStable(sliced, func(i, j int) bool { return sliced[i].Balance > sliced[j].Balance }) for i, coin := range sliced { coin.Rank = i + 1 } return sliced } // GetPortfolioTotal returns the total balance of portfolio entries func (ct *Cointop) GetPortfolioTotal() float64 { log.Debug("GetPortfolioTotal()") portfolio := ct.GetPortfolioSlice() var total float64 for _, p := range portfolio { total += p.Balance } return total } // RefreshPortfolioCoins refreshes portfolio entry coin data func (ct *Cointop) RefreshPortfolioCoins() error { log.Debug("RefreshPortfolioCoins()") holdings := ct.GetPortfolioSlice() holdingCoins := make([]string, len(holdings)) for i, entry := range holdings { holdingCoins[i] = entry.Name } coins, err := ct.api.GetCoinDataBatch(holdingCoins, ct.State.currencyConversion) ct.processCoins(coins) if err != nil { return err } return nil } // TablePrintOptions are options for ascii table output. type TablePrintOptions struct { SortBy string SortDesc bool HumanReadable bool Format string Filter []string Cols []string Convert string NoHeader bool PercentChange24H bool HideBalances bool } // outputFormats is list of valid output formats var outputFormats = map[string]bool{ "table": true, "csv": true, "json": true, } // portfolioColumns is list of valid column keys for portfolio var portfolioColumns = map[string]bool{ "name": true, "symbol": true, "price": true, "holdings": true, "balance": true, "24h": true, } // PrintHoldingsTable prints the holdings in an ASCII table func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error { log.Debug("PrintHoldingsTable()") if options == nil { options = &TablePrintOptions{} } if err := ct.SetCurrencyConverstion(options.Convert); err != nil { return err } ct.RefreshPortfolioCoins() sortBy := options.SortBy sortDesc := options.SortDesc format := options.Format humanReadable := options.HumanReadable filterCoins := options.Filter filterCols := options.Cols holdings := ct.GetPortfolioSlice() noHeader := options.NoHeader hideBalances := options.HideBalances if format == "" { format = "table" } if sortBy != "" { if _, ok := portfolioColumns[sortBy]; !ok { return fmt.Errorf("the option %q is not a valid column name", sortBy) } ct.Sort(sortBy, sortDesc, holdings, true) } if _, ok := outputFormats[format]; !ok { return fmt.Errorf("the option %q is not a valid format type", format) } total := ct.GetPortfolioTotal() records := make([][]string, len(holdings)) symbol := ct.CurrencySymbol() 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 for _, h := range headers { if col == h { valid = true break } } switch col { case "amount": return fmt.Errorf("did you mean %q?", "balance") case "24H": fallthrough case "24H%": fallthrough case "24h": fallthrough case "24h_change": return fmt.Errorf("did you mean %q?", "24h%") case "percent_holdings": return fmt.Errorf("did you mean %q?", "%holdings") } if !valid { return fmt.Errorf("unsupported column value %q", col) } } headers = filterCols } for i, entry := range holdings { if len(filterCoins) > 0 { found := false for _, item := range filterCoins { item = strings.ToLower(strings.TrimSpace(item)) if strings.ToLower(entry.Symbol) == item || strings.ToLower(entry.Name) == item { found = true break } } if !found { continue } } percentHoldings := (entry.Balance / total) * 1e2 if math.IsNaN(percentHoldings) { percentHoldings = 0 } item := make([]string, len(headers)) for i, header := range headers { switch header { case "name": item[i] = entry.Name case "symbol": item[i] = entry.Symbol case "price": if humanReadable { item[i] = fmt.Sprintf("%s%s", symbol, ct.FormatPrice(entry.Price)) } else { item[i] = strconv.FormatFloat(entry.Price, 'f', -1, 64) } case "holdings": if humanReadable { item[i] = humanize.Monetaryf(entry.Holdings, 2) } else { item[i] = strconv.FormatFloat(entry.Holdings, 'f', -1, 64) } if hideBalances { item[i] = HiddenBalanceChars } case "balance": if humanReadable { item[i] = fmt.Sprintf("%s%s", symbol, humanize.Monetaryf(entry.Balance, 2)) } else { item[i] = strconv.FormatFloat(entry.Balance, 'f', -1, 64) } if hideBalances { item[i] = HiddenBalanceChars } case "24h%": if humanReadable { item[i] = fmt.Sprintf("%s%%", humanize.Numericf(entry.PercentChange24H, 2)) } else { item[i] = fmt.Sprintf("%.2f", entry.PercentChange24H) } case "%holdings": if humanReadable { item[i] = fmt.Sprintf("%s%%", humanize.Numericf(percentHoldings, 2)) } else { item[i] = fmt.Sprintf("%.2f", percentHoldings) } 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 } if format == "csv" { csvWriter := csv.NewWriter(os.Stdout) if !noHeader { if err := csvWriter.Write(headers); err != nil { return err } } for _, record := range records { if err := csvWriter.Write(record); err != nil { return err } } csvWriter.Flush() if err := csvWriter.Error(); err != nil { return err } return nil } else if format == "json" { var output []byte var err error if noHeader { output, err = json.Marshal(records) if err != nil { return err } } else { list := make([]map[string]string, len(records)) for i, record := range records { obj := make(map[string]string, len(record)) for j, column := range record { obj[headers[j]] = column } list[i] = obj } output, err = json.Marshal(list) if err != nil { return err } } fmt.Println(string(output)) return nil } alignment := []int{-1, -1, 1, 1, 1, 1, 1} var tableHeaders []string if !noHeader { tableHeaders = headers } table := asciitable.NewAsciiTable(&asciitable.Input{ Data: records, Headers: tableHeaders, Alignment: alignment, }) fmt.Println(table.String()) return nil } // PrintHoldingsTotal prints the total holdings amount func (ct *Cointop) PrintHoldingsTotal(options *TablePrintOptions) error { log.Debug("PrintHoldingsTotal()") if options == nil { options = &TablePrintOptions{} } if err := ct.SetCurrencyConverstion(options.Convert); err != nil { return err } ct.RefreshPortfolioCoins() humanReadable := options.HumanReadable symbol := ct.CurrencySymbol() format := options.Format filter := options.Filter portfolio := ct.GetPortfolioSlice() var total float64 for _, entry := range portfolio { if len(filter) > 0 { found := false for _, item := range filter { item = strings.ToLower(strings.TrimSpace(item)) if strings.ToLower(entry.Symbol) == item || strings.ToLower(entry.Name) == item { found = true break } } if !found { continue } } total += entry.Balance } value := strconv.FormatFloat(total, 'f', -1, 64) if humanReadable { value = fmt.Sprintf("%s%s", symbol, humanize.Monetaryf(total, 2)) } if format == "csv" { csvWriter := csv.NewWriter(os.Stdout) if err := csvWriter.Write([]string{"total"}); err != nil { return err } if err := csvWriter.Write([]string{value}); err != nil { return err } csvWriter.Flush() if err := csvWriter.Error(); err != nil { return err } return nil } else if format == "json" { obj := map[string]string{"total": value} output, err := json.Marshal(obj) if err != nil { return err } fmt.Println(string(output)) return nil } fmt.Println(value) return nil } // PrintHoldings24HChange prints the total holdings amount func (ct *Cointop) PrintHoldings24HChange(options *TablePrintOptions) error { log.Debug("PrintHoldings24HChange()") if options == nil { options = &TablePrintOptions{} } if err := ct.SetCurrencyConverstion(options.Convert); err != nil { return err } ct.RefreshPortfolioCoins() humanReadable := options.HumanReadable format := options.Format filter := options.Filter portfolio := ct.GetPortfolioSlice() total := ct.GetPortfolioTotal() var percentChange24H float64 for _, entry := range portfolio { if len(filter) > 0 { found := false for _, item := range filter { item = strings.ToLower(strings.TrimSpace(item)) if strings.ToLower(entry.Symbol) == item || strings.ToLower(entry.Name) == item { found = true break } } if !found { continue } } n := (entry.Balance / total) * entry.PercentChange24H if math.IsNaN(n) { continue } percentChange24H += n } value := fmt.Sprintf("%.2f", percentChange24H) if humanReadable { value = fmt.Sprintf("%s%%", value) } if format == "csv" { csvWriter := csv.NewWriter(os.Stdout) if err := csvWriter.Write([]string{"24H%"}); err != nil { return err } if err := csvWriter.Write([]string{value}); err != nil { return err } csvWriter.Flush() if err := csvWriter.Error(); err != nil { return err } return nil } else if format == "json" { obj := map[string]string{"24H%": value} output, err := json.Marshal(obj) if err != nil { return err } fmt.Println(string(output)) return nil } fmt.Println(value) return nil } // GetPortfolioCoinIndex returns the row index of coin in portfolio func (ct *Cointop) GetPortfolioCoinIndex(coin *Coin) int { coins := ct.GetPortfolioSlice() for i, c := range coins { if c.ID == coin.ID { return i } } return 0 } func (ct *Cointop) GetLastPortfolioRowIndex() int { l := ct.PortfolioLen() if l > 0 { l -= 1 } return l } // IsPortfolioVisible returns true if portfolio view is visible func (ct *Cointop) IsPortfolioVisible() bool { return ct.State.selectedView == PortfolioView } // PortfolioLen returns the number of portfolio entries func (ct *Cointop) PortfolioLen() int { return len(ct.GetPortfolioSlice()) } // TogglePortfolioBalances toggles hide/show portfolio balances. Useful for keeping balances secret when sharing screen or taking screenshots. func (ct *Cointop) TogglePortfolioBalances() error { ct.State.hidePortfolioBalances = !ct.State.hidePortfolioBalances ct.UpdateUI(func() error { go ct.UpdateChart() go ct.UpdateTable() return nil }) return nil }