diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eb82cbe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,440 @@
+Skip to content
+Search or jump to…
+Pull requests
+Issues
+Codespaces
+Marketplace
+Explore
+
+@harrystuart
+github
+/
+gitignore
+Public
+Code
+Pull requests
+350
+Actions
+Security
+Insights
+gitignore/VisualStudio.gitignore
+@n0099
+n0099 [VisualStudio.gitignore] remove a trailing space
+Latest commit 491040e on Jan 27
+ History
+ 165 contributors
+@shiftkey@arcresu@aroben@bbodenmiller@HassanHashemi@haacked@niik@AArnott@sayedihashimi@saschanaz@bdougie@OsirisTerje
+398 lines (319 sloc) 6.7 KB
+
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+Footer
+© 2022 GitHub, Inc.
+Footer navigation
+Terms
+Privacy
+Security
+Status
+Docs
+Contact GitHub
+Pricing
+API
+Training
+Blog
+About
diff --git a/Arbitrage.sln b/Arbitrage.sln
new file mode 100644
index 0000000..59846ec
--- /dev/null
+++ b/Arbitrage.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.1.32228.430
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arbitrage", "Arbitrage\Arbitrage.csproj", "{04687387-8899-4BF1-83A6-03870E6AE918}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {04687387-8899-4BF1-83A6-03870E6AE918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {04687387-8899-4BF1-83A6-03870E6AE918}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {04687387-8899-4BF1-83A6-03870E6AE918}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {04687387-8899-4BF1-83A6-03870E6AE918}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {A93770F5-598D-47F7-A933-CBCDF691B476}
+ EndGlobalSection
+EndGlobal
diff --git a/Arbitrage/Arbitrage.csproj b/Arbitrage/Arbitrage.csproj
new file mode 100644
index 0000000..f400d6d
--- /dev/null
+++ b/Arbitrage/Arbitrage.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Exe
+ net6.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ Never
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/Arbitrage/BaseDataService.cs b/Arbitrage/BaseDataService.cs
new file mode 100644
index 0000000..0574118
--- /dev/null
+++ b/Arbitrage/BaseDataService.cs
@@ -0,0 +1,291 @@
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class BaseDataService : IDataService
+ {
+ private LadbrokesService mLadBrokesService { get; set; }
+ private UnibetService mUnibetService { get; set; }
+ private BookerkeeperEntityComparer mBookerkeeperEntityComparer { get; set; }
+
+ public BaseDataService()
+ {
+ mLadBrokesService = new LadbrokesService();
+ mUnibetService = new UnibetService();
+ mBookerkeeperEntityComparer = new BookerkeeperEntityComparer();
+ }
+
+ public async Task InitialiseAsync()
+ {
+ await mLadBrokesService.InitialiseAsync();
+ await mUnibetService.InitialiseAsync();
+ }
+
+ public async Task>> ReconcileSportEventsAsync(Sport sport)
+ {
+ Task> ladbrokesEventsTask = mLadBrokesService.GetSportEventsAsync(sport);
+ Task> unibetEventsTask = mUnibetService.GetSportEventsAsync(sport);
+
+ Task.WaitAll(ladbrokesEventsTask, unibetEventsTask);
+
+ List ladbrokesEvents = ladbrokesEventsTask.Result.ToList();
+ List unibetEvents = unibetEventsTask.Result.ToList();
+
+ List> allBookkeeperEvents = new List>();
+ allBookkeeperEvents.Add(ladbrokesEvents);
+ allBookkeeperEvents.Add(unibetEvents);
+
+ IEnumerable> bookkeeperEventGroups = FindEquiavelentBookkeeperEventsInLists(allBookkeeperEvents);
+
+ Dictionary> events = new Dictionary>();
+
+ foreach (List group in bookkeeperEventGroups)
+ {
+ events.Add(new Event()
+ {
+ Competition = group[0].Competition,
+ Name = group[0].Name,
+ Sport = sport,
+ Commencement = group[0].Commencement,
+ TMPUnibetEventId = group[1].BookkeeperEventId
+ }, group);
+ }
+
+ return events;
+ }
+
+ public async Task>> ReconcileEventMarketsAsync(Event @event, IEnumerable bookkeeperEvents)
+ {
+ if (@event.Name == "Tepatitlan FC vs CD Tapatio")
+ {
+ var v = "";
+ }
+
+ Task> ladbrokesMarketsTask = mLadBrokesService
+ .GetEventMarketsAsync(bookkeeperEvents.First(x => x.Bookkeeper == Bookkeeper.Ladbrokes));
+ Task> unibetMarketsTask = mUnibetService
+ .GetEventMarketsAsync(bookkeeperEvents.First(x => x.Bookkeeper == Bookkeeper.Unibet));
+
+ Task.WaitAll(ladbrokesMarketsTask, unibetMarketsTask);
+
+ List ladbrokesMarkets = ladbrokesMarketsTask.Result.ToList();
+ List unibetMarkets = unibetMarketsTask.Result.ToList();
+
+ List> allBookkeeperMarkets = new List>();
+ allBookkeeperMarkets.Add(ladbrokesMarkets);
+ allBookkeeperMarkets.Add(unibetMarkets);
+
+ IEnumerable> bookkeeperMarketGroups = FindEquiavelentBookkeeperMarketsInLists(allBookkeeperMarkets, @event);
+
+ Dictionary> markets = new Dictionary>();
+
+ foreach (List group in bookkeeperMarketGroups)
+ {
+ markets.Add(new Market() { Name = group[0].Name }, group);
+ }
+
+ return markets;
+ }
+
+ // A market is only valid if all odds for all bookkeepers operating in that market can be reconciled
+ public async Task> ReconcileMarketOddsAsync(Event @event, IEnumerable bookkeeperMarkets)
+ {
+ Task> ladbrokesOddsTask = mLadBrokesService
+ .GetMarketOddsAsync(@event, bookkeeperMarkets.First(x => x.Bookkeeper == Bookkeeper.Ladbrokes));
+ Task> unibetOddsTask = mUnibetService
+ .GetMarketOddsAsync(@event, bookkeeperMarkets.First(x => x.Bookkeeper == Bookkeeper.Unibet));
+
+ Task.WaitAll(ladbrokesOddsTask, unibetOddsTask);
+
+ List ladbrokesOdds = ladbrokesOddsTask.Result.ToList();
+ List unibetOdds = unibetOddsTask.Result.ToList();
+
+ List>> bookkeeperOdds = new List>>();
+ bookkeeperOdds.Add(new KeyValuePair>(Bookkeeper.Ladbrokes, ladbrokesOdds));
+ bookkeeperOdds.Add(new KeyValuePair>(Bookkeeper.Unibet, unibetOdds));
+
+ int numOddsPerBookkeeper = bookkeeperOdds.First().Value.Count;
+
+ foreach (List odds in bookkeeperOdds.Select(x => x.Value))
+ {
+ if (odds.Count != numOddsPerBookkeeper)
+ {
+ return new List();
+ }
+ }
+
+ List> bookkeeperOddsGroups = new List>();
+
+ for (int i = 0; i < bookkeeperOdds.Count - 1; i++)
+ {
+ List bookkeeperOdds1 = bookkeeperOdds[i].Value;
+ List bookkeeperOdds2 = bookkeeperOdds[i + 1].Value;
+
+ for (int m = 0; m < numOddsPerBookkeeper; m++)
+ {
+ bookkeeperOddsGroups.Add(new List());
+
+ bool foundMatch = false;
+
+ for (int n = 0; n < numOddsPerBookkeeper; n++)
+ {
+ if (mBookerkeeperEntityComparer.OddsEqual(bookkeeperOdds1[m], bookkeeperOdds2[n], @event))
+ {
+ foundMatch = true;
+
+ bookkeeperOddsGroups[m].Add(bookkeeperOdds1[m]);
+
+ if (i == bookkeeperOdds.Count - 2)
+ {
+ bookkeeperOddsGroups[m].Add(bookkeeperOdds2[n]);
+ }
+
+ break;
+ }
+ }
+
+ if (!foundMatch)
+ {
+ return new List();
+ }
+ }
+ }
+
+ List standardisedOdds = new List();
+
+ foreach (List bookkeeperOddsGroup in bookkeeperOddsGroups)
+ {
+ standardisedOdds.Add(new Odds()
+ {
+ Outcome = bookkeeperOddsGroup[0].Outcome,
+ BookkeeperOdds = bookkeeperOddsGroup
+ });
+ }
+
+ return standardisedOdds;
+ }
+
+ private IEnumerable> FindEquiavelentBookkeeperEventsInLists(List> bookkeeperEvents)
+ {
+ List> groups = new List>();
+
+ for (int i = 0; i < bookkeeperEvents.Count; i++)
+ {
+ for (int j = i + 1; j < bookkeeperEvents.Count; j++)
+ {
+ for (int m = 0; m < bookkeeperEvents[i].Count; m++)
+ {
+ for (int n = 0; n < bookkeeperEvents[j].Count; n++)
+ {
+ if (mBookerkeeperEntityComparer.EventsEqual(bookkeeperEvents[i][m], bookkeeperEvents[j][n]))
+ {
+ bool foundMatchToExistingGroup = false;
+
+ for (int k = 0; k < groups.Count; k++)
+ {
+ if (groups[k][0] == bookkeeperEvents[i][m]) // Works if this is reference based
+ {
+ foundMatchToExistingGroup = true;
+ groups[k].Add(bookkeeperEvents[j][n]);
+ break;
+ }
+ }
+
+ if (!foundMatchToExistingGroup)
+ {
+ groups.Add(new List() { bookkeeperEvents[i][m], bookkeeperEvents[j][n] });
+ }
+
+ bookkeeperEvents[j].RemoveAt(n);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ return groups;
+ }
+
+ private IEnumerable> FindEquiavelentBookkeeperMarketsInLists(List> bookkeeperMarkets, Event @event)
+ {
+ List> groups = new List>();
+
+ for (int i = 0; i < bookkeeperMarkets.Count; i++)
+ {
+ for (int j = i + 1; j < bookkeeperMarkets.Count; j++)
+ {
+ // Get all pairings with scores
+ List> bookkeeperMarketPairings = new List>();
+
+ for (int m = 0; m < bookkeeperMarkets[i].Count; m++)
+ {
+ for (int n = 0; n < bookkeeperMarkets[j].Count; n++)
+ {
+ double similarity = mBookerkeeperEntityComparer.CalculateMarketSimilarity(
+ bookkeeperMarkets[i][m], bookkeeperMarkets[j][n], @event);
+
+ if (similarity > 0.8)
+ {
+ bookkeeperMarketPairings.Add(new KeyValuePair<(BookkeeperMarket, BookkeeperMarket), double>(
+ (bookkeeperMarkets[i][m], bookkeeperMarkets[j][n]), similarity));
+ }
+ }
+ }
+
+ // Order by score and then by string length similarity
+ bookkeeperMarketPairings = bookkeeperMarketPairings.OrderByDescending(x => x.Value)
+ .ThenBy(x => Math.Abs(x.Key.Item1.Name.Length - x.Key.Item2.Name.Length))
+ .ToList();
+
+ foreach (var pairing in bookkeeperMarketPairings)
+ {
+ Console.WriteLine($"{pairing.Value}:{pairing.Key.Item1.Name}:{pairing.Key.Item2.Name}");
+ }
+ Console.WriteLine();
+
+ while (bookkeeperMarketPairings.Any())
+ {
+ (BookkeeperMarket bookkeeperMarket1, BookkeeperMarket bookkeeperMarket2) = bookkeeperMarketPairings.First().Key;
+
+ bool foundMatchToExistingGroup = false;
+
+ for (int k = 0; k < groups.Count; k++)
+ {
+ if (groups[k].Contains(bookkeeperMarket1)) // Works if this is reference based
+ {
+ foundMatchToExistingGroup = true;
+ groups[k].Add(bookkeeperMarket2);
+ break;
+ }
+ }
+
+ if (!foundMatchToExistingGroup)
+ {
+ groups.Add(new List() { bookkeeperMarket1, bookkeeperMarket2 });
+ }
+
+ bookkeeperMarketPairings.RemoveAll(x => {
+ if (x.Key.Item1 == bookkeeperMarket1 ||
+ x.Key.Item1 == bookkeeperMarket2 ||
+ x.Key.Item2 == bookkeeperMarket1 ||
+ x.Key.Item2 == bookkeeperMarket2)
+ {
+ return true;
+ }
+
+ return false;
+ });
+ }
+ }
+ }
+
+ return groups;
+ }
+ }
+}
diff --git a/Arbitrage/BookerkeeperEntityComparer.cs b/Arbitrage/BookerkeeperEntityComparer.cs
new file mode 100644
index 0000000..d892c22
--- /dev/null
+++ b/Arbitrage/BookerkeeperEntityComparer.cs
@@ -0,0 +1,167 @@
+using Microsoft.Extensions.Configuration;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class BookerkeeperEntityComparer
+ {
+ private BookerkeeperEntityComparerConfiguration mConfiguration;
+
+ public BookerkeeperEntityComparer()
+ {
+ IConfiguration config = new ConfigurationBuilder()
+ .AddJsonFile("configuration.json")
+ .Build();
+
+ mConfiguration = config.GetRequiredSection("bookerkeeperEntityComparer").Get();
+ }
+
+ public bool EventsEqual(BookkeeperEvent a, BookkeeperEvent b)
+ {
+ if (a == null || b == null)
+ {
+ return false;
+ }
+
+ if (a.Competition != null && b.Competition != null)
+ {
+ if (StringDistance.NumberOfSubstringMovementsWithLengthPenalty(a.Competition, b.Competition) > 0.3)
+ {
+ return false;
+ }
+ }
+
+ if (StringDistance.NumberOfSubstringMovementsWithLengthPenalty(a.Name, b.Name) > 0.3)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public double CalculateMarketSimilarity(BookkeeperMarket a, BookkeeperMarket b, Event @event)
+ {
+ if (a == null || b == null)
+ {
+ return 0;
+ }
+
+ if (mConfiguration.KnownCorrespondingMarketNames.ContainsKey(a.Bookkeeper) &&
+ mConfiguration.KnownCorrespondingMarketNames[a.Bookkeeper].ContainsKey(@event.Sport) &&
+ mConfiguration.KnownCorrespondingMarketNames[a.Bookkeeper][@event.Sport].ContainsKey(a.Name))
+ {
+ if (mConfiguration.KnownCorrespondingMarketNames[a.Bookkeeper][@event.Sport][a.Name].Contains(b.Name))
+ {
+ return 1;
+ }
+
+ return 1;
+ }
+
+ if (mConfiguration.KnownCorrespondingMarketNames.ContainsKey(b.Bookkeeper) &&
+ mConfiguration.KnownCorrespondingMarketNames[b.Bookkeeper].ContainsKey(@event.Sport) &&
+ mConfiguration.KnownCorrespondingMarketNames[b.Bookkeeper][@event.Sport].ContainsKey(b.Name))
+ {
+ if (mConfiguration.KnownCorrespondingMarketNames[b.Bookkeeper][@event.Sport][b.Name].Contains(a.Name))
+ {
+ return 1;
+ }
+
+ return 1;
+ }
+
+ double score = StringDistance.NumberOfSubstringMovementsWithLengthPenalty(a.Name, b.Name);
+
+ return 1 - score;
+ }
+
+ public bool MarketsEqual(BookkeeperMarket a, BookkeeperMarket b, Event @event)
+ {
+ if (a == null || b == null)
+ {
+ return false;
+ }
+
+ if (mConfiguration.KnownCorrespondingMarketNames.ContainsKey(a.Bookkeeper) &&
+ mConfiguration.KnownCorrespondingMarketNames[a.Bookkeeper].ContainsKey(@event.Sport) &&
+ mConfiguration.KnownCorrespondingMarketNames[a.Bookkeeper][@event.Sport].ContainsKey(a.Name))
+ {
+ if (mConfiguration.KnownCorrespondingMarketNames[a.Bookkeeper][@event.Sport][a.Name].Contains(b.Name)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ if (mConfiguration.KnownCorrespondingMarketNames.ContainsKey(b.Bookkeeper) &&
+ mConfiguration.KnownCorrespondingMarketNames[b.Bookkeeper].ContainsKey(@event.Sport) &&
+ mConfiguration.KnownCorrespondingMarketNames[b.Bookkeeper][@event.Sport].ContainsKey(b.Name))
+ {
+ if (mConfiguration.KnownCorrespondingMarketNames[b.Bookkeeper][@event.Sport][b.Name].Contains(a.Name))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ if (StringDistance.NumberOfSubstringMovementsWithLengthPenalty(a.Name, b.Name) > 0.3)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public bool OddsEqual(BookkeeperOdds a, BookkeeperOdds b, Event @event)
+ {
+ if (a == null || b == null)
+ {
+ return false;
+ }
+
+ if (mConfiguration.KnownCorrespondingOddsNames.ContainsKey(a.Bookkeeper) &&
+ mConfiguration.KnownCorrespondingOddsNames[a.Bookkeeper].ContainsKey(@event.Sport) &&
+ mConfiguration.KnownCorrespondingOddsNames[a.Bookkeeper][@event.Sport].ContainsKey(a.Outcome))
+ {
+ if (mConfiguration.KnownCorrespondingOddsNames[a.Bookkeeper][@event.Sport][a.Outcome].Contains(b.Outcome))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ if (mConfiguration.KnownCorrespondingOddsNames.ContainsKey(b.Bookkeeper) &&
+ mConfiguration.KnownCorrespondingOddsNames[b.Bookkeeper].ContainsKey(@event.Sport) &&
+ mConfiguration.KnownCorrespondingOddsNames[b.Bookkeeper][@event.Sport].ContainsKey(b.Outcome))
+ {
+ if (mConfiguration.KnownCorrespondingOddsNames[b.Bookkeeper][@event.Sport][b.Outcome].Contains(a.Outcome))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ if (a.Description != null && b.Description != null)
+ {
+ if (StringDistance.NumberOfSubstringMovementsWithLengthPenalty(a.Description, b.Description) > 0.3)
+ {
+ return false;
+ }
+ }
+
+ if (StringDistance.NumberOfSubstringMovementsWithLengthPenalty(a.Outcome, b.Outcome) > 0.3)
+ {
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/Arbitrage/BookerkeeperEntityComparerConfiguration.cs b/Arbitrage/BookerkeeperEntityComparerConfiguration.cs
new file mode 100644
index 0000000..f65cf24
--- /dev/null
+++ b/Arbitrage/BookerkeeperEntityComparerConfiguration.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class BookerkeeperEntityComparerConfiguration
+ {
+ public Dictionary>>> KnownCorrespondingMarketNames { get; set; } = null!;
+ public Dictionary>>> KnownCorrespondingOddsNames { get; set; } = null!;
+ }
+}
diff --git a/Arbitrage/Bookkeeper.cs b/Arbitrage/Bookkeeper.cs
new file mode 100644
index 0000000..ac0b598
--- /dev/null
+++ b/Arbitrage/Bookkeeper.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public enum Bookkeeper
+ {
+ Ladbrokes,
+ Unibet
+ }
+}
diff --git a/Arbitrage/BookkeeperEvent.cs b/Arbitrage/BookkeeperEvent.cs
new file mode 100644
index 0000000..1069ae6
--- /dev/null
+++ b/Arbitrage/BookkeeperEvent.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class BookkeeperEvent
+ {
+ public Bookkeeper Bookkeeper { get; set; }
+ public string BookkeeperEventId { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public string? Competition { get; set; }
+ public DateTimeOffset? Commencement { get; set; }
+ public string? Url { get; set; }
+ }
+}
diff --git a/Arbitrage/BookkeeperMarket.cs b/Arbitrage/BookkeeperMarket.cs
new file mode 100644
index 0000000..89edce1
--- /dev/null
+++ b/Arbitrage/BookkeeperMarket.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class BookkeeperMarket
+ {
+ public Bookkeeper Bookkeeper { get; set; }
+ public string BookkeeperMarketId { get; set; } = null!;
+ public string Name { get; set; } = null!;
+ public string? Url { get; set; }
+ }
+}
diff --git a/Arbitrage/BookkeeperOdds.cs b/Arbitrage/BookkeeperOdds.cs
new file mode 100644
index 0000000..66c6192
--- /dev/null
+++ b/Arbitrage/BookkeeperOdds.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class BookkeeperOdds
+ {
+ public Bookkeeper Bookkeeper { get; set; }
+ public string BookkeeperOddsId { get; set; } = null!;
+ public string Outcome { get; set; } = null!;
+ public double Value { get; set; }
+ public string? Description { get; set; }
+ public string? Url { get; set; }
+ }
+}
diff --git a/Arbitrage/BookkeeperService.cs b/Arbitrage/BookkeeperService.cs
new file mode 100644
index 0000000..7082929
--- /dev/null
+++ b/Arbitrage/BookkeeperService.cs
@@ -0,0 +1,34 @@
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public abstract class BookkeeperService
+ {
+ protected HttpClient mHttpClient;
+ protected readonly ILogger mLogger;
+
+ public BookkeeperService()
+ {
+ var loggerFactory = LoggerFactory.Create(builder =>
+ {
+ builder
+ .AddFilter("Microsoft", LogLevel.Warning)
+ .AddFilter("System", LogLevel.Warning)
+ .AddConsole()
+ .AddEventLog();
+ });
+
+ mLogger = loggerFactory.CreateLogger();
+ mHttpClient = new HttpClient();
+ }
+
+ public abstract Task> GetSportEventsAsync(Sport sport);
+ public abstract Task> GetEventMarketsAsync(BookkeeperEvent bookkeeperEvent);
+ public abstract Task> GetMarketOddsAsync(Event @event, BookkeeperMarket market);
+ }
+}
diff --git a/Arbitrage/Calculator.cs b/Arbitrage/Calculator.cs
new file mode 100644
index 0000000..02f05f4
--- /dev/null
+++ b/Arbitrage/Calculator.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public static class Calculator
+ {
+ public static (double, IEnumerable) FindBestArbitrage(Market market)
+ {
+ List optimalBookkeeperOdds = FindBestOdds(market);
+
+ double arbitrage = optimalBookkeeperOdds.Select(x => x.Value).Sum(x => 1.0 / x);
+
+ return (arbitrage, optimalBookkeeperOdds);
+ }
+
+ private static List FindBestOdds(Market market)
+ {
+ if (market.Odds == null)
+ {
+ throw new Exception($"Odds are null for market {market}.");
+ }
+
+ List optimalBookkeeperOdds = new List();
+
+ foreach (Odds odds in market.Odds)
+ {
+ BookkeeperOdds maxBookkeeperOdds = odds.BookkeeperOdds.MaxBy(x => x.Value);
+ optimalBookkeeperOdds.Add(maxBookkeeperOdds);
+ }
+
+ return optimalBookkeeperOdds;
+ }
+ }
+}
diff --git a/Arbitrage/ConsoleNotificationChannel.cs b/Arbitrage/ConsoleNotificationChannel.cs
new file mode 100644
index 0000000..5584dcf
--- /dev/null
+++ b/Arbitrage/ConsoleNotificationChannel.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class ConsoleNotificationChannel : INotificationChannel
+ {
+ public bool Notify(Sport sport, Event @event, Market market, IEnumerable bookkeeperMarkets, double arbitrage, IEnumerable bestBookkeeperOdds)
+ {
+ Console.WriteLine(Enum.GetName(typeof(Sport), sport));
+ Console.WriteLine(@event.Name);
+ Console.WriteLine(market.Name);
+ Console.WriteLine(arbitrage);
+ Console.WriteLine(@event.Commencement.ToString());
+
+ foreach (BookkeeperMarket bookkeeperMarket in bookkeeperMarkets)
+ {
+ Console.WriteLine(bookkeeperMarket.Name);
+ }
+
+ foreach (BookkeeperOdds bookkeeperOdds in bestBookkeeperOdds)
+ {
+ Console.WriteLine(bookkeeperOdds.Url);
+ Console.WriteLine($"{Enum.GetName(typeof(Bookkeeper), bookkeeperOdds.Bookkeeper)}\t{bookkeeperOdds.Outcome}\t{bookkeeperOdds.Value}");
+ }
+
+ Console.WriteLine();
+
+ return true;
+ }
+ }
+}
diff --git a/Arbitrage/DiscordNotificationChannel.cs b/Arbitrage/DiscordNotificationChannel.cs
new file mode 100644
index 0000000..600b3a2
--- /dev/null
+++ b/Arbitrage/DiscordNotificationChannel.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Discord;
+using Discord.Commands;
+using Discord.WebSocket;
+using Microsoft.Extensions.Configuration;
+
+namespace Arbitrage
+{
+ public class DiscordNotificationChannel : INotificationChannel
+ {
+ private readonly DiscordSocketClient mClient;
+ private SocketTextChannel? mChannel;
+
+ public DiscordNotificationChannel()
+ {
+ mClient = new DiscordSocketClient();
+ }
+
+ public async Task InitialiseAsync()
+ {
+ IConfiguration config = new ConfigurationBuilder()
+ .AddJsonFile("configuration.json")
+ .Build();
+
+ string token = config.GetRequiredSection("discord").GetValue("token");
+
+ await mClient.LoginAsync(TokenType.Bot, token);
+ await mClient.StartAsync();
+
+ ulong serverId = config.GetRequiredSection("discord").GetValue("serverId");
+ ulong channelId = config.GetRequiredSection("discord").GetValue("channelId");
+
+ SocketGuild? socketGuild = null;
+
+ int maxNumRetries = 100;
+ int numRetries = 0;
+
+ while (socketGuild == null)
+ {
+ if (numRetries >= maxNumRetries)
+ {
+ throw new Exception("Could not connect Discord bot.");
+ }
+
+ socketGuild = mClient.GetGuild(serverId);
+ await Task.Delay(50);
+
+ numRetries++;
+ }
+
+ mChannel = socketGuild.GetTextChannel(channelId);
+ }
+
+ public bool Notify(Sport sport, Event @event, Market market, IEnumerable bookkeeperMarkets, double arbitrage, IEnumerable bestBookkeeperOdds)
+ {
+ Thread.Sleep(2000);
+
+ if (mChannel == null)
+ {
+ throw new Exception("Discord channel not connected");
+ }
+
+ string longFormat = "0.00";
+
+ string header = "```yaml\nNEW ARBITRAGE OPPORTUNITY FOUND\n```";
+ string sportSection = $"**Sport** {Enum.GetName(typeof(Sport), sport)}";
+ string eventSection = $"**Event** {@event.Name}";
+ string startSection = $"**Start Time** {@event.Commencement.ToString()}";
+ string marketSection = $"**Market** {market.Name}";
+ string arbitrageSection = $"**Arbitrage** {arbitrage.ToString(longFormat)}";
+ string oddsSection = "__**Optimal Odds**__";
+
+ List bookkeeperSections = new List();
+
+ foreach (BookkeeperOdds bookkeeperOdds in bestBookkeeperOdds)
+ {
+ BookkeeperMarket bookkeeperMarket = bookkeeperMarkets.First(x => x.Bookkeeper == bookkeeperOdds.Bookkeeper);
+ string bookkeeperSection = $"**Bookie** {Enum.GetName(typeof(Bookkeeper), bookkeeperOdds.Bookkeeper)} **Market** {bookkeeperMarket.Name} **Outcome** {bookkeeperOdds.Outcome} **Odds** {bookkeeperOdds.Value.ToString(longFormat)} **Url** {bookkeeperOdds.Url}";
+ bookkeeperSections.Add(bookkeeperSection);
+ }
+
+ string content = header + "\n" + sportSection + "\n" + eventSection + "\n" + startSection + "\n" + marketSection + "\n" + arbitrageSection + "\n\n" + oddsSection + "\n\n";
+
+ foreach (string bookkeeperSection in bookkeeperSections)
+ {
+ content += bookkeeperSection + "\n";
+ }
+
+ mChannel.SendMessageAsync(content);
+
+ return true;
+ }
+ }
+}
diff --git a/Arbitrage/Engine.cs b/Arbitrage/Engine.cs
new file mode 100644
index 0000000..4a51531
--- /dev/null
+++ b/Arbitrage/Engine.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class Engine
+ {
+ private IDataService mDataService { get; set; }
+ private IEnumerable mNotificationChannels { get; set; }
+
+ public Engine(IDataService dataService,
+ IEnumerable notificationChannels)
+ {
+ mDataService = dataService;
+ mNotificationChannels = notificationChannels;
+ }
+
+ public async Task RunAsync(IEnumerable sports)
+ {
+ await mDataService.InitialiseAsync();
+
+ foreach (Sport sport in sports)
+ {
+ Dictionary> events = await mDataService.ReconcileSportEventsAsync(sport);
+
+ foreach ((Event @event, IEnumerable bookkeeperEvents) in events)
+ {
+ Dictionary> markets = await mDataService.ReconcileEventMarketsAsync(@event, bookkeeperEvents);
+ @event.Markets = markets.Keys;
+
+ foreach ((Market market, IEnumerable bookkeeperMarkets) in markets)
+ {
+ IEnumerable odds = await mDataService.ReconcileMarketOddsAsync(@event, bookkeeperMarkets);
+ market.Odds = odds;
+
+ if (odds.Any())
+ {
+ (double arbitrage, IEnumerable bookkeeperOdds) = Calculator.FindBestArbitrage(market);
+
+ if (arbitrage < 1)
+ {
+ foreach (INotificationChannel notificationChannel in mNotificationChannels)
+ {
+ notificationChannel.Notify(sport, @event, market, bookkeeperMarkets, arbitrage, bookkeeperOdds);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Arbitrage/Event.cs b/Arbitrage/Event.cs
new file mode 100644
index 0000000..018e858
--- /dev/null
+++ b/Arbitrage/Event.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class Event
+ {
+ public string Name { get; set; } = null!;
+ public string? Competition { get; set; }
+ public Sport Sport { get; set; }
+ public IEnumerable? Markets { get; set; }
+ public DateTimeOffset? Commencement { get; set; }
+ public string TMPUnibetEventId { get; set; }
+ }
+}
diff --git a/Arbitrage/IDataService.cs b/Arbitrage/IDataService.cs
new file mode 100644
index 0000000..181a994
--- /dev/null
+++ b/Arbitrage/IDataService.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public interface IDataService
+ {
+ public Task InitialiseAsync();
+ public Task>> ReconcileSportEventsAsync(Sport sport);
+ public Task>> ReconcileEventMarketsAsync(Event @event, IEnumerable bookkeeperEvents);
+ public Task> ReconcileMarketOddsAsync(Event @event, IEnumerable bookkeeperMarkets);
+ }
+}
diff --git a/Arbitrage/INotificationChannel.cs b/Arbitrage/INotificationChannel.cs
new file mode 100644
index 0000000..e423e98
--- /dev/null
+++ b/Arbitrage/INotificationChannel.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public interface INotificationChannel
+ {
+ public bool Notify(Sport sport, Event @event, Market market, IEnumerable bookkeeperMarkets, double arbitrage, IEnumerable bestBookkeeperOdds);
+ }
+}
diff --git a/Arbitrage/LadbrokesService.cs b/Arbitrage/LadbrokesService.cs
new file mode 100644
index 0000000..634f907
--- /dev/null
+++ b/Arbitrage/LadbrokesService.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Microsoft.Extensions.Configuration;
+using System.Globalization;
+
+namespace Arbitrage
+{
+ public class LadbrokesService : BookkeeperService
+ {
+ private LadbrokesServiceConfiguration mConfiguration { get; set; }
+ private dynamic? EventsListData { get; set; }
+ private List EventsData { get; set; } = new List();
+
+ public LadbrokesService()
+ {
+ IConfiguration config = new ConfigurationBuilder()
+ .AddJsonFile("configuration.json")
+ .Build();
+
+ mConfiguration = config.GetRequiredSection("ladbrokesService").Get();
+ }
+
+ public async Task InitialiseAsync()
+ {
+ UriBuilder uriBuilder = new UriBuilder(mConfiguration.BaseSiteUrl);
+
+ var httpResult = await mHttpClient.GetAsync(uriBuilder.ToString());
+
+ if (!httpResult.IsSuccessStatusCode)
+ {
+ mLogger.LogWarning($"Could not get visit Ladbrokes");
+ }
+ }
+
+ public override async Task> GetSportEventsAsync(Sport sport)
+ {
+ if (!mConfiguration.Categories.Select(x => x.Sport).Contains(sport))
+ {
+ mLogger.LogWarning($"Ladbroke servive cannot get {sport}.");
+ return new List();
+ }
+
+ Dictionary queryDictionary = new Dictionary()
+ {
+ {"category_ids", $"[\"{mConfiguration.Categories.First(x => x.Sport == sport).Id}\"]"}
+ };
+
+ var queryString = new FormUrlEncodedContent(queryDictionary)
+ .ReadAsStringAsync().Result;
+
+ UriBuilder uriBuilder = new UriBuilder(mConfiguration.GetEventsUrl);
+ uriBuilder.Query = queryString;
+
+ var httpResult = await mHttpClient.GetAsync(uriBuilder.ToString());
+
+ if (!httpResult.IsSuccessStatusCode)
+ {
+ mLogger.LogWarning($"Could not get events from Ladbroke.");
+ return new List();
+ }
+
+ dynamic? data = JsonConvert.DeserializeObject(await httpResult.Content.ReadAsStringAsync());
+
+ if (data == null)
+ {
+ mLogger.LogWarning("Could not deserialise Ladbrokes get events response content.");
+ return new List();
+ }
+
+ EventsListData = data;
+
+ List events = new List();
+
+ foreach (var x in EventsListData["events"])
+ {
+ var obj = ((IEnumerable)x).First();
+
+ DateTimeOffset commencementTime = DateTimeOffset.Parse((string)obj["actual_start"].ToString()).ToUniversalTime();
+
+ string eventId = obj["id"].ToString();
+
+ string url = new Uri(new Uri(mConfiguration.BaseEventUrl), $"{sport}/nice/website/{eventId}").ToString(); //TODO: add proper URL
+
+ events.Add(new BookkeeperEvent()
+ {
+ Bookkeeper = Bookkeeper.Ladbrokes,
+ BookkeeperEventId = eventId,
+ Name = obj["name"].ToString(),
+ Competition = obj["competition"]?["name"]?.ToString(),
+ Commencement = commencementTime,
+ Url = url
+ });
+ }
+
+ return events;
+ }
+
+ public override async Task> GetEventMarketsAsync(BookkeeperEvent bookkeeperEvent)
+ {
+ Dictionary queryDictionary = new Dictionary()
+ {
+ {"id", bookkeeperEvent.BookkeeperEventId}
+ };
+
+ var queryString = new FormUrlEncodedContent(queryDictionary)
+ .ReadAsStringAsync().Result;
+
+ UriBuilder uriBuilder = new UriBuilder(mConfiguration.GetOddsUrl);
+ uriBuilder.Query = queryString;
+
+ var httpResult = await mHttpClient.GetAsync(uriBuilder.ToString());
+
+ if (!httpResult.IsSuccessStatusCode)
+ {
+ mLogger.LogWarning($"Could not get event from Ladbrokes for event {bookkeeperEvent.BookkeeperEventId}.");
+ return new List();
+ }
+
+ dynamic? data = JsonConvert.DeserializeObject(await httpResult.Content.ReadAsStringAsync());
+
+ if (data == null)
+ {
+ mLogger.LogWarning($"Could not deserialise Ladbrokes get event for {bookkeeperEvent.BookkeeperEventId}.");
+ return new List();
+ }
+
+ EventsData.Add(data);
+
+ List markets = new List();
+
+ foreach (var market in data["markets"])
+ {
+ markets.Add(new BookkeeperMarket()
+ {
+ Bookkeeper = Bookkeeper.Ladbrokes,
+ BookkeeperMarketId = market.Value.id,
+ Name = market.Value.name,
+ Url = bookkeeperEvent.Url
+ });
+ }
+
+ return markets;
+ }
+
+ public async override Task> GetMarketOddsAsync(Event @event, BookkeeperMarket bookkeeperMarket)
+ {
+ dynamic? eventDynamic = null;
+ List entrantIds = new List();
+
+ foreach (dynamic eventData in EventsData)
+ {
+ foreach (dynamic eventMarket in eventData["markets"])
+ {
+ if (eventMarket.Value.id == bookkeeperMarket.BookkeeperMarketId)
+ {
+ eventDynamic = eventData;
+
+ foreach (string entrantId in eventMarket.Value["entrant_ids"])
+ {
+ entrantIds.Add(entrantId);
+ }
+ }
+ }
+ }
+
+ if (eventDynamic == null)
+ {
+ throw new Exception($"EventsData does not contain data for market {bookkeeperMarket.BookkeeperMarketId}");
+ }
+
+ List bookkeeperOdds = new List();
+
+ foreach (string entrantId in entrantIds)
+ {
+ string name = eventDynamic["entrants"][entrantId]["name"].ToString();
+ dynamic price = ((IEnumerable)eventDynamic["prices"]).First(x => x.Name.Split(":")[0] == entrantId).Value["odds"];
+ double value = double.Parse(price["numerator"].ToString()) / double.Parse(price["denominator"].ToString());
+
+ bookkeeperOdds.Add(new BookkeeperOdds()
+ {
+ Bookkeeper = Bookkeeper.Ladbrokes,
+ BookkeeperOddsId = entrantId,
+ Outcome = name,
+ Value = value + 1,
+ Url = bookkeeperMarket.Url
+ });
+ }
+
+ return bookkeeperOdds;
+ }
+ }
+}
diff --git a/Arbitrage/LadbrokesServiceConfiguration.cs b/Arbitrage/LadbrokesServiceConfiguration.cs
new file mode 100644
index 0000000..61dbb9b
--- /dev/null
+++ b/Arbitrage/LadbrokesServiceConfiguration.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class LadbrokesServiceConfiguration
+ {
+ public string BaseEventUrl { get; set; } = null!;
+ public string BaseSiteUrl { get; set; } = null!;
+ public string GetEventsUrl { get; set; } = null!;
+ public string GetOddsUrl { get; set; } = null!;
+ public ICollection Categories { get; set; } = null!;
+
+ public class Category
+ {
+ public string Id { get; set; } = null!;
+ public Sport Sport { get; set; }
+ }
+ }
+}
diff --git a/Arbitrage/Market.cs b/Arbitrage/Market.cs
new file mode 100644
index 0000000..c34f511
--- /dev/null
+++ b/Arbitrage/Market.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class Market
+ {
+ public string Name { get; set; } = null!;
+ public IEnumerable? Odds { get; set; }
+ }
+}
diff --git a/Arbitrage/Odds.cs b/Arbitrage/Odds.cs
new file mode 100644
index 0000000..bcc9cc4
--- /dev/null
+++ b/Arbitrage/Odds.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class Odds
+ {
+ public string Outcome { get; set; } = null!;
+ public IEnumerable BookkeeperOdds { get; set; } = null!;
+ }
+}
diff --git a/Arbitrage/Program.cs b/Arbitrage/Program.cs
new file mode 100644
index 0000000..acacf15
--- /dev/null
+++ b/Arbitrage/Program.cs
@@ -0,0 +1,16 @@
+using Arbitrage;
+using Newtonsoft.Json;
+using System.Reflection;
+
+BaseDataService dataService = new BaseDataService();
+ConsoleNotificationChannel consoleNotificationChannel = new ConsoleNotificationChannel();
+DiscordNotificationChannel discordNotificationChannel = new DiscordNotificationChannel();
+await discordNotificationChannel.InitialiseAsync();
+
+Engine engine = new Engine(dataService, new List() { consoleNotificationChannel, discordNotificationChannel });
+
+while (true)
+{
+ await engine.RunAsync(new List() { Sport.AFL, Sport.Baseball, Sport.Soccer, Sport.Boxing, Sport.TableTennis, Sport.Cricket });
+ Thread.Sleep(900000);
+}
\ No newline at end of file
diff --git a/Arbitrage/Sport.cs b/Arbitrage/Sport.cs
new file mode 100644
index 0000000..77745c5
--- /dev/null
+++ b/Arbitrage/Sport.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public enum Sport
+ {
+ AFL,
+ TableTennis,
+ Boxing,
+ Baseball,
+ Soccer,
+ Cricket
+ }
+}
diff --git a/Arbitrage/StringDistance.cs b/Arbitrage/StringDistance.cs
new file mode 100644
index 0000000..2a97569
--- /dev/null
+++ b/Arbitrage/StringDistance.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public static class StringDistance
+ {
+ ///
+ /// Compute the distance between two strings.
+ ///
+ public static int LevenshteinDistance(string s, string t)
+ {
+ int n = s.Length;
+ int m = t.Length;
+ int[,] d = new int[n + 1, m + 1];
+
+ // Step 1
+ if (n == 0)
+ {
+ return m;
+ }
+
+ if (m == 0)
+ {
+ return n;
+ }
+
+ // Step 2
+ for (int i = 0; i <= n; d[i, 0] = i++)
+ {
+ }
+
+ for (int j = 0; j <= m; d[0, j] = j++)
+ {
+ }
+
+ // Step 3
+ for (int i = 1; i <= n; i++)
+ {
+ //Step 4
+ for (int j = 1; j <= m; j++)
+ {
+ // Step 5
+ int cost = (t[j - 1] == s[i - 1]) ? 0 : 1;
+
+ // Step 6
+ d[i, j] = Math.Min(
+ Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1),
+ d[i - 1, j - 1] + cost);
+ }
+ }
+ // Step 7
+ return d[n, m];
+ }
+
+ public static int LengthUnbiasedLevenshteinDistance(string s, string t)
+ {
+ int levenshteinDist = LevenshteinDistance(s, t);
+ return levenshteinDist - Math.Abs(s.Length - t.Length);
+ }
+
+ public static bool TryFindLongestCommonSubstringSlice(string str1, string str2, out int start, out int end)
+ {
+ int[,] num = new int[str1.Length, str2.Length];
+ int maxlen = 0;
+ int lastSubsBegin = 0;
+ StringBuilder subStrBuilder = new StringBuilder();
+
+ for (int i = 0; i < str1.Length; i++)
+ {
+ for (int j = 0; j < str2.Length; j++)
+ {
+ if (str1[i] != str2[j])
+ {
+ num[i, j] = 0;
+ }
+ else
+ {
+ if ((i == 0) || (j == 0))
+ num[i, j] = 1;
+ else
+ num[i, j] = 1 + num[i - 1, j - 1];
+
+ if (num[i, j] > maxlen)
+ {
+ maxlen = num[i, j];
+
+ int thisSubsBegin = i - num[i, j] + 1;
+
+ if (lastSubsBegin == thisSubsBegin)
+ {
+ subStrBuilder.Append(str1[i]);
+ }
+ else
+ {
+ lastSubsBegin = thisSubsBegin;
+ subStrBuilder.Length = 0;
+ subStrBuilder.Append(str1.Substring(lastSubsBegin, (i + 1) - lastSubsBegin));
+ }
+ }
+ }
+ }
+ }
+
+ if (maxlen > 0)
+ {
+ // Inclusive
+ start = lastSubsBegin;
+ end = lastSubsBegin + maxlen - 1;
+ return true;
+ }
+ else
+ {
+ start = 0;
+ end = 0;
+ return false;
+ }
+ }
+
+ public static double NumberOfSubstringMovementsWithLengthPenalty(string s, string t)
+ {
+ s = s.Trim().ToLower();
+ t = t.Trim().ToLower();
+
+ int sLen = s.Length;
+ int tLen = t.Length;
+
+ string shortestString;
+ string longestString;
+
+ if (sLen > tLen)
+ {
+ shortestString = t;
+ longestString = s;
+ }
+ else
+ {
+ shortestString = s;
+ longestString = t;
+ }
+
+ int shortestStringLen = shortestString.Length;
+
+ List substrings = new List();
+
+ while (shortestString.Length > 0)
+ {
+ int start;
+ int end;
+
+ if (TryFindLongestCommonSubstringSlice(shortestString, longestString, out start, out end))
+ {
+ substrings.Add(shortestString.Substring(start, end - start + 1));
+ shortestString = shortestString.Remove(start, end - start + 1).Trim();
+ }
+ else
+ {
+ substrings.Add(shortestString);
+ break;
+ }
+ }
+
+ if (substrings.Count == 1)
+ {
+ return 0;
+ }
+
+ return substrings.Count / (float)shortestStringLen;
+ }
+ }
+}
diff --git a/Arbitrage/UnibetService.cs b/Arbitrage/UnibetService.cs
new file mode 100644
index 0000000..1eb2c4b
--- /dev/null
+++ b/Arbitrage/UnibetService.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+
+namespace Arbitrage
+{
+ public class UnibetService : BookkeeperService
+ {
+ private UnibetServiceConfiguration mConfiguration { get; set; }
+ private dynamic? OddsData { get; set; }
+ private Dictionary> mEventParticipants { get; set; }
+
+ public UnibetService()
+ {
+ IConfiguration config = new ConfigurationBuilder()
+ .AddJsonFile("configuration.json")
+ .Build();
+
+ mConfiguration = config.GetRequiredSection("unibetService").Get();
+
+ mEventParticipants = new Dictionary>();
+ }
+
+ public async Task InitialiseAsync()
+ {
+ UriBuilder uriBuilder = new UriBuilder(mConfiguration.BaseSiteUrl);
+
+ var httpResult = await mHttpClient.GetAsync(uriBuilder.ToString());
+
+ if (!httpResult.IsSuccessStatusCode)
+ {
+ mLogger.LogWarning($"Could not get visit Ladbrokes");
+ }
+ }
+
+ public override async Task> GetSportEventsAsync(Sport sport)
+ {
+ if (!mConfiguration.GetSportEventsUrls.Select(x => x.Key).Contains(sport))
+ {
+ mLogger.LogWarning($"Unibet not configured to retrieve {sport} events.");
+ return new List();
+ }
+
+ UriBuilder uriBuilder = new UriBuilder(mConfiguration.GetSportEventsUrls[sport]);
+
+ var httpResult = await mHttpClient.GetAsync(uriBuilder.ToString());
+
+ if (!httpResult.IsSuccessStatusCode)
+ {
+ mLogger.LogWarning($"Could not get {sport} events from Unibet");
+ return new List();
+ }
+
+ dynamic? data = JsonConvert.DeserializeObject(await httpResult.Content.ReadAsStringAsync());
+
+ if (data == null)
+ {
+ mLogger.LogWarning($"Could not deserialise Unibet events for {sport}");
+ return new List();
+ }
+
+ List events = new List();
+
+ foreach (dynamic section in data["layout"]["sections"])
+ {
+ foreach (dynamic widget in section["widgets"])
+ {
+ foreach (dynamic group in widget["matches"]["groups"])
+ {
+ List eventDatas = GetEventsFromGroup(group);
+
+ foreach (dynamic eventData in eventDatas)
+ {
+ string eventId = eventData.id.ToString();
+
+ List participantIds = new List();
+
+ if (eventData.ContainsKey("participants"))
+ {
+ foreach (dynamic participant in eventData["participants"])
+ {
+ participantIds.Add(participant["name"].ToString());
+ }
+ }
+
+ DateTimeOffset commencementTime = DateTimeOffset.Parse((string)eventData["start"].ToString()).ToUniversalTime();
+
+ string url = new Uri(new Uri(mConfiguration.BaseEventUrl), eventId).ToString();
+
+ events.Add(new BookkeeperEvent()
+ {
+ Bookkeeper = Bookkeeper.Unibet,
+ BookkeeperEventId = eventId,
+ Name = eventData.name.ToString(),
+ Competition = string.Join(" ", ((IEnumerable)eventData.path).Select(x => x.name.ToString())),
+ Commencement = commencementTime,
+ Url = url
+ });
+
+ mEventParticipants.Add(eventId, participantIds);
+ }
+ }
+ }
+ }
+
+ return events;
+ }
+
+ private static List GetEventsFromGroup(dynamic group)
+ {
+ List events = new List();
+
+ dynamic subGroups = group["subGroups"];
+
+ if (subGroups == null)
+ {
+ foreach (dynamic @event in group["events"])
+ {
+ events.Add(@event["event"]);
+ }
+
+ return events;
+ }
+
+ foreach (dynamic subgroup in subGroups)
+ {
+ events.AddRange(GetEventsFromGroup(subgroup));
+ }
+
+ return events;
+ }
+
+ public override async Task> GetEventMarketsAsync(BookkeeperEvent bookkeeperEvent)
+ {
+ UriBuilder uriBuilder = new UriBuilder(new Uri(new Uri(mConfiguration.GetOddsUrl), bookkeeperEvent.BookkeeperEventId));
+
+ var httpResult = await mHttpClient.GetAsync(uriBuilder.ToString());
+
+ if (!httpResult.IsSuccessStatusCode)
+ {
+ mLogger.LogWarning($"Could not get Unibet markets for event {bookkeeperEvent.BookkeeperEventId}");
+ return new List();
+ }
+
+ dynamic? data = JsonConvert.DeserializeObject(await httpResult.Content.ReadAsStringAsync());
+
+ if (data == null)
+ {
+ mLogger.LogWarning($"Could not deserialise Unibet markets for event {bookkeeperEvent.BookkeeperEventId}");
+ return new List();
+ }
+
+ OddsData = data;
+
+ List markets = new List();
+
+ foreach (dynamic market in OddsData["betOffers"])
+ {
+ string marketName = market["betOfferType"]["name"];
+
+ if (market["criterion"] != null)
+ {
+ marketName += " " + market["criterion"]["label"];
+ }
+
+ dynamic firstOutcome = market["outcomes"][0];
+
+ if (firstOutcome.ContainsKey("line"))
+ {
+ float line;
+ bool didParse = float.TryParse(firstOutcome["line"].ToString(), out line);
+
+ if (didParse)
+ {
+ marketName += " " + (line / 1000.0).ToString("0.0");
+ }
+ }
+
+ markets.Add(new BookkeeperMarket()
+ {
+ Bookkeeper = Bookkeeper.Unibet,
+ BookkeeperMarketId = market.id,
+ Name = marketName,
+ Url = bookkeeperEvent.Url
+ });
+ }
+
+ return markets;
+ }
+
+ public async override Task> GetMarketOddsAsync(Event @event, BookkeeperMarket bookkeeperMarket)
+ {
+ if (OddsData == null)
+ {
+ throw new Exception("OddsData property is not populated.");
+ }
+
+ dynamic betOffer = ((IEnumerable)OddsData["betOffers"]).First(x => x.id == bookkeeperMarket.BookkeeperMarketId);
+
+ List bookkeeperOdds = new List();
+
+ foreach (dynamic outcome in betOffer["outcomes"])
+ {
+
+ if (!outcome.ContainsKey("odds"))
+ {
+ continue;
+ }
+
+ double value = double.Parse(outcome["odds"].ToString()) / 1000;
+
+ string outcomeString = outcome["label"].ToString();
+
+ List validParticipantIndices = new List();
+
+ if (mEventParticipants.Keys.Contains(@event.TMPUnibetEventId))
+ {
+ for (int i = 0; i < mEventParticipants[@event.TMPUnibetEventId].Count; i++)
+ {
+ validParticipantIndices.Add(i);
+ }
+ }
+
+ string newOutcomeString = outcomeString;
+
+ foreach (string c in outcomeString.ToList().Select(x => x.ToString()))
+ {
+ int representation = -1;
+
+ if (int.TryParse(c, out representation))
+ {
+ if (validParticipantIndices.Contains(representation - 1))
+ {
+ newOutcomeString = newOutcomeString.Replace(c, mEventParticipants[@event.TMPUnibetEventId][representation - 1]);
+ }
+ }
+ else if (c == "X")
+ {
+ newOutcomeString = newOutcomeString.Replace(c, "Draw");
+ }
+ }
+
+ bookkeeperOdds.Add(new BookkeeperOdds()
+ {
+ Bookkeeper = Bookkeeper.Unibet,
+ BookkeeperOddsId = outcome["id"],
+ Outcome = newOutcomeString,
+ Value = value,
+ Url = bookkeeperMarket.Url
+ });
+ }
+
+ return bookkeeperOdds;
+ }
+ }
+}
diff --git a/Arbitrage/UnibetServiceConfiguration.cs b/Arbitrage/UnibetServiceConfiguration.cs
new file mode 100644
index 0000000..72e359c
--- /dev/null
+++ b/Arbitrage/UnibetServiceConfiguration.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Arbitrage
+{
+ public class UnibetServiceConfiguration
+ {
+ public string BaseEventUrl { get; set; } = null!;
+ public string BaseSiteUrl { get; set; } = null!;
+ public string GetOddsUrl { get; set; } = null!;
+ public Dictionary GetSportEventsUrls { get; set; } = null!;
+ }
+}
diff --git a/Arbitrage/configuration.json b/Arbitrage/configuration.json
new file mode 100644
index 0000000..c51ef07
--- /dev/null
+++ b/Arbitrage/configuration.json
@@ -0,0 +1,87 @@
+{
+ "ladbrokesService": {
+ "baseSiteUrl": "https://www.ladbrokes.com.au",
+ "categories": [
+ {
+ "sport": "AFL",
+ "id": "23d497e6-8aab-4309-905b-9421f42c9bc5"
+ },
+ {
+ "sport": "TableTennis",
+ "id": "b92b2d14-10f7-46c7-8655-16eeed36ec4b"
+ },
+ {
+ "sport": "Boxing",
+ "id": "a8217d48-3257-402b-b3b5-9db706fdc1e0"
+ },
+ {
+ "sport": "Baseball",
+ "id": "02721435-4671-4cd0-98f7-15d41ee4103e"
+ },
+ {
+ "sport": "Soccer",
+ "id": "71955b54-62f6-4ac5-abaa-df88cad0aeef"
+ },
+ {
+ "sport": "Cricket",
+ "id": "94984918-dbac-432b-b420-c219ec9203f4"
+ }
+ ],
+ "getEventsUrl": "https://api.ladbrokes.com.au/v2/sport/event-request",
+ "getOddsUrl": "https://api.ladbrokes.com.au/v2/sport/event-card",
+ "baseEventUrl": "https://www.ladbrokes.com.au/sports/"
+ },
+ "unibetService": {
+ "baseSiteUrl": "https://www.unibet.com.au",
+ "getOddsUrl": "https://o1-api.aws.kambicdn.com/offering/v2018/ubau/betoffer/event/",
+ "getSportEventsUrls": {
+ "TableTennis": "https://www.unibet.com.au/sportsbook-feeds/views/filter/table_tennis/all/matches",
+ "Boxing": "https://www.unibet.com.au/sportsbook-feeds/views/filter/boxing/all/matches",
+ "Baseball": "https://www.unibet.com.au/sportsbook-feeds/views/filter/baseball/all/matches",
+ "Soccer": "https://www.unibet.com.au/sportsbook-feeds/views/filter/football/all/matches",
+ "Cricket": "https://www.unibet.com.au/sportsbook-feeds/views/filter/cricket/all/matches"
+ },
+ "baseEventUrl": "https://www.unibet.com.au/betting/sports/event/"
+ },
+ "bookerkeeperEntityComparer": {
+ "knownCorrespondingMarketNames": {
+ "Ladbrokes": {
+ "Boxing": {
+ "Fight Betting": [
+ "Match"
+ ]
+ }
+ },
+ "Unibet": {
+ "Boxing": {
+ "Match": [
+ "Fight Betting"
+ ]
+ }
+ }
+ },
+ "knownCorrespondingOddsNames": {
+ "Ladbrokes": {
+ "Boxing": {
+ "Draw": [
+ "X"
+ ],
+ "Draw or Technical Draw": [
+ "X"
+ ]
+ }
+ },
+ "Unibet": {
+ "Boxing": {
+ "X": [
+ "Draw",
+ "Draw or Technical Draw"
+ ]
+ }
+ }
+ }
+ },
+ "discord": {
+
+ }
+}
\ No newline at end of file
diff --git a/Arbitrage/lib/bookkeepers/ladbrokes/configuration.json b/Arbitrage/lib/bookkeepers/ladbrokes/configuration.json
new file mode 100644
index 0000000..5fd7979
--- /dev/null
+++ b/Arbitrage/lib/bookkeepers/ladbrokes/configuration.json
@@ -0,0 +1,14 @@
+{
+ "categories": [
+ {
+ "sport": "AFL",
+ "id": "23d497e6-8aab-4309-905b-9421f42c9bc5"
+ },
+ {
+ "sport": "TableTennis",
+ "id": "b92b2d14-10f7-46c7-8655-16eeed36ec4b"
+ }
+ ],
+ "getEventsUrl": "https://api.ladbrokes.com.au/v2/sport/event-request",
+ "getOddsUrl": "https://api.ladbrokes.com.au/v2/sport/event-card"
+}
\ No newline at end of file
diff --git a/Arbitrage/lib/bookkeepers/unibet/configuration.json b/Arbitrage/lib/bookkeepers/unibet/configuration.json
new file mode 100644
index 0000000..6048c45
--- /dev/null
+++ b/Arbitrage/lib/bookkeepers/unibet/configuration.json
@@ -0,0 +1,6 @@
+{
+ "GetOddsUrl": "https://o1-api.aws.kambicdn.com/offering/v2018/ubau/betoffer/event/",
+ "GetSportEventsUrls": {
+ "TableTennis": "https://www.unibet.com.au/sportsbook-feeds/views/filter/table_tennis/all/matches"
+ }
+}
\ No newline at end of file