diff --git a/README.md b/README.md index c06315d..47eeef0 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,18 @@ colors: * Terminals supporting TrueColor will be able to represent the full color space and in other cases colors will be down sampled * Any omitted or invalid colors will revert to default color scheme values +### Printing Holdings + +`ticker` supports printing holdings to the terminal as text by using `ticker print`. Output defaults to JSON but CSV output can also be generated by passing the `--format=csv` flag. + +```sh +$ ticker --config=./.ticker.yaml print +[{"name":"Airbnb, Inc.","symbol":"ABNB","price":164.71,"value":16965.13,"cost":15038,"quantity":103,"weight":53.66651978212161},{"name":"Tesla, Inc.","symbol":"TSLA","price":732.35,"value":14647,"cost":15660,"quantity":20,"weight":46.33348021787839}] +``` + +* Ensure there is at least one lot in the configuration file in order to generate output +* A specific config file can be specified with the `--config` flag + ## Notes * **Real-time quotes** - Quotes are pulled from Yahoo finance which may provide delayed stock quotes depending on the exchange. The major US exchanges (NYSE, NASDAQ) have real-time quotes however other exchanges may not. Consult the [help article](https://help.yahoo.com/kb/SLN2310.html) on exchange delays to determine which exchanges you can expect delays for or use the `--show-tags` flag to include timeliness of data alongside quotes in `ticker`. diff --git a/cmd/root.go b/cmd/root.go index 57dc79b..9d984af 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,24 +10,31 @@ import ( "github.com/achannarasappa/ticker/internal/cli" c "github.com/achannarasappa/ticker/internal/common" + "github.com/achannarasappa/ticker/internal/print" "github.com/achannarasappa/ticker/internal/ui" ) var ( // Version is a placeholder that is replaced at build time with a linker flag (-ldflags) - Version string = "v0.0.0" - configPath string - dep c.Dependencies - ctx c.Context - options cli.Options - err error - rootCmd = &cobra.Command{ + Version string = "v0.0.0" + configPath string + dep c.Dependencies + ctx c.Context + options cli.Options + optionsPrint print.Options + err error + rootCmd = &cobra.Command{ Version: Version, Use: "ticker", Short: "Terminal stock ticker and stock gain/loss tracker", Args: cli.Validate(&ctx, &options, &err), Run: cli.Run(ui.Start(&dep, &ctx)), } + printCmd = &cobra.Command{ + Use: "print", + Short: "Prints holdings", + Run: print.Run(&dep, &ctx, &optionsPrint), + } ) // Execute starts the CLI or prints an error is there is one @@ -50,6 +57,9 @@ func init() { rootCmd.Flags().BoolVar(&options.ShowHoldings, "show-holdings", false, "display average unit cost, quantity, portfolio weight") rootCmd.Flags().StringVar(&options.Proxy, "proxy", "", "proxy URL for requests (default is none)") rootCmd.Flags().StringVar(&options.Sort, "sort", "", "sort quotes on the UI. Set \"alpha\" to sort by ticker name. Set \"value\" to sort by position value. Keep empty to sort according to change percent") + rootCmd.AddCommand(printCmd) + printCmd.Flags().StringVar(&optionsPrint.Format, "format", "", "output format for printing holdings. Set \"csv\" to print as a CSV or \"json\" for JSON. Defaults to JSON.") + printCmd.Flags().StringVar(&configPath, "config", "", "config file (default is $HOME/.ticker.yaml)") } func initConfig() { diff --git a/internal/print/print.go b/internal/print/print.go new file mode 100644 index 0000000..93df9e6 --- /dev/null +++ b/internal/print/print.go @@ -0,0 +1,97 @@ +package print + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + + "github.com/achannarasappa/ticker/internal/asset" + c "github.com/achannarasappa/ticker/internal/common" + quote "github.com/achannarasappa/ticker/internal/quote/yahoo" + "github.com/achannarasappa/ticker/internal/ui/util" + + "github.com/spf13/cobra" +) + +// Options to configure print behavior +type Options struct { + Format string +} + +type jsonRow struct { + Name string `json:"name"` + Symbol string `json:"symbol"` + Price float64 `json:"price"` + Value float64 `json:"value"` + Cost float64 `json:"cost"` + Quantity float64 `json:"quantity"` + Weight float64 `json:"weight"` +} + +func convertAssetsToCSV(assets []c.Asset) string { + rows := [][]string{ + {"name", "symbol", "price", "value", "cost", "quantity", "weight"}, + } + + for _, asset := range assets { + if asset.Holding.Quantity > 0 { + rows = append(rows, []string{ + asset.Name, + asset.Symbol, + util.ConvertFloatToString(asset.QuotePrice.Price, true), + util.ConvertFloatToString(asset.Holding.Value, true), + util.ConvertFloatToString(asset.Holding.Cost, true), + util.ConvertFloatToString(asset.Holding.Quantity, true), + util.ConvertFloatToString(asset.Holding.Weight, true), + }) + } + } + + b := new(bytes.Buffer) + w := csv.NewWriter(b) + w.WriteAll(rows) + + return b.String() + +} + +func convertAssetsToJSON(assets []c.Asset) string { + var rows []jsonRow + + for _, asset := range assets { + if asset.Holding.Quantity > 0 { + rows = append(rows, jsonRow{ + Name: asset.Name, + Symbol: asset.Symbol, + Price: asset.QuotePrice.Price, + Value: asset.Holding.Value, + Cost: asset.Holding.Cost, + Quantity: asset.Holding.Quantity, + Weight: asset.Holding.Weight, + }) + } + } + + out, _ := json.Marshal(rows) + + return string(out) + +} + +// RunHolding prints holdings to the terminal +func Run(dep *c.Dependencies, ctx *c.Context, options *Options) func(*cobra.Command, []string) { + return func(cmd *cobra.Command, args []string) { + ctx.Config.ShowHoldings = true + symbols := asset.GetSymbols(ctx.Config) + assetQuotes := quote.GetAssetQuotes(*dep.HttpClient, symbols)() + assets, _ := asset.GetAssets(*ctx, assetQuotes) + + if options.Format == "csv" { + fmt.Println(convertAssetsToCSV(assets)) + return + } + + fmt.Println(convertAssetsToJSON(assets)) + } +} diff --git a/internal/print/print_suite_test.go b/internal/print/print_suite_test.go new file mode 100644 index 0000000..be8931e --- /dev/null +++ b/internal/print/print_suite_test.go @@ -0,0 +1,93 @@ +package print_test + +import ( + "net/http" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/jarcoal/httpmock" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" +) + +var client = resty.New() + +func mockResponse() { + response := `{ + "quoteResponse": { + "result": [ + { + "quoteType": "EQUITY", + "currency": "USD", + "marketState": "CLOSED", + "shortName": "Alphabet Inc.", + "preMarketChange": null, + "preMarketChangePercent": null, + "regularMarketChange": -59.850098, + "regularMarketChangePercent": -2.0650284, + "regularMarketPrice": 2838.42, + "regularMarketDayHigh": 2920.27, + "regularMarketDayLow": 2834.83, + "regularMarketVolume": 1644831, + "regularMarketPreviousClose": 2898.27, + "fullExchangeName": "NasdaqGS", + "regularMarketOpen": 2908.87, + "fiftyTwoWeekLow": 1406.55, + "fiftyTwoWeekHigh": 2936.41, + "marketCap": 1885287088128, + "exchangeDataDelayedBy": 0, + "symbol": "GOOG" + }, + { + "quoteType": "EQUITY", + "currency": "USD", + "marketState": "CLOSED", + "shortName": "Roblox Corporation", + "preMarketChange": null, + "preMarketChangePercent": null, + "regularMarketChange": 1.5299988, + "regularMarketChangePercent": 1.7718574, + "regularMarketPrice": 87.88, + "regularMarketDayHigh": 90.43, + "regularMarketDayLow": 84.67, + "regularMarketVolume": 17465966, + "regularMarketPreviousClose": 86.35, + "fullExchangeName": "NYSE", + "regularMarketOpen": 86.75, + "fiftyTwoWeekLow": 60.5, + "fiftyTwoWeekHigh": 103.866, + "marketCap": 50544357376, + "exchangeDataDelayedBy": 0, + "symbol": "RBLX" + } + ], + "error": null + } + }` + responseURL := "https://query1.finance.yahoo.com/v7/finance/quote?lang=en-US®ion=US&corsDomain=finance.yahoo.com&symbols=GOOG,RBLX" + httpmock.RegisterResponder("GET", responseURL, func(req *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(200, response) + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }) +} + +var _ = BeforeSuite(func() { + httpmock.ActivateNonDefault(client.GetClient()) + mockResponse() +}) + +var _ = BeforeEach(func() { + httpmock.Reset() +}) + +var _ = AfterSuite(func() { + httpmock.DeactivateAndReset() +}) + +func TestPrint(t *testing.T) { + format.TruncatedDiff = false + RegisterFailHandler(Fail) + RunSpecs(t, "Print Suite") +} diff --git a/internal/print/print_test.go b/internal/print/print_test.go new file mode 100644 index 0000000..1b20653 --- /dev/null +++ b/internal/print/print_test.go @@ -0,0 +1,73 @@ +package print_test + +import ( + "io/ioutil" + "os" + + c "github.com/achannarasappa/ticker/internal/common" + "github.com/achannarasappa/ticker/internal/print" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +func getStdout(fn func()) string { + rescueStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + out, _ := ioutil.ReadAll(r) + os.Stdout = rescueStdout + + return string(out) +} + +var _ = Describe("Print", func() { + + BeforeEach(func() { + mockResponse() + }) + + Describe("Run", func() { + var ( + inputOptions = print.Options{} + inputContext = c.Context{Config: c.Config{Lots: []c.Lot{ + { + Symbol: "GOOG", + UnitCost: 1000, + Quantity: 10, + }, + { + Symbol: "RBLX", + UnitCost: 50, + Quantity: 10, + }, + }}} + inputDependencies = c.Dependencies{ + HttpClient: client, + } + ) + + It("should print holdings in JSON format", func() { + output := getStdout(func() { + print.Run(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{}) + }) + Expect(output).To(Equal("[{\"name\":\"Alphabet Inc.\",\"symbol\":\"GOOG\",\"price\":2838.42,\"value\":28384.2,\"cost\":10000,\"quantity\":10,\"weight\":96.99689027099068},{\"name\":\"Roblox Corporation\",\"symbol\":\"RBLX\",\"price\":87.88,\"value\":878.8,\"cost\":500,\"quantity\":10,\"weight\":3.0031097290093287}]\n")) + }) + + When("the format option is set to csv", func() { + It("should print the holdings in CSV format", func() { + inputOptions := print.Options{ + Format: "csv", + } + output := getStdout(func() { + print.Run(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{}) + }) + Expect(output).To(Equal("name,symbol,price,value,cost,quantity,weight\nAlphabet Inc.,GOOG,2838.42,28384,10000,10.000,96.997\nRoblox Corporation,RBLX,87.880,878.80,500.00,10.000,3.0031\n\n")) + }) + }) + }) +})