Skip to content

Commit

Permalink
feat: added printing holdings
Browse files Browse the repository at this point in the history
  • Loading branch information
achannarasappa committed Sep 12, 2021
1 parent 2b195b3 commit bdd0a64
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 7 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
24 changes: 17 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
97 changes: 97 additions & 0 deletions internal/print/print.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
93 changes: 93 additions & 0 deletions internal/print/print_suite_test.go
Original file line number Diff line number Diff line change
@@ -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&region=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")
}
73 changes: 73 additions & 0 deletions internal/print/print_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
})
})

0 comments on commit bdd0a64

Please sign in to comment.