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