From fc8ba9f24441142b7e6b55cb36af785215abf189 Mon Sep 17 00:00:00 2001 From: Ani Channarasappa Date: Wed, 3 Feb 2021 22:03:35 -0500 Subject: [PATCH] feat: added summary of all positions i.e. totals --- cmd/root.go | 9 ++-- internal/cli/cli.go | 3 ++ internal/cli/cli_test.go | 47 +++++++++++++++++ internal/ui/component/summary/summary.go | 50 ++++++++++++++++++ .../component/summary/summary_suite_test.go | 15 ++++++ internal/ui/component/summary/summary_test.go | 52 +++++++++++++++++++ internal/ui/component/watchlist/watchlist.go | 24 ++++++++- internal/ui/ui.go | 32 ++++++++++-- internal/ui/util/format.go | 20 ------- 9 files changed, 223 insertions(+), 29 deletions(-) create mode 100644 internal/ui/component/summary/summary.go create mode 100644 internal/ui/component/summary/summary_suite_test.go create mode 100644 internal/ui/component/summary/summary_test.go diff --git a/cmd/root.go b/cmd/root.go index 4370dab..2c6bbf4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,7 +19,8 @@ var ( separate bool extraInfoExchange bool extraInfoFundamentals bool - proxy string + proxy string + showSummary bool err error rootCmd = &cobra.Command{ Use: "ticker", @@ -33,7 +34,8 @@ var ( Separate: &separate, ExtraInfoExchange: &extraInfoExchange, ExtraInfoFundamentals: &extraInfoFundamentals, - Proxy: &proxy, + ShowSummary: &showSummary, + Proxy: &proxy, }, err, ), @@ -56,7 +58,8 @@ func init() { rootCmd.Flags().BoolVar(&separate, "show-separator", false, "layout with separators between each quote") rootCmd.Flags().BoolVar(&extraInfoExchange, "show-tags", false, "display currency, exchange name, and quote delay for each quote") rootCmd.Flags().BoolVar(&extraInfoFundamentals, "show-fundamentals", false, "display open price, high, low, and volume for each quote") - rootCmd.Flags().StringVar(&proxy, "proxy", "", "proxy URL for requests (default is none)") + rootCmd.Flags().BoolVar(&showSummary, "show-summary", false, "display summary of total gain and loss for positions") + rootCmd.Flags().StringVar(&proxy, "proxy", "", "proxy URL for requests (default is none)") } func initConfig() { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 35cb94d..6718b40 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -21,6 +21,7 @@ type Config struct { Separate bool `yaml:"show-separator"` ExtraInfoExchange bool `yaml:"show-tags"` ExtraInfoFundamentals bool `yaml:"show-fundamentals"` + ShowSummary bool `yaml:"show-summary"` Proxy string `yaml:"proxy"` } @@ -30,6 +31,7 @@ type Options struct { Separate *bool ExtraInfoExchange *bool ExtraInfoFundamentals *bool + ShowSummary *bool Proxy *string } @@ -92,6 +94,7 @@ func mergeConfig(config Config, options Options) Config { config.Separate = getBoolOption(*options.Separate, config.Separate) config.ExtraInfoExchange = getBoolOption(*options.ExtraInfoExchange, config.ExtraInfoExchange) config.ExtraInfoFundamentals = getBoolOption(*options.ExtraInfoFundamentals, config.ExtraInfoFundamentals) + config.ShowSummary = getBoolOption(*options.ShowSummary, config.ShowSummary) config.Proxy = getProxy(*options.Proxy, config.Proxy) return config diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8756bb5..914c084 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -69,6 +69,7 @@ var _ = Describe("Cli", func() { separate bool extraInfoExchange bool extraInfoFundamentals bool + showSummary bool proxy string ) @@ -79,6 +80,7 @@ var _ = Describe("Cli", func() { Separate: &separate, ExtraInfoExchange: &extraInfoExchange, ExtraInfoFundamentals: &extraInfoFundamentals, + ShowSummary: &showSummary, Proxy: &proxy, } watchlist = "GME,BB" @@ -87,6 +89,7 @@ var _ = Describe("Cli", func() { separate = false extraInfoExchange = false extraInfoFundamentals = false + showSummary = false fs = afero.NewMemMapFs() //nolint:errcheck fs.MkdirAll("./", 0755) @@ -389,6 +392,50 @@ var _ = Describe("Cli", func() { }) }) }) + + Describe("show-summary option", func() { + When("show-summary flag is set as a cli argument", func() { + It("should set the config to the cli argument value", func() { + showSummary = true + inputConfig := cli.Config{} + outputErr := Validate(&inputConfig, fs, options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(BeNil()) + Expect(inputConfig.ShowSummary).To(Equal(true)) + }) + + When("the config file also has a show-summary flag defined", func() { + It("should set the show-summary flag from the cli argument", func() { + showSummary = true + inputConfig := cli.Config{ + ShowSummary: false, + } + outputErr := Validate(&inputConfig, fs, options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(BeNil()) + Expect(inputConfig.ShowSummary).To(Equal(true)) + }) + }) + }) + + When("show-summary flag is set in the config file", func() { + It("should set the config to the cli argument value", func() { + inputConfig := cli.Config{ + ShowSummary: true, + } + outputErr := Validate(&inputConfig, fs, options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(BeNil()) + Expect(inputConfig.ShowSummary).To(Equal(true)) + }) + }) + + When("show-summary flag is not set", func() { + It("should disable the option", func() { + inputConfig := cli.Config{} + outputErr := Validate(&inputConfig, fs, options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(BeNil()) + Expect(inputConfig.ShowSummary).To(Equal(false)) + }) + }) + }) }) //nolint:errcheck diff --git a/internal/ui/component/summary/summary.go b/internal/ui/component/summary/summary.go new file mode 100644 index 0000000..d3aa606 --- /dev/null +++ b/internal/ui/component/summary/summary.go @@ -0,0 +1,50 @@ +package summary + +import ( + "strings" + "ticker/internal/position" + . "ticker/internal/ui/util" +) + +type Model struct { + Width int + Summary position.PositionSummary +} + +// NewModel returns a model with default values. +func NewModel() Model { + return Model{ + Width: 80, + } +} + +func (m Model) View() string { + + if m.Width < 80 { + return "" + } + + return strings.Join([]string{ + StyleNeutralFaded("Day:"), + quoteChangeText(m.Summary.DayChange, m.Summary.DayChangePercent), + StyleNeutralFaded("•"), + StyleNeutralFaded("Change:"), + quoteChangeText(m.Summary.Change, m.Summary.ChangePercent), + StyleNeutralFaded("•"), + StyleNeutralFaded("Value:"), + ValueText(m.Summary.Value), + }, " ") + "\n" + StyleLine(strings.Repeat("━", m.Width)) + +} + +func quoteChangeText(change float64, changePercent float64) string { + if change == 0.0 { + return StyleNeutralFaded(ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") + } + + if change > 0.0 { + return StylePricePositive(changePercent)("↑ " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") + } + + return StylePriceNegative(changePercent)("↓ " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") +} diff --git a/internal/ui/component/summary/summary_suite_test.go b/internal/ui/component/summary/summary_suite_test.go new file mode 100644 index 0000000..63cb81d --- /dev/null +++ b/internal/ui/component/summary/summary_suite_test.go @@ -0,0 +1,15 @@ +package summary_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" +) + +func TestSummary(t *testing.T) { + format.TruncatedDiff = false + RegisterFailHandler(Fail) + RunSpecs(t, "Summary Suite") +} diff --git a/internal/ui/component/summary/summary_test.go b/internal/ui/component/summary/summary_test.go new file mode 100644 index 0000000..1e0681e --- /dev/null +++ b/internal/ui/component/summary/summary_test.go @@ -0,0 +1,52 @@ +package summary_test + +import ( + "strings" + "ticker/internal/position" + . "ticker/internal/ui/component/summary" + + "github.com/acarl005/stripansi" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func removeFormatting(text string) string { + return stripansi.Strip(text) +} + +var _ = Describe("Summary", func() { + + It("should render a summary", func() { + m := NewModel() + m.Summary = position.PositionSummary{ + Value: 10000, + Cost: 1000, + Change: 9000, + DayChange: 100.0, + ChangePercent: 1000.0, + DayChangePercent: 10.0, + } + Expect(removeFormatting(m.View())).To(Equal(strings.Join([]string{ + "Day: ↑ 100.00 (10.00%) • Change: ↑ 9000.00 (1000.00%) • Value: 10000.00", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + }, "\n"))) + }) + + When("no quotes are set", func() { + It("should render an empty summary", func() { + m := NewModel() + Expect(removeFormatting(m.View())).To(Equal(strings.Join([]string{ + "Day: 0.00 (0.00%) • Change: 0.00 (0.00%) • Value: ", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + }, "\n"))) + }) + }) + + When("the window width is less than the minimum", func() { + It("should render an empty summary", func() { + m := NewModel() + m.Width = 10 + Expect(m.View()).To(Equal("")) + }) + }) +}) diff --git a/internal/ui/component/watchlist/watchlist.go b/internal/ui/component/watchlist/watchlist.go index fb8f031..3261a2a 100644 --- a/internal/ui/component/watchlist/watchlist.go +++ b/internal/ui/component/watchlist/watchlist.go @@ -101,12 +101,12 @@ func item(q quote.Quote, p position.Position, width int) string { }, Cell{ Width: 25, - Text: ValueChangeText(p.DayChange, p.DayChangePercent), + Text: valueChangeText(p.DayChange, p.DayChangePercent), Align: RightAlign, }, Cell{ Width: 25, - Text: QuoteChangeText(q.Change, q.ChangePercent), + Text: quoteChangeText(q.Change, q.ChangePercent), Align: RightAlign, }, ), @@ -177,6 +177,26 @@ func marketStateText(q quote.Quote) string { return "" } +func valueChangeText(change float64, changePercent float64) string { + if change == 0.0 { + return "" + } + + return quoteChangeText(change, changePercent) +} + +func quoteChangeText(change float64, changePercent float64) string { + if change == 0.0 { + return StyleNeutralFaded(" " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") + } + + if change > 0.0 { + return StylePricePositive(changePercent)("↑ " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") + } + + return StylePriceNegative(changePercent)("↓ " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") +} + // Sort by change percent and keep all inactive quotes at the end func sortQuotes(q []quote.Quote) []quote.Quote { if len(q) <= 0 { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index cc4acd3..4c03fd5 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2,9 +2,11 @@ package ui import ( "fmt" + "strings" "ticker/internal/cli" "ticker/internal/position" "ticker/internal/quote" + "ticker/internal/ui/component/summary" "ticker/internal/ui/component/watchlist" "time" @@ -22,16 +24,18 @@ var ( ) const ( - verticalMargins = 1 + footerHeight = 1 ) type Model struct { ready bool + headerHeight int getQuotes func() []quote.Quote getPositions func([]quote.Quote) map[string]position.Position requestInterval int viewport viewport.Model watchlist watchlist.Model + summary summary.Model lastUpdateTime string } @@ -55,11 +59,13 @@ func NewModel(config cli.Config, client *resty.Client) Model { symbols := position.GetSymbols(config.Watchlist, aggregatedLots) return Model{ + headerHeight: getVerticalMargin(config), ready: false, requestInterval: 3, getQuotes: quote.GetQuotes(*client, symbols), getPositions: position.GetPositions(aggregatedLots), watchlist: watchlist.NewModel(config.Separate, config.ExtraInfoExchange, config.ExtraInfoFundamentals), + summary: summary.NewModel(), } } @@ -93,7 +99,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.watchlist.Width = msg.Width - viewportHeight := msg.Height - verticalMargins + m.summary.Width = msg.Width + viewportHeight := msg.Height - m.headerHeight - footerHeight if !m.ready { m.viewport = viewport.Model{Width: msg.Width, Height: viewportHeight} @@ -106,8 +113,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.SetContent(m.watchlist.View()) case QuoteMsg: + positions := m.getPositions(msg.quotes) m.watchlist.Quotes = msg.quotes - m.watchlist.Positions = m.getPositions(msg.quotes) + m.watchlist.Positions = positions + m.summary.Summary = position.GetPositionSummary(positions) m.lastUpdateTime = msg.time if m.ready { m.viewport.SetContent(m.watchlist.View()) @@ -126,7 +135,14 @@ func (m Model) View() string { return "\n Initalizing..." } - return fmt.Sprintf("%s\n%s", m.viewport.View(), footer(m.viewport.Width, m.lastUpdateTime)) + return strings.Join( + []string{ + m.summary.View(), + m.viewport.View(), + footer(m.viewport.Width, m.lastUpdateTime), + }, + "\n", + ) } func footer(width int, time string) string { @@ -152,3 +168,11 @@ func footer(width int, time string) string { ) } + +func getVerticalMargin(config cli.Config) int { + if config.ShowSummary { + return 2 + } + + return 0 +} diff --git a/internal/ui/util/format.go b/internal/ui/util/format.go index f8e9075..24bcf71 100644 --- a/internal/ui/util/format.go +++ b/internal/ui/util/format.go @@ -13,23 +13,3 @@ func ValueText(value float64) string { return StyleNeutral(ConvertFloatToString(value)) } - -func ValueChangeText(change float64, changePercent float64) string { - if change == 0.0 { - return "" - } - - return QuoteChangeText(change, changePercent) -} - -func QuoteChangeText(change float64, changePercent float64) string { - if change == 0.0 { - return StyleNeutralFaded(" " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") - } - - if change > 0.0 { - return StylePricePositive(changePercent)("↑ " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") - } - - return StylePriceNegative(changePercent)("↓ " + ConvertFloatToString(change) + " (" + ConvertFloatToString(changePercent) + "%)") -}