diff --git a/README.md b/README.md index 8184b883..30eac38d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 😃. diff --git a/buf.lock b/buf.lock new file mode 100644 index 00000000..c91b5810 --- /dev/null +++ b/buf.lock @@ -0,0 +1,2 @@ +# Generated by buf. DO NOT EDIT. +version: v1 diff --git a/cli/commands/portfolio.go b/cli/commands/portfolio.go index bef7b2e4..c915052e 100644 --- a/cli/commands/portfolio.go +++ b/cli/commands/portfolio.go @@ -24,6 +24,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/fatih/color" kongcompletion "github.com/jotaen/kong-completion" @@ -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{} @@ -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"` @@ -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 + }), ) diff --git a/cli/commands/securities.go b/cli/commands/securities.go index 77ba9620..de4a4684 100644 --- a/cli/commands/securities.go +++ b/cli/commands/securities.go @@ -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 { @@ -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 + }), +) diff --git a/cmd/mgo/mgo.go b/cmd/mgo/mgo.go index 5c373731..541c1b70 100644 --- a/cmd/mgo/mgo.go +++ b/cmd/mgo/mgo.go @@ -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:]) diff --git a/money-gopher.code-workspace b/money-gopher.code-workspace index a75a227b..f9201ce5 100644 --- a/money-gopher.code-workspace +++ b/money-gopher.code-workspace @@ -22,7 +22,10 @@ "jotaen", "kongcompletion", "modernc", + "moneyd", "moneygopher", + "mybank", + "myportfolio", "oxisto", "portfoliov", "protobuf", diff --git a/ui/src/lib/gen/mgo_connect.ts b/ui/src/lib/gen/mgo_connect.ts index 6afcd499..778250a6 100644 --- a/ui/src/lib/gen/mgo_connect.ts +++ b/ui/src/lib/gen/mgo_connect.ts @@ -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 diff --git a/ui/src/lib/gen/mgo_pb.ts b/ui/src/lib/gen/mgo_pb.ts index cdbdb436..fad4696c 100644 --- a/ui/src/lib/gen/mgo_pb.ts +++ b/ui/src/lib/gen/mgo_pb.ts @@ -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