Unlocking Profit Potential: Building an Arbitrage Betting Client with Hexagonal Architecture in Golang
Tags: go, datastructures, webdev, github
Published: 2024-05-04 09:24:06

## 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](https://github.com/robinmuhia/arbitrageClient).

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

```go
go get github.com/robinmuhia/arbitrageClient@latest
```

## 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](https://www.sbo.net/strategy/arbitrage-betting/).

## 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](https://medium.com/@matiasvarela/hexagonal-architecture-in-go-cfd4e436faa3).

## Prerequisites

We will be build our Arbitrage Client around the [Odds API](https://the-odds-api.com/) 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

```bash
git clone git@github.com:robinmuhia/arbitrageClient.git .
```

Make sure the project is structured as shown below;


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mfny4nen9q0f6pqioao3.png)

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
```go
package enums

type endpoint string

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

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

```
This is where we put the endpoints we will call.

envs.go
```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)
}
```

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

params.go
```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)
}
```

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

### Domain folder

arbs.go
 ```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
}
```

These are how the arbs will be represented.

odds.go

```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"`
}
```
These is how odds from the API will be represented.

sport.go

```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"`
}
```
These is how sports from the API will be represented.

### Infrastructure

client.go
```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
}
```

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.

```go
// 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

```
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.

```go
// 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)
}
```
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.


```go
// 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
}
```
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.


```go
// 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
}
```

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


```go
// 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
}

```

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
```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
			}
		})
	}
}

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

# Usecases 

arbs.go 
```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
}

```

We make a struct that will implement the Arbitrage functionality.

We need the APIclient to access the GetAllOdds method.

```go
// 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
}
```
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.


```go
// 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
}
```

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.


```go
// 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
						}
					}
				}
			}
		}
	}
}

```

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.


```go
// 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
}
```
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

```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)
			}
		})
	}
}
```
Here are the tests to validate the logic runs as expected.

# Arbs.go

```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)
}

```

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;

```go
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)
}

```

# Results


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bzmv17i4u9jsqoqs5q30.png)



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5n8xybt60x73n5n3rugm.png)

## 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.