From 83e58ecb15a48fc6047a4619edc82c38f43d6009 Mon Sep 17 00:00:00 2001 From: MendelGusmao Date: Wed, 25 Dec 2019 03:14:28 -0300 Subject: [PATCH] Implement expression-based filtering --- README.md | 21 ++++++++++++++++ cmd/mop/main.go | 7 ++++++ filter.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ layout.go | 9 +++++++ line_editor.go | 22 +++++++++++++++-- profile.go | 40 ++++++++++++++++++++++++------ sorter.go | 4 +++ 7 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 filter.go diff --git a/README.md b/README.md index 6f66f31..74634c7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ following keyboard commands: - Remove stocks from the list. o Change column sort order. g Group stocks by advancing/declining issues. + f Set a filtering expression. + F Unset a filtering expression. ? Display help screen. esc Quit mop. @@ -33,6 +35,25 @@ When prompted please enter comma-delimited list of stock tickers. The list and other settings are stored in ``.moprc`` file in your ``$HOME`` directory. +#### Filtering +Mop has an realtime expression-based filtering engine that is very easy to use. + +At the main screen, press `f` and a prompt will appear. Now you can +a expression that uses the stock properties. + +Example: + +```last <= 5``` + +This expression will make Mop show only stocks whose `last` values are less than $5. + +The available properties are: `last`, `change`, `changePercent`, `open`, `low`, `high`, `low52`, `high52`, `volume`, `avgVolume`, `pe`, `peX`, `dividend`, `yield`, `mktCap`, `mktCapX` and `advancing`. + +The expression **must** return a boolean value, otherwise it will fail. + +For detailed information about the syntax, please refer to [Knetic/govaluate#what-operators-and-types-does-this-support](https://github.com/Knetic/govaluate#what-operators-and-types-does-this-support). + +To clear the filter, press `Shift+F`. ### Contributing ### diff --git a/cmd/mop/main.go b/cmd/mop/main.go index b2f80f2..3c5ff72 100644 --- a/cmd/mop/main.go +++ b/cmd/mop/main.go @@ -24,6 +24,8 @@ NO WARRANTIES OF ANY KIND WHATSOEVER. SEE THE LICENSE FILE FOR DETAILS. + Add stocks to the list. - Remove stocks from the list. ? Display this help screen. + f Set filtering expression. + F Unset filtering expression. g Group stocks by advancing/declining issues. o Change column sort order. p Pause market data and stock updates. @@ -69,6 +71,11 @@ loop: } else if event.Ch == '+' || event.Ch == '-' { lineEditor = mop.NewLineEditor(screen, quotes) lineEditor.Prompt(event.Ch) + } else if event.Ch == 'f' { + lineEditor = mop.NewLineEditor(screen, quotes) + lineEditor.Prompt(event.Ch) + } else if event.Ch == 'F' { + profile.SetFilter("") } else if event.Ch == 'o' || event.Ch == 'O' { columnEditor = mop.NewColumnEditor(screen, quotes) } else if event.Ch == 'g' || event.Ch == 'G' { diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..9c30615 --- /dev/null +++ b/filter.go @@ -0,0 +1,66 @@ +// Copyright (c) 2013-2019 by Michael Dvorkin and contributors. All Rights Reserved. +// Use of this source code is governed by a MIT-style license that can +// be found in the LICENSE file. + +package mop + +// Filter gets called to sort stock quotes by one of the columns. The +// setup is rather lengthy; there should probably be more concise way +// that uses reflection and avoids hardcoding the column names. +type Filter struct { + profile *Profile // Pointer to where we store sort column and order. +} + +// Returns new Filter struct. +func NewFilter(profile *Profile) *Filter { + return &Filter{ + profile: profile, + } +} + +// Apply builds a list of sort interface based on current sort +// order, then calls sort.Sort to do the actual job. +func (filter *Filter) Apply(stocks []Stock) []Stock { + var filteredStocks []Stock + + for _, stock := range stocks { + var values = map[string]interface{}{ + "ticker": strings.TrimSpace(stock.Ticker), + "last": m(stock.LastTrade), + "change": c(stock.Change), + "changePercent": c(stock.ChangePct), + "open": m(stock.Open), + "low": m(stock.Low), + "high": m(stock.High), + "low52": m(stock.Low52), + "high52": m(stock.High52), + "volume": m(stock.Volume), + "avgVolume": m(stock.AvgVolume), + "pe": m(stock.PeRatio), + "peX": m(stock.PeRatioX), + "dividend": m(stock.Dividend), + "yield": m(stock.Yield), + "mktCap": m(stock.MarketCap), + "mktCapX": m(stock.MarketCapX), + "advancing": stock.Advancing, + } + + result, err := filter.profile.filterExpression.Evaluate(values) + + if err != nil { + panic(err) + } + + truthy, ok := result.(bool) + + if !ok { + panic("Expression `" + filter.profile.Filter + "` should return a boolean value") + } + + if truthy { + filteredStocks = append(filteredStocks, stock) + } + } + + return filteredStocks +} diff --git a/layout.go b/layout.go index 9002eb2..e7287d7 100644 --- a/layout.go +++ b/layout.go @@ -28,6 +28,7 @@ type Column struct { type Layout struct { columns []Column // List of stock quotes columns. sorter *Sorter // Pointer to sorting receiver. + filter *Filter // Pointer to filtering receiver. regex *regexp.Regexp // Pointer to regular expression to align decimal points. marketTemplate *template.Template // Pointer to template to format market data. quotesTemplate *template.Template // Pointer to template to format the list of stock quotes. @@ -153,6 +154,14 @@ func (layout *Layout) prettify(quotes *Quotes) []Stock { } profile := quotes.profile + + if profile.filterExpression != nil { + if layout.filter == nil { // Initialize filter on first invocation. + layout.filter = NewFilter(profile) + } + pretty = layout.filter.Apply(pretty) + } + if layout.sorter == nil { // Initialize sorter on first invocation. layout.sorter = NewSorter(profile) } diff --git a/line_editor.go b/line_editor.go index 9d66af0..35e803d 100644 --- a/line_editor.go +++ b/line_editor.go @@ -5,9 +5,10 @@ package mop import ( - `github.com/nsf/termbox-go` `regexp` `strings` + + `github.com/nsf/termbox-go` ) // LineEditor kicks in when user presses '+' or '-' to add or delete stock @@ -37,7 +38,16 @@ func NewLineEditor(screen *Screen, quotes *Quotes) *LineEditor { // are simply ignored. The prompt is displayed on the 3rd line (between the market // data and the stock quotes). func (editor *LineEditor) Prompt(command rune) *LineEditor { - prompts := map[rune]string{'+': `Add tickers: `, '-': `Remove tickers: `} + filterPrompt := `Set filter: ` + + if filter := editor.quotes.profile.Filter; len(filter) > 0 { + filterPrompt = `Set filter (` + filter + `): ` + } + + prompts := map[rune]string{ + '+': `Add tickers: `, '-': `Remove tickers: `, + 'f': filterPrompt, + } if prompt, ok := prompts[command]; ok { editor.prompt = prompt editor.command = command @@ -184,6 +194,14 @@ func (editor *LineEditor) execute() *LineEditor { } } } + case 'f': + if len(editor.input) == 0 { + editor.input = editor.quotes.profile.Filter + } + + editor.quotes.profile.SetFilter(editor.input) + case 'F': + editor.quotes.profile.SetFilter("") } return editor diff --git a/profile.go b/profile.go index c8bb056..6bb3c1e 100644 --- a/profile.go +++ b/profile.go @@ -8,20 +8,24 @@ import ( "encoding/json" "io/ioutil" "sort" + + "github.com/Knetic/govaluate" ) // Profile manages Mop program settings as defined by user (ex. list of // stock tickers). The settings are serialized using JSON and saved in // the ~/.moprc file. type Profile struct { - Tickers []string // List of stock tickers to display. - MarketRefresh int // Time interval to refresh market data. - QuotesRefresh int // Time interval to refresh stock quotes. - SortColumn int // Column number by which we sort stock quotes. - Ascending bool // True when sort order is ascending. - Grouped bool // True when stocks are grouped by advancing/declining. - selectedColumn int // Stores selected column number when the column editor is active. - filename string // Path to the file in which the configuration is stored + Tickers []string // List of stock tickers to display. + MarketRefresh int // Time interval to refresh market data. + QuotesRefresh int // Time interval to refresh stock quotes. + SortColumn int // Column number by which we sort stock quotes. + Ascending bool // True when sort order is ascending. + Grouped bool // True when stocks are grouped by advancing/declining. + Filter string // Filter in human form + filterExpression *govaluate.EvaluableExpression // The filter as a govaluate expression + selectedColumn int // Stores selected column number when the column editor is active. + filename string // Path to the file in which the configuration is stored } // Creates the profile and attempts to load the settings from ~/.moprc file. @@ -36,9 +40,11 @@ func NewProfile(filename string) *Profile { profile.Tickers = []string{`AAPL`, `C`, `GOOG`, `IBM`, `KO`, `ORCL`, `V`} profile.SortColumn = 0 // Stock quotes are sorted by ticker name. profile.Ascending = true // A to Z. + profile.Filter = "" profile.Save() } else { json.Unmarshal(data, profile) + profile.SetFilter(profile.Filter) } profile.selectedColumn = -1 @@ -120,3 +126,21 @@ func (profile *Profile) Regroup() error { profile.Grouped = !profile.Grouped return profile.Save() } + +// SetFilter creates a govaluate.EvaluableExpression. +func (profile *Profile) SetFilter(filter string) { + if len(filter) > 0 { + var err error + profile.filterExpression, err = govaluate.NewEvaluableExpression(filter) + + if err != nil { + panic(err) + } + + } else if len(filter) == 0 && profile.filterExpression != nil { + profile.filterExpression = nil + } + + profile.Filter = filter + profile.Save() +} diff --git a/sorter.go b/sorter.go index d3d7e80..5dc1290 100644 --- a/sorter.go +++ b/sorter.go @@ -208,6 +208,10 @@ func c(str string) float32 { // When sorting by the market value we must first convert 42B etc. notations // to proper numeric values. func m(str string) float32 { + if len(str) == 0 { + return 0 + } + multiplier := 1.0 switch str[len(str)-1 : len(str)] { // Check the last character.