Set up price alerts config

pull/94/head
Miguel Mota 3 years ago
parent 060286af9c
commit cba53fe5d4

@ -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)

@ -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
}

@ -14,3 +14,6 @@ const CoinsView = "coins"
// FavoritesView is favorites table constant
const FavoritesView = "favorites"
// AlertsView is alerts table constant
const AlertsView = "alerts"

@ -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",

@ -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":

@ -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
}

@ -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()
}

@ -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()
}

@ -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()
}

@ -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}

Loading…
Cancel
Save