diff --git a/poetry.lock b/poetry.lock
index 8791d4e..8f3a165 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -32,6 +32,25 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)",
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"]
trio = ["trio (>=0.26.1)"]
+[[package]]
+name = "arrow"
+version = "1.3.0"
+description = "Better dates & times for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"},
+ {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.7.0"
+types-python-dateutil = ">=2.8.10"
+
+[package.extras]
+doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"]
+test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"]
+
[[package]]
name = "attrs"
version = "23.2.0"
@@ -1084,6 +1103,20 @@ pluggy = ">=1.5,<2.0"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
[[package]]
name = "python-dotenv"
version = "1.0.1"
@@ -1171,6 +1204,112 @@ files = [
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
+[[package]]
+name = "rapidfuzz"
+version = "3.13.0"
+description = "rapid fuzzy string matching"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "rapidfuzz-3.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aafc42a1dc5e1beeba52cd83baa41372228d6d8266f6d803c16dbabbcc156255"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85c9a131a44a95f9cac2eb6e65531db014e09d89c4f18c7b1fa54979cb9ff1f3"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7cec4242d30dd521ef91c0df872e14449d1dffc2a6990ede33943b0dae56c3"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e297c09972698c95649e89121e3550cee761ca3640cd005e24aaa2619175464e"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef0f5f03f61b0e5a57b1df7beafd83df993fd5811a09871bad6038d08e526d0d"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8cf5f7cd6e4d5eb272baf6a54e182b2c237548d048e2882258336533f3f02b7"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9256218ac8f1a957806ec2fb9a6ddfc6c32ea937c0429e88cf16362a20ed8602"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bdd2e6d0c5f9706ef7595773a81ca2b40f3b33fd7f9840b726fb00c6c4eb2e"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5280be8fd7e2bee5822e254fe0a5763aa0ad57054b85a32a3d9970e9b09bbcbf"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd742c03885db1fce798a1cd87a20f47f144ccf26d75d52feb6f2bae3d57af05"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5435fcac94c9ecf0504bf88a8a60c55482c32e18e108d6079a0089c47f3f8cf6"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:93a755266856599be4ab6346273f192acde3102d7aa0735e2f48b456397a041f"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-win32.whl", hash = "sha256:3abe6a4e8eb4cfc4cda04dd650a2dc6d2934cbdeda5def7e6fd1c20f6e7d2a0b"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8ddb58961401da7d6f55f185512c0d6bd24f529a637078d41dd8ffa5a49c107"},
+ {file = "rapidfuzz-3.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:c523620d14ebd03a8d473c89e05fa1ae152821920c3ff78b839218ff69e19ca3"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d395a5cad0c09c7f096433e5fd4224d83b53298d53499945a9b0e5a971a84f3a"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7b3eda607a019169f7187328a8d1648fb9a90265087f6903d7ee3a8eee01805"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98e0bfa602e1942d542de077baf15d658bd9d5dcfe9b762aff791724c1c38b70"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bef86df6d59667d9655905b02770a0c776d2853971c0773767d5ef8077acd624"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fedd316c165beed6307bf754dee54d3faca2c47e1f3bcbd67595001dfa11e969"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5158da7f2ec02a930be13bac53bb5903527c073c90ee37804090614cab83c29e"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b6f913ee4618ddb6d6f3e387b76e8ec2fc5efee313a128809fbd44e65c2bbb2"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d25fdbce6459ccbbbf23b4b044f56fbd1158b97ac50994eaae2a1c0baae78301"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25343ccc589a4579fbde832e6a1e27258bfdd7f2eb0f28cb836d6694ab8591fc"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9ad1f37894e3ffb76bbab76256e8a8b789657183870be11aa64e306bb5228fd"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5dc71ef23845bb6b62d194c39a97bb30ff171389c9812d83030c1199f319098c"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7f4c65facdb94f44be759bbd9b6dda1fa54d0d6169cdf1a209a5ab97d311a75"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b5104b62711565e0ff6deab2a8f5dbf1fbe333c5155abe26d2cfd6f1849b6c87"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:9093cdeb926deb32a4887ebe6910f57fbcdbc9fbfa52252c10b56ef2efb0289f"},
+ {file = "rapidfuzz-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:f70f646751b6aa9d05be1fb40372f006cc89d6aad54e9d79ae97bd1f5fce5203"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a1a6a906ba62f2556372282b1ef37b26bca67e3d2ea957277cfcefc6275cca7"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fd0975e015b05c79a97f38883a11236f5a24cca83aa992bd2558ceaa5652b26"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d4e13593d298c50c4f94ce453f757b4b398af3fa0fd2fde693c3e51195b7f69"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed6f416bda1c9133000009d84d9409823eb2358df0950231cc936e4bf784eb97"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dc82b6ed01acb536b94a43996a94471a218f4d89f3fdd9185ab496de4b2a981"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9d824de871daa6e443b39ff495a884931970d567eb0dfa213d234337343835f"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d18228a2390375cf45726ce1af9d36ff3dc1f11dce9775eae1f1b13ac6ec50f"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5fe634c9482ec5d4a6692afb8c45d370ae86755e5f57aa6c50bfe4ca2bdd87"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:694eb531889f71022b2be86f625a4209c4049e74be9ca836919b9e395d5e33b3"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11b47b40650e06147dee5e51a9c9ad73bb7b86968b6f7d30e503b9f8dd1292db"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:98b8107ff14f5af0243f27d236bcc6e1ef8e7e3b3c25df114e91e3a99572da73"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b836f486dba0aceb2551e838ff3f514a38ee72b015364f739e526d720fdb823a"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-win32.whl", hash = "sha256:4671ee300d1818d7bdfd8fa0608580d7778ba701817216f0c17fb29e6b972514"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e2065f68fb1d0bf65adc289c1bdc45ba7e464e406b319d67bb54441a1b9da9e"},
+ {file = "rapidfuzz-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:65cc97c2fc2c2fe23586599686f3b1ceeedeca8e598cfcc1b7e56dc8ca7e2aa7"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264"},
+ {file = "rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc64da907114d7a18b5e589057e3acaf2fec723d31c49e13fedf043592a3f6a7"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d9d7f84c8e992a8dbe5a3fdbea73d733da39bf464e62c912ac3ceba9c0cff93"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a79a2f07786a2070669b4b8e45bd96a01c788e7a3c218f531f3947878e0f956"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f338e71c45b69a482de8b11bf4a029993230760120c8c6e7c9b71760b6825a1"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb40ca8ddfcd4edd07b0713a860be32bdf632687f656963bcbce84cea04b8d8"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48719f7dcf62dfb181063b60ee2d0a39d327fa8ad81b05e3e510680c44e1c078"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9327a4577f65fc3fb712e79f78233815b8a1c94433d0c2c9f6bc5953018b3565"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:200030dfc0a1d5d6ac18e993c5097c870c97c41574e67f227300a1fb74457b1d"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cc269e74cad6043cb8a46d0ce580031ab642b5930562c2bb79aa7fbf9c858d26"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e62779c6371bd2b21dbd1fdce89eaec2d93fd98179d36f61130b489f62294a92"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f4797f821dc5d7c2b6fc818b89f8a3f37bcc900dd9e4369e6ebf1e525efce5db"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d21f188f6fe4fbf422e647ae9d5a68671d00218e187f91859c963d0738ccd88c"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-win32.whl", hash = "sha256:45dd4628dd9c21acc5c97627dad0bb791764feea81436fb6e0a06eef4c6dceaa"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:624a108122039af89ddda1a2b7ab2a11abe60c1521956f142f5d11bcd42ef138"},
+ {file = "rapidfuzz-3.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:435071fd07a085ecbf4d28702a66fd2e676a03369ee497cc38bcb69a46bc77e2"},
+ {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe5790a36d33a5d0a6a1f802aa42ecae282bf29ac6f7506d8e12510847b82a45"},
+ {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cdb33ee9f8a8e4742c6b268fa6bd739024f34651a06b26913381b1413ebe7590"},
+ {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c99b76b93f7b495eee7dcb0d6a38fb3ce91e72e99d9f78faa5664a881cb2b7d"},
+ {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af42f2ede8b596a6aaf6d49fdee3066ca578f4856b85ab5c1e2145de367a12d"},
+ {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c0efa73afbc5b265aca0d8a467ae2a3f40d6854cbe1481cb442a62b7bf23c99"},
+ {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7ac21489de962a4e2fc1e8f0b0da4aa1adc6ab9512fd845563fecb4b4c52093a"},
+ {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1ba007f4d35a45ee68656b2eb83b8715e11d0f90e5b9f02d615a8a321ff00c27"},
+ {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d7a217310429b43be95b3b8ad7f8fc41aba341109dc91e978cd7c703f928c58f"},
+ {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:558bf526bcd777de32b7885790a95a9548ffdcce68f704a81207be4a286c1095"},
+ {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:202a87760f5145140d56153b193a797ae9338f7939eb16652dd7ff96f8faf64c"},
+ {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcccc08f671646ccb1e413c773bb92e7bba789e3a1796fd49d23c12539fe2e4"},
+ {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86"},
+ {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ccbd0e7ea1a216315f63ffdc7cd09c55f57851afc8fe59a74184cb7316c0598b"},
+ {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50856f49a4016ef56edd10caabdaf3608993f9faf1e05c3c7f4beeac46bd12a"},
+ {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd05336db4d0b8348d7eaaf6fa3c517b11a56abaa5e89470ce1714e73e4aca7"},
+ {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573ad267eb9b3f6e9b04febce5de55d8538a87c56c64bf8fd2599a48dc9d8b77"},
+ {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30fd1451f87ccb6c2f9d18f6caa483116bbb57b5a55d04d3ddbd7b86f5b14998"},
+ {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6dd36d4916cf57ddb05286ed40b09d034ca5d4bca85c17be0cb6a21290597d9"},
+ {file = "rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8"},
+]
+
+[package.extras]
+all = ["numpy"]
+
[[package]]
name = "requests"
version = "2.31.0"
@@ -1247,6 +1386,17 @@ files = [
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
]
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -1413,6 +1563,17 @@ rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
+[[package]]
+name = "types-python-dateutil"
+version = "2.9.0.20250708"
+description = "Typing stubs for python-dateutil"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "types_python_dateutil-2.9.0.20250708-py3-none-any.whl", hash = "sha256:4d6d0cc1cc4d24a2dc3816024e502564094497b713f7befda4d5bc7a8e3fd21f"},
+ {file = "types_python_dateutil-2.9.0.20250708.tar.gz", hash = "sha256:ccdbd75dab2d6c9696c350579f34cffe2c281e4c5f27a585b2a2438dd1d5c8ab"},
+]
+
[[package]]
name = "typing-extensions"
version = "4.11.0"
@@ -1701,4 +1862,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
-content-hash = "4aff13aa5de2a13e5d9a0e8e92808191a716fc5f5ae3dcd1110e20c7000b59f2"
+content-hash = "5da85b721ab6df8e0a9d1e8c7e4baad93347e7d332d78951fa2346f60dd3f5d2"
diff --git a/pyproject.toml b/pyproject.toml
index e43ca0f..97d135b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,8 @@ uvicorn = "^0.34.0"
sqlmodel = "^0.0.24"
psycopg2-binary = "^2.9.10"
hashids = "^1.3.1"
+arrow = "^1.3.0"
+rapidfuzz = "^3.13.0"
[tool.poetry.group.dev.dependencies]
diff --git a/sweepy-ui/src/components/SweepstakeTable.jsx b/sweepy-ui/src/components/SweepstakeTable.jsx
index d33108f..06f6b82 100644
--- a/sweepy-ui/src/components/SweepstakeTable.jsx
+++ b/sweepy-ui/src/components/SweepstakeTable.jsx
@@ -7,6 +7,18 @@ function SweepstakeTable({ data }) {
setExpandedRow(expandedRow === index ? null : index);
};
+ const stringifyScore = (score) => {
+ if (score === null || score === undefined) {
+ return "-";
+ } else if (score == 0) {
+ return "E";
+ } else if (score > 0) {
+ return `+${score}`;
+ } else {
+ return `${score}`;
+ }
+ };
+
const sortedParticipants = [...data.participants].sort((a, b) => {
return parseFloat(b.equity) - parseFloat(a.equity);
});
@@ -39,6 +51,9 @@ function SweepstakeTable({ data }) {
| Runner |
Probability |
+ {data.tournament_id && (
+ Score |
+ )}
@@ -51,6 +66,11 @@ function SweepstakeTable({ data }) {
).toFixed(2)}
%
+ {data.tournament_id && (
+
+ {stringifyScore(assignment.score)}
+ |
+ )}
))}
diff --git a/sweepy/api.py b/sweepy/api.py
index 08f3308..8758f9d 100644
--- a/sweepy/api.py
+++ b/sweepy/api.py
@@ -14,6 +14,8 @@
from sweepy.database import get_session, init_db
from sweepy import db_models, tasks
from sweepy.integrations.betfair import BetfairClient
+from sweepy.integrations.live_golf import LiveGolfClient
+from sweepy import matchmaker
from sweepy.models import (
SweepstakesRequest,
Sweepstakes,
@@ -29,11 +31,12 @@
logging.basicConfig(level=logging.INFO)
__bf_client = None
+__live_golf_client = None
@asynccontextmanager
async def lifespan(app: FastAPI):
- global __bf_client
+ global __bf_client, __live_golf_client
logging.info("Starting up the FastAPI application.")
@@ -45,12 +48,19 @@ async def lifespan(app: FastAPI):
logging.info("Betfair client initialized.")
+ __live_golf_client = LiveGolfClient(api_key=os.getenv("LIVE_GOLF_API_KEY"))
+
+ logging.info("Live Golf client initialized.")
+
recreate_db = os.getenv("RECREATE_DB", "false").lower() == "true"
init_db(recreate=recreate_db)
logging.info("Database initialized.")
- logging.info("Starting the sweepstakes refresh task.")
- asyncio.create_task(tasks.refresh_all_sweepstakes_task(__bf_client))
+ logging.info("Starting the sweepstakes refresh odds task.")
+ asyncio.create_task(tasks.refresh_all_odds_task(__bf_client))
+
+ logging.info("Starting the sweepstakes refresh scores task.")
+ asyncio.create_task(tasks.refresh_all_scores_task(__live_golf_client))
yield
@@ -100,8 +110,9 @@ def create_sweepstakes(
try:
sweepstakes_db = generate_sweepstakes.generate_sweepstakes(
- __bf_client, request, session
+ __bf_client, __live_golf_client, request, session
)
+
except (
MarketNotFoundException,
NotEnoughSelectionsException,
@@ -110,6 +121,33 @@ def create_sweepstakes(
logging.error(f"Error generating sweepstake: {e}")
raise HTTPException(status_code=400, detail=str(e))
+ if sweepstakes_db.has_leaderboard:
+ logging.info("Sweepstake has leaderboard data, updating Live Golf matching.")
+ try:
+ matchmaker.match_runners_to_live_golf(
+ runners=sweepstakes_db.runners,
+ lg_tournament_id=sweepstakes_db.tournament_id,
+ season=sweepstakes_db.start_date.year,
+ lg_client=__live_golf_client,
+ session=session,
+ )
+
+ generate_sweepstakes.refresh_sweepstake_leaderboard(
+ client=__live_golf_client,
+ sweepstake_db=sweepstakes_db,
+ session=session,
+ )
+ except Exception as e:
+ logging.error(f"Error updating Live Golf matching: {e}")
+ session.rollback()
+ raise HTTPException(
+ status_code=500, detail="Failed to update Live Golf matching."
+ )
+ else:
+ logging.info(
+ "Sweepstake does not have a leaderboard, skipping Live Golf matching."
+ )
+
session.refresh(sweepstakes_db)
response = generate_sweepstakes.convert_db_model_to_response(sweepstakes_db)
@@ -195,7 +233,7 @@ def refresh_sweepstake(
logging.info(f"Refreshing sweepstake: {sweepstake.stringified_id}")
- updated_sweepstake = generate_sweepstakes.refresh_sweepstake(
+ updated_sweepstake = generate_sweepstakes.refresh_sweepstake_odds(
__bf_client, sweepstake, session
)
@@ -268,7 +306,7 @@ def get_outright_markets(event_type_id: str):
logging.info(f"Found {len(markets)} markets for event type {event_type_id}")
- deduplicated_markets = list(set([MarketInfo(**market) for market in markets]))
+ deduplicated_markets = list(set(market for market in markets))
deduplicated_markets.sort(key=lambda x: (x.event_name, x.market_name))
logging.info(f"Deduplicated markets count: {len(deduplicated_markets)}")
diff --git a/sweepy/cache.py b/sweepy/cache.py
index e6b7795..6db7d0e 100644
--- a/sweepy/cache.py
+++ b/sweepy/cache.py
@@ -31,3 +31,32 @@ def wrapper(self):
return wrapper
return decorator
+
+
+def timed_cache(ttl_seconds: int):
+ """
+ Decorator to cache the result of a function for a specified time-to-live (TTL) in seconds.
+ After the TTL expires, the function will be recomputed.
+ """
+
+ if ttl_seconds <= 0 or not isinstance(ttl_seconds, int):
+ raise ValueError("TTL must be an integer greater than 0 seconds")
+
+ def decorator(func):
+ cache = {}
+ last_update = {}
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ current_time = time.time()
+ key = (args, frozenset(kwargs.items()))
+
+ if key not in cache or (current_time - last_update[key]) > ttl_seconds:
+ cache[key] = func(*args, **kwargs)
+ last_update[key] = current_time
+
+ return cache[key]
+
+ return wrapper
+
+ return decorator
diff --git a/sweepy/cli.py b/sweepy/cli.py
deleted file mode 100644
index 55fdf4a..0000000
--- a/sweepy/cli.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import os
-import dotenv
-
-import typer
-from sweepy import generate_sweepstakes
-from sweepy.integrations.betfair import BetfairClient
-from sweepy.models import AssignmentMethod, SweepstakesRequest
-
-
-app = typer.Typer()
-dotenv.load_dotenv()
-
-
-@app.command()
-def generate_sweepstakes_cli(
- name: str = typer.Argument(..., help="The name of the sweepstakes to generate"),
- market_id: str = typer.Argument(
- ..., help="The betfair market ID to generate the sweepstakes for"
- ),
- participants: list[str] = typer.Argument(
- ..., help="The participants in the sweepstakes"
- ),
- method: AssignmentMethod = typer.Option(
- AssignmentMethod.TIERED,
- case_sensitive=False,
- help="The method to use to assign selections to participants",
- ),
- ignore_longshots: bool = typer.Option(
- False,
- help="Exclude selections with odds greater than 1000 (i.e. not available to lay)",
- ),
-):
- client = BetfairClient(
- username=os.getenv("BETFAIR_USERNAME"),
- password=os.getenv("BETFAIR_PASSWORD"),
- app_key=os.getenv("BETFAIR_APP_KEY"),
- )
-
- request = SweepstakesRequest(
- name=name,
- market_id=market_id,
- method=method,
- participant_names=participants,
- ignore_longshots=ignore_longshots,
- )
-
- response = generate_sweepstakes.generate_sweepstakes(
- client=client,
- request=request,
- )
-
- print(response)
-
-
-if __name__ == "__main__":
- app()
diff --git a/sweepy/database.py b/sweepy/database.py
index 64f1c6f..b6146c0 100644
--- a/sweepy/database.py
+++ b/sweepy/database.py
@@ -1,13 +1,16 @@
+from contextlib import contextmanager
from sqlmodel import SQLModel, create_engine, Session
import os
from dotenv import load_dotenv
+
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
+DEV_MODE = os.getenv("ENVIRONMENT") == "development"
-if os.getenv("ENVIRONMENT") == "development":
+if DEV_MODE:
connect_args = {}
else:
connect_args = {"sslmode": "require"}
@@ -20,10 +23,32 @@
def init_db(recreate: bool = False):
- if recreate:
- SQLModel.metadata.drop_all(engine)
+ print(f"Initialising database: {DEV_MODE=} {DATABASE_URL=}")
+
+ # Needed for db refresh
+
+ try:
+ if recreate:
+ SQLModel.metadata.drop_all(engine)
+ print("Dropped existing database tables.")
+
+ SQLModel.metadata.create_all(engine)
+ print("Created database tables.")
+
+ except Exception as e:
+ print(f"Error initializing database: {e}")
+
+ else:
+ print("Database recreated.")
+
- SQLModel.metadata.create_all(engine)
+@contextmanager
+def get_session_context():
+ session = Session(engine)
+ try:
+ yield session
+ finally:
+ session.close()
def get_session():
@@ -31,5 +56,5 @@ def get_session():
if __name__ == "__main__":
- init_db(recreate=True)
- print("Database initialized.")
+ recreate_db = os.getenv("RECREATE_DB", "false").lower() == "true"
+ init_db(recreate=recreate_db)
diff --git a/sweepy/db_models.py b/sweepy/db_models.py
index 6aea106..a9258ae 100644
--- a/sweepy/db_models.py
+++ b/sweepy/db_models.py
@@ -15,11 +15,15 @@ class Sweepstakes(SQLModel, table=True):
competition: str
method: str
active: bool
+ start_date: datetime.datetime = Field(
+ sa_column=Column(DateTime(timezone=True)),
+ )
updated_at: datetime.datetime = Field(
sa_column=Column(DateTime(timezone=True)),
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc),
)
participants: List["Participant"] = Relationship(back_populates="sweepstake")
+ tournament_id: Optional[str] = None
@property
def stringified_id(self) -> Optional[str]:
@@ -41,6 +45,24 @@ def decode_stringified_id(stringified_id: str) -> Optional[int]:
except IndexError:
return None
+ @property
+ def runners(self) -> List["Runner"]:
+ """
+ Returns the list of runners associated with the sweepstake.
+ """
+ return [
+ runner
+ for participant in self.participants
+ for runner in participant.runners
+ ]
+
+ @property
+ def has_leaderboard(self) -> bool:
+ """
+ Returns whether the sweepstake has score data.
+ """
+ return self.tournament_id is not None
+
class Participant(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
@@ -79,7 +101,9 @@ class ParticipantOdds(SQLModel, table=True):
class Runner(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
- provider_id: str
+ market_provider_id: str
+ score_provider_id: Optional[str] = None
+ score: Optional[int] = None
participant_id: Optional[int] = Field(default=None, foreign_key="participant.id")
participant: Optional["Participant"] = Relationship(back_populates="runners")
diff --git a/sweepy/generate_sweepstakes.py b/sweepy/generate_sweepstakes.py
index 21834d3..aa35a41 100644
--- a/sweepy/generate_sweepstakes.py
+++ b/sweepy/generate_sweepstakes.py
@@ -6,6 +6,8 @@
import sqlmodel
from sweepy.calculator import compute_market_probabilities
from sweepy.integrations.betfair import BetfairClient
+from sweepy.integrations.live_golf.client import LiveGolfClient
+from sweepy.matchmaker import get_live_golf_tournament
from sweepy.models import (
AssignmentMethod,
RunnerOdds,
@@ -20,7 +22,8 @@
def get_selections(
- betfair_client: BetfairClient, market_id: str, ignore_longshots: bool
+ betfair_client: BetfairClient,
+ market_id: str,
) -> list[RunnerOdds]:
runner_names = betfair_client.get_selection_names(market_id)
@@ -40,7 +43,7 @@ def get_selections(
last_price_traded=runner_book.get("lastPriceTraded"),
)
- if ignore_longshots and not runner.available_to_lay:
+ if not runner.available_to_lay:
continue
runners.append(runner)
@@ -49,12 +52,27 @@ def get_selections(
def generate_sweepstakes(
- client: BetfairClient,
+ bf_client: BetfairClient,
+ lg_client: LiveGolfClient,
request: SweepstakesRequest,
db_session: sqlmodel.Session,
) -> db_models.Sweepstakes:
timestamp = datetime.datetime.now(datetime.timezone.utc)
- selections = get_selections(client, request.market_id, request.ignore_longshots)
+ selections = get_selections(bf_client, request.market_id)
+
+ bf_info = bf_client.get_market_info(request.market_id)
+
+ if bf_info.is_golf:
+ try:
+ lg_tournament = get_live_golf_tournament(bf_info, lg_client)
+ except ValueError as e:
+ logging.error(f"Error getting Live Golf tournament: {e}")
+ tournament_id = None
+ else:
+ tournament_id = lg_tournament.id
+ else:
+ tournament_id = None
+
num_selections = len(selections)
num_participants = len(request.participant_names)
@@ -90,6 +108,8 @@ def generate_sweepstakes(
active=True,
updated_at=timestamp,
competition=request.competition,
+ tournament_id=tournament_id,
+ start_date=bf_info.market_start_time,
)
for participant_name, selections in sweepstake_assignments.items():
@@ -108,7 +128,7 @@ def generate_sweepstakes(
for selection in sorted_selections:
runner = db_models.Runner(
name=selection.name,
- provider_id=selection.provider_id,
+ market_provider_id=selection.provider_id,
participant=participant_db,
)
db_session.add(runner)
@@ -157,9 +177,10 @@ def convert_db_model_to_response(
"equity": participant.latest_odds.implied_probability,
"assignments": [
{
- "provider_id": runner.provider_id,
+ "provider_id": runner.market_provider_id,
"name": runner.name,
"implied_probability": runner.latest_odds.implied_probability,
+ "score": runner.score,
}
for runner in participant.runners
],
@@ -169,17 +190,10 @@ def convert_db_model_to_response(
jsondata["participants"] = participants
jsondata["updated_at"] = sweepstakes_db.updated_at.isoformat()
- # logging.info(
- # f"Converting sweepstake {sweepstakes_db.id} to response model, {jsondata=}"
- # )
- logging.info(
- f"Updated at timestamp : {sweepstakes_db.updated_at.isoformat()} {sweepstakes_db.updated_at.tzinfo}"
- )
-
return Sweepstakes(**jsondata)
-def refresh_sweepstake(
+def refresh_sweepstake_odds(
client: BetfairClient,
sweepstake_db: db_models.Sweepstakes,
session: sqlmodel.Session,
@@ -202,19 +216,20 @@ def refresh_sweepstake(
updated_equity = Decimal(0)
for runner in participant.runners:
# Find the latest runner data
- if runner.provider_id in seen:
+ if runner.market_provider_id in seen:
continue
- seen.add(runner.provider_id)
+ seen.add(runner.market_provider_id)
latest_runner = next(
- (r for r in latest_data if r.provider_id == runner.provider_id), None
+ (r for r in latest_data if r.provider_id == runner.market_provider_id),
+ None,
)
if latest_runner:
p = latest_runner.implied_probability
else:
# If the runner is not found in the latest data, keep the old one but assume probability is 0
logging.warning(
- f"Runner {runner.name} with provider_id {runner.provider_id} not found in latest data."
+ f"Runner {runner.name} with market_provider_id {runner.market_provider_id} not found in latest data."
)
p = 0.0
@@ -240,5 +255,56 @@ def refresh_sweepstake(
sweepstake_db.updated_at = fetched_at
session.add(sweepstake_db)
session.commit()
- logging.info(f"Refreshed sweepstake: {sweepstake_db.id}")
+ logging.info(f"Refreshed sweepstake odds: {sweepstake_db.id}")
return sweepstake_db
+
+
+def refresh_sweepstake_leaderboard(
+ client: LiveGolfClient,
+ sweepstake_db: db_models.Sweepstakes,
+ session: sqlmodel.Session,
+):
+ """
+ Refresh the sweepstakes leaderboard by re-fetching the market data and updating the participants.
+ """
+
+ if not sweepstake_db.tournament_id:
+ raise ValueError(
+ f"Sweepstake {sweepstake_db.id} does not have a tournament ID."
+ )
+
+ leaderboard_data = client.get_leaderboard(
+ sweepstake_db.start_date.year, sweepstake_db.tournament_id
+ )
+
+ score_map = {
+ player["playerId"]: player["total"]
+ for player in leaderboard_data["leaderboardRows"]
+ }
+
+ for participant in sweepstake_db.participants:
+ for runner in participant.runners:
+ if runner.score_provider_id is None:
+ continue
+
+ # Update the score for the runner
+ if runner.score_provider_id in score_map:
+ score = score_map[runner.score_provider_id]
+ if score == "-":
+ score = None
+ elif score == "E":
+ score = 0
+ else:
+ score = int(score)
+ else:
+ logging.warning(
+ f"Runner {runner.name} with score_provider_id {runner.score_provider_id} not found in leaderboard data."
+ )
+ score = None
+
+ runner.score = score
+
+ session.add(runner)
+
+ session.commit()
+ logging.info(f"Refreshed sweepstakes leaderboard for {sweepstake_db.id}")
diff --git a/sweepy/integrations/betfair/__init__.py b/sweepy/integrations/betfair/__init__.py
new file mode 100644
index 0000000..3363660
--- /dev/null
+++ b/sweepy/integrations/betfair/__init__.py
@@ -0,0 +1,4 @@
+from .client import BetfairClient
+from .models import MarketInfo
+
+__all__ = ["BetfairClient", "MarketInfo"]
diff --git a/sweepy/integrations/betfair.py b/sweepy/integrations/betfair/client.py
similarity index 66%
rename from sweepy/integrations/betfair.py
rename to sweepy/integrations/betfair/client.py
index 3b69efb..0337f88 100644
--- a/sweepy/integrations/betfair.py
+++ b/sweepy/integrations/betfair/client.py
@@ -1,6 +1,7 @@
-import logging
+import arrow
import requests
from sweepy.cache import timed_cached_property
+from .models import MarketInfo
LOGIN_URL = "https://identitysso.betfair.com/api/login"
@@ -25,7 +26,6 @@ def __headers(self, include_token: bool = True):
@timed_cached_property(ttl_seconds=1800)
def token(self):
- logging.info("Fetching Betfair API token.")
response = requests.post(
LOGIN_URL,
headers={
@@ -42,7 +42,7 @@ def token(self):
response_data = response.json()
return response_data["token"]
- def get_selection_names(self, market_id: str):
+ def get_selection_names(self, market_id: str) -> dict[str, str]:
url = f"{self.BASE_SPORTS_URL}listMarketCatalogue/"
response = requests.post(
url,
@@ -94,7 +94,7 @@ def get_event_types(self) -> list[dict]:
return [item["eventType"] for item in response.json()]
- def get_outright_markets(self, event_type_id: str) -> list[dict]:
+ def get_outright_markets(self, event_type_id: str) -> list[MarketInfo]:
url = f"{self.BASE_SPORTS_URL}listMarketCatalogue/"
response = requests.post(
url,
@@ -110,19 +110,51 @@ def get_outright_markets(self, event_type_id: str) -> list[dict]:
],
},
"maxResults": 25,
- "marketProjection": ["EVENT", "COMPETITION"],
+ "marketProjection": ["EVENT", "COMPETITION", "MARKET_START_TIME"],
"sort": "FIRST_TO_START",
},
)
response.raise_for_status()
return [
- {
- "market_id": market["marketId"],
- "market_name": market["marketName"],
- "event_name": market["event"]["name"],
- "competition_name": market["competition"]["name"],
- }
+ MarketInfo(
+ market_id=market["marketId"],
+ market_name=market["marketName"].strip(),
+ event_name=market["event"]["name"].strip(),
+ event_type=event_type_id,
+ competition_name=market["competition"]["name"].strip(),
+ market_start_time=market["marketStartTime"],
+ )
for market in response.json()
if "event" in market and "competition" in market
]
+
+ def get_market_info(self, market_id: str) -> MarketInfo:
+ url = f"{self.BASE_SPORTS_URL}listMarketCatalogue/"
+ response = requests.post(
+ url,
+ headers=self.__headers(),
+ json={
+ "filter": {
+ "marketIds": [market_id],
+ },
+ "maxResults": 1,
+ "marketProjection": [
+ "EVENT",
+ "COMPETITION",
+ "MARKET_START_TIME",
+ "EVENT_TYPE",
+ ],
+ },
+ )
+ response.raise_for_status()
+ market_info = response.json()[0]
+ print(f"Market Info: {market_info}") # Debugging line
+ return MarketInfo(
+ market_id=market_info["marketId"],
+ market_name=market_info["marketName"].strip(),
+ event_name=market_info["event"]["name"].strip(),
+ event_type=market_info["eventType"]["id"],
+ competition_name=market_info["competition"]["name"].strip(),
+ market_start_time=arrow.get(market_info["marketStartTime"]).datetime,
+ )
diff --git a/sweepy/integrations/betfair/models.py b/sweepy/integrations/betfair/models.py
new file mode 100644
index 0000000..d1751d1
--- /dev/null
+++ b/sweepy/integrations/betfair/models.py
@@ -0,0 +1,32 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+GOLF_EVENT_TYPE_ID = 3
+
+
+class MarketInfo(BaseModel):
+ """
+ Model for market information.
+ """
+
+ market_id: str
+ market_name: str
+ event_type: int
+ event_name: str
+ competition_name: str
+ market_start_time: datetime
+
+ model_config = {"frozen": True}
+
+ def __eq__(self, other: "MarketInfo") -> bool:
+ if not isinstance(other, MarketInfo):
+ return NotImplemented
+
+ return self.market_id == other.market_id
+
+ @property
+ def is_golf(self) -> bool:
+ """
+ Determines if the market is related to golf.
+ """
+ return self.event_type == GOLF_EVENT_TYPE_ID
diff --git a/sweepy/integrations/live_golf/__init__.py b/sweepy/integrations/live_golf/__init__.py
new file mode 100644
index 0000000..55f42b0
--- /dev/null
+++ b/sweepy/integrations/live_golf/__init__.py
@@ -0,0 +1,4 @@
+from .client import LiveGolfClient
+from .models import Tournament
+
+__all__ = ["LiveGolfClient", "Tournament"]
diff --git a/sweepy/integrations/live_golf/client.py b/sweepy/integrations/live_golf/client.py
new file mode 100644
index 0000000..b3e8cb9
--- /dev/null
+++ b/sweepy/integrations/live_golf/client.py
@@ -0,0 +1,60 @@
+import requests
+from datetime import datetime
+
+from sweepy.cache import timed_cache
+
+from .models import Tournament
+
+
+BASE_URL = "https://live-golf-data.p.rapidapi.com/"
+
+
+def parse_tournament_from_schedule_response(tournament_data: dict) -> Tournament:
+ """
+ Parses a tournament from the schedule data.
+ """
+
+ tournament_id = tournament_data["tournId"]
+ name = tournament_data["name"]
+ start_date = datetime.fromtimestamp(
+ int(tournament_data["date"]["start"]["$date"]["$numberLong"]) // 1000
+ ).date()
+
+ return Tournament(
+ id=tournament_id,
+ name=name,
+ start_date=start_date,
+ )
+
+
+class LiveGolfClient:
+ def __init__(self, api_key: str):
+ self.api_key = api_key
+
+ @property
+ def __headers(self):
+ return {
+ "x-rapidapi-key": self.api_key,
+ "x-rapidapi-host": "live-golf-data.p.rapidapi.com",
+ "Content-Type": "application/json",
+ }
+
+ def get_schedule(self, season: int, tour_id: int = 1) -> list[Tournament]:
+ url = f"{BASE_URL}schedule"
+ response = requests.get(
+ url, headers=self.__headers, params={"orgId": tour_id, "year": season}
+ )
+ response.raise_for_status()
+ schedule = response.json()["schedule"]
+ return list(map(parse_tournament_from_schedule_response, schedule))
+
+ @timed_cache(60)
+ def get_leaderboard(self, year: int, tournament_id: str, tour_id: int = 1):
+ url = f"{BASE_URL}leaderboard"
+ response = requests.get(
+ url,
+ headers=self.__headers,
+ params={"orgId": tour_id, "year": year, "tournId": tournament_id},
+ )
+ response.raise_for_status()
+ return response.json()
diff --git a/sweepy/integrations/live_golf/models.py b/sweepy/integrations/live_golf/models.py
new file mode 100644
index 0000000..67d8d44
--- /dev/null
+++ b/sweepy/integrations/live_golf/models.py
@@ -0,0 +1,8 @@
+import datetime
+from pydantic import BaseModel
+
+
+class Tournament(BaseModel):
+ id: str
+ name: str
+ start_date: datetime.date
diff --git a/sweepy/matchmaker.py b/sweepy/matchmaker.py
new file mode 100644
index 0000000..01f06cd
--- /dev/null
+++ b/sweepy/matchmaker.py
@@ -0,0 +1,78 @@
+import logging
+import rapidfuzz
+from sqlmodel import Session
+
+from sweepy import db_models
+from sweepy.integrations.live_golf import LiveGolfClient, Tournament
+from sweepy.integrations.betfair import MarketInfo
+
+
+START_DAY_DIFF_THRESHOLD = 1
+NAME_MATCH_RATIO_THRESHOLD = 65
+
+
+def get_live_golf_tournament(
+ bf_info: MarketInfo,
+ live_golf_client: LiveGolfClient,
+) -> Tournament:
+ """
+ Finds the corresponding Live Golf tournament ID for a given Betfair market ID.
+ """
+
+ try:
+ lg_schedule = live_golf_client.get_schedule(
+ season=bf_info.market_start_time.year
+ )
+ except Exception as e:
+ raise ValueError(f"Error fetching Live Golf schedule: {e}")
+
+ for tournament in lg_schedule:
+ name_match_ratio = rapidfuzz.fuzz.ratio(tournament.name, bf_info.event_name)
+
+ date_match = (tournament.start_date - bf_info.market_start_time.date()).days
+ if (
+ abs(date_match) <= START_DAY_DIFF_THRESHOLD
+ and name_match_ratio >= NAME_MATCH_RATIO_THRESHOLD
+ ):
+ return tournament
+
+ raise ValueError(
+ f"No matching Live Golf tournament found for Betfair market ID {bf_info}. "
+ f"Event name: {bf_info.event_name}, Start date: {bf_info.market_start_time.date()}"
+ )
+
+
+def match_runners_to_live_golf(
+ runners: list[db_models.Runner],
+ lg_tournament_id: str,
+ season: int,
+ lg_client: LiveGolfClient,
+ session: Session,
+) -> None:
+ """
+ Matches Betfair runners to Live Golf runners for a specific tournament.
+ """
+
+ leaderboard = lg_client.get_leaderboard(season, lg_tournament_id)
+
+ unmatched_runners = {runner.name: runner for runner in runners}
+ runners_matched = 0
+
+ for player in leaderboard["leaderboardRows"]:
+ lg_player_name = f"{player['firstName']} {player['lastName']}"
+ lg_id = player["playerId"]
+
+ if lg_player_name in unmatched_runners:
+ runner = unmatched_runners.pop(lg_player_name)
+ runner.score_provider_id = lg_id
+ session.add(runner)
+ runners_matched += 1
+ else:
+ logging.warning(
+ f"No Betfair runner matched for Live Golf player '{lg_player_name}' with ID {lg_id}."
+ )
+
+ logging.info(
+ f"Total runners matched: {runners_matched} / {len(leaderboard['leaderboardRows'])}"
+ )
+ session.commit()
diff --git a/sweepy/models/api.py b/sweepy/models/api.py
index 12d1b83..2c2a44e 100644
--- a/sweepy/models/api.py
+++ b/sweepy/models/api.py
@@ -13,7 +13,6 @@ class SweepstakesRequest(BaseModel):
method: AssignmentMethod
participant_names: conlist(str, min_length=2)
competition: str | None = None
- ignore_longshots: bool = False
class EventType(BaseModel, frozen=True):
diff --git a/sweepy/models/sweepstakes.py b/sweepy/models/sweepstakes.py
index 6544ea6..aa9d874 100644
--- a/sweepy/models/sweepstakes.py
+++ b/sweepy/models/sweepstakes.py
@@ -9,6 +9,7 @@ class RunnerOdds(BaseModel):
provider_id: str
name: str
implied_probability: Decimal = condecimal(ge=0, le=1)
+ score: Decimal | None = None
def __le__(self, other: "RunnerOdds") -> bool:
return self.implied_probability <= other.implied_probability
@@ -44,8 +45,8 @@ class SweepstakesBase(BaseModel):
method: AssignmentMethod
updated_at: datetime.datetime
active: bool
- ignore_longshots: bool = False
competition: str | None = None
+ tournament_id: str | None = None
class Sweepstakes(SweepstakesBase):
diff --git a/sweepy/tasks.py b/sweepy/tasks.py
index 95faefe..4a3cae1 100644
--- a/sweepy/tasks.py
+++ b/sweepy/tasks.py
@@ -1,42 +1,94 @@
import asyncio
+from collections.abc import Iterable
import logging
import os
import dotenv
-from sqlalchemy import select
+from sqlalchemy import select, and_
from sweepy import db_models, generate_sweepstakes, database
from sweepy.integrations import betfair
+from sweepy.integrations.live_golf.client import LiveGolfClient
REDIS_URL = os.getenv("REDIS_URL")
dotenv.load_dotenv()
-REFRESH_INTERVAL = int(os.getenv("REFRESH_INTERVAL", 900))
+MARKET_REFRESH_INTERVAL = int(os.getenv("MARKET_REFRESH_INTERVAL", 900))
+SCORE_REFRESH_INTERVAL = int(os.getenv("SCORE_REFRESH_INTERVAL", 3000))
-async def refresh_all_sweepstakes_task(
- bf_client: betfair.BetfairClient, delay_seconds: int = REFRESH_INTERVAL
+async def refresh_all_odds_task(
+ bf_client: betfair.BetfairClient,
+ delay_seconds: int = MARKET_REFRESH_INTERVAL,
):
"""
- Task to refresh all sweepstakes.
+ Task to refresh odds for all active sweepstakes.
"""
+
+ if delay_seconds <= 0:
+ return
+
while True:
logging.info("Starting to refresh all sweepstakes.")
- db_session = database.get_session()
-
- statement = select(db_models.Sweepstakes).where(db_models.Sweepstakes.active)
-
- all_active_sweepstakes = db_session.exec(statement).scalars().all()
- if not all_active_sweepstakes:
- logging.info("No active sweepstakes found.")
- else:
- for sweepstake in all_active_sweepstakes:
- logging.info(f"Refreshing sweepstake: {sweepstake.id}")
- generate_sweepstakes.refresh_sweepstake(
- client=bf_client,
- sweepstake_db=sweepstake,
- session=db_session,
+ with database.get_session_context() as db_session:
+ statement = select(db_models.Sweepstakes).where(
+ db_models.Sweepstakes.active
+ )
+
+ all_active_sweepstakes = db_session.exec(statement).scalars().all()
+ if not all_active_sweepstakes:
+ logging.info("No active sweepstakes found.")
+ else:
+ for sweepstake in all_active_sweepstakes:
+ logging.info(f"Refreshing sweepstake: {sweepstake.stringified_id}")
+ generate_sweepstakes.refresh_sweepstake_odds(
+ client=bf_client,
+ sweepstake_db=sweepstake,
+ session=db_session,
+ )
+
+ logging.info("Odds for all active sweepstakes have been refreshed.")
+
+ logging.info(f"Waiting for {delay_seconds} seconds before the next refresh.")
+ await asyncio.sleep(delay_seconds)
+
+
+async def refresh_all_scores_task(
+ lg_client: LiveGolfClient,
+ delay_seconds: int = SCORE_REFRESH_INTERVAL,
+):
+ """
+ Task to refresh all scores.
+ """
+
+ if delay_seconds <= 0:
+ return
+
+ while True:
+ logging.info("Starting to refresh all scores.")
+ with database.get_session_context() as db_session:
+ statement = select(db_models.Sweepstakes).where(
+ and_(
+ db_models.Sweepstakes.active,
+ db_models.Sweepstakes.tournament_id.isnot(None),
)
+ )
+
+ all_active_sweepstakes: Iterable[db_models.Sweepstakes] = (
+ db_session.exec(statement).scalars().all()
+ )
+ if not all_active_sweepstakes:
+ logging.info("No active sweepstakes found with scores linked.")
+ else:
+ for sweepstake in all_active_sweepstakes:
+ logging.info(
+ f"Refreshing scores for sweepstake: {sweepstake.stringified_id}"
+ )
+ generate_sweepstakes.refresh_sweepstake_leaderboard(
+ client=lg_client,
+ sweepstake_db=sweepstake,
+ session=db_session,
+ )
- logging.info("All active sweepstakes have been refreshed.")
+ logging.info("All active scores have been refreshed.")
logging.info(f"Waiting for {delay_seconds} seconds before the next refresh.")
await asyncio.sleep(delay_seconds)