diff --git a/README.md b/README.md
index e05976f..a6364e1 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ In action
- [Features](#features)
- [Installing](#install)
- [Updating](#updating)
-- [Usage](#usage)
+- [Getting started](#getting-started)
- [Shortcuts](#shortcuts)
- [Config](#config)
- [FAQ](#faq)
@@ -47,11 +47,12 @@ In action
- Fuzzy searching for finding coins
- Currency conversion
- Save and view favorite coins
+- Portfolio tracking of holdings
- Color support
- Help menu
- Offline cache
- Works on macOS, Linux, and Windows
-- It's very lightweight; can be left running for weeks
+- It's very lightweight; can be left running indefinitely
## Installing
@@ -198,7 +199,7 @@ Use the `refresh` command to update snap.
sudo snap refresh cointop --stable
```
-## Usage
+## Getting started
Just run the `cointop` command to get started:
@@ -206,6 +207,26 @@ Just run the `cointop` command to get started:
$ cointop
```
+### Navigation
+
+- Easiest way to navigate up and down is using the arrow keys ↑ and ↓
+- To go the next and previous pages, use → and ←
+- To go to the top and bottom of the page, use g and G
+- Check out the rest of [shortcut](#shortcuts) keys for vim-inspired navigation
+
+### Favorites
+
+- To toggle a coin as a favorite, press Space on the highlighted coin
+- To view all your favorite coins, press F
+- To exit out of the favorites view, press F again or q
+
+### Portfolio
+
+- To add a coin to your portfolio, press e on the highlighted coin
+- To edit the holdings of coin in your portfolio, press e on the highlighted coin
+- To view your portfolio, press P
+- To exit out of the portfolio view press, P again or q
+
## Shortcuts
List of default shortcut keys:
@@ -244,6 +265,9 @@ Key|Action
a|Sort table by *[a]vailable supply*
b|Sort table by *[b]alance*
c|Show currency convert menu
+C|Show currency convert menu
+e|Show portfolio edit holdings menu
+E|Show portfolio edit holdings menu
f|Toggle coin as favorite
F|Toggle show favorites
g|Go to first line of page (vim inspired)
@@ -304,6 +328,7 @@ defaultView = "default"
"{" = "first_chart_range"
"}" = "last_chart_range"
C = "show_currency_convert_menu"
+ E = "show_portfolio_edit_menu"
G = "move_to_page_last_row"
H = "move_to_page_visible_first_row"
L = "move_to_page_visible_last_row"
@@ -329,6 +354,7 @@ defaultView = "default"
"ctrl+r" = "refresh"
"ctrl+s" = "save"
"ctrl+u" = "page_up"
+ e = "show_portfolio_edit_menu"
end = "move_to_page_last_row"
enter = "toggle_row_chart"
esc = "quit"
@@ -414,6 +440,7 @@ Action|Description
`toggle_show_favorites`|Toggle show favorites
`toggle_portfolio`|Toggle portfolio view
`toggle_show_portfolio`|Toggle show portfolio view
+`show_portfolio_edit_menu`|Show portfolio edit holdings menu
## FAQ
@@ -476,7 +503,19 @@ Frequently asked questions:
- A: The yellow asterisk or star means that you've selected that coin to be a favorite.
-- Q: How do I view all my portfolio?
+- Q: How do I add a coin to my portfolio?
+
+ - Press e on the highlighted coin to enter holdings and add to your portfolio.
+
+- Q: How do I edit the holdings of a coin in my portfolio?
+
+ - Press e on the highlighted coin to edit the holdings.
+
+- Q: How do I remove a coin in my portfolio?
+
+ - Press e on the highlighted coin to edit the holdings and set the value to any empty string (blank value). Set it to `0` if you want to keep the coin without a value.
+
+- Q: How do I view my portfolio?
- A: Press P (shift+p) to toggle view your portfolio.
diff --git a/cointop/cointop.go b/cointop/cointop.go
index a2d4546..3a72b6d 100644
--- a/cointop/cointop.go
+++ b/cointop/cointop.go
@@ -58,20 +58,26 @@ type Cointop struct {
searchfieldviewname string
searchfieldvisible bool
// DEPRECATED: favorites by 'symbol' is deprecated because of collisions.
- favoritesbysymbol map[string]bool
- favorites map[string]bool
- filterByFavorites bool
- savemux sync.Mutex
- cache *cache.Cache
- debug bool
- helpview *gocui.View
- helpviewname string
- helpvisible bool
- currencyconversion string
- convertmenuview *gocui.View
- convertmenuviewname string
- convertmenuvisible bool
- portfolio *portfolio
+ favoritesbysymbol map[string]bool
+ favorites map[string]bool
+ filterByFavorites bool
+ savemux sync.Mutex
+ cache *cache.Cache
+ debug bool
+ helpview *gocui.View
+ helpviewname string
+ helpvisible bool
+ currencyconversion string
+ convertmenuview *gocui.View
+ convertmenuviewname string
+ convertmenuvisible bool
+ portfolio *portfolio
+ portfolioupdatemenuview *gocui.View
+ portfolioupdatemenuviewname string
+ portfolioupdatemenuvisible bool
+ inputview *gocui.View
+ inputviewname string
+ defaultView string
}
// PortfolioEntry is portfolio entry
@@ -142,6 +148,8 @@ func New() *Cointop {
"name",
"symbol",
"price",
+ "holdings",
+ "balance",
"marketcap",
"24hvolume",
"1hchange",
@@ -158,6 +166,8 @@ func New() *Cointop {
portfolio: &portfolio{
Entries: make(map[string]*portfolioEntry, 0),
},
+ portfolioupdatemenuviewname: "portfolioupdatemenu",
+ inputviewname: "input",
}
err := ct.setupConfig()
if err != nil {
diff --git a/cointop/config.go b/cointop/config.go
index a9e7cf9..e37db7e 100644
--- a/cointop/config.go
+++ b/cointop/config.go
@@ -143,18 +143,23 @@ func (ct *Cointop) configToToml() ([]byte, error) {
portfolioIfc := map[string]interface{}{}
for name := range ct.portfolio.Entries {
- entry := ct.portfolio.Entries[name]
+ entry, ok := ct.portfolio.Entries[name]
+ if !ok || entry.Coin == "" {
+ continue
+ }
var i interface{} = entry.Holdings
portfolioIfc[entry.Coin] = i
}
var currencyIfc interface{} = ct.currencyconversion
+ var defaultViewIfc interface{} = ct.defaultView
var inputs = &config{
- Shortcuts: shortcutsIfcs,
- Favorites: favoritesIfcs,
- Portfolio: portfolioIfc,
- Currency: currencyIfc,
+ Shortcuts: shortcutsIfcs,
+ Favorites: favoritesIfcs,
+ Portfolio: portfolioIfc,
+ Currency: currencyIfc,
+ DefaultView: defaultViewIfc,
}
var b bytes.Buffer
@@ -191,6 +196,7 @@ func (ct *Cointop) loadCurrencyFromConfig() error {
func (ct *Cointop) loadDefaultViewFromConfig() error {
if defaultView, ok := ct.config.DefaultView.(string); ok {
+ defaultView = strings.ToLower(defaultView)
switch defaultView {
case "portfolio":
ct.portfoliovisible = true
@@ -201,7 +207,9 @@ func (ct *Cointop) loadDefaultViewFromConfig() error {
default:
ct.portfoliovisible = false
ct.filterByFavorites = false
+ defaultView = "default"
}
+ ct.defaultView = defaultView
}
return nil
}
@@ -235,10 +243,8 @@ func (ct *Cointop) loadPortfolioFromConfig() error {
holdings = float64(holdingsInt)
}
}
- ct.portfolio.Entries[strings.ToLower(name)] = &portfolioEntry{
- Coin: name,
- Holdings: holdings,
- }
+
+ ct.setPortfolioEntry(name, holdings)
}
return nil
}
diff --git a/cointop/keybindings.go b/cointop/keybindings.go
index ce4797e..846c4ad 100644
--- a/cointop/keybindings.go
+++ b/cointop/keybindings.go
@@ -214,7 +214,7 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error {
case "move_down":
fn = ct.keyfn(ct.cursorDown)
case "previous_page":
- fn = ct.handleHkey()
+ fn = ct.handleHkey(key)
case "next_page":
fn = ct.keyfn(ct.nextPage)
case "page_down":
@@ -321,6 +321,8 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error {
fn = ct.keyfn(ct.togglePortfolio)
case "toggle_show_portfolio":
fn = ct.keyfn(ct.toggleShowPortfolio)
+ case "show_portfolio_edit_menu":
+ fn = ct.keyfn(ct.togglePortfolioUpdateMenu)
default:
fn = ct.keyfn(ct.noop)
}
@@ -340,6 +342,13 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error {
ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname)
ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname)
+ // keys to quit portfolio update menu when open
+ ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hidePortfolioUpdateMenu), ct.inputviewname)
+ ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hidePortfolioUpdateMenu), ct.inputviewname)
+
+ // keys to update portfolio holdings
+ ct.setKeybindingMod(gocui.KeyEnter, gocui.ModNone, ct.keyfn(ct.setPortfolioHoldings), ct.inputviewname)
+
// keys to quit convert menu when open
ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hideConvertMenu), ct.convertmenuviewname)
ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hideConvertMenu), ct.convertmenuviewname)
@@ -371,9 +380,9 @@ func (ct *Cointop) keyfn(fn func() error) func(g *gocui.Gui, v *gocui.View) erro
}
}
-func (ct *Cointop) handleHkey() func(g *gocui.Gui, v *gocui.View) error {
+func (ct *Cointop) handleHkey(key interface{}) func(g *gocui.Gui, v *gocui.View) error {
return func(g *gocui.Gui, v *gocui.View) error {
- if ct.portfoliovisible {
+ if k, ok := key.(rune); ok && k == 'h' && ct.portfoliovisible {
ct.sortToggle("holdings", true)
} else {
ct.prevPage()
diff --git a/cointop/layout.go b/cointop/layout.go
index e1671a2..88e87d6 100644
--- a/cointop/layout.go
+++ b/cointop/layout.go
@@ -115,6 +115,28 @@ func (ct *Cointop) layout(g *gocui.Gui) error {
ct.helpview.FgColor = gocui.ColorWhite
}
+ if v, err := g.SetView(ct.portfolioupdatemenuviewname, 1, 1, ct.maxtablewidth-2, maxY-1); err != nil {
+ if err != gocui.ErrUnknownView {
+ return err
+ }
+ ct.portfolioupdatemenuview = v
+ ct.portfolioupdatemenuview.Frame = false
+ ct.portfolioupdatemenuview.BgColor = gocui.ColorBlack
+ ct.portfolioupdatemenuview.FgColor = gocui.ColorWhite
+ }
+
+ if v, err := g.SetView(ct.inputviewname, 3, 6, 30, 8); err != nil {
+ if err != gocui.ErrUnknownView {
+ return err
+ }
+ ct.inputview = v
+ ct.inputview.Frame = true
+ ct.inputview.Editable = true
+ ct.inputview.Wrap = true
+ ct.inputview.BgColor = gocui.ColorBlack
+ ct.inputview.FgColor = gocui.ColorWhite
+ }
+
if v, err := g.SetView(ct.convertmenuviewname, 1, 1, ct.maxtablewidth-2, maxY-1); err != nil {
if err != gocui.ErrUnknownView {
return err
@@ -127,9 +149,11 @@ func (ct *Cointop) layout(g *gocui.Gui) error {
// run only once on init.
// this bit of code should be at the bottom
ct.g = g
- g.SetViewOnBottom(ct.searchfieldviewname) // hide
- g.SetViewOnBottom(ct.helpviewname) // hide
- g.SetViewOnBottom(ct.convertmenuviewname) // hide
+ g.SetViewOnBottom(ct.searchfieldviewname) // hide
+ g.SetViewOnBottom(ct.helpviewname) // hide
+ g.SetViewOnBottom(ct.convertmenuviewname) // hide
+ g.SetViewOnBottom(ct.portfolioupdatemenuviewname) // hide
+ g.SetViewOnBottom(ct.inputviewname) // hide
ct.setActiveView(ct.tableviewname)
ct.intervalFetchData()
}
@@ -147,6 +171,10 @@ func (ct *Cointop) setActiveView(v string) error {
} else if v == ct.tableviewname {
ct.g.SetViewOnTop(ct.statusbarviewname)
}
+ if v == ct.portfolioupdatemenuviewname {
+ ct.g.SetViewOnTop(ct.inputviewname)
+ ct.g.SetCurrentView(ct.inputviewname)
+ }
return nil
}
diff --git a/cointop/navigation.go b/cointop/navigation.go
index 53920ed..97f4ef7 100644
--- a/cointop/navigation.go
+++ b/cointop/navigation.go
@@ -212,6 +212,9 @@ func (ct *Cointop) navigatePageLastLine() error {
}
func (ct *Cointop) prevPage() error {
+ if ct.isFirstPage() {
+ return nil
+ }
ct.setPage(ct.page - 1)
ct.updateTable()
ct.rowChanged()
@@ -232,6 +235,10 @@ func (ct *Cointop) firstPage() error {
return nil
}
+func (ct *Cointop) isFirstPage() bool {
+ return ct.page == 0
+}
+
func (ct *Cointop) lastPage() error {
ct.page = ct.getListCount() / ct.perpage
ct.updateTable()
diff --git a/cointop/portfolio.go b/cointop/portfolio.go
index 9315bd1..6c0213e 100644
--- a/cointop/portfolio.go
+++ b/cointop/portfolio.go
@@ -1,5 +1,15 @@
package cointop
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/miguelmota/cointop/pkg/color"
+ "github.com/miguelmota/cointop/pkg/pad"
+)
+
func (ct *Cointop) togglePortfolio() error {
ct.filterByFavorites = false
ct.portfoliovisible = !ct.portfoliovisible
@@ -13,3 +23,159 @@ func (ct *Cointop) toggleShowPortfolio() error {
ct.updateTable()
return nil
}
+
+func (ct *Cointop) togglePortfolioUpdateMenu() error {
+ ct.portfolioupdatemenuvisible = !ct.portfolioupdatemenuvisible
+ if ct.portfolioupdatemenuvisible {
+ return ct.showPortfolioUpdateMenu()
+ }
+ return ct.hidePortfolioUpdateMenu()
+}
+
+func (ct *Cointop) updatePortfolioUpdateMenu() {
+ coin := ct.highlightedRowCoin()
+ exists := ct.portfolioEntryExists(coin)
+ value := strconv.FormatFloat(coin.Holdings, 'f', -1, 64)
+ 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 := color.GreenBg(fmt.Sprintf(" %s Portfolio Entry %s\n\n", mode, pad.Left("[q] close ", ct.maxtablewidth-15, " ")))
+ label := fmt.Sprintf(" Enter holdings for %s %s", color.Yellow(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.update(func() {
+ ct.portfolioupdatemenuview.Clear()
+ ct.portfolioupdatemenuview.Frame = true
+ fmt.Fprintln(ct.portfolioupdatemenuview, content)
+ fmt.Fprintln(ct.inputview, value)
+ ct.inputview.SetCursor(len(value), 0)
+ })
+}
+
+func (ct *Cointop) showPortfolioUpdateMenu() error {
+ ct.portfolioupdatemenuvisible = true
+ ct.updatePortfolioUpdateMenu()
+ ct.setActiveView(ct.portfolioupdatemenuviewname)
+ return nil
+}
+
+func (ct *Cointop) hidePortfolioUpdateMenu() error {
+ ct.portfolioupdatemenuvisible = false
+ ct.setViewOnBottom(ct.portfolioupdatemenuviewname)
+ ct.setViewOnBottom(ct.inputviewname)
+ ct.setActiveView(ct.tableviewname)
+ ct.update(func() {
+ ct.portfolioupdatemenuview.Clear()
+ ct.portfolioupdatemenuview.Frame = false
+ fmt.Fprintln(ct.portfolioupdatemenuview, "")
+
+ ct.inputview.Clear()
+ fmt.Fprintln(ct.inputview, "")
+ })
+ return nil
+}
+
+// sets portfolio entry holdings from inputed value
+func (ct *Cointop) setPortfolioHoldings() error {
+ defer ct.hidePortfolioUpdateMenu()
+ coin := ct.highlightedRowCoin()
+
+ b := make([]byte, 100)
+ n, err := ct.inputview.Read(b)
+ if n == 0 {
+ return nil
+ }
+
+ value := normalizeFloatstring(string(b))
+ shouldDelete := value == ""
+ var holdings float64
+
+ if !shouldDelete {
+ holdings, err = strconv.ParseFloat(value, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ ct.setPortfolioEntry(coin.Name, holdings)
+
+ if shouldDelete {
+ ct.removePortfolioEntry(coin.Name)
+ ct.updateTable()
+ ct.goToGlobalIndex(0)
+ } else {
+ ct.updateTable()
+ ct.goToGlobalIndex(coin.Rank - 1)
+ }
+
+ return nil
+}
+
+func (ct *Cointop) portfolioEntry(c *coin) (*portfolioEntry, bool) {
+ if c == nil {
+ return &portfolioEntry{}, true
+ }
+
+ var p *portfolioEntry
+ var isNew bool
+ var ok bool
+ key := strings.ToLower(c.Name)
+ if p, ok = ct.portfolio.Entries[key]; !ok {
+ // NOTE: if not found then try the symbol
+ key := strings.ToLower(c.Symbol)
+ if p, ok = ct.portfolio.Entries[key]; !ok {
+ p = &portfolioEntry{
+ Coin: c.Name,
+ Holdings: 0,
+ }
+ isNew = true
+ }
+ }
+
+ return p, isNew
+}
+
+func (ct *Cointop) setPortfolioEntry(coin string, holdings float64) {
+ c, _ := ct.allcoinsslugmap[strings.ToLower(coin)]
+ p, isNew := ct.portfolioEntry(c)
+ if isNew {
+ key := strings.ToLower(coin)
+ ct.portfolio.Entries[key] = &portfolioEntry{
+ Coin: coin,
+ Holdings: holdings,
+ }
+ } else {
+ p.Holdings = holdings
+ }
+}
+
+func (ct *Cointop) removePortfolioEntry(coin string) {
+ delete(ct.portfolio.Entries, strings.ToLower(coin))
+}
+
+func (ct *Cointop) portfolioEntryExists(c *coin) bool {
+ _, isNew := ct.portfolioEntry(c)
+ return !isNew
+}
+
+func (ct *Cointop) portfolioEntriesCount() int {
+ return len(ct.portfolio.Entries)
+}
+
+func normalizeFloatstring(input string) string {
+ re := regexp.MustCompile(`(\d+\.\d+|\.\d+|\d+)`)
+ result := re.FindStringSubmatch(input)
+ if len(result) > 0 {
+ return result[0]
+ }
+
+ return ""
+}
diff --git a/cointop/shortcuts.go b/cointop/shortcuts.go
index 7e6645e..52d77d0 100644
--- a/cointop/shortcuts.go
+++ b/cointop/shortcuts.go
@@ -38,6 +38,8 @@ func defaultShortcuts() map[string]string {
"b": "sort_column_balance",
"c": "show_currency_convert_menu",
"C": "show_currency_convert_menu",
+ "e": "show_portfolio_edit_menu",
+ "E": "show_portfolio_edit_menu",
"f": "toggle_favorite",
"F": "toggle_show_favorites",
"g": "move_to_page_first_row",
diff --git a/cointop/table.go b/cointop/table.go
index bc8100f..e6830ee 100644
--- a/cointop/table.go
+++ b/cointop/table.go
@@ -54,7 +54,7 @@ func (ct *Cointop) refreshTable() error {
namecolor(pad.Right(fmt.Sprintf("%.22s", name), 21, " ")),
color.White(pad.Right(fmt.Sprintf("%.6s", coin.Symbol), 5, " ")),
colorprice(fmt.Sprintf("%13s", humanize.Commaf(coin.Price))),
- color.White(fmt.Sprintf("%15s", humanize.Commaf(coin.Holdings))),
+ color.White(fmt.Sprintf("%15s", strconv.FormatFloat(coin.Holdings, 'f', -1, 64))),
colorbalance(fmt.Sprintf("%15s", humanize.Commaf(coin.Balance))),
color24h(fmt.Sprintf("%8.2f%%", coin.PercentChange24H)),
color.White(pad.Right(fmt.Sprintf("%17s", lastUpdated), 80, " ")),
@@ -170,24 +170,15 @@ func (ct *Cointop) updateTable() error {
if ct.portfoliovisible {
for i := range ct.allcoins {
- if len(ct.portfolio.Entries) == 0 {
+ if ct.portfolioEntriesCount() == 0 {
break
}
coin := ct.allcoins[i]
- var p *portfolioEntry
- var ok bool
- if p, ok = ct.portfolio.Entries[strings.ToLower(coin.Name)]; !ok {
- // NOTE: if not found then try the symbol
- if p, ok = ct.portfolio.Entries[strings.ToLower(coin.Symbol)]; !ok {
- continue
- }
+ p, isNew := ct.portfolioEntry(coin)
+ if isNew {
+ continue
}
- holdingsstr := fmt.Sprintf("%.2f", p.Holdings)
- if ct.currencyconversion == "ETH" || ct.currencyconversion == "BTC" {
- holdingsstr = fmt.Sprintf("%.5f", p.Holdings)
- }
- holdings, _ := strconv.ParseFloat(holdingsstr, 64)
- coin.Holdings = holdings
+ coin.Holdings = p.Holdings
balance := coin.Price * p.Holdings
balancestr := fmt.Sprintf("%.2f", balance)
@@ -298,6 +289,17 @@ func (ct *Cointop) allCoins() []*coin {
return list
}
+ if ct.portfoliovisible {
+ var list []*coin
+ for i := range ct.allcoins {
+ coin := ct.allcoins[i]
+ if ct.portfolioEntryExists(coin) {
+ list = append(list, coin)
+ }
+ }
+ return list
+ }
+
return ct.allcoins
}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 934ff8f..7fe56a0 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -11,4 +11,5 @@ func NewCMC() Interface {
// NewCC new CryptoCompare API
func NewCC() {
+ // TODO
}