diff --git a/cmd/mop/main.go b/cmd/mop/main.go index 00eb8bf..2418a52 100644 --- a/cmd/mop/main.go +++ b/cmd/mop/main.go @@ -138,10 +138,10 @@ func main() { profileName := flag.String("profile", path.Join(usr.HomeDir, defaultProfile), "path to profile") flag.Parse() - screen := mop.NewScreen() + profile := mop.NewProfile(*profileName) + screen := mop.NewScreen(profile) defer screen.Close() - profile := mop.NewProfile(*profileName) mainLoop(screen, profile) - profile.Save() + profile.Save() } diff --git a/cnn_market.go b/cnn_market.go index f7d0648..40f1df9 100644 --- a/cnn_market.go +++ b/cnn_market.go @@ -108,7 +108,7 @@ func (market *Market) Fetch() (self *Market) { return market.extract(market.trim(body)) } -// Ok returns two values: 1) boolean indicating whether the error has occured, +// Ok returns two values: 1) boolean indicating whether the error has occurred, // and 2) the error text itself. func (market *Market) Ok() (bool, string) { return market.errors == ``, market.errors @@ -156,16 +156,16 @@ func (market *Market) extract(snippet []byte) *Market { market.Yield[`change`] = matches[11] market.Oil[`latest`] = matches[12] - market.Oil[`change`] = matches[13] + market.Oil[`change`] = matches[13] + `%` market.Yen[`latest`] = matches[14] - market.Yen[`change`] = matches[15] + market.Yen[`change`] = matches[15] + `%` market.Euro[`latest`] = matches[16] - market.Euro[`change`] = matches[17] + market.Euro[`change`] = matches[17] + `%` market.Gold[`latest`] = matches[18] - market.Gold[`change`] = matches[19] + market.Gold[`change`] = matches[19] + `%` market.Tokyo[`change`] = matches[20] market.Tokyo[`latest`] = matches[21] diff --git a/filter.go b/filter.go index 68ae446..0cd77f5 100644 --- a/filter.go +++ b/filter.go @@ -63,7 +63,7 @@ func (filter *Filter) Apply(stocks []Stock) []Stock { values["avgVolume"] = stringToNumber(stock.AvgVolume) values["pe"] = stringToNumber(stock.PeRatio) values["peX"] = stringToNumber(stock.PeRatioX) - values["advancing"] = stock.Advancing // Remains bool. + values["direction"] = stock.Direction // Remains int. result, err := filter.profile.filterExpression.Evaluate(values) diff --git a/layout.go b/layout.go index e7549b4..7d495ae 100644 --- a/layout.go +++ b/layout.go @@ -9,6 +9,7 @@ import ( "fmt" "reflect" "regexp" + "strconv" "strings" "text/template" "time" @@ -82,7 +83,7 @@ func (layout *Layout) Market(market *Market) string { highlight(market.Dow, market.Sp500, market.Nasdaq, market.Tokyo, market.HongKong, market.London, market.Frankfurt, - market.Yield, market.Oil, market.Euro, market.Gold) + market.Yield, market.Oil, market.Euro, market.Yen, market.Gold) buffer := new(bytes.Buffer) layout.marketTemplate.Execute(buffer, market) @@ -146,7 +147,7 @@ func (layout *Layout) prettify(quotes *Quotes) []Stock { // Iterate over the list of stocks and properly format all its columns. // for i, stock := range quotes.stocks { - pretty[i].Advancing = stock.Advancing + pretty[i].Direction = stock.Direction // // Iterate over the list of stock columns. For each column name: // - Get current column value. @@ -167,7 +168,7 @@ func (layout *Layout) prettify(quotes *Quotes) []Stock { profile := quotes.profile - if profile.Filter != ""{ // Fix for blank display if invalid filter expression was cleared. + if profile.Filter != "" { // Fix for blank display if invalid filter expression was cleared. if profile.filterExpression != nil { if layout.filter == nil { // Initialize filter on first invocation. layout.filter = NewFilter(profile) @@ -208,21 +209,21 @@ func (layout *Layout) pad(str string, width int) string { //----------------------------------------------------------------------------- func buildMarketTemplate() *template.Template { - markup := `Dow {{.Dow.change}} ({{.Dow.percent}}) at {{.Dow.latest}} S&P 500 {{.Sp500.change}} ({{.Sp500.percent}}) at {{.Sp500.latest}} NASDAQ {{.Nasdaq.change}} ({{.Nasdaq.percent}}) at {{.Nasdaq.latest}} -Tokyo {{.Tokyo.change}} ({{.Tokyo.percent}}) at {{.Tokyo.latest}} HK {{.HongKong.change}} ({{.HongKong.percent}}) at {{.HongKong.latest}} London {{.London.change}} ({{.London.percent}}) at {{.London.latest}} Frankfurt {{.Frankfurt.change}} ({{.Frankfurt.percent}}) at {{.Frankfurt.latest}} {{if .IsClosed}}U.S. markets closed{{end}} -10-Year Yield {{.Yield.latest}}% ({{.Yield.change}}) Euro ${{.Euro.latest}} ({{.Euro.change}}%) Yen ¥{{.Yen.latest}} ({{.Yen.change}}%) Oil ${{.Oil.latest}} ({{.Oil.change}}%) Gold ${{.Gold.latest}} ({{.Gold.change}}%)` + markup := `Dow {{.Dow.change}} ({{.Dow.percent}}) at {{.Dow.latest}} S&P 500 {{.Sp500.change}} ({{.Sp500.percent}}) at {{.Sp500.latest}} NASDAQ {{.Nasdaq.change}} ({{.Nasdaq.percent}}) at {{.Nasdaq.latest}} +Tokyo {{.Tokyo.change}} ({{.Tokyo.percent}}) at {{.Tokyo.latest}} HK {{.HongKong.change}} ({{.HongKong.percent}}) at {{.HongKong.latest}} London {{.London.change}} ({{.London.percent}}) at {{.London.latest}} Frankfurt {{.Frankfurt.change}} ({{.Frankfurt.percent}}) at {{.Frankfurt.latest}} {{if .IsClosed}}U.S. markets closed{{end}} +10-Year Yield {{.Yield.latest}} ({{.Yield.change}}) Euro ${{.Euro.latest}} ({{.Euro.change}}) Yen ¥{{.Yen.latest}} ({{.Yen.change}}) Oil ${{.Oil.latest}} ({{.Oil.change}}) Gold ${{.Gold.latest}} ({{.Gold.change}})` return template.Must(template.New(`market`).Parse(markup)) } //----------------------------------------------------------------------------- func buildQuotesTemplate() *template.Template { - markup := `{{.Now}} + markup := ` -{{.Header}} -{{range.Stocks}}{{if .Advancing}}{{end}}{{.Ticker}}{{.LastTrade}}{{.Change}}{{.ChangePct}}{{.Open}}{{.Low}}{{.High}}{{.Low52}}{{.High52}}{{.Volume}}{{.AvgVolume}}{{.PeRatio}}{{.Dividend}}{{.Yield}}{{.MarketCap}}{{.PreOpen}}{{.AfterHours}} +
{{.Header}} +{{range.Stocks}}{{if eq .Direction 1}}{{else if eq .Direction -1}}{{end}}{{.Ticker}}{{.LastTrade}}{{.Change}}{{.ChangePct}}{{.Open}}{{.Low}}{{.High}}{{.Low52}}{{.High52}}{{.Volume}}{{.AvgVolume}}{{.PeRatio}}{{.Dividend}}{{.Yield}}{{.MarketCap}}{{.PreOpen}}{{.AfterHours}} {{end}}` return template.Must(template.New(`quotes`).Parse(markup)) @@ -231,8 +232,17 @@ func buildQuotesTemplate() *template.Template { //----------------------------------------------------------------------------- func highlight(collections ...map[string]string) { for _, collection := range collections { - if collection[`change`][0:1] != `-` { - collection[`change`] = `` + collection[`change`] + `` + change := collection[`change`] + if change[len(change)-1:] == `%` { + change = change[0 : len(change)-1] + } + adv, err := strconv.ParseFloat(change, 64) + if err == nil { + if adv < 0.0 { + collection[`change`] = `` + collection[`change`] + `` + } else if adv > 0.0 { + collection[`change`] = `` + collection[`change`] + `` + } } } } @@ -243,13 +253,13 @@ func group(stocks []Stock) []Stock { current := 0 for _, stock := range stocks { - if stock.Advancing { + if stock.Direction >= 0 { grouped[current] = stock current++ } } for _, stock := range stocks { - if !stock.Advancing { + if stock.Direction < 0 { grouped[current] = stock current++ } @@ -262,9 +272,9 @@ func group(stocks []Stock) []Stock { func arrowFor(column int, profile *Profile) string { if column == profile.SortColumn { if profile.Ascending { - return string('\U00002191') + return string('▲') } - return string('\U00002193') + return string('▼') } return `` } diff --git a/markup.go b/markup.go index d2abc5e..331926f 100644 --- a/markup.go +++ b/markup.go @@ -5,9 +5,10 @@ package mop import ( - `github.com/nsf/termbox-go` - `regexp` - `strings` + "regexp" + "strings" + + "github.com/nsf/termbox-go" ) // Markup implements some minimalistic text formatting conventions that @@ -33,11 +34,8 @@ type Markup struct { // Creates markup to define tag to Termbox translation rules and store default // colors and column alignments. -func NewMarkup() *Markup { +func NewMarkup(profile *Profile) *Markup { markup := &Markup{} - markup.Foreground = termbox.ColorDefault - markup.Background = termbox.ColorDefault - markup.RightAligned = false markup.tags = make(map[string]termbox.Attribute) markup.tags[`/`] = termbox.ColorDefault @@ -49,10 +47,33 @@ func NewMarkup() *Markup { markup.tags[`magenta`] = termbox.ColorMagenta markup.tags[`cyan`] = termbox.ColorCyan markup.tags[`white`] = termbox.ColorWhite + markup.tags[`darkgray`] = termbox.ColorDarkGray + markup.tags[`lightred`] = termbox.ColorLightRed + markup.tags[`lightgreen`] = termbox.ColorLightGreen + markup.tags[`lightyellow`] = termbox.ColorLightYellow + markup.tags[`lightblue`] = termbox.ColorLightBlue + markup.tags[`lightmagenta`] = termbox.ColorLightMagenta + markup.tags[`lightcyan`] = termbox.ColorLightCyan + markup.tags[`lightgray`] = termbox.ColorLightGray + markup.tags[`right`] = termbox.ColorDefault // Termbox can combine attributes and a single color using bitwise OR. markup.tags[`b`] = termbox.AttrBold // Attribute = 1 << (iota + 4) markup.tags[`u`] = termbox.AttrUnderline markup.tags[`r`] = termbox.AttrReverse + + // Semantic markups + markup.tags[`gain`] = markup.tags[profile.Colors.Gain] + markup.tags[`loss`] = markup.tags[profile.Colors.Loss] + markup.tags[`tag`] = markup.tags[profile.Colors.Tag] + markup.tags[`header`] = markup.tags[profile.Colors.Header] + markup.tags[`time`] = markup.tags[profile.Colors.Time] + markup.tags[`default`] = markup.tags[profile.Colors.Default] + + markup.Foreground = markup.tags[profile.Colors.Default] + + markup.Background = termbox.ColorDefault + markup.RightAligned = false + markup.regex = markup.supportedTags() // Once we have the hash we could build the regex. return markup @@ -77,7 +98,7 @@ func (markup *Markup) Tokenize(str string) []string { tail = match[0] if match[1] != 0 { if head != 0 || tail != 0 { - // Apend the text between tags. + // Append the text between tags. strings = append(strings, str[head:tail]) } // Append the tag itmarkup. @@ -123,7 +144,7 @@ func (markup *Markup) process(tag string, open bool) bool { if attribute >= termbox.AttrBold { markup.Foreground &= ^attribute // Clear the Termbox attribute. } else { - markup.Foreground = termbox.ColorDefault + markup.Foreground = markup.tags[`default`] } } } diff --git a/profile.go b/profile.go index ce393ed..d6a3779 100644 --- a/profile.go +++ b/profile.go @@ -8,26 +8,66 @@ import ( "encoding/json" "io/ioutil" "sort" + "strings" "github.com/Knetic/govaluate" ) +const defaultGainColor = "green" +const defaultLossColor = "red" +const defaultTagColor = "yellow" +const defaultHeaderColor = "lightgray" +const defaultTimeColor = "lightgray" +const defaultColor = "lightgray" + // 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. - Filter string // Filter in human form + 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 + Colors struct { // User defined colors + Gain string + Loss string + Tag string + Header string + Time string + Default string + } 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 } +func IsSupportedColor(colorName string) bool { + switch colorName { + case + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "darkgray", + "lightred", + "lightgreen", + "lightyellow", + "lightblue", + "lightmagenta", + "lightcyan", + "lightgray": + return true + } + return false +} + // Creates the profile and attempts to load the settings from ~/.moprc file. // If the file is not there it gets created with default values. func NewProfile(filename string) *Profile { @@ -41,9 +81,23 @@ func NewProfile(filename string) *Profile { profile.SortColumn = 0 // Stock quotes are sorted by ticker name. profile.Ascending = true // A to Z. profile.Filter = "" + profile.Colors.Gain = defaultGainColor + profile.Colors.Loss = defaultLossColor + profile.Colors.Tag = defaultTagColor + profile.Colors.Header = defaultHeaderColor + profile.Colors.Time = defaultTimeColor + profile.Colors.Default = defaultColor profile.Save() } else { json.Unmarshal(data, profile) + + InitColor(&profile.Colors.Gain, defaultGainColor) + InitColor(&profile.Colors.Loss, defaultLossColor) + InitColor(&profile.Colors.Tag, defaultTagColor) + InitColor(&profile.Colors.Header, defaultHeaderColor) + InitColor(&profile.Colors.Time, defaultTimeColor) + InitColor(&profile.Colors.Default, defaultColor) + profile.SetFilter(profile.Filter) } profile.selectedColumn = -1 @@ -51,9 +105,16 @@ func NewProfile(filename string) *Profile { return profile } +func InitColor(color *string, defaultValue string) { + *color = strings.ToLower(*color) + if !IsSupportedColor(*color) { + *color = defaultValue + } +} + // Save serializes settings using JSON and saves them in ~/.moprc file. func (profile *Profile) Save() error { - data, err := json.Marshal(profile) + data, err := json.MarshalIndent(profile, "", " ") if err != nil { return err } @@ -61,7 +122,7 @@ func (profile *Profile) Save() error { return ioutil.WriteFile(profile.filename, data, 0644) } -// AddTickers updates the list of existing tikers to add the new ones making +// AddTickers updates the list of existing tickers to add the new ones making // sure there are no duplicates. func (profile *Profile) AddTickers(tickers []string) (added int, err error) { added, err = 0, nil diff --git a/screen.go b/screen.go index 51e8d8e..9a9b45d 100644 --- a/screen.go +++ b/screen.go @@ -5,15 +5,16 @@ package mop import ( - `github.com/nsf/termbox-go` - `strings` - `time` - `strconv` - `fmt` + "fmt" + "strconv" + "strings" + "time" + + "github.com/nsf/termbox-go" ) -// Screen is thin wrapper aroung Termbox library to provide basic display -// capabilities as requied by Mop. +// Screen is thin wrapper around Termbox library to provide basic display +// capabilities as required by Mop. type Screen struct { width int // Current number of columns. height int // Current number of rows. @@ -26,13 +27,13 @@ type Screen struct { // Initializes Termbox, creates screen along with layout and markup, and // calculates current screen dimensions. Once initialized the screen is // ready for display. -func NewScreen() *Screen { +func NewScreen(profile *Profile) *Screen { if err := termbox.Init(); err != nil { panic(err) } screen := &Screen{} screen.layout = NewLayout() - screen.markup = NewMarkup() + screen.markup = NewMarkup(profile) return screen.Resize() } @@ -90,7 +91,7 @@ func (screen *Screen) ClearLine(x int, y int) *Screen { func (screen *Screen) Draw(objects ...interface{}) *Screen { zonename, _ := time.Now().In(time.Local).Zone() if screen.pausedAt != nil { - defer screen.DrawLine(0, 0, ``+screen.pausedAt.Format(`3:04:05pm ` + zonename)+``) + defer screen.DrawLine(0, 0, ``+screen.pausedAt.Format(`3:04:05pm `+zonename)+``) } for _, ptr := range objects { switch ptr.(type) { @@ -102,7 +103,7 @@ func (screen *Screen) Draw(objects ...interface{}) *Screen { screen.draw(screen.layout.Quotes(object.Fetch())) case time.Time: timestamp := ptr.(time.Time).Format(`3:04:05pm ` + zonename) - screen.DrawLine(0, 0, ``+timestamp+``) + screen.DrawLine(0, 0, ``) default: screen.draw(ptr.(string)) } @@ -146,7 +147,7 @@ func (screen *Screen) draw(str string) { drewHeading := false tempFormat := "%" + strconv.Itoa(screen.width) + "s" - blankLine := fmt.Sprintf(tempFormat,"") + blankLine := fmt.Sprintf(tempFormat, "") allLines = strings.Split(str, "\n") // Write the lines being updated. @@ -154,9 +155,9 @@ func (screen *Screen) draw(str string) { screen.DrawLine(0, row, allLines[row]) // Did we draw the underlined heading row? This is a crude // check, but--see comments below... - if strings.Contains(allLines[row],"Ticker") && - strings.Contains(allLines[row],"Last") && - strings.Contains(allLines[row],"Change") { + if strings.Contains(allLines[row], "Ticker") && + strings.Contains(allLines[row], "Last") && + strings.Contains(allLines[row], "Change") { drewHeading = true } } @@ -173,7 +174,7 @@ func (screen *Screen) draw(str string) { // cycle. In that case, padding with blank lines would overwrite the // stocks list.) if drewHeading { - for i := len(allLines)-1; i < screen.height; i++ { + for i := len(allLines) - 1; i < screen.height; i++ { screen.DrawLine(0, i, blankLine) } } diff --git a/sorter.go b/sorter.go index 7ea8e4a..d193557 100644 --- a/sorter.go +++ b/sorter.go @@ -223,7 +223,7 @@ func (sorter *Sorter) SortByCurrentColumn(stocks []Stock) *Sorter { } // The same exact method is used to sort by $Change and Change%. In both cases -// we sort by the value of Change% so that multiple $0.00s get sorted proferly. +// we sort by the value of Change% so that multiple $0.00s get sorted properly. func c(str string) float32 { c := "$" for _, v := range currencies { diff --git a/yahoo_quotes.go b/yahoo_quotes.go index c2523f6..1807601 100644 --- a/yahoo_quotes.go +++ b/yahoo_quotes.go @@ -22,7 +22,7 @@ const quotesURLv7QueryParts = `&range=1d&interval=5m&indicators=close&includeTim const noDataIndicator = `N/A` // Stock stores quote information for the particular stock ticker. The data -// for all the fields except 'Advancing' is fetched using Yahoo market API. +// for all the fields except 'Direction' is fetched using Yahoo market API. type Stock struct { Ticker string `json:"symbol"` // Stock ticker. LastTrade string `json:"regularMarketPrice"` // l1: last trade. @@ -42,7 +42,7 @@ type Stock struct { MarketCap string `json:"marketCap"` // j3: market cap real time. MarketCapX string `json:"marketCap"` // j1: market cap (fallback when real time is N/A). Currency string `json:"currency"` // String code for currency of stock. - Advancing bool // True when change is >= $0. + Direction int // -1 when change is < $0, 0 when change is = $0, 1 when change is > $0. PreOpen string `json:"preMarketChangePercent,omitempty"` AfterHours string `json:"postMarketChangePercent,omitempty"` } @@ -96,7 +96,7 @@ func (quotes *Quotes) Fetch() (self *Quotes) { return quotes } -// Ok returns two values: 1) boolean indicating whether the error has occured, +// Ok returns two values: 1) boolean indicating whether the error has occurred, // and 2) the error text itself. func (quotes *Quotes) Ok() (bool, string) { return quotes.errors == ``, quotes.errors @@ -188,8 +188,13 @@ func (quotes *Quotes) parse2(body []byte) (*Quotes, error) { fmt.Println("-------------------") */ adv, err := strconv.ParseFloat(quotes.stocks[i].Change, 64) + quotes.stocks[i].Direction = 0 if err == nil { - quotes.stocks[i].Advancing = adv >= 0.0 + if adv < 0.0 { + quotes.stocks[i].Direction = -1 + } else if adv > 0.0 { + quotes.stocks[i].Direction = 1 + } } } return quotes, nil @@ -202,7 +207,7 @@ func (quotes *Quotes) parse(body []byte) *Quotes { quotes.stocks = make([]Stock, len(lines)) // // Get the total number of fields in the Stock struct. Skip the last - // Advanicing field which is not fetched. + // Advancing field which is not fetched. // fieldsCount := reflect.ValueOf(quotes.stocks[0]).NumField() - 1 // @@ -226,10 +231,17 @@ func (quotes *Quotes) parse(body []byte) *Quotes { quotes.stocks[i].MarketCap = quotes.stocks[i].MarketCapX } // - // Stock is advancing if the change is not negative (i.e. $0.00 - // is also "advancing"). + // Get the direction of the stock // - quotes.stocks[i].Advancing = (quotes.stocks[i].Change[0:1] != `-`) + adv, err := strconv.ParseFloat(quotes.stocks[i].Change, 64) + quotes.stocks[i].Direction = 0 + if err == nil { + if adv < 0 { + quotes.stocks[i].Direction = -1 + } else if (adv > 0) { + quotes.stocks[i].Direction = 1 + } + } } return quotes