Merge pull request #96 from joce/add-red-on-down

Values displayed in red if retreating
master
Brandon Lee Camilleri 3 years ago committed by GitHub
commit 60cf8cc1bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      cmd/mop/main.go
  2. 10
      cnn_market.go
  3. 2
      filter.go
  4. 40
      layout.go
  5. 39
      markup.go
  6. 65
      profile.go
  7. 33
      screen.go
  8. 2
      sorter.go
  9. 28
      yahoo_quotes.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()
}

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

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

@ -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 := `<yellow>Dow</> {{.Dow.change}} ({{.Dow.percent}}) at {{.Dow.latest}} <yellow>S&P 500</> {{.Sp500.change}} ({{.Sp500.percent}}) at {{.Sp500.latest}} <yellow>NASDAQ</> {{.Nasdaq.change}} ({{.Nasdaq.percent}}) at {{.Nasdaq.latest}}
<yellow>Tokyo</> {{.Tokyo.change}} ({{.Tokyo.percent}}) at {{.Tokyo.latest}} <yellow>HK</> {{.HongKong.change}} ({{.HongKong.percent}}) at {{.HongKong.latest}} <yellow>London</> {{.London.change}} ({{.London.percent}}) at {{.London.latest}} <yellow>Frankfurt</> {{.Frankfurt.change}} ({{.Frankfurt.percent}}) at {{.Frankfurt.latest}} {{if .IsClosed}}<right>U.S. markets closed</right>{{end}}
<yellow>10-Year Yield</> {{.Yield.latest}}% ({{.Yield.change}}) <yellow>Euro</> ${{.Euro.latest}} ({{.Euro.change}}%) <yellow>Yen</> ¥{{.Yen.latest}} ({{.Yen.change}}%) <yellow>Oil</> ${{.Oil.latest}} ({{.Oil.change}}%) <yellow>Gold</> ${{.Gold.latest}} ({{.Gold.change}}%)`
markup := `<tag>Dow</> {{.Dow.change}} ({{.Dow.percent}}) at {{.Dow.latest}} <tag>S&P 500</> {{.Sp500.change}} ({{.Sp500.percent}}) at {{.Sp500.latest}} <tag>NASDAQ</> {{.Nasdaq.change}} ({{.Nasdaq.percent}}) at {{.Nasdaq.latest}}
<tag>Tokyo</> {{.Tokyo.change}} ({{.Tokyo.percent}}) at {{.Tokyo.latest}} <tag>HK</> {{.HongKong.change}} ({{.HongKong.percent}}) at {{.HongKong.latest}} <tag>London</> {{.London.change}} ({{.London.percent}}) at {{.London.latest}} <tag>Frankfurt</> {{.Frankfurt.change}} ({{.Frankfurt.percent}}) at {{.Frankfurt.latest}} {{if .IsClosed}}<right>U.S. markets closed</right>{{end}}
<tag>10-Year Yield</> {{.Yield.latest}} ({{.Yield.change}}) <tag>Euro</> ${{.Euro.latest}} ({{.Euro.change}}) <tag>Yen</> ¥{{.Yen.latest}} ({{.Yen.change}}) <tag>Oil</> ${{.Oil.latest}} ({{.Oil.change}}) <tag>Gold</> ${{.Gold.latest}} ({{.Gold.change}})`
return template.Must(template.New(`market`).Parse(markup))
}
//-----------------------------------------------------------------------------
func buildQuotesTemplate() *template.Template {
markup := `<right><white>{{.Now}}</></right>
markup := `<right><time>{{.Now}}</></right>
{{.Header}}
{{range.Stocks}}{{if .Advancing}}<green>{{end}}{{.Ticker}}{{.LastTrade}}{{.Change}}{{.ChangePct}}{{.Open}}{{.Low}}{{.High}}{{.Low52}}{{.High52}}{{.Volume}}{{.AvgVolume}}{{.PeRatio}}{{.Dividend}}{{.Yield}}{{.MarketCap}}{{.PreOpen}}{{.AfterHours}}</>
<header>{{.Header}}</>
{{range.Stocks}}{{if eq .Direction 1}}<gain>{{else if eq .Direction -1}}<loss>{{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`] = `<green>` + 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`] = `<loss>` + collection[`change`] + `</>`
} else if adv > 0.0 {
collection[`change`] = `<gain>` + 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 ``
}

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

@ -8,10 +8,18 @@ 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.
@ -23,11 +31,43 @@ type Profile struct {
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

@ -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, `<right><r>`+screen.pausedAt.Format(`3:04:05pm ` + zonename)+`</r></right>`)
defer screen.DrawLine(0, 0, `<right><r>`+screen.pausedAt.Format(`3:04:05pm `+zonename)+`</r></right>`)
}
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, `<right>`+timestamp+`</right>`)
screen.DrawLine(0, 0, `<right><time>`+timestamp+`</></right>`)
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)
}
}

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

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

Loading…
Cancel
Save