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)