Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing CLI command to create transaction #231

Merged
merged 1 commit into from
Dec 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ The Money Gopher will help you to keep track of your investments.

Surely, there are a number of programs and services out there that already
manage your portfolio(s), why creating another one? Well there are several
reasons or rather requirements that I need. Note, that these might be very
specific to my use case, but maybe somebody else will appreciate them as well.
reasons or rather requirements that I have. Note, that these might be very
specific to my use-case, but maybe somebody else will appreciate them as well.

* 🏘️ I need to manage several portfolios for several distinct people, for
example my own and my children's. I want to keep these portfolios completely
Expand Down Expand Up @@ -44,13 +44,80 @@ creating this.

* 📞 I wanted to explore new ways of providing RPC-style APIs that are not based
on the arguably bloated gRPC framework. Therefore, I am exploring Buf's
[Connect](https://connect.build) framework in this project, which seems
[Connect](https://connectrpc.com) framework in this project, which seems
promising, even for browser-based interactions.
* 🔲 I am still on the spiritual search for a good UI framework, so this might
be a good chance to explore different options.
* 📈 I wanted to understand the math behind some of the used performance models,
such as time-weighted rate of return a little bit better.

# Usage

This project has currently three main components:
* A server component [`moneyd`](./cmd/moneyd), which manages the connection to
the database and offers a RPC-API using [Connect](https://connectrpc.com) to
manage portfolios and securities.
* A simple CLI [`mgo`](./cmd/mgo) which can be used to interact with
the API.
* An even simpler [web-based user interface](./ui/), based on
[SvelteKit](https://kit.svelte.dev).


## Starting `moneyd`

After checking out the source-code, the necessary Go binaries can be compiled
using `go build ./cmd/moneyd` and can be started using `./moneyd`. It will print
out some information, but logging definitely needs to improved.

On startup, an SQLite database named `money.db` will be created (or loaded) in
the same directory as the started binary. If the database is empty, a new
portfolio named `mybank/myportfolio` and one example security will be created.

As a simple check, one can simply interact with the RPC-API with a normal HTTP
client, for example to list all portfolios.
```zsh
curl \
--header 'Content-Type: application/json' \
--data '{}' \
http://localhost:8080/mgo.portfolio.v1.PortfolioService/ListPortfolios
```

This should print something like the following.

```json
{"portfolios":[{"name":"bank/myportfolio","displayName":"My Portfolio"}]}
```

## Using `mgo`

Alternatively, a simple CLI called `mgo` can be used. It is preferable to
install it for the current user using `go install ./cmd/mgo`. Afterwards, it can
for example used to display all portfolios with `mgo portfolio list`.

### Adding Transactions

To add transactions (buy, sell, etc.) to an existing portfolio, the command `mgo
portfolio transaction create` can be used. The following shows an example for
the security with the ISIN US0378331005.
```zsh
mgo portfolio transactions create US0378331005 --portfolio-name bank/myportfolio --amount 5 --price 120 --time="2022-01-01 10:00"
```

When successful, this should print something like the following.

```
Successfully created a buy transaction (1c12ac28dfbc5440) for security US09075V1026 in bank/myportfolio.
```

The unique identifier (also called 'name') of the transaction can be used in
other calls, e.g., to modify it.

### Available Commands and Shell Completion

For a detailed list of all available commands see `mgo --help`. The CLI also
supports (basic) shell completion. For details how to activate it, please see
`mgo completion`.

# When is it finished?

Since I am working on this in my spare time, it will probably take a while 😃.
2 changes: 2 additions & 0 deletions buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Generated by buf. DO NOT EDIT.
version: v1
122 changes: 95 additions & 27 deletions cli/commands/portfolio.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"net/http"
"os"
"strings"
"time"

"github.com/fatih/color"
kongcompletion "github.com/jotaen/kong-completion"
Expand All @@ -37,10 +38,13 @@ import (
)

type PortfolioCmd struct {
Create CreatePortfolioCmd `cmd:"" help:"Creates a new portfolio."`
List ListPortfolioCmd `cmd:"" help:"Lists all portfolios."`
Show ShowPortfolioCmd `cmd:"" help:"Shows details about one portfolio."`
ImportTransactions ImportTransactionsCmd `cmd:"" help:"Imports transactions from CSV."`
Create CreatePortfolioCmd `cmd:"" help:"Creates a new portfolio."`
List ListPortfolioCmd `cmd:"" help:"Lists all portfolios."`
Show ShowPortfolioCmd `cmd:"" help:"Shows details about one portfolio."`
Transactions struct {
Create CreateTransactionCmd `cmd:"" help:"Creates a transaction. Defaults to a \"buy\" transaction."`
Import ImportTransactionsCmd `cmd:"" help:"Imports transactions from CSV."`
} `cmd:"" help:"Subcommands supporting transactions within one portfolio"`
}

type ListPortfolioCmd struct{}
Expand Down Expand Up @@ -153,6 +157,74 @@ func greenOrRed(f float32) string {
}
}

type CreateTransactionCmd struct {
PortfolioName string `required:"" predictor:"portfolio" help:"The name of the portfolio where the transaction will be created in"`
SecurityName string `arg:"" predictor:"security" help:"The name of the security this transaction belongs to (its ISIN)"`
Type string `required:"" enum:"buy,sell,delivery-inbound,delivery-outbound,dividend" default:"buy"`
Amount float32 `required:"" help:"The amount of securities involved in the transaction"`
Price float32 `required:"" help:"The price without fees or taxes"`
Fees float32 `help:"Any fees that applied to the transaction"`
Taxes float32 `help:"Any taxes that applied to the transaction"`
Time time.Time `help:"The time of the transaction. Defaults to 'now'" format:"2006-01-02 15:04"`
}

func (cmd *CreateTransactionCmd) Run(s *cli.Session) error {
var req = connect.NewRequest(&portfoliov1.CreatePortfolioTransactionRequest{
Transaction: &portfoliov1.PortfolioEvent{
PortfolioName: cmd.PortfolioName,
SecurityName: cmd.SecurityName,
Type: eventTypeFrom(cmd.Type), // eventTypeFrom(cmd.Type)
Amount: cmd.Amount,
Time: timeOrNow(cmd.Time),
Price: cmd.Price,
Fees: cmd.Fees,
Taxes: cmd.Taxes,
},
})

client := portfoliov1connect.NewPortfolioServiceClient(
http.DefaultClient, "http://localhost:8080",
connect.WithHTTPGet(),
)
res, err := client.CreatePortfolioTransaction(context.Background(), req)
if err != nil {
return err
}

fmt.Printf("Successfully created a %s transaction (%s) for security %s in %s.\n",
color.CyanString(cmd.Type),
color.BlackString(res.Msg.Name),
color.BlueString(res.Msg.SecurityName),
color.BlueString(res.Msg.PortfolioName),
)

return nil
}

func eventTypeFrom(typ string) portfoliov1.PortfolioEventType {
if typ == "buy" {
return portfoliov1.PortfolioEventType_PORTFOLIO_EVENT_TYPE_BUY
} else if typ == "sell" {
return portfoliov1.PortfolioEventType_PORTFOLIO_EVENT_TYPE_SELL
} else if typ == "delivery-inbound" {
return portfoliov1.PortfolioEventType_PORTFOLIO_EVENT_TYPE_DELIVERY_INBOUND
} else if typ == "delivery-outbound" {
return portfoliov1.PortfolioEventType_PORTFOLIO_EVENT_TYPE_DELIVERY_OUTBOUND
} else if typ == "dividend" {
return portfoliov1.PortfolioEventType_PORTFOLIO_EVENT_TYPE_DIVIDEND
}

return portfoliov1.PortfolioEventType_PORTFOLIO_EVENT_TYPE_UNSPECIFIED
}

func timeOrNow(t time.Time) *timestamppb.Timestamp {
if t.IsZero() {
return timestamppb.Now()
}

return timestamppb.New(t)
}

type ImportTransactionsCmd struct {
PortfolioName string `required:"" predictor:"portfolio"`
CsvFile string `arg:"" help:"The path to the CSV file to import"`
Expand Down Expand Up @@ -191,29 +263,25 @@ func (cmd *ImportTransactionsCmd) Run(s *cli.Session) error {
return nil
}

type PortfolioPredictor struct{}

func (p *PortfolioPredictor) Predict(complete.Args) (names []string) {
client := portfoliov1connect.NewPortfolioServiceClient(
http.DefaultClient, "http://localhost:8080",
connect.WithHTTPGet(),
)
res, err := client.ListPortfolios(
context.Background(),
connect.NewRequest(&portfoliov1.ListPortfoliosRequest{}),
)
if err != nil {
return nil
}

for _, p := range res.Msg.Portfolios {
names = append(names, p.Name)
}

return
}

var PredictPortfolios = kongcompletion.WithPredictor(
"portfolio",
&PortfolioPredictor{},
complete.PredictFunc(func(complete.Args) (names []string) {
client := portfoliov1connect.NewPortfolioServiceClient(
http.DefaultClient, "http://localhost:8080",
connect.WithHTTPGet(),
)
res, err := client.ListPortfolios(
context.Background(),
connect.NewRequest(&portfoliov1.ListPortfoliosRequest{}),
)
if err != nil {
return nil
}

for _, p := range res.Msg.Portfolios {
names = append(names, p.Name)
}

return
}),
)
25 changes: 25 additions & 0 deletions cli/commands/securities.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import (
"net/http"

"connectrpc.com/connect"
kongcompletion "github.com/jotaen/kong-completion"
"github.com/oxisto/money-gopher/cli"
portfoliov1 "github.com/oxisto/money-gopher/gen"
"github.com/oxisto/money-gopher/gen/portfoliov1connect"
"github.com/posener/complete"
)

type SecurityCmd struct {
Expand Down Expand Up @@ -99,3 +101,26 @@ func (cmd *UpdateAllQuotesCmd) Run(s *cli.Session) error {

return err
}

var PredictSecurities = kongcompletion.WithPredictor(
"security",
complete.PredictFunc(func(complete.Args) (names []string) {
client := portfoliov1connect.NewSecuritiesServiceClient(
http.DefaultClient, "http://localhost:8080",
connect.WithHTTPGet(),
)
res, err := client.ListSecurities(
context.Background(),
connect.NewRequest(&portfoliov1.ListSecuritiesRequest{}),
)
if err != nil {
return nil
}

for _, p := range res.Msg.Securities {
names = append(names, p.Name)
}

return
}),
)
5 changes: 4 additions & 1 deletion cmd/mgo/mgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ func main() {
kong.UsageOnError(),
)

kongcompletion.Register(parser, commands.PredictPortfolios)
kongcompletion.Register(parser,
commands.PredictPortfolios,
commands.PredictSecurities,
)

// Proceed as normal after kongplete.Complete.
ctx, err := parser.Parse(os.Args[1:])
Expand Down
3 changes: 3 additions & 0 deletions money-gopher.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"jotaen",
"kongcompletion",
"modernc",
"moneyd",
"moneygopher",
"mybank",
"myportfolio",
"oxisto",
"portfoliov",
"protobuf",
Expand Down
2 changes: 1 addition & 1 deletion ui/src/lib/gen/mgo_connect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @generated by protoc-gen-connect-es v1.1.3 with parameter "target=ts"
// @generated by protoc-gen-connect-es v1.1.4 with parameter "target=ts"
// @generated from file mgo.proto (package mgo.portfolio.v1, syntax proto3)
/* eslint-disable */
// @ts-nocheck
Expand Down
2 changes: 1 addition & 1 deletion ui/src/lib/gen/mgo_pb.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @generated by protoc-gen-es v1.4.2 with parameter "target=ts"
// @generated by protoc-gen-es v1.5.1 with parameter "target=ts"
// @generated from file mgo.proto (package mgo.portfolio.v1, syntax proto3)
/* eslint-disable */
// @ts-nocheck
Expand Down