DEV Community

Robin Muhia
Robin Muhia

Posted on

Unlocking Profit Potential: Building an Arbitrage Betting Client with Hexagonal Architecture in Golang

Introduction

What if i told you 🫵 that money can grow on trees? Sounds like a scam, right?

However, mathematics is on our side when it comes to betting.

In the fast-paced world of sports betting, the pursuit of profit often hinges on the ability to capitalize on fleeting opportunities. Arbitrage betting, a strategy that exploits pricing discrepancies in the market, stands as a beacon for those seeking consistent gains in this dynamic landscape. However, harnessing the potential of arbitrage demands more than just keen intuition—it requires robust technological solutions that can swiftly identify and exploit these fleeting differentials.

In this article, we delve into the realm of arbitrage betting and explore how to construct a powerful betting client using the principles of hexagonal architecture in the Go programming language (Golang). By employing this modular and scalable approach, we aim to empower both novice and seasoned bettors with the tools necessary to navigate the complexities of the betting market and unlock untapped profit potential.

Join us on this journey as we dissect the intricacies of arbitrage betting, unveil the inner workings of the hexagonal architecture, and demonstrate how their harmonious integration in Golang can revolutionize your approach to sports betting.

If you're only here for the repository, this is the Github link.

If you want to use the library in your application, then get the library as shown below;

go get github.com/robinmuhia/arbitrageClient@latest
Enter fullscreen mode Exit fullscreen mode

What is Arbitrage Betting?

Arbitrage betting is a strategy where a bettor takes advantage of differences in odds offered by different bookmakers to guarantee a profit. By placing bets on all possible outcomes of a sports event across different bookmakers, the bettor ensures that they will make a profit regardless of the outcome. This is possible when the odds offered by different bookmakers imply probabilities that add up to less than 100%. The bettor calculates the optimal bet sizes to ensure a profit regardless of the outcome. Arbitrage opportunities are usually short-lived and require quick action to exploit.

Simply put, say a game has three possible outcomes, we can bet on all 3 possible outcomes i.e. the home team wins, the away team wins or a draw happens and in each case, we are GUARANTEED a profit. We will make money regardless of the outcome.

To learn more about arbitrage betting, read this article.

What is the hexagonal architecture?

The hexagonal architecture, also known as Ports and Adapters architecture or the onion architecture, is a software design pattern that promotes modular and loosely coupled systems. It was first introduced by Alistair Cockburn in 2005 as a way to address some of the shortcomings of traditional layered architectures.

At its core, the hexagonal architecture revolves around the idea of organizing the components of a system into concentric layers, with the business logic or core functionality residing at the center. These layers are typically represented as hexagons, hence the name.

The key principles of the hexagonal architecture include:

  1. Separation of Concerns: The architecture emphasizes dividing the system into distinct layers, each responsible for a specific concern. This separation facilitates easier maintenance, testing, and evolution of the system.

  2. Ports and Adapters: In hexagonal architecture, components communicate through well-defined interfaces known as ports. These ports abstract away external dependencies and allow the core business logic to remain decoupled from implementation details. Adapters are then used to connect these ports to external systems or frameworks.

  3. Domain-Driven Design (DDD): Hexagonal architecture encourages a domain-centric approach, where the core business logic is modeled based on the problem domain rather than technical considerations. This helps ensure that the system closely aligns with the real-world problem it aims to solve.

  4. Testability: By isolating the core business logic from external dependencies, such as databases or external services, hexagonal architecture promotes easier testing. Components can be tested in isolation using mock implementations of dependencies, leading to more robust and reliable software.

Overall, the hexagonal architecture promotes flexibility, maintainability, and testability, making it well-suited for complex and evolving software systems. It provides a solid foundation for building scalable and adaptable applications across various domains, including web development, microservices, and domain-specific applications.

Read more about the hexagonal architecture here.

Prerequisites

We will be build our Arbitrage Client around the Odds API so go to that link and grab an API key. They have a free tier which is limited to 500 requests per month. We will need around 75 requests per session so it is enough for testing the functionality and logic.

Additionally, we hope you have some basic understanding of programming so as to follow the article. Some Golang knowlegde is a plus as well.

Project Structure

You can follow along or clone the repository in a folder

git clone git@github.com:robinmuhia/arbitrageClient.git .
Enter fullscreen mode Exit fullscreen mode

Make sure the project is structured as shown below;

Image description

Lets explore the folders;

  1. Application - this holds application specific code which in our case is enums that we will use in the project.

  2. Domain - this holds domain specific models such as how an odd, sport, bookmaker etc should be represented. The domain is only an interface for the models in the project.

  3. Infrastructure - this is where we put our external actors that we depend on. Here we can put external services that we require such as a database or external services such as messaging client like Twilio.

  4. Usecases - this is where we put our business logic . It is independent of other components. We can swap out our services and the business logic should work as is.

This structure allows us to swap out components as is. We can change the odds API and use another API. Our usecases should remain as is. This reduces the coupling of our code and allows us to use dependency injection efficiently.

The Arbitrage client

Application folder

We have our enums here which will be shared across the project.

endpoints.go

package enums

type endpoint string

const (
    Sport endpoint = "sports"
    Odds  endpoint = "odds"
)

func (e endpoint) String() string {
    return string(e)
}

Enter fullscreen mode Exit fullscreen mode

This is where we put the endpoints we will call.

envs.go

package enums

type env string

const (
    BaseURL   env = "ODDS_API_BASE_URL"
    ApiKeyEnv env = "ODDS_API_KEY" //nolint: gosec
)

func (e env) String() string {
    return string(e)
}
Enter fullscreen mode Exit fullscreen mode

This is where we will house how our environment variables are called.

params.go

package enums

type params string

const (
    ApiKey     params = "apiKey"
    Region     params = "regions"
    Markets    params = "markets"
    OddsFormat params = "oddsFormat"
    DateFormat params = "dateFormat"
)

func (e params) String() string {
    return string(e)
}
Enter fullscreen mode Exit fullscreen mode

These are the parameters that we will we will use to embed in our calls to the API

Domain folder

arbs.go

package domain

// ThreeOddsArb represents the structure of how a match with three possible outcomes
// i.e a win, draw or loss will be represented in the response
type ThreeOddsArb struct {
    Title     string
    Home      string
    HomeOdds  float64
    HomeStake float64
    Draw      string
    DrawOdds  float64
    DrawStake float64
    Away      string
    AwayStake float64
    AwayOdds  float64
    GameType  string
    League    string
    Profit    float64
    GameTime  string
}

// TwoOddsArb represents the structure of how a match with three possible outcomes
// i.e a win or loss will be represented in the response
type TwoOddsArb struct {
    Title     string
    Home      string
    HomeOdds  float64
    HomeStake float64
    Away      string
    AwayStake float64
    AwayOdds  float64
    GameType  string
    League    string
    Profit    float64
    GameTime  string
}
Enter fullscreen mode Exit fullscreen mode

These are how the arbs will be represented.

odds.go

package domain

// Outcome the name and price of an individual outcome of a bet eg. Bayern 1.26
type Outcome struct {
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

// Market represents the bookmarkers' odds for a game
type Market struct {
    Key        string    `json:"key"`
    LastUpdate string    `json:"last_update"`
    Outcomes   []Outcome `json:"outcomes"`
}

// Bookmaker describes the bookmarker such as bet365
type Bookmaker struct {
    Key        string   `json:"key"`
    Title      string   `json:"title"`
    LastUpdate string   `json:"last_update"`
    Markets    []Market `json:"markets"`
}

// Odds represent the odds structure of the games's odds
type Odds struct {
    ID           string      `json:"id"`
    SportKey     string      `json:"sport_key"`
    SportTitle   string      `json:"sport_title"`
    CommenceTime string      `json:"commence_time"`
    HomeTeam     string      `json:"home_team"`
    AwayTeam     string      `json:"away_team"`
    Bookmakers   []Bookmaker `json:"bookmakers"`
}
Enter fullscreen mode Exit fullscreen mode

These is how odds from the API will be represented.

sport.go

package domain

// Sport represents a sport response from OddsAPI
type Sport struct {
    Key          string `json:"key"`
    Group        string `json:"group"`
    Title        string `json:"title"`
    Description  string `json:"description"`
    Active       bool   `json:"active"`
    HasOutrights bool   `json:"has_outrights"`
}
Enter fullscreen mode Exit fullscreen mode

These is how sports from the API will be represented.

Infrastructure

client.go

package services

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "sync"
    "time"

    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/application/enums"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/domain"
)

var (
    baseURL = os.Getenv(enums.BaseURL.String())
    apiKey  = os.Getenv(enums.ApiKeyEnv.String())
)

// oddsoddsAPIHTTPClient instantiates a client to call the odds API url
type oddsAPIHTTPClient struct {
    client  *http.Client
    baseURL string
    apiKey  string
}

// ArbClient implements methods intended to be exposed by the oddsHTTPClient
type ArbClient interface {
    GetAllOdds(ctx context.Context, oddsParams OddsParams) ([]domain.Odds, error)
}

// OddsParams represent the parameters required to query for specific odds
type OddsParams struct {
    Region     string
    Markets    string
    OddsFormat string
    DateFormat string
}
Enter fullscreen mode Exit fullscreen mode

These are the structs, interfaces and variables we will use to be able to call the API.

Our ArbClient interface has the GetAllOdds method. This method is the only method we will expose to other external services/folders. Thus, in future, if we were to change the api we call, we can refactor the code but the method should always be provided for this service.

// NewServiceOddsAPI returns a new instance of an OddsAPI service
func NewServiceOddsAPI() (*oddsAPIHTTPClient, error) {
    if baseURL == "" {
        return nil, fmt.Errorf("empty env variables, %s", enums.BaseURL.String())
    }

    if apiKey == "" {
        return nil, fmt.Errorf("empty env variables, %s", enums.ApiKeyEnv.String())
    }

    return &oddsAPIHTTPClient{
        client: &http.Client{
            Timeout: time.Second * 10,
        },
        baseURL: baseURL,
        apiKey:  apiKey,
    }, nil

Enter fullscreen mode Exit fullscreen mode

This function is simple, it returns a HTTP client that should have the above configuration.

As seen above, the apikey and baseUrl should be provided from our environment variables.

// makeRequest calls the Odds API endpoint
func (s *oddsAPIHTTPClient) makeRequest(ctx context.Context, method, urlPath string, queryParams url.Values, _ interface{}) (*http.Response, error) {
    var request *http.Request

    switch method {
    case http.MethodGet:
        req, err := http.NewRequestWithContext(ctx, method, urlPath, nil)
        if err != nil {
            return nil, err
        }

        request = req

    default:
        return nil, fmt.Errorf("unsupported http method: %s", method)
    }

    request.Header.Set("Accept", "application/json")
    request.Header.Set("Content-Type", "application/json")

    if queryParams != nil {
        request.URL.RawQuery = queryParams.Encode()
    }

    return s.client.Do(request)
}
Enter fullscreen mode Exit fullscreen mode

The makerequest function is used to make requests as explained by the docstring. Currently, it only allows GET method, constructs the requests and does the request.

// getSports returns a list of sports and an error
func (s oddsAPIHTTPClient) getSports(ctx context.Context) ([]domain.Sport, error) {
    urlPath := fmt.Sprintf("%s/%s", s.baseURL, enums.Sport.String())

    queryParams := url.Values{}
    queryParams.Add(enums.ApiKey.String(), s.apiKey)

    resp, err := s.makeRequest(ctx, http.MethodGet, urlPath, queryParams, nil)
    if err != nil {
        return nil, err
    }

    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("failed to get sports data: %s", resp.Status)
    }

    var sports []domain.Sport
    if err := json.NewDecoder(resp.Body).Decode(&sports); err != nil {
        return nil, fmt.Errorf("failed to get sports: %w", err)
    }

    return sports, nil
}
Enter fullscreen mode Exit fullscreen mode

This function retrieves all the sports from the API and serializes them to our Sport domain.

A sport in this case is for example in football/soccer, EPL, La liga and Champions League are all a type of sport.

// getOdd returns all odds from one sport
func (s oddsAPIHTTPClient) getOdd(ctx context.Context, oddParams OddsParams, sport domain.Sport, wg *sync.WaitGroup) ([]domain.Odds, error) {
    defer wg.Done()

    urlPath := fmt.Sprintf("%s/%s/%s/%s", s.baseURL, enums.Sport.String(), sport.Key, enums.Odds.String())

    queryParams := url.Values{}
    queryParams.Add(enums.ApiKey.String(), s.apiKey)
    queryParams.Add(enums.Region.String(), oddParams.Region)
    queryParams.Add(enums.Markets.String(), oddParams.Markets)
    queryParams.Add(enums.OddsFormat.String(), oddParams.OddsFormat)
    queryParams.Add(enums.DateFormat.String(), oddParams.DateFormat)

    resp, err := s.makeRequest(ctx, http.MethodGet, urlPath, queryParams, nil)
    if err != nil {
        return nil, err
    }

    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("failed to get sports data: %s", resp.Status)
    }

    var odds []domain.Odds
    if err := json.NewDecoder(resp.Body).Decode(&odds); err != nil {
        return nil, fmt.Errorf("failed to decode odds data for %s: %w", sport.Title, err)
    }

    return odds, nil
}
Enter fullscreen mode Exit fullscreen mode

This functions gets all the odds from one sport. It serializes the response into an odd.

// GetOdds returns a list of all available odds given various parameters across all sports
func (s oddsAPIHTTPClient) GetAllOdds(ctx context.Context, oddsParams OddsParams) ([]domain.Odds, error) {
    sports, err := s.getSports(ctx)
    if err != nil {
        return nil, err
    }

    ticker := time.NewTicker(1 * time.Second)

    var wg sync.WaitGroup

    var mu sync.Mutex

    var allOdds []domain.Odds

    for _, sport := range sports {
        if sport.Active {
            wg.Add(1)

            go func() {
                odds, err := s.getOdd(ctx, oddsParams, sport, &wg)
                if err != nil {
                    log.Print(err.Error())
                } else {
                mu.Lock()
                allOdds = append(allOdds, odds...)
                mu.Unlock()
                }
            }()
        }

        <-ticker.C // waits a second to send next goroutine, intended to prevent ddosing and rate limiting
    }

    wg.Wait()

    ticker.Stop()

    return allOdds, nil
}

Enter fullscreen mode Exit fullscreen mode

This is where the magic happens, we want to get all the odds from all the sports as quick as possible. A normal for loop was getting rate limited as we are making requests for each odd.

The optimal way i found was to first get all the sports and then loop through each sport and checking if it is active. If it is active, we then get odd from each sport in a goroutine.

Since making requests is an I/O bound task, the response can take an unforeseen amount of time to execute hence the use of goroutines. Each call will execute in its own thread. We synchronize these goroutines using the sync waitgroup library. Each time a goroutine is span up, we add one to the waitgroup then in the sport function, we defer the decrement of the waitgroup.

The reason for this is to ensure all goroutines finish executing before we proceed, hence the wg.Wait(). The code will not proceed till the waitgroup reaches a value of 0.

Once we receive the odds, we append the odds to the allOdds slice. We use a mutex to make sure only one goroutine can append the odds to the slice at a time. This is done to avoid race conditions in case some goroutines complete at the same time. Thus we lock the mutex and unlock it after the odds have been appended.

client_test.go

package services

import (
    "context"
    "fmt"
    "net/http"
    "net/url"
    "sync"
    "testing"

    "github.com/jarcoal/httpmock"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/application/enums"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/domain"
)

func Test_oddsAPIHTTPClient_makeRequest(t *testing.T) {
    type args struct {
        ctx         context.Context
        method      string
        urlPath     string
        queryParams url.Values
        in4         interface{}
    }

    queryParams := url.Values{}
    queryParams.Add("foo", "bar")

    tests := []struct {
        name    string
        args    args
        wantErr bool
    }{
        {
            name: "happy case: successful request",
            args: args{
                ctx:         context.Background(),
                method:      http.MethodGet,
                urlPath:     "https://www.foo.com",
                queryParams: queryParams,
            },
            wantErr: false,
        },
        {
            name: "sad case: invalid http method",
            args: args{
                ctx:         context.Background(),
                method:      http.MethodPost,
                urlPath:     "https://www.foo.com",
                queryParams: queryParams,
            },
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if tt.name == "happy case: successful request" {
                httpmock.RegisterResponder(http.MethodGet, tt.args.urlPath, func(r *http.Request) (*http.Response, error) { //nolint:all
                    return httpmock.NewJsonResponse(http.StatusOK, nil)
                })
            }

            httpmock.Activate()
            defer httpmock.DeactivateAndReset()

            s, _ := NewServiceOddsAPI()
            resp, err := s.makeRequest(tt.args.ctx, tt.args.method, tt.args.urlPath, tt.args.queryParams, tt.args.in4)

            if err == nil {
                defer resp.Body.Close()
            }

            if (err != nil) != tt.wantErr {
                t.Errorf("oddsAPIHTTPClient.makeRequest() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
        })
    }
}

func Test_oddsAPIHTTPClient_getSports(t *testing.T) {
    type args struct {
        ctx context.Context
    }

    tests := []struct {
        name    string
        args    args
        wantErr bool
    }{
        {
            name: "happy case: successful retrieval of sport",
            args: args{
                ctx: context.Background(),
            },
            wantErr: false,
        },
        {
            name: "sad case: unable to decode sport",
            args: args{
                ctx: context.Background(),
            },
            wantErr: true,
        },
        {
            name: "sad case: unsuccessful retrieval of sport",
            args: args{
                ctx: context.Background(),
            },
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            s, _ := NewServiceOddsAPI()
            urlPath := fmt.Sprintf("%s/%s", s.baseURL, enums.Sport.String())

            if tt.name == "happy case: successful retrieval of sport" {
                httpmock.RegisterResponder(http.MethodGet, urlPath, func(r *http.Request) (*http.Response, error) {
                    resp := []domain.Sport{
                        {
                            Key:   "foo",
                            Title: "bar",
                        },
                    }

                    return httpmock.NewJsonResponse(http.StatusOK, resp)
                })
            }

            if tt.name == "sad case: unsuccessful retrieval of sport" {
                httpmock.RegisterResponder(http.MethodGet, urlPath, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusUnauthorized, nil)
                })
            }

            if tt.name == "sad case: unable to decode sport" {
                httpmock.RegisterResponder(http.MethodGet, urlPath, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusOK, "nana")
                })
            }

            httpmock.Activate()
            defer httpmock.DeactivateAndReset()

            _, err := s.getSports(tt.args.ctx)

            if (err != nil) != tt.wantErr {
                t.Errorf("oddsAPIHTTPClient.makeRequest() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
        })
    }
}

func Test_oddsAPIHTTPClient_getOdd(t *testing.T) {
    type args struct {
        ctx       context.Context
        oddParams OddsParams
        sport     domain.Sport
        wg        *sync.WaitGroup
    }

    tests := []struct {
        name    string
        args    args
        wantErr bool
    }{
        {
            name: "happy case: successful retrieval of odd",
            args: args{
                ctx: context.Background(),
                oddParams: OddsParams{
                    Region:     "foo",
                    Markets:    "bar",
                    OddsFormat: "foo",
                    DateFormat: "bar",
                },
                sport: domain.Sport{
                    Key: "foo",
                },
                wg: &sync.WaitGroup{},
            },
            wantErr: false,
        },
        {
            name: "sad case: unable to decode odd",
            args: args{
                ctx: context.Background(),
                oddParams: OddsParams{
                    Region:     "foo",
                    Markets:    "bar",
                    OddsFormat: "foo",
                    DateFormat: "bar",
                },
                sport: domain.Sport{
                    Key: "foo",
                },
                wg: &sync.WaitGroup{},
            },
            wantErr: true,
        },
        {
            name: "sad case: unsuccessful retrieval of odd",
            args: args{
                ctx: context.Background(),
                oddParams: OddsParams{
                    Region:     "foo",
                    Markets:    "bar",
                    OddsFormat: "foo",
                    DateFormat: "bar",
                },
                sport: domain.Sport{
                    Key: "foo",
                },
                wg: &sync.WaitGroup{},
            },
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            s, _ := NewServiceOddsAPI()

            urlPath := fmt.Sprintf("%s/%s/%s/%s", s.baseURL, enums.Sport.String(), tt.args.sport.Key, enums.Odds.String())

            tt.args.wg.Add(1)

            if tt.name == "happy case: successful retrieval of odd" {
                httpmock.RegisterResponder(http.MethodGet, urlPath, func(r *http.Request) (*http.Response, error) {
                    resp := []domain.Odds{{
                        ID:       "foo",
                        SportKey: "bar",
                    },
                    }

                    return httpmock.NewJsonResponse(http.StatusOK, resp)
                })
            }

            if tt.name == "sad case: unsuccessful retrieval odd" {
                httpmock.RegisterResponder(http.MethodGet, urlPath, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusUnauthorized, nil)
                })
            }

            if tt.name == "sad case: unable to decode odd" {
                httpmock.RegisterResponder(http.MethodGet, urlPath, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusOK, "nana")
                })
            }

            httpmock.Activate()
            defer httpmock.DeactivateAndReset()

            _, err := s.getOdd(tt.args.ctx, tt.args.oddParams, tt.args.sport, tt.args.wg)
            if (err != nil) != tt.wantErr {
                t.Errorf("oddsAPIHTTPClient.getOdd() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
        })
    }
}

func Test_oddsAPIHTTPClient_GetAllOdds(t *testing.T) {
    type args struct {
        ctx        context.Context
        oddsParams OddsParams
    }

    tests := []struct {
        name    string
        args    args
        wantErr bool
    }{
        {
            name: "happy case: successfully get all odds",
            args: args{
                ctx: context.Background(),
                oddsParams: OddsParams{
                    Region:     "foo",
                    Markets:    "bar",
                    OddsFormat: "foo",
                    DateFormat: "bar",
                },
            },
            wantErr: false,
        },
        {
            name: "sad case: unsuccessfully get all odds",
            args: args{
                ctx: context.Background(),
                oddsParams: OddsParams{
                    Region:     "foo",
                    Markets:    "bar",
                    OddsFormat: "foo",
                    DateFormat: "bar",
                },
            },
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            s, _ := NewServiceOddsAPI()

            sportPath := fmt.Sprintf("%s/%s", s.baseURL, enums.Sport.String())

            if tt.name == "happy case: successfully get all odds" {
                resp := []domain.Sport{
                    {
                        Key:    "foo",
                        Title:  "bar",
                        Active: true,
                    },
                    {
                        Key:    "bar",
                        Title:  "foo",
                        Active: true,
                    },
                }

                httpmock.RegisterResponder(http.MethodGet, sportPath, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusOK, resp)
                })

                oddPath1 := fmt.Sprintf("%s/%s/%s/%s", s.baseURL, enums.Sport.String(), resp[0].Key, enums.Odds.String())
                httpmock.RegisterResponder(http.MethodGet, oddPath1, func(r *http.Request) (*http.Response, error) {
                    oddResp1 := []domain.Odds{
                        {
                            ID:       "foo",
                            SportKey: "bar",
                        },
                    }

                    return httpmock.NewJsonResponse(http.StatusOK, oddResp1)
                })

                oddPath2 := fmt.Sprintf("%s/%s/%s/%s", s.baseURL, enums.Sport.String(), resp[1].Key, enums.Odds.String())
                httpmock.RegisterResponder(http.MethodGet, oddPath2, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusNotFound, nil)
                })
            }

            if tt.name == "sad case: unsuccessfully get all odds" {
                httpmock.RegisterResponder(http.MethodGet, sportPath, func(r *http.Request) (*http.Response, error) {
                    return httpmock.NewJsonResponse(http.StatusUnauthorized, nil)
                })
            }

            httpmock.Activate()
            defer httpmock.DeactivateAndReset()

            _, err := s.GetAllOdds(tt.args.ctx, tt.args.oddsParams)
            if (err != nil) != tt.wantErr {
                t.Errorf("oddsAPIHTTPClient.GetAllOdds() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
        })
    }
}

func TestNewServiceOddsAPI(t *testing.T) {
    tests := []struct {
        name    string
        wantErr bool
    }{
        {
            name:    "happy case: successful instantiation",
            wantErr: false,
        },
        {
            name:    "sad case: no api key provided",
            wantErr: true,
        },
        {
            name:    "sad case: no base URL provided",
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if tt.name == "sad case: no base URL provided" {
                baseURL = ""
            }

            if tt.name == "sad case: no api key provided" {
                apiKey = ""
            }

            _, err := NewServiceOddsAPI()
            if (err != nil) != tt.wantErr {
                t.Errorf("NewServiceOddsAPI() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
        })
    }
}

Enter fullscreen mode Exit fullscreen mode

These are unit tests that are written to ensure that the code works as expected.

Usecases

arbs.go

package arbs

import (
    "context"
    "fmt"
    "sync"

    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/domain"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/infrastructure/services"
)

type UseCasesArbsImpl struct {
    OddsApiClient services.ArbClient
}

Enter fullscreen mode Exit fullscreen mode

We make a struct that will implement the Arbitrage functionality.

We need the APIclient to access the GetAllOdds method.

// composeTwoArbsBet process an Odd to check if an arbitrage oppurtunity exists
func (us *UseCasesArbsImpl) composeTwoArbsBet(odd domain.Odds, i int, j int) (domain.TwoOddsArb, bool) {
    homeOdd := odd.Bookmakers[i].Markets[0].Outcomes[0].Price
    awayOdd := odd.Bookmakers[j].Markets[0].Outcomes[1].Price
    arb := (1 / homeOdd) + (1 / awayOdd)

    if arb < 1.0 {
        profit := (1 - arb) * 100
        twowayArb := domain.TwoOddsArb{
            Title:     fmt.Sprintf("%s - %s", odd.HomeTeam, odd.AwayTeam),
            Home:      odd.Bookmakers[i].Title,
            HomeOdds:  homeOdd,
            HomeStake: 1 / homeOdd,
            Away:      odd.Bookmakers[j].Title,
            AwayOdds:  awayOdd,
            AwayStake: 1 / awayOdd,
            GameType:  odd.SportKey,
            League:    odd.SportTitle,
            Profit:    profit,
            GameTime:  odd.CommenceTime,
        }

        return twowayArb, true
    }

    return domain.TwoOddsArb{}, false
}

// composeThreeArbsBet processes an Odd to check if an arbitrage oppurtunity exists
func (us *UseCasesArbsImpl) composeThreeArbsBet(odd domain.Odds, i int, j int, k int) (domain.ThreeOddsArb, bool) {
    homeOdd := odd.Bookmakers[i].Markets[0].Outcomes[0].Price
    awayOdd := odd.Bookmakers[j].Markets[0].Outcomes[1].Price
    drawOdd := odd.Bookmakers[k].Markets[0].Outcomes[2].Price
    arb := (1 / homeOdd) + (1 / awayOdd) + (1 / drawOdd)

    if arb < 1.0 {
        profit := (1 - arb) * 100
        threewayArb := domain.ThreeOddsArb{
            Title:     fmt.Sprintf("%s - %s", odd.HomeTeam, odd.AwayTeam),
            Home:      odd.Bookmakers[i].Title,
            HomeOdds:  homeOdd,
            HomeStake: 1 / homeOdd,
            Away:      odd.Bookmakers[j].Title,
            AwayOdds:  awayOdd,
            AwayStake: 1 / awayOdd,
            Draw:      odd.Bookmakers[k].Title,
            DrawOdds:  drawOdd,
            DrawStake: 1 / drawOdd,
            GameType:  odd.SportKey,
            League:    odd.SportTitle,
            Profit:    profit,
            GameTime:  odd.CommenceTime,
        }

        return threewayArb, true
    }

    return domain.ThreeOddsArb{}, false
}
Enter fullscreen mode Exit fullscreen mode

These two functions check if an arbitrage opportunity exists given some conditions about odds. One checks for matches that have two possible outcomes that is a home win or away win while the other checks for matches that have three possible outcomes that is home win, away win or draw.

// checkIfMarketHasEnoughGames checks whether a market has enough games to analyze an arbitrage oppurtunity
func (us *UseCasesArbsImpl) checkIfMarketHasEnoughGames(bookmarker domain.Bookmaker) bool {
    return len(bookmarker.Markets) >= 1
}
Enter fullscreen mode Exit fullscreen mode

These function checks whether a markets in the bookmarker object has more than one market as there is no need to check for an arbitrage opportunity if only one market or 0 markets is available as we would never get one. This is because a bookmarker usually negates arbitrage opportunity on their own odds as they would make losses if arbs were readily available on their own platforms.

// findPossibleArbOppurtunity finds possible arbs and sends them over a channel
func (us *UseCasesArbsImpl) findPossibleArbOpportunity(odd domain.Odds,
    threeOddsCh chan<- domain.ThreeOddsArb,
    twoOddsCh chan<- domain.TwoOddsArb,
    wg *sync.WaitGroup) {
    defer wg.Done()

    if len(odd.Bookmakers) < 2 {
        return // Skip if there are not enough bookmakers for comparison
    }

    for i := 0; i < len(odd.Bookmakers); i++ {
        if !us.checkIfMarketHasEnoughGames(odd.Bookmakers[i]) {
            return
        }

        for j := 0; j < len(odd.Bookmakers); j++ {
            if !us.checkIfMarketHasEnoughGames(odd.Bookmakers[j]) {
                return
            }

            switch {
            case len(odd.Bookmakers[i].Markets[0].Outcomes) == 2 && len(odd.Bookmakers[j].Markets[0].Outcomes) == 2:
                twoWayArb, isArb := us.composeTwoArbsBet(odd, i, j)
                if isArb {
                    twoOddsCh <- twoWayArb
                }

            case len(odd.Bookmakers[i].Markets[0].Outcomes) == 3 && len(odd.Bookmakers[j].Markets[0].Outcomes) == 3:
                for k := 0; k < len(odd.Bookmakers); k++ {
                    if !us.checkIfMarketHasEnoughGames(odd.Bookmakers[k]) {
                        return
                    }

                    if len(odd.Bookmakers[k].Markets[0].Outcomes) == 3 {
                        threeWayArb, isArb := us.composeThreeArbsBet(odd, i, j, k)
                        if isArb {
                            threeOddsCh <- threeWayArb
                        }
                    }
                }
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

This function is simple. It checks whether a game is of two outcomes or three outcomes then calls the compose method that will return an arb and true if an arb is found or an empty struct and false if an arb is not found.

if an arb is found, it sends it over a channel.

The waitgroup is then decremented by the Done method by one.

// GetArbs gets all possible arbitrage oppurtunities.
func (us *UseCasesArbsImpl) GetArbs(ctx context.Context, oddsParams services.OddsParams) ([]domain.ThreeOddsArb, []domain.TwoOddsArb, error) {
    odds, err := us.OddsApiClient.GetAllOdds(ctx, oddsParams)
    if err != nil {
        return nil, nil, err
    }

    var ThreeOddsArbs []domain.ThreeOddsArb

    var TwoOddsArbs []domain.TwoOddsArb

    // Create channels to receive arbitrage results
    threeOddsCh := make(chan domain.ThreeOddsArb)
    twoOddsCh := make(chan domain.TwoOddsArb)

    // Create a wait group to ensure all goroutines finish before returning
    var wg sync.WaitGroup

    var once sync.Once

    for _, odd := range odds {
        wg.Add(1)

        go us.findPossibleArbOpportunity(odd, threeOddsCh, twoOddsCh, &wg)
    }

    // Close the channels once all goroutines finish processing
    go func() {
        wg.Wait()
        once.Do(func() {
            close(threeOddsCh)
            close(twoOddsCh)
        })
    }()

    // Collect the results from channels
    for {
        select {
        case arb, ok := <-threeOddsCh:
            if !ok {
                threeOddsCh = nil // Set to nil to exit the loop when both channels are closed
            } else {
                ThreeOddsArbs = append(ThreeOddsArbs, arb)
            }
        case arb, ok := <-twoOddsCh:
            if !ok {
                twoOddsCh = nil // Set to nil to exit the loop when both channels are closed
            } else {
                TwoOddsArbs = append(TwoOddsArbs, arb)
            }
        }
        // Exit the loop when both channels are closed
        if threeOddsCh == nil && twoOddsCh == nil {
            break
        }
    }

    return ThreeOddsArbs, TwoOddsArbs, nil
}
Enter fullscreen mode Exit fullscreen mode

The function starts by calling GetAllOdds on us.OddsApiClient to fetch all the odds using the provided oddsParams.

It then initializes two channels (threeOddsCh and twoOddsCh) to receive results for three-odds and two-odds arbitrage opportunities, respectively.
A wait group (wg) is created to ensure that all spawned goroutines finish execution before closing the channels.
For each odd fetched, a goroutine is launched to find possible arbitrage opportunities.

Each goroutine increments the wait group to signal its completion when finished.

Another goroutine is spawned to wait for all goroutines to finish (wg.Wait()), and then closes the channels (threeOddsCh and twoOddsCh) using a sync.Once to ensure they're closed only once.

The function continuously listens on both channels (threeOddsCh and twoOddsCh) using a select statement.
When a result is received on either channel, it is appended to the corresponding array (ThreeOddsArbs or TwoOddsArbs).
The function exits the loop when both channels are closed.

Once both channels are closed and all results are collected, the function returns the arrays containing arbitrage opportunities and a nil error.

arbs_test.go

package arbs

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "reflect"
    "testing"

    "github.com/jarcoal/httpmock"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/application/enums"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/domain"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/infrastructure/services"
)

func setUpUsecase() *UseCasesArbsImpl {
    var client services.ArbClient

    svc, err := services.NewServiceOddsAPI()
    if err != nil {
        log.Panic("error: %w", err)
    }

    client = svc

    return &UseCasesArbsImpl{
        OddsApiClient: client,
    }
}

var (
    twoWayArbodd = domain.Odds{
        HomeTeam:     "Vuvu",
        AwayTeam:     "Zela",
        SportKey:     "baseball_np",
        SportTitle:   "Baseball",
        CommenceTime: "2024-01-01T08:29:59Z",
        Bookmakers: []domain.Bookmaker{
            {
                Title: "Betway",
                Markets: []domain.Market{
                    {
                        Outcomes: []domain.Outcome{
                            {Price: 1.44},
                            {Price: 8.5},
                        }}}},
            {
                Title: "Betway",
                Markets: []domain.Market{
                    {
                        Outcomes: []domain.Outcome{
                            {Price: 0},
                            {Price: 0},
                        }}}},
        },
    }
    twoWayNotArbodd = domain.Odds{
        HomeTeam:     "Vuvu",
        AwayTeam:     "Zela",
        SportKey:     "baseball_np",
        SportTitle:   "Baseball",
        CommenceTime: "2024-01-01T08:29:59Z",
        Bookmakers: []domain.Bookmaker{
            {
                Title: "Betway",
                Markets: []domain.Market{
                    {
                        Outcomes: []domain.Outcome{
                            {Price: 1.44},
                            {Price: 1.44},
                        }}}}},
    }
    threeWayArbodd = domain.Odds{
        HomeTeam:     "Vuvu",
        AwayTeam:     "Zela",
        SportKey:     "baseball_np",
        SportTitle:   "Baseball",
        CommenceTime: "2024-01-01T08:29:59Z",
        Bookmakers: []domain.Bookmaker{
            {
                Title: "Betway",
                Markets: []domain.Market{
                    {
                        Outcomes: []domain.Outcome{
                            {Price: 4.9},
                            {Price: 17},
                            {Price: 1.57},
                        }}}},
            {
                Title: "Betway",
                Markets: []domain.Market{
                    {
                        Outcomes: []domain.Outcome{
                            {Price: 0.0},
                            {Price: 0.0},
                            {Price: 0.0},
                        }}}},
        },
    }
    threeWayNotArbodd = domain.Odds{
        HomeTeam:     "Vuvu",
        AwayTeam:     "Zela",
        SportKey:     "baseball_np",
        SportTitle:   "Baseball",
        CommenceTime: "2024-01-01T08:29:59Z",
        Bookmakers: []domain.Bookmaker{
            {
                Title: "Betway",
                Markets: []domain.Market{
                    {
                        Outcomes: []domain.Outcome{
                            {Price: 4.9},
                            {Price: 1.87},
                            {Price: 1.57},
                        }}}}},
    }
)

func TestUseCasesArbsImpl_composeTwoArbsBet(t *testing.T) {
    type args struct {
        odd domain.Odds
        i   int
        j   int
    }

    tests := []struct {
        name string
        args args
        want bool
    }{
        {
            name: "happy case: arb is found",
            args: args{
                odd: twoWayArbodd,
                i:   0,
                j:   0,
            },
            want: true,
        },
        {
            name: "sad case: arb is not found",
            args: args{
                odd: twoWayNotArbodd,
                i:   0,
                j:   0,
            },
            want: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            us := setUpUsecase()
            _, got := us.composeTwoArbsBet(tt.args.odd, tt.args.i, tt.args.j)

            if got != tt.want {
                t.Errorf("UseCasesArbsImpl.composeTwoArbsBet() got1 = %v, want %v", got, tt.want)
            }
        })
    }
}

func TestUseCasesArbsImpl_composeThreeArbsBet(t *testing.T) {
    type args struct {
        odd domain.Odds
        i   int
        j   int
        k   int
    }

    tests := []struct {
        name string
        args args
        want bool
    }{
        {
            name: "happy case: arb is found",
            args: args{
                odd: threeWayArbodd,
                i:   0,
                j:   0,
                k:   0,
            },
            want: true,
        },
        {
            name: "sad case: arb is not found",
            args: args{
                odd: threeWayNotArbodd,
                i:   0,
                j:   0,
                k:   0,
            },
            want: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            us := setUpUsecase()
            _, got := us.composeThreeArbsBet(tt.args.odd, tt.args.i, tt.args.j, tt.args.k)

            if got != tt.want {
                t.Errorf("UseCasesArbsImpl.composeThreeArbsBet() got1 = %v, want %v", got, tt.want)
            }
        })
    }
}

func TestUseCasesArbsImpl_GetArbs(t *testing.T) {
    type args struct {
        ctx        context.Context
        oddsParams services.OddsParams
    }

    tests := []struct {
        name    string
        args    args
        want    int
        want1   int
        wantErr bool
    }{
        {
            name: "happy case: get arbs",
            args: args{
                ctx:        context.Background(),
                oddsParams: services.OddsParams{},
            },
            want:    1,
            want1:   1,
            wantErr: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            us := setUpUsecase()

            baseUrl := os.Getenv(enums.BaseURL.String())

            sportPath := fmt.Sprintf("%s/%s", baseUrl, enums.Sport.String())

            resp := []domain.Sport{
                {
                    Key:    "foo",
                    Title:  "bar",
                    Active: true,
                },
            }

            httpmock.RegisterResponder(http.MethodGet, sportPath, func(r *http.Request) (*http.Response, error) {
                return httpmock.NewJsonResponse(http.StatusOK, resp)
            })

            oddPath := fmt.Sprintf("%s/%s/%s/%s", baseUrl, enums.Sport.String(), resp[0].Key, enums.Odds.String())
            httpmock.RegisterResponder(http.MethodGet, oddPath, func(r *http.Request) (*http.Response, error) {
                oddResp := []domain.Odds{}
                oddResp = append(oddResp, twoWayArbodd)
                oddResp = append(oddResp, twoWayNotArbodd)
                oddResp = append(oddResp, threeWayArbodd)
                oddResp = append(oddResp, threeWayNotArbodd)

                return httpmock.NewJsonResponse(http.StatusOK, oddResp)
            })

            httpmock.Activate()
            defer httpmock.DeactivateAndReset()

            got, got1, err := us.GetArbs(tt.args.ctx, tt.args.oddsParams)

            if (err != nil) != tt.wantErr {
                t.Errorf("UseCasesArbsImpl.GetArbs() error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if !reflect.DeepEqual(len(got), tt.want) {
                t.Errorf("UseCasesArbsImpl.GetArbs() got = %v, want %v", len(got), tt.want)
            }

            if !reflect.DeepEqual(len(got1), tt.want1) {
                t.Errorf("UseCasesArbsImpl.GetArbs() got1 = %v, want %v", len(got1), tt.want1)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Here are the tests to validate the logic runs as expected.

Arbs.go

package arbs

import (
    "context"
    "log"

    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/domain"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/infrastructure/services"
    "github.com/robinmuhia/arbitrageClient/pkg/oddsWrapper/usecases/arbs"
)

var client services.ArbClient

type ArbsParams struct {
    Region     string
    Markets    string
    OddsFormat string
    DateFormat string
}

func init() {
    svc, err := services.NewServiceOddsAPI()
    if err != nil {
        log.Panic("error: %w", err)
    }

    client = svc
}

// GetAllArbs returns all possible arbs from the odds API.
func GetAllArbs(ctx context.Context, arbParams ArbsParams) ([]domain.ThreeOddsArb, []domain.TwoOddsArb, error) {
    us := arbs.UseCasesArbsImpl{
        OddsApiClient: client,
    }

    params := services.OddsParams{
        Region:     arbParams.Region,
        Markets:    arbParams.Markets,
        OddsFormat: arbParams.OddsFormat,
        DateFormat: arbParams.DateFormat,
    }

    return us.GetArbs(ctx, params)
}

Enter fullscreen mode Exit fullscreen mode

Lastly, these are the functions we use to initialize our client and export only the GetAllArbs function to be used by others.

We have hidden all this complexity and only export one function for our users to use.

This library can thus be used as shown below;

package main

import (
  "context"

    "github.com/robinmuhia/arbitrageClient/arbs"
)

func main() {
    // Create a context
  ctx := context.Background()

  // We need to pass the params to get odds from specific formats
  // We currently only support decimal format for oddsFormat
  arbParams := arbs.ArbsParams{
              Region : "uk",
              Markets: "h2h",
              OddsFormat: "decimal",
              DateFormat: "iso",
             }
  threeArbs, twoArbs, err := arbs.GetAllArbs(ctx, arbParams)

  if err != nil {
    // handle err
  }

  fmt.Println(threeArbs, twoArbs)
}

Enter fullscreen mode Exit fullscreen mode

Results

Image description

Image description

AOB

You can check the .github folder to learn how to configure a CI/CD for Golang services, a dependabot for Golang and an automatic workflow to mark issues as stale.

Top comments (3)

Collapse
 
nghtslvr profile image
Nightsilver Academy

I want to ask is my summary was right, so hexagonal architecture ways is, makes all external libraries into an utility and we use it inside use case, whenever the external need a changes we just change the external?

Collapse
 
robinmuhia profile image
Robin Muhia

Yes you are right, exported functions from a library should always be configured to return similar output. Thus we can swap one service with another service but we make sure that the exported function returns the same output and our use case will not be affected

Collapse
 
nghtslvr profile image
Nightsilver Academy

I see, this architecture makes me confuse, thanks for the confirmation