From 3c4a463d8d78d6fcc5935979c1c954a08079b20f Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 7 Oct 2023 21:15:32 +0200 Subject: [PATCH] Implemented simple quote provider for ING Can be useful for derivates. Fixes #165 --- import/csv/csv_importer.go | 8 +- import/csv/csv_importer_test.go | 7 +- service/securities/quote_provider.go | 1 + service/securities/quote_provider_ing.go | 69 ++++++++++ service/securities/quote_provider_ing_test.go | 119 ++++++++++++++++++ 5 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 service/securities/quote_provider_ing.go create mode 100644 service/securities/quote_provider_ing_test.go diff --git a/import/csv/csv_importer.go b/import/csv/csv_importer.go index fd071420..f31ee28b 100644 --- a/import/csv/csv_importer.go +++ b/import/csv/csv_importer.go @@ -149,7 +149,13 @@ func readLine(cr *csv.Reader, pname string) (tx *portfoliov1.PortfolioEvent, sec Currency: lsCurrency(record[3], record[5]), }, } - sec.QuoteProvider = moneygopher.Ref(securities.QuoteProviderYF) + + // Default to YF, but only if we have a ticker symbol, otherwise, let's try ING + if len(sec.ListedOn) >= 0 && len(sec.ListedOn[0].Ticker) > 0 { + sec.QuoteProvider = moneygopher.Ref(securities.QuoteProviderYF) + } else { + sec.QuoteProvider = moneygopher.Ref(securities.QuoteProviderING) + } tx.PortfolioName = pname tx.SecurityName = sec.Name diff --git a/import/csv/csv_importer_test.go b/import/csv/csv_importer_test.go index 530860f5..9c312574 100644 --- a/import/csv/csv_importer_test.go +++ b/import/csv/csv_importer_test.go @@ -26,6 +26,7 @@ import ( "github.com/oxisto/assert" moneygopher "github.com/oxisto/money-gopher" portfoliov1 "github.com/oxisto/money-gopher/gen" + "github.com/oxisto/money-gopher/service/securities" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -111,7 +112,7 @@ func Test_readLine(t *testing.T) { wantSec: &portfoliov1.Security{ Name: "US0378331005", DisplayName: "Apple Inc.", - QuoteProvider: moneygopher.Ref("yf"), + QuoteProvider: moneygopher.Ref(securities.QuoteProviderYF), ListedOn: []*portfoliov1.ListedSecurity{ { SecurityName: "US0378331005", @@ -141,7 +142,7 @@ func Test_readLine(t *testing.T) { wantSec: &portfoliov1.Security{ Name: "US00827B1061", DisplayName: "Affirm Holdings Inc.", - QuoteProvider: moneygopher.Ref("yf"), + QuoteProvider: moneygopher.Ref(securities.QuoteProviderYF), ListedOn: []*portfoliov1.ListedSecurity{ { SecurityName: "US00827B1061", @@ -173,7 +174,7 @@ func Test_readLine(t *testing.T) { wantSec: &portfoliov1.Security{ Name: "DE0005557508", DisplayName: "Deutsche Telekom AG", - QuoteProvider: moneygopher.Ref("yf"), + QuoteProvider: moneygopher.Ref(securities.QuoteProviderYF), ListedOn: []*portfoliov1.ListedSecurity{ { SecurityName: "DE0005557508", diff --git a/service/securities/quote_provider.go b/service/securities/quote_provider.go index 15862088..13835bfc 100644 --- a/service/securities/quote_provider.go +++ b/service/securities/quote_provider.go @@ -28,6 +28,7 @@ var providers map[string]QuoteProvider = make(map[string]QuoteProvider) func init() { RegisterQuoteProvider(QuoteProviderYF, &yf{}) + RegisterQuoteProvider(QuoteProviderING, &ing{}) } // AddCommand adds a command using the specific symbol. diff --git a/service/securities/quote_provider_ing.go b/service/securities/quote_provider_ing.go new file mode 100644 index 00000000..0a8dcc65 --- /dev/null +++ b/service/securities/quote_provider_ing.go @@ -0,0 +1,69 @@ +// Copyright 2023 Christian Banse +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// This file is part of The Money Gopher. + +package securities + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + portfoliov1 "github.com/oxisto/money-gopher/gen" +) + +const QuoteProviderING = "ing" + +type ing struct { + http.Client +} + +type header struct { + Ask float32 `json:"ask"` + AskDate time.Time `json:"askDate"` + Bid float32 `json:"bid"` + BidDate time.Time `json:"bidDate"` + Currency string `json:"currency"` + ISIN string `json:"isin"` + HasBidAsk bool `json:"hasBidAsk"` + Price float32 `json:"price"` + PriceChangedDate time.Time `json:"priceChangeDate"` + WKN string `json:"wkn"` +} + +func (ing *ing) LatestQuote(ctx context.Context, ls *portfoliov1.ListedSecurity) (quote float32, t time.Time, err error) { + var ( + res *http.Response + h header + ) + + res, err = ing.Get(fmt.Sprintf("https://component-api.wertpapiere.ing.de/api/v1/components/instrumentheader/%s", ls.SecurityName)) + if err != nil { + return 0, t, fmt.Errorf("could not fetch quote: %w", err) + } + + err = json.NewDecoder(res.Body).Decode(&h) + if err != nil { + return 0, t, fmt.Errorf("could not decode JSON: %w", err) + } + + if h.HasBidAsk { + return h.Bid, h.BidDate, nil + } else { + return h.Price, h.PriceChangedDate, nil + } +} diff --git a/service/securities/quote_provider_ing_test.go b/service/securities/quote_provider_ing_test.go new file mode 100644 index 00000000..f571598f --- /dev/null +++ b/service/securities/quote_provider_ing_test.go @@ -0,0 +1,119 @@ +// Copyright 2023 Christian Banse +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// This file is part of The Money Gopher. + +package securities + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/oxisto/assert" + portfoliov1 "github.com/oxisto/money-gopher/gen" +) + +func Test_ing_LatestQuote(t *testing.T) { + type fields struct { + Client http.Client + } + type args struct { + ctx context.Context + ls *portfoliov1.ListedSecurity + } + tests := []struct { + name string + fields fields + args args + wantQuote float32 + wantTime time.Time + wantErr assert.Want[error] + }{ + { + name: "http response error", + fields: fields{ + Client: newMockClient(func(req *http.Request) (res *http.Response, err error) { + return nil, http.ErrNotSupported + }), + }, + args: args{ + ctx: context.TODO(), + ls: &portfoliov1.ListedSecurity{ + SecurityName: "My Security", + Ticker: "TICK", + Currency: "USD", + }, + }, + wantErr: func(t *testing.T, err error) bool { + return errors.Is(err, http.ErrNotSupported) + }, + }, + { + name: "invalid JSON", + fields: fields{ + Client: newMockClient(func(req *http.Request) (res *http.Response, err error) { + r := httptest.NewRecorder() + r.WriteString(`{]`) + return r.Result(), nil + }), + }, + args: args{ + ls: &portfoliov1.ListedSecurity{ + SecurityName: "My Security", + Ticker: "TICK", + Currency: "USD", + }, + }, + wantErr: func(t *testing.T, err error) bool { + return strings.Contains(err.Error(), "invalid") + }, + }, + { + name: "happy path", + fields: fields{ + Client: newMockClient(func(req *http.Request) (res *http.Response, err error) { + r := httptest.NewRecorder() + r.WriteString(`{"id":1,"name":"Call on My Security","bid": 100.0, "bidDate":"2023-05-04T20:00:00+00:00","hasBidAsk":true}`) + return r.Result(), nil + }), + }, + args: args{ + ls: &portfoliov1.ListedSecurity{ + SecurityName: "DE0000000001", + Ticker: "", + Currency: "EUR", + }, + }, + wantQuote: float32(100.0), + wantTime: time.Date(2023, 05, 04, 20, 0, 0, 0, time.UTC), + wantErr: func(t *testing.T, err error) bool { return true }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ing := &ing{ + Client: tt.fields.Client, + } + gotQuote, gotTime, err := ing.LatestQuote(tt.args.ctx, tt.args.ls) + assert.Equals(t, true, tt.wantErr(t, err)) + assert.Equals(t, tt.wantQuote, gotQuote) + assert.Equals(t, tt.wantTime.UTC(), gotTime.UTC()) + }) + } +}