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

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.

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:

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.

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.

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.

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 .

Make sure the project is structured as shown below;

Lets explore the folders;

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

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.

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.

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

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

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

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
}

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

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

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
}

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

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

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
}

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
}

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
}

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

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
}

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
}

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
}

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

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 found of an empty struct and false if 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
}

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

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

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

Results

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.

Leave a Reply

Your email address will not be published. Required fields are marked *