Skip to content

Commit

Permalink
feat: implement websocket client for quotes
Browse files Browse the repository at this point in the history
Signed-off-by: Marek Cermak <[email protected]>
  • Loading branch information
CermakM committed Nov 22, 2024
1 parent 1fa6f67 commit 2e95d85
Show file tree
Hide file tree
Showing 11 changed files with 386 additions and 193 deletions.
6 changes: 2 additions & 4 deletions client/client.go → client/rest/client.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package client
package rest

import (
"context"
Expand All @@ -15,16 +15,14 @@ import (
)

const (
apiURL = "https://financialmodelingprep.com"
clientVersion = "v0.0.0"
)

const (
DefaultRetryCount = 3
DefaultClientTimeout = 10 * time.Second
)

func New(
apiURL string,
apiKey string,
logger *slog.Logger,
) *Client {
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@ module go.tradeforge.dev/fmp
go 1.23.3

require (
github.com/eapache/go-resiliency v1.7.0
github.com/caarlos0/env/v10 v10.0.0
github.com/go-playground/form/v4 v4.2.1
github.com/go-playground/validator/v10 v10.23.0
github.com/go-resty/resty/v2 v2.11.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.9.0
go.tradeforge.dev/background v0.2.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA=
github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand All @@ -21,6 +23,8 @@ github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqx
github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
Expand Down
60 changes: 0 additions & 60 deletions market/client.go

This file was deleted.

4 changes: 2 additions & 2 deletions market/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"net/http"

"go.tradeforge.dev/fmp/client"
"go.tradeforge.dev/fmp/client/rest"
"go.tradeforge.dev/fmp/model"
)

Expand All @@ -14,7 +14,7 @@ const (
)

type EventClient struct {
*client.Client
*rest.Client
}

func (ec *EventClient) GetEarningsCalendar(ctx context.Context, params *model.GetEarningsCalendarParams, opts ...model.RequestOption) ([]model.GetEarningsCalendarResponse, error) {
Expand Down
90 changes: 90 additions & 0 deletions market/fmp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Package market defines HTTP and a Websocket client for the FMP API.
package market

import (
"context"
"errors"
"log/slog"
"sync"

"github.com/gorilla/websocket"
"go.tradeforge.dev/background/manager"

"go.tradeforge.dev/fmp/client/rest"
"go.tradeforge.dev/fmp/model"
)

type HTTPClientConfig struct {
ApiKey string `validate:"required" env:"FMP_API_KEY"`

Check failure on line 18 in market/fmp.go

View workflow job for this annotation

GitHub Actions / Lint

ST1003: struct field ApiKey should be APIKey (stylecheck)
}

// HTTPClient defines a client to the Polygon REST API.
type HTTPClient struct {
QuoteClient
TickerClient
EventClient
}

// NewHTTPClient returns a new HTTP client with the specified API key and config.
func NewHTTPClient(
config HTTPClientConfig,
logger *slog.Logger,
) *HTTPClient {
c := rest.New(
config.ApiKey,
logger,
)

return &HTTPClient{
QuoteClient: QuoteClient{
Client: c,
},
TickerClient: TickerClient{
Client: c,
},
EventClient: EventClient{
Client: c,
},
}
}

type WebsocketClientConfig struct {
ApiKey string `validate:"required" env:"FMP_API_KEY"`

Check failure on line 52 in market/fmp.go

View workflow job for this annotation

GitHub Actions / Lint

ST1003: struct field ApiKey should be APIKey (stylecheck)
}

func NewWebsocketClient(
ctx context.Context,
config WebsocketClientConfig,
logger *slog.Logger,
) (*WebsocketClient, error) {
if ctx.Done() != nil {
return nil, errors.New("context is already cancelled")
}
return &WebsocketClient{
ctx: ctx,
config: config,
logger: logger,
manager: manager.New(ctx, manager.WithCancelOnError(), manager.WithFirstError()),

events: make(chan model.WebsocketMesssage),
quotes: make(chan model.WebsocketQuote),
}, nil
}

type WebsocketClient struct {
ctx context.Context
config WebsocketClientConfig
logger *slog.Logger

manager *manager.Manager

connectOnce sync.Once
connectionLock sync.Mutex

Check failure on line 82 in market/fmp.go

View workflow job for this annotation

GitHub Actions / Lint

field `connectionLock` is unused (unused)
connection *websocket.Conn

subscribedQuotesLock sync.RWMutex
subscribedQuotes map[string]struct{}

Check failure on line 86 in market/fmp.go

View workflow job for this annotation

GitHub Actions / Lint

field `subscribedQuotes` is unused (unused)

events chan model.WebsocketMesssage
quotes chan model.WebsocketQuote
}
94 changes: 47 additions & 47 deletions market/quote.go
Original file line number Diff line number Diff line change
@@ -1,77 +1,77 @@
package market

import (
"context"
"fmt"
"net/http"
"context"
"fmt"
"net/http"

"go.tradeforge.dev/fmp/client"
"go.tradeforge.dev/fmp/model"
"go.tradeforge.dev/fmp/client/rest"
"go.tradeforge.dev/fmp/model"
)

const (
GetRealTimeQuotePath = "/api/v3/stock/full/real-time-price/:symbol"
GetFullPricePath = "/api/v3/quote/:symbol"
GetPriceChangePath = "/api/v3/stock-price-target/:symbol"
GetRealTimeQuotePath = "/api/v3/stock/full/real-time-price/:symbol"
GetFullPricePath = "/api/v3/quote/:symbol"
GetPriceChangePath = "/api/v3/stock-price-target/:symbol"

BatchGetRealTimeQuotePath = "/api/v3/stock/full/real-time-price/:symbols"
BatchGetFullPricePath = "/api/v3/quote/:symbols"
BatchGetRealTimeQuotePath = "/api/v3/stock/full/real-time-price/:symbols"
BatchGetFullPricePath = "/api/v3/quote/:symbols"
)

type QuoteClient struct {
*client.Client
*rest.Client
}

func (qc *QuoteClient) GetFullPrice(ctx context.Context, params *model.GetFullPriceParams, opts ...model.RequestOption) (response *model.GetFullPriceResponse, err error) {
var res []model.GetFullPriceResponse
_, err = qc.Call(ctx, http.MethodGet, GetFullPricePath, params, &res, opts...)
if err != nil {
return nil, err
}
if len(res) != 1 {
return nil, fmt.Errorf("expected response of length 1, got %d", len(res))
}
return &res[0], nil
var res []model.GetFullPriceResponse
_, err = qc.Call(ctx, http.MethodGet, GetFullPricePath, params, &res, opts...)
if err != nil {
return nil, err
}
if len(res) != 1 {
return nil, fmt.Errorf("expected response of length 1, got %d", len(res))
}
return &res[0], nil
}

func (qc *QuoteClient) BatchGetFullPrice(ctx context.Context, params *model.BatchGetFullPriceParams, opts ...model.RequestOption) (model.BatchGetFullPriceResponse, error) {
var res model.BatchGetFullPriceResponse
_, err := qc.Call(ctx, http.MethodGet, BatchGetFullPricePath, params, &res, opts...)
return res, err
var res model.BatchGetFullPriceResponse
_, err := qc.Call(ctx, http.MethodGet, BatchGetFullPricePath, params, &res, opts...)
return res, err
}

func (qc *QuoteClient) GetPriceChange(ctx context.Context, params *model.GetPriceChangeParams, opts ...model.RequestOption) (response *model.GetPriceChangeResponse, err error) {
var res []model.GetPriceChangeResponse
_, err = qc.Call(ctx, http.MethodGet, GetPriceChangePath, params, &res, opts...)
if err != nil {
return nil, err
}
if len(res) != 1 {
return nil, fmt.Errorf("expected response of length 1, got %d", len(res))
}
return &res[0], nil
var res []model.GetPriceChangeResponse
_, err = qc.Call(ctx, http.MethodGet, GetPriceChangePath, params, &res, opts...)
if err != nil {
return nil, err
}
if len(res) != 1 {
return nil, fmt.Errorf("expected response of length 1, got %d", len(res))
}
return &res[0], nil
}

func (qc *QuoteClient) BatchGetPriceChange(ctx context.Context, params *model.BatchGetPriceChangeParams, opts ...model.RequestOption) ([]model.GetPriceChangeResponse, error) {
var res []model.GetPriceChangeResponse
_, err := qc.Call(ctx, http.MethodGet, BatchGetRealTimeQuotePath, params, &res, opts...)
return res, err
var res []model.GetPriceChangeResponse
_, err := qc.Call(ctx, http.MethodGet, BatchGetRealTimeQuotePath, params, &res, opts...)
return res, err
}

func (qc *QuoteClient) GetRealTimeQuote(ctx context.Context, params *model.GetRealTimeQuoteParams, opts ...model.RequestOption) (response *model.GetRealTimeQuoteResponse, err error) {
var res []model.GetRealTimeQuoteResponse
_, err = qc.Call(ctx, http.MethodGet, GetRealTimeQuotePath, params, &res, opts...)
if err != nil {
return nil, err
}
if len(res) != 1 {
return nil, fmt.Errorf("expected response of length 1, got %d", len(res))
}
return &res[0], nil
var res []model.GetRealTimeQuoteResponse
_, err = qc.Call(ctx, http.MethodGet, GetRealTimeQuotePath, params, &res, opts...)
if err != nil {
return nil, err
}
if len(res) != 1 {
return nil, fmt.Errorf("expected response of length 1, got %d", len(res))
}
return &res[0], nil
}

func (qc *QuoteClient) BatchGetRealTimeQuote(ctx context.Context, params *model.BatchGetRealTimeQuoteParams, opts ...model.RequestOption) (model.BatchGetRealTimeQuoteResponse, error) {
var res model.BatchGetRealTimeQuoteResponse
_, err := qc.Call(ctx, http.MethodGet, BatchGetRealTimeQuotePath, params, &res, opts...)
return res, err
var res model.BatchGetRealTimeQuoteResponse
_, err := qc.Call(ctx, http.MethodGet, BatchGetRealTimeQuotePath, params, &res, opts...)
return res, err
}
4 changes: 2 additions & 2 deletions market/ticker.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"fmt"
"net/http"

"go.tradeforge.dev/fmp/client"
"go.tradeforge.dev/fmp/client/rest"
"go.tradeforge.dev/fmp/model"
)

Expand All @@ -25,7 +25,7 @@ const (
)

type TickerClient struct {
*client.Client
*rest.Client
}

func (tc *TickerClient) GetCompanyProfile(ctx context.Context, params *model.GetCompanyProfileParams, opts ...model.RequestOption) (_ *model.GetCompanyProfileResponse, err error) {
Expand Down
Loading

0 comments on commit 2e95d85

Please sign in to comment.