You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cointop/cointop/portfolio.go

1264 lines
32 KiB
Go

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
}