summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMiguel Mota <hello@miguelmota.com>2021-10-24 21:58:16 -0700
committerMiguel Mota <hello@miguelmota.com>2021-10-24 21:58:16 -0700
commitb921c091d6f882b6c34cdde15e846f7ffd44ea6d (patch)
treedb8b7c93abc3f6564bd104a66ae213ba83848287
parentf34eb3ef8fb4684ebf9661068a530267c470769b (diff)
parent2acbb39496584015059522932b97bbe20bb520e6 (diff)
Merge branch 'lyricnz-feature/portfolio-buy2'
-rw-r--r--cointop/actions.go3
-rw-r--r--cointop/coin.go6
-rw-r--r--cointop/cointop.go13
-rw-r--r--cointop/config.go102
-rw-r--r--cointop/conversion.go19
-rw-r--r--cointop/default_shortcuts.go3
-rw-r--r--cointop/keybindings.go6
-rw-r--r--cointop/portfolio.go227
-rw-r--r--cointop/sort.go8
-rw-r--r--cointop/table_header.go25
-rw-r--r--docs/content/faq.md27
-rw-r--r--pkg/api/impl/coingecko/coingecko.go64
-rw-r--r--pkg/api/impl/coinmarketcap/coinmarketcap.go8
-rw-r--r--pkg/api/interface.go1
-rw-r--r--pkg/humanize/humanize.go5
-rw-r--r--pkg/ui/ui.go6
16 files changed, 456 insertions, 67 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 4b15410..4050175 100644
--- a/cointop/cointop.go
+++ b/cointop/cointop.go
@@ -92,6 +92,7 @@ type State struct {
tableCompactNotation bool
favoritesCompactNotation bool
portfolioCompactNotation bool
+ enableMouse bool
}
// Cointop cointop
@@ -125,8 +126,10 @@ type Cointop struct {
// PortfolioEntry is portfolio entry
type PortfolioEntry struct {
- Coin string
- Holdings float64
+ Coin string
+ Holdings float64
+ BuyPrice float64
+ BuyCurrency string
}
// Portfolio is portfolio structure
@@ -187,6 +190,9 @@ var DefaultChartRange = "1Y"
// DefaultCompactNotation ...
var DefaultCompactNotation = false
+// DefaultEnableMouse ...
+var DefaultEnableMouse = true
+
// DefaultMaxChartWidth ...
var DefaultMaxChartWidth = 175
@@ -296,6 +302,7 @@ func NewCointop(config *Config) (*Cointop, error) {
SoundEnabled: true,
},
compactNotation: DefaultCompactNotation,
+ enableMouse: DefaultEnableMouse,
tableCompactNotation: DefaultCompactNotation,
favoritesCompactNotation: DefaultCompactNotation,
portfolioCompactNotation: DefaultCompactNotation,
@@ -488,7 +495,7 @@ func (ct *Cointop) Run() error {
defer ui.Close()
ui.SetInputEsc(true)
- ui.SetMouse(true)
+ ui.SetMouse(ct.State.enableMouse)
ui.SetHighlight(true)
ui.SetManagerFunc(ct.layout)
if err := ct.SetKeybindings(); err != nil {
diff --git a/cointop/config.go b/cointop/config.go
index aa81a49..40b7af3 100644
--- a/cointop/config.go
+++ b/cointop/config.go
@@ -49,6 +49,7 @@ type ConfigFileConfig struct {
RefreshRate interface{} `toml:"refresh_rate"`
CacheDir interface{} `toml:"cache_dir"`
CompactNotation interface{} `toml:"compact_notation"`
+ EnableMouse interface{} `toml:"enable_mouse"`
Table map[string]interface{} `toml:"table"`
Chart map[string]interface{} `toml:"chart"`
}
@@ -72,6 +73,7 @@ func (ct *Cointop) SetupConfig() error {
ct.loadRefreshRateFromConfig,
ct.loadCacheDirFromConfig,
ct.loadCompactNotationFromConfig,
+ ct.loadEnableMouseFromConfig,
ct.loadPriceAlertsFromConfig,
ct.loadPortfolioFromConfig,
}
@@ -227,9 +229,12 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) {
if !ok || entry.Coin == "" {
continue
}
- amount := strconv.FormatFloat(entry.Holdings, 'f', -1, 64)
- coinName := entry.Coin
- tuple := []string{coinName, amount}
+ tuple := []string{
+ entry.Coin,
+ strconv.FormatFloat(entry.Holdings, 'f', -1, 64),
+ strconv.FormatFloat(entry.BuyPrice, 'f', -1, 64),
+ entry.BuyCurrency,
+ }
holdingsIfc = append(holdingsIfc, tuple)
}
sort.Slice(holdingsIfc, func(i, j int) bool {
@@ -289,6 +294,7 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) {
Table: tableMapIfc,
Chart: chartMapIfc,
CompactNotation: ct.State.compactNotation,
+ EnableMouse: ct.State.enableMouse,
}
var b bytes.Buffer
@@ -506,6 +512,16 @@ func (ct *Cointop) loadCompactNotationFromConfig() error {
return nil
}
+// loadCompactNotationFromConfig loads compact-notation setting from config file to struct
+func (ct *Cointop) loadEnableMouseFromConfig() error {
+ log.Debug("loadEnableMouseFromConfig()")
+ if enableMouse, ok := ct.config.EnableMouse.(bool); ok {
+ ct.State.enableMouse = enableMouse
+ }
+
+ return nil
+}
+
// LoadAPIChoiceFromConfig loads API choices from config file to struct
func (ct *Cointop) loadAPIChoiceFromConfig() error {
log.Debug("loadAPIKeysFromConfig()")
@@ -584,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 {
@@ -620,12 +610,62 @@ func (ct *Cointop) loadPortfolioFromConfig() error {
return err
}
- if err := ct.SetPortfolioEntry(key, holdings); err != nil {
+ if err := ct.SetPortfolioEntry(key, holdings, 0.0, ""); err != nil {
return err
}
}
}
+ // Process holdings last because it causes a ct.Save()
+ if valueIfc, ok := ct.config.Portfolio["holdings"]; ok {
+ if holdingsIfc, ok := valueIfc.([]interface{}); ok {
+ ct.loadPortfolioHoldingsFromConfig(holdingsIfc)
+ }
+ }
+
+ return nil
+}
+
+func (ct *Cointop) loadPortfolioHoldingsFromConfig(holdingsIfc []interface{}) error {
+ for _, itemIfc := range holdingsIfc {
+ tupleIfc, ok := itemIfc.([]interface{})
+ if !ok {
+ continue
+ }
+ if len(tupleIfc) > 4 {
+ continue
+ }
+ name, ok := tupleIfc[0].(string)
+ if !ok {
+ continue // was not a string
+ }
+
+ holdings, err := ct.InterfaceToFloat64(tupleIfc[1])
+ if err != nil {
+ return err // was not a float64
+ }
+
+ buyPrice := 0.0
+ if len(tupleIfc) >= 3 {
+ if buyPrice, err = ct.InterfaceToFloat64(tupleIfc[2]); err != nil {
+ return err
+ }
+ }
+
+ buyCurrency := ""
+ if len(tupleIfc) >= 4 {
+ if parseCurrency, ok := tupleIfc[3].(string); !ok {
+ return err // was not a string
+ } else {
+ buyCurrency = parseCurrency
+ }
+ }
+
+ // Watch out - this calls ct.Save() which may save a half-loaded configuration
+ if err := ct.SetPortfolioEntry(name, holdings, buyPrice, buyCurrency); err != nil {
+ return err
+ }
+ }
return nil
}
diff --git a/cointop/conversion.go b/cointop/conversion.go
index 7094ad7..51756c7 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 "?"
}
+
+// Convert converts an amount to another currency type
+func (ct *Cointop) Convert(convertFrom, convertTo string, amount float64) (float64, error) {
+ convertFrom = strings.ToLower(convertFrom)
+ convertTo = strings.ToLower(convertTo)
+
+ if convertFrom == convertTo {
+ return amount, nil
+ }
+
+ rate, err := ct.api.GetExchangeRate(convertFrom, convertTo, true)
+ if err != nil {
+ return 0, err
+ }
+
+ return rate * amount, nil
+}
diff --git a/cointop/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..b220901 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("pnl", true)
+ case "sort_column_pnl_percent":
+ fn = ct.Sortfn("pnl_percent", true)
default:
fn = ct.Keyfn(ct.Noop)
}
diff --git a/cointop/portfolio.go b/cointop/portfolio.go
index 3848bec..f6613fb 100644
--- a/cointop/portfolio.go
+++ b/cointop/portfolio.go
@@ -35,6 +35,10 @@ var SupportedPortfolioTableHeaders = []string{
"1y_change",
"percent_holdings",
"last_updated",
+ "cost_price",
+ "cost",
+ "pnl",
+ "pnl_percent",
}
// DefaultPortfolioTableHeaders are the default portfolio table header columns
@@ -49,12 +53,23 @@ var DefaultPortfolioTableHeaders = []string{
"24h_change",
"7d_change",
"percent_holdings",
+ "cost_price",
+ "cost",
+ "pnl",
+ "pnl_percent",
"last_updated",
}
// HiddenBalanceChars are the characters to show when hidding balances
var HiddenBalanceChars = "********"
+var costColumns = map[string]bool{
+ "cost_price": true,
+ "cost": true,
+ "pnl": true,
+ "pnl_percent": true,
+}
+
// ValidPortfolioTableHeader returns the portfolio table headers
func (ct *Cointop) ValidPortfolioTableHeader(name string) bool {
for _, v := range SupportedPortfolioTableHeaders {
@@ -80,6 +95,25 @@ func (ct *Cointop) GetPortfolioTable() *table.Table {
headers := ct.GetPortfolioTableHeaders()
ct.ClearSyncMap(&ct.State.tableColumnWidths)
ct.ClearSyncMap(&ct.State.tableColumnAlignLeft)
+
+ displayCostColumns := false
+ for _, coin := range ct.State.coins {
+ if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
+ displayCostColumns = true
+ break
+ }
+ }
+
+ if !displayCostColumns {
+ filtered := make([]string, 0)
+ for _, header := range headers {
+ if _, ok := costColumns[header]; !ok {
+ filtered = append(filtered, header)
+ }
+ }
+ headers = filtered
+ }
+
for _, coin := range ct.State.coins {
leftMargin := 1
rightMargin := 1
@@ -301,6 +335,117 @@ func (ct *Cointop) GetPortfolioTable() *table.Table {
Color: ct.colorscheme.TableRow,
Text: lastUpdated,
})
+ case "cost_price":
+ text := fmt.Sprintf("%s %s", coin.BuyCurrency, ct.FormatPrice(coin.BuyPrice))
+ if coin.BuyPrice == 0.0 || coin.BuyCurrency == "" {
+ text = ""
+ }
+ if ct.State.hidePortfolioBalances {
+ text = HiddenBalanceChars
+ }
+ symbolPadding := 1
+ ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding)
+ ct.SetTableColumnAlignLeft(header, false)
+ rowCells = append(rowCells,
+ &table.RowCell{
+ LeftMargin: leftMargin,
+ RightMargin: rightMargin,
+ LeftAlign: false,
+ Color: ct.colorscheme.TableRow,
+ Text: text,
+ })
+ case "cost":
+ cost := 0.0
+ if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
+ costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice)
+ if err == nil {
+ cost = costPrice * coin.Holdings
+ }
+ }
+ text := humanize.FixedMonetaryf(cost, 2)
+ if coin.BuyPrice == 0.0 {
+ text = ""
+ }
+ if ct.State.hidePortfolioBalances {
+ text = HiddenBalanceChars
+ }
+
+ symbolPadding := 1
+ ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding)
+ ct.SetTableColumnAlignLeft(header, false)
+ rowCells = append(rowCells,
+ &table.RowCell{
+ LeftMargin: leftMargin,
+ RightMargin: rightMargin,
+ LeftAlign: false,
+ Color: ct.colorscheme.TableColumnPrice,
+ Text: text,
+ })
+ case "pnl":
+ text := ""
+ colorProfit := ct.colorscheme.TableColumnChange
+ if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
+ costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice)
+ if err == nil {
+ profit := (coin.Price - costPrice) * coin.Holdings
+ text = humanize.FixedMonetaryf(profit, 2)
+ if profit > 0 {
+ colorProfit = ct.colorscheme.TableColumnChangeUp
+ } else if profit < 0 {
+ colorProfit = ct.colorscheme.TableColumnChangeDown
+ }
+ } else {
+ text = "?"
+ }
+ }
+ if ct.State.hidePortfolioBalances {
+ text = HiddenBalanceChars
+ colorProfit = ct.colorscheme.TableColumnChange
+ }
+
+ symbolPadding := 1
+ ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding)
+ ct.SetTableColumnAlignLeft(header, false)
+ rowCells = append(rowCells,
+ &table.RowCell{
+ LeftMargin: leftMargin,
+ RightMargin: rightMargin,
+ LeftAlign: false,
+ Color: colorProfit,
+ Text: text,
+ })
+ case "pnl_percent":
+ profitPercent := 0.0
+ if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
+ costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice)
+ if err == nil {
+ profitPercent = 100 * (coin.Price/costPrice - 1)
+ }
+ }
+ colorProfit := ct.colorscheme.TableColumnChange
+ if profitPercent > 0 {
+ colorProfit = ct.colorscheme.TableColumnChangeUp
+ } else if profitPercent < 0 {
+ colorProfit = ct.colorscheme.TableColumnChangeDown
+ }
+ text := fmt.Sprintf("%.2f%%", profitPercent)
+ if coin.BuyPrice == 0.0 {
+ text = ""
+ }
+ if ct.State.hidePortfolioBalances {
+ text = HiddenBalanceChars
+ 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,
+ })
}
}
@@ -456,8 +601,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 +652,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 +660,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 +715,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,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"}
+ 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
@@ -785,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
diff --git a/cointop/sort.go b/cointop/sort.go
index 1ff5816..f45b2f7 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 "pnl":
+ return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice)
+ 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 7c9df10..cc71b24 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",
+ },
+ "pnl": {
+ Slug: "pnl",
+ Label: "[@]PNL",
+ PlainLabel: "PNL",
+ },
+ "pnl_percent": {
+ Slug: "pnl_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", "pnl", "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..cbe8547 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 <kbd>ctrl</kbd>+<kbd>s</kbd> 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
+ - `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:
+
+ ![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)?
You can run cointop with the `--hide-portfolio-balances` flag to hide portfolio balances or use the keyboard shortcut <kbd>Ctrl</kbd>+<kbd>space</kbd> on the portfolio page to toggle hide/show.
@@ -497,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.
@@ -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..5046ecf 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, 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..71a7b73 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, 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..55ffcf2 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, 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
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