diff --git a/cointop/cointop.go b/cointop/cointop.go index f4421c4..b01c896 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -75,6 +75,7 @@ type State struct { tableOffsetX int onlyTable bool chartHeight int + priceAlerts *PriceAlerts } // Cointop cointop @@ -118,6 +119,23 @@ type Portfolio struct { Entries map[string]*PortfolioEntry } +// PriceAlert is price alert structure +type PriceAlert struct { + ID string + CoinName string + TargetPrice float64 + Direction string + Frequency string + CreatedAt string + Expired bool +} + +// PriceAlerts is price alerts structure +type PriceAlerts struct { + Entries []*PriceAlert + SoundEnabled bool +} + // Config config options type Config struct { APIChoice string @@ -213,6 +231,10 @@ func NewCointop(config *Config) (*Cointop, error) { }, chartHeight: 10, tableOffsetX: 0, + priceAlerts: &PriceAlerts{ + Entries: make([]*PriceAlert, 0), + SoundEnabled: true, + }, }, TableColumnOrder: TableColumnOrder(), Views: &Views{ @@ -422,6 +444,7 @@ func (ct *Cointop) Run() error { return fmt.Errorf("keybindings: %v", err) } + go ct.PriceAlertWatcher() ct.State.running = true if err := ui.MainLoop(); err != nil && err != gocui.ErrQuit { return fmt.Errorf("main loop: %v", err) diff --git a/cointop/config.go b/cointop/config.go index 6e43676..f3736f3 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -2,10 +2,13 @@ package cointop import ( "bytes" + "errors" "fmt" "io/ioutil" "os" "path/filepath" + "sort" + "strconv" "strings" "time" @@ -15,6 +18,9 @@ import ( var fileperm = os.FileMode(0644) +// ErrInvalidPriceAlert is error for invalid price alert value +var ErrInvalidPriceAlert = errors.New("Invalid price alert value") + // NOTE: this is to support previous default config filepaths var possibleConfigPaths = []string{ ":PREFERRED_CONFIG_HOME:/cointop/config.toml", @@ -28,6 +34,7 @@ type config struct { Shortcuts map[string]interface{} `toml:"shortcuts"` Favorites map[string][]interface{} `toml:"favorites"` Portfolio map[string]interface{} `toml:"portfolio"` + PriceAlerts map[string]interface{} `toml:"price_alerts"` Currency interface{} `toml:"currency"` DefaultView interface{} `toml:"default_view"` CoinMarketCap map[string]interface{} `toml:"coinmarketcap"` @@ -73,6 +80,9 @@ func (ct *Cointop) SetupConfig() error { if err := ct.loadCacheDirFromConfig(); err != nil { return err } + if err := ct.loadPriceAlertsFromConfig(); err != nil { + return err + } if err := ct.loadPortfolioFromConfig(); err != nil { return err } @@ -193,18 +203,22 @@ func (ct *Cointop) configToToml() ([]byte, error) { shortcutsIfcs[k] = i } - var favorites []interface{} + var favoritesIfc []interface{} for k, ok := range ct.State.favorites { if ok { var i interface{} = k - favorites = append(favorites, i) + favoritesIfc = append(favoritesIfc, i) } } - var favoritesBySymbol []interface{} - favoritesIfcs := map[string][]interface{}{ + sort.Slice(favoritesIfc, func(i, j int) bool { + return favoritesIfc[i].(string) < favoritesIfc[j].(string) + }) + + var favoritesBySymbolIfc []interface{} + favoritesMapIfc := map[string][]interface{}{ // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility. - "symbols": favoritesBySymbol, - "names": favorites, + "symbols": favoritesBySymbolIfc, + "names": favoritesIfc, } portfolioIfc := map[string]interface{}{} @@ -228,16 +242,32 @@ func (ct *Cointop) configToToml() ([]byte, error) { } var apiChoiceIfc interface{} = ct.apiChoice + + priceAlertsIfc := make([]interface{}, len(ct.State.priceAlerts.Entries)) + for i, priceAlert := range ct.State.priceAlerts.Entries { + priceAlertsIfc[i] = []string{ + priceAlert.CoinName, + priceAlert.Direction, + strconv.FormatFloat(priceAlert.TargetPrice, 'f', -1, 64), + priceAlert.Frequency, + } + } + priceAlertsMapIfc := map[string]interface{}{ + "alerts": priceAlertsIfc, + "sound": ct.State.priceAlerts.SoundEnabled, + } + var inputs = &config{ API: apiChoiceIfc, Colorscheme: colorschemeIfc, CoinMarketCap: cmcIfc, Currency: currencyIfc, DefaultView: defaultViewIfc, - Favorites: favoritesIfcs, + Favorites: favoritesMapIfc, RefreshRate: refreshRateIfc, Shortcuts: shortcutsIfcs, Portfolio: portfolioIfc, + PriceAlerts: priceAlertsMapIfc, CacheDir: cacheDirIfc, } @@ -429,3 +459,67 @@ func (ct *Cointop) loadPortfolioFromConfig() error { return nil } + +// LoadPriceAlertsFromConfig loads price alerts from config file to struct +func (ct *Cointop) loadPriceAlertsFromConfig() error { + ct.debuglog("loadPriceAlertsFromConfig()") + priceAlertsIfc, ok := ct.config.PriceAlerts["alerts"] + if !ok { + return nil + } + priceAlertsSliceIfc, ok := priceAlertsIfc.([]interface{}) + if !ok { + return nil + } + for _, priceAlertIfc := range priceAlertsSliceIfc { + priceAlert, ok := priceAlertIfc.([]interface{}) + if !ok { + return ErrInvalidPriceAlert + } + coinName, ok := priceAlert[0].(string) + if !ok { + return ErrInvalidPriceAlert + } + direction, ok := priceAlert[1].(string) + if !ok { + return ErrInvalidPriceAlert + } + if _, ok := PriceAlertDirectionsMap[direction]; !ok { + return ErrInvalidPriceAlert + } + targetPriceStr, ok := priceAlert[2].(string) + if !ok { + return ErrInvalidPriceAlert + } + targetPrice, err := strconv.ParseFloat(targetPriceStr, 64) + if err != nil { + return err + } + frequency, ok := priceAlert[3].(string) + if !ok { + return ErrInvalidPriceAlert + } + if _, ok := PriceAlertFrequencyMap[frequency]; !ok { + return ErrInvalidPriceAlert + } + id := strings.ToLower(fmt.Sprintf("%s_%s_%v_%s", coinName, direction, targetPrice, frequency)) + entry := &PriceAlert{ + ID: id, + CoinName: coinName, + Direction: direction, + TargetPrice: targetPrice, + Frequency: frequency, + } + ct.State.priceAlerts.Entries = append(ct.State.priceAlerts.Entries, entry) + } + soundIfc, ok := ct.config.PriceAlerts["sound"] + if ok { + enabled, ok := soundIfc.(bool) + if !ok { + return ErrInvalidPriceAlert + } + ct.State.priceAlerts.SoundEnabled = enabled + } + + return nil +} diff --git a/cointop/constants.go b/cointop/constants.go index 2293d73..c7fb6f3 100644 --- a/cointop/constants.go +++ b/cointop/constants.go @@ -14,3 +14,6 @@ const CoinsView = "coins" // FavoritesView is favorites table constant const FavoritesView = "favorites" + +// AlertsView is alerts table constant +const AlertsView = "alerts" diff --git a/cointop/default_shortcuts.go b/cointop/default_shortcuts.go index 5ee8a35..75c5341 100644 --- a/cointop/default_shortcuts.go +++ b/cointop/default_shortcuts.go @@ -44,6 +44,7 @@ func DefaultShortcuts() map[string]string { "C": "show_currency_convert_menu", "e": "show_portfolio_edit_menu", "E": "show_portfolio_edit_menu", + "A": "toggle_alerts", "f": "toggle_favorite", "F": "toggle_show_favorites", "g": "move_to_page_first_row", diff --git a/cointop/keybindings.go b/cointop/keybindings.go index dcca762..80765d0 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -306,6 +306,8 @@ func (ct *Cointop) Keybindings(g *gocui.Gui) error { case "open_search": fn = ct.Keyfn(ct.openSearch) view = "" + case "toggle_alerts": + fn = ct.Keyfn(ct.ToggleAlerts) case "toggle_favorite": fn = ct.Keyfn(ct.ToggleFavorite) case "toggle_favorites": diff --git a/cointop/price_alerts.go b/cointop/price_alerts.go new file mode 100644 index 0000000..cde98d7 --- /dev/null +++ b/cointop/price_alerts.go @@ -0,0 +1,198 @@ +package cointop + +import ( + "fmt" + "log" + "time" + + "github.com/miguelmota/cointop/pkg/humanize" + "github.com/miguelmota/cointop/pkg/notifier" + "github.com/miguelmota/cointop/pkg/table" +) + +// GetAlertsTableHeaders returns the alerts table headers +func (ct *Cointop) GetAlertsTableHeaders() []string { + return []string{ + "name", + "symbol", + "targetprice", //>600 + "price", + "frequency", + } +} + +var gt = ">" +var gte = "≥" +var lte = "≤" +var lt = "<" +var eq = "=" + +// PriceAlertDirectionsMap is map of valid price alert direction symbols +var PriceAlertDirectionsMap = map[string]bool{ + ">": true, + "<": true, + ">=": true, + "<=": true, + "=": true, +} + +// PriceAlertFrequencyMap is map of valid price alert frequency values +var PriceAlertFrequencyMap = map[string]bool{ + "once": true, + "reoccurring": true, +} + +// GetAlertsTable returns the table for displaying alerts +func (ct *Cointop) GetAlertsTable() *table.Table { + maxX := ct.width() + t := table.NewTable().SetWidth(maxX) + + for _, entry := range ct.State.priceAlerts.Entries { + ifc, ok := ct.State.allCoinsSlugMap.Load(entry.CoinName) + if !ok { + continue + } + coin, ok := ifc.(*Coin) + if !ok { + continue + } + name := TruncateString(entry.CoinName, 20) + symbol := TruncateString(coin.Symbol, 6) + namecolor := ct.colorscheme.TableRow + frequency := entry.Frequency + targetPrice := fmt.Sprintf("%s%v", gte, entry.TargetPrice) + + t.AddRowCells( + &table.RowCell{ + LeftMargin: 1, + Width: 22, + LeftAlign: true, + Color: namecolor, + Text: name, + }, + &table.RowCell{ + LeftMargin: 1, + Width: 10, + LeftAlign: true, + Color: ct.colorscheme.TableRow, + Text: symbol, + }, + &table.RowCell{ + LeftMargin: 1, + Width: 16, + LeftAlign: false, + Color: ct.colorscheme.TableRow, + Text: targetPrice, + }, + &table.RowCell{ + LeftMargin: 1, + Width: 11, + LeftAlign: false, + Color: ct.colorscheme.TableRow, + Text: humanize.Commaf(coin.Price), + }, + &table.RowCell{ + LeftMargin: 2, + Width: 11, + LeftAlign: true, + Color: ct.colorscheme.TableRow, + Text: frequency, + }, + ) + } + + return t +} + +// ToggleAlerts toggles the alerts view +func (ct *Cointop) ToggleAlerts() error { + ct.debuglog("toggleAlerts()") + ct.ToggleSelectedView(AlertsView) + go ct.UpdateTable() + return nil +} + +// IsAlertsVisible returns true if alerts view is visible +func (ct *Cointop) IsAlertsVisible() bool { + return ct.State.selectedView == AlertsView +} + +// PriceAlertWatcher starts the price alert watcher +func (ct *Cointop) PriceAlertWatcher() { + ct.debuglog("priceAlertWatcher()") + alerts := ct.State.priceAlerts.Entries + ticker := time.NewTicker(2 * time.Second) + for { + select { + case <-ticker.C: + for _, alert := range alerts { + err := ct.CheckPriceAlert(alert) + if err != nil { + log.Fatal(err) + } + } + } + } +} + +// CheckPriceAlert checks the price alert +func (ct *Cointop) CheckPriceAlert(alert *PriceAlert) error { + ct.debuglog("checkPriceAlert()") + if alert.Expired { + return nil + } + + cacheKey := ct.CacheKey("priceAlerts") + var cachedEntries []*PriceAlert + ct.filecache.Get(cacheKey, &cachedEntries) + for _, cachedEntry := range cachedEntries { + if cachedEntry.ID == alert.ID { + alert.Expired = cachedEntry.Expired + if alert.Expired { + return nil + } + } + } + + coinIfc, _ := ct.State.allCoinsSlugMap.Load(alert.CoinName) + coin, ok := coinIfc.(*Coin) + if !ok { + return nil + } + var msg string + title := "Cointop Alert" + priceStr := fmt.Sprintf("$%s", humanize.Commaf(alert.TargetPrice)) + if alert.Direction == ">" { + if coin.Price > alert.TargetPrice { + msg = fmt.Sprintf("%s price is greater than %v", alert.CoinName, priceStr) + } + } else if alert.Direction == ">=" { + if coin.Price >= alert.TargetPrice { + msg = fmt.Sprintf("%s price is greater than or equal to %v", alert.CoinName, priceStr) + } + } else if alert.Direction == "<" { + if coin.Price < alert.TargetPrice { + msg = fmt.Sprintf("%s price is less than %v", alert.CoinName, priceStr) + } + } else if alert.Direction == "<=" { + if coin.Price <= alert.TargetPrice { + msg = fmt.Sprintf("%s price is less than or equal to %v", alert.CoinName, priceStr) + } + } else if alert.Direction == "=" { + if coin.Price == alert.TargetPrice { + msg = fmt.Sprintf("%s price is equal to %v", alert.CoinName, priceStr) + } + } + + if msg != "" { + if ct.State.priceAlerts.SoundEnabled { + notifier.NotifyWithSound(title, msg) + } else { + notifier.Notify(title, msg) + } + + alert.Expired = true + ct.filecache.Set(cacheKey, ct.State.priceAlerts.Entries, 87600*time.Hour) + } + return nil +} diff --git a/cointop/quit.go b/cointop/quit.go index cc72a4b..fd8abf0 100644 --- a/cointop/quit.go +++ b/cointop/quit.go @@ -14,11 +14,7 @@ func (ct *Cointop) Quit() error { // QuitView exists the current view func (ct *Cointop) QuitView() error { ct.debuglog("quitView()") - if ct.IsPortfolioVisible() { - ct.SetSelectedView(CoinsView) - return ct.UpdateTable() - } - if ct.IsFavoritesVisible() { + if ct.State.selectedView != CoinsView { ct.SetSelectedView(CoinsView) return ct.UpdateTable() } diff --git a/cointop/table.go b/cointop/table.go index 7cacfe3..3de4fe5 100644 --- a/cointop/table.go +++ b/cointop/table.go @@ -44,9 +44,12 @@ const dots = "..." func (ct *Cointop) RefreshTable() error { ct.debuglog("refreshTable()") - if ct.IsPortfolioVisible() { + switch ct.State.selectedView { + case PortfolioView: ct.table = ct.GetPortfolioTable() - } else { + case AlertsView: + ct.table = ct.GetAlertsTable() + default: ct.table = ct.GetCoinsTable() } diff --git a/cointop/table_header.go b/cointop/table_header.go index 586c63b..758b705 100644 --- a/cointop/table_header.go +++ b/cointop/table_header.go @@ -19,7 +19,6 @@ func NewTableHeaderView() *TableHeaderView { // UpdateTableHeader renders the table header func (ct *Cointop) UpdateTableHeader() error { ct.debuglog("UpdateTableHeader()") - var cols []string type t struct { colorfn func(a ...interface{}) string @@ -34,7 +33,9 @@ func (ct *Cointop) UpdateTableHeader() error { "rank": {baseColor, "[r]ank", 0, 1, " "}, "name": {baseColor, "[n]ame", 0, 11, " "}, "symbol": {baseColor, "[s]ymbol", 4, 0, " "}, + "targetprice": {baseColor, "[t]arget price", 2, 0, " "}, "price": {baseColor, "[p]rice", 2, 0, " "}, + "frequency": {baseColor, "frequency", 1, 0, " "}, "holdings": {baseColor, "[h]oldings", 5, 0, " "}, "balance": {baseColor, "[b]alance", 5, 0, " "}, "marketcap": {baseColor, "[m]arket cap", 5, 0, " "}, @@ -60,9 +61,13 @@ func (ct *Cointop) UpdateTableHeader() error { } } - if ct.IsPortfolioVisible() { + var cols []string + switch ct.State.selectedView { + case PortfolioView: cols = ct.GetPortfolioTableHeaders() - } else { + case AlertsView: + cols = ct.GetAlertsTableHeaders() + default: cols = ct.GetCoinsTableHeaders() } diff --git a/pkg/api/impl/coingecko/coingecko.go b/pkg/api/impl/coingecko/coingecko.go index 4aafe43..33aa12b 100644 --- a/pkg/api/impl/coingecko/coingecko.go +++ b/pkg/api/impl/coingecko/coingecko.go @@ -47,7 +47,7 @@ func (s *Service) Ping() error { func (s *Service) getPaginatedCoinData(convert string, offset int, names []string) ([]apitypes.Coin, error) { var ret []apitypes.Coin - page := offset + page := offset + 1 // page starts at 1 sparkline := false pcp := geckoTypes.PriceChangePercentageObject priceChangePercentage := []string{pcp.PCP1h, pcp.PCP24h, pcp.PCP7d}