From b25190f3261cfcbf2ffdd44a6602c38d0c5d3305 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Thu, 26 Dec 2024 19:20:44 +0100 Subject: [PATCH] Setup new bot data controller (#636) * Setup new bot data controller * Simplify database operations By creating simpler and atomic db operations as a separate layer, we reduce complexity for testing (duplicated code), decouple dependencies of database and services and also reduce circular imports. * Refactor and unify simulated orders (paper trading) * Fix tests for new SQL bots migration * Refactor all data models to use a single BotModel The reason behind this is that pydantic table models are SQL models which are different from traditional pydantic models. So to reduce repetition, we have to create a BotBase which is inherited by the "guardian" i.e. the BotModel which is use to guard data everywhere, and the separation of database tables i.e. BotTable * Fix all SQLModel vs Pydantic model compatibility issues * Refactor deals into a singleton The DealFactory is a literal factory, not a factory pattern, in that we do know what kind of object we are creating, but we are unifying into a single point of entry (singleton) --- api/.python-version | 1 + api/Pipfile | 3 +- api/Pipfile.lock | 580 ++++++++---------- api/account/account.py | 7 +- api/account/assets.py | 14 +- api/alembic/env.py | 2 +- api/autotrade/routes.py | 10 +- api/bots/controllers.py | 338 ---------- api/bots/models.py | 165 +++++ api/bots/routes.py | 236 ++++--- api/bots/schemas.py | 161 ----- api/charts/controllers.py | 15 +- api/database/api_db.py | 97 ++- .../autotrade_crud.py} | 4 +- api/database/bot_crud.py | 253 ++++++++ api/database/db.py | 85 +-- api/database/models/__init__.py | 1 - api/database/models/autotrade_table.py | 52 +- api/database/models/bot_table.py | 140 ++++- api/database/models/deal_table.py | 35 +- api/database/models/order_table.py | 55 +- api/database/models/paper_trading_table.py | 74 --- api/database/paper_trading_crud.py | 214 +++++++ api/database/utils.py | 14 +- api/deals/base.py | 169 +---- api/deals/controllers.py | 312 ---------- api/deals/factory.py | 358 +++++++++++ api/deals/margin.py | 254 ++++---- api/deals/models.py | 98 +-- api/deals/spot.py | 166 ++--- api/main.py | 9 +- api/mypy.ini | 7 +- api/orders/controller.py | 98 ++- api/paper_trading/routes.py | 117 ++-- api/streaming/streaming_controller.py | 183 +++--- api/tests/AutotradeSettingsMock.py | 27 - api/tests/active_bots.py | 34 + api/tests/model_mocks.py | 170 +++++ api/tests/table_mocks.py | 99 +++ api/tests/test_assets.py | 12 +- api/tests/test_autotrade_settings.py | 65 +- api/tests/test_bots.py | 212 ++++--- api/tests/test_market_domination.py | 29 +- api/tools/exceptions.py | 2 +- api/tools/handle_error.py | 34 +- api/tools/round_numbers.py | 10 + api/user/models/user.py | 4 +- binquant | 2 +- terminal/src/features/bots/profits.ts | 2 +- 49 files changed, 2781 insertions(+), 2248 deletions(-) create mode 100644 api/.python-version delete mode 100644 api/bots/controllers.py create mode 100644 api/bots/models.py delete mode 100644 api/bots/schemas.py rename api/{autotrade/controller.py => database/autotrade_crud.py} (97%) create mode 100644 api/database/bot_crud.py delete mode 100644 api/database/models/paper_trading_table.py create mode 100644 api/database/paper_trading_crud.py delete mode 100644 api/deals/controllers.py create mode 100644 api/deals/factory.py delete mode 100644 api/tests/AutotradeSettingsMock.py create mode 100644 api/tests/active_bots.py create mode 100644 api/tests/model_mocks.py create mode 100644 api/tests/table_mocks.py diff --git a/api/.python-version b/api/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/api/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/api/Pipfile b/api/Pipfile index cb64c8a78..ec261c992 100644 --- a/api/Pipfile +++ b/api/Pipfile @@ -5,7 +5,7 @@ verify_ssl = false [packages] uvicorn = "*" -fastapi = "==0.110.0" +fastapi = "==0.115.6" pymongo = "==4.6.3" python-jose = "*" passlib = "*" @@ -30,6 +30,7 @@ psycopg2 = "*" pydantic = {extras = ["email"], version = "*"} alembic-postgresql-enum = "*" python-dotenv = "*" +psycopg = "*" [dev-packages] pytest = "==8.1.1" diff --git a/api/Pipfile.lock b/api/Pipfile.lock index 240af4a28..b332d04f2 100644 --- a/api/Pipfile.lock +++ b/api/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f02a4bbea7483e279e1661336b2a921a4206900c3ccd5c1e50ae36ddb4a3222b" + "sha256": "a929173a9361c69475071e54e053ae37e3e4bc1f620ffe47e9e044eaae6e6302" }, "pipfile-spec": 6, "requires": { @@ -26,11 +26,11 @@ }, "alembic-postgresql-enum": { "hashes": [ - "sha256:42539e225f8b6c9adc9862a259edeed105b02143d55503152eeeea7a0b5af70b", - "sha256:64d5de7ac2ea39433afd965b057ca882fb420eb5cd6a7db8e2b4d0e7e673cae1" + "sha256:c9eb20cb594085a48175aabcc5ffb448e6abfcc3f34cd9d03a1b7d5cc35457b8", + "sha256:fb0af50059891bf3fb9638d4c32a61b4eee116c302a90e0c74ed3f5f396153f4" ], "index": "pypi", - "version": "==1.3.0" + "version": "==1.4.0" }, "annotated-types": { "hashes": [ @@ -42,11 +42,11 @@ }, "anyio": { "hashes": [ - "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", - "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d" + "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", + "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352" ], "markers": "python_version >= '3.9'", - "version": "==4.6.2.post1" + "version": "==4.7.0" }, "appdirs": { "hashes": [ @@ -65,11 +65,11 @@ }, "attrs": { "hashes": [ - "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", - "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" + "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", + "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" ], - "markers": "python_version >= '3.7'", - "version": "==24.2.0" + "markers": "python_version >= '3.8'", + "version": "==24.3.0" }, "beautifulsoup4": { "hashes": [ @@ -96,11 +96,11 @@ }, "certifi": { "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", + "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" ], "markers": "python_version >= '3.6'", - "version": "==2024.8.30" + "version": "==2024.12.14" }, "charset-normalizer": { "hashes": [ @@ -149,107 +149,21 @@ ], "version": "==2.2.0" }, - "exceptiongroup": { - "hashes": [ - "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", - "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" - ], - "markers": "python_version < '3.11'", - "version": "==1.2.2" - }, "fake-useragent": { "hashes": [ - "sha256:57415096557c8a4e23b62a375c21c55af5fd4ba30549227f562d2c4f5b60e3b3", - "sha256:6387269f5a2196b5ba7ed8935852f75486845a1c95c50e72460e6a8e762f5c49" + "sha256:8bae50abb72c309a5b3ae2f01a0b82426613fd5c4e2a04dca9332399ec44daa1", + "sha256:af86a26ef8229efece8fed529b4aeb5b73747d889b60f01cd477b6f301df46e6" ], - "version": "==1.5.1" + "markers": "python_version >= '3.9'", + "version": "==2.0.3" }, "fastapi": { "hashes": [ - "sha256:266775f0dcc95af9d3ef39bad55cff525329a931d5fd51930aadd4f428bf7ff3", - "sha256:87a1f6fb632a218222c5984be540055346a8f5d8a68e8f6fb647b1dc9934de4b" + "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", + "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305" ], "index": "pypi", - "version": "==0.110.0" - }, - "greenlet": { - "hashes": [ - "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", - "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7", - "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", - "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", - "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", - "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563", - "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83", - "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", - "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", - "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa", - "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", - "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", - "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", - "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22", - "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9", - "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0", - "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba", - "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3", - "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", - "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", - "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291", - "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", - "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", - "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", - "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", - "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef", - "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c", - "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", - "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c", - "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", - "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", - "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8", - "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d", - "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", - "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145", - "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80", - "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", - "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e", - "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", - "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1", - "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef", - "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc", - "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", - "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120", - "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437", - "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd", - "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981", - "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", - "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", - "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798", - "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7", - "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", - "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", - "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", - "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af", - "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", - "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", - "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42", - "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e", - "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81", - "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", - "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617", - "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc", - "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de", - "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111", - "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", - "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", - "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6", - "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", - "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", - "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803", - "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", - "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f" - ], - "markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", - "version": "==3.1.1" + "version": "==0.115.6" }, "h11": { "hashes": [ @@ -269,11 +183,11 @@ }, "httpx": { "hashes": [ - "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", - "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2" + "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", + "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" ], "index": "pypi", - "version": "==0.27.2" + "version": "==0.28.1" }, "idna": { "hashes": [ @@ -445,11 +359,11 @@ }, "mako": { "hashes": [ - "sha256:9ec3a1583713479fae654f83ed9fa8c9a4c16b7bb0daba0e6bbebff50c0d983d", - "sha256:a91198468092a2f1a0de86ca92690fb0cfc43ca90ee17e15d93662b4c04b241a" + "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", + "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8" ], "markers": "python_version >= '3.8'", - "version": "==1.3.6" + "version": "==1.3.8" }, "markupsafe": { "hashes": [ @@ -631,6 +545,14 @@ "markers": "python_version >= '3.8'", "version": "==4.3.6" }, + "psycopg": { + "hashes": [ + "sha256:644d3973fe26908c73d4be746074f6e5224b03c1101d302d9a53bf565ad64907", + "sha256:a5764f67c27bec8bfac85764d23c534af2c27b893550377e37ce59c12aac47a2" + ], + "index": "pypi", + "version": "==3.2.3" + }, "psycopg2": { "hashes": [ "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4", @@ -717,106 +639,118 @@ "email" ], "hashes": [ - "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", - "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12" + "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", + "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9" ], "index": "pypi", - "version": "==2.9.2" + "markers": null, + "version": "==2.10.3" }, "pydantic-core": { "hashes": [ - "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", - "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", - "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", - "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", - "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", - "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", - "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", - "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", - "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", - "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", - "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", - "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", - "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", - "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", - "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", - "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", - "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368", - "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", - "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", - "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2", - "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", - "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", - "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", - "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", - "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", - "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", - "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271", - "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", - "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb", - "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13", - "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", - "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556", - "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665", - "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", - "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", - "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", - "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", - "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", - "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", - "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", - "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", - "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", - "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", - "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", - "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", - "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", - "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658", - "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", - "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", - "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", - "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", - "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", - "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", - "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", - "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", - "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", - "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", - "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad", - "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", - "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", - "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", - "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", - "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", - "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", - "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", - "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", - "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", - "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", - "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555", - "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", - "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6", - "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", - "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", - "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", - "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", - "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", - "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", - "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", - "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", - "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12", - "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", - "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", - "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", - "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", - "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", - "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", - "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", - "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", - "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607" + "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9", + "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b", + "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", + "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", + "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", + "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854", + "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", + "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", + "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a", + "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", + "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", + "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", + "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", + "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", + "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", + "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97", + "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", + "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", + "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", + "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4", + "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", + "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131", + "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", + "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd", + "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", + "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", + "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", + "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60", + "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", + "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", + "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", + "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", + "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2", + "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", + "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", + "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", + "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62", + "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", + "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be", + "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067", + "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", + "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f", + "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", + "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840", + "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5", + "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", + "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", + "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", + "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864", + "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e", + "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", + "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", + "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", + "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a", + "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3", + "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", + "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", + "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31", + "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", + "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", + "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", + "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36", + "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", + "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154", + "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", + "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", + "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd", + "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3", + "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", + "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78", + "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", + "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618", + "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", + "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", + "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", + "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c", + "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", + "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", + "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792", + "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", + "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9", + "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", + "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01", + "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", + "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", + "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f", + "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd", + "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", + "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab", + "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", + "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", + "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", + "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", + "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", + "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967", + "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", + "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", + "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", + "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", + "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b" ], "markers": "python_version >= '3.8'", - "version": "==2.23.4" + "version": "==2.27.1" }, "pyee": { "hashes": [ @@ -955,11 +889,11 @@ }, "python-multipart": { "hashes": [ - "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d", - "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538" + "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", + "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13" ], "index": "pypi", - "version": "==0.0.17" + "version": "==0.0.20" }, "pytz": { "hashes": [ @@ -1029,19 +963,19 @@ }, "setuptools": { "hashes": [ - "sha256:5c4ccb41111392671f02bb5f8436dfc5a9a7185e80500531b133f5775c4163ef", - "sha256:87cb777c3b96d638ca02031192d40390e0ad97737e27b6b4fa831bea86f2f829" + "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", + "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d" ], "markers": "python_version >= '3.9'", - "version": "==75.5.0" + "version": "==75.6.0" }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "version": "==1.17.0" }, "sniffio": { "hashes": [ @@ -1124,19 +1058,19 @@ }, "starlette": { "hashes": [ - "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044", - "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080" + "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", + "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7" ], "markers": "python_version >= '3.8'", - "version": "==0.36.3" + "version": "==0.41.3" }, "tqdm": { "hashes": [ - "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be", - "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a" + "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", + "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" ], "markers": "python_version >= '3.7'", - "version": "==4.67.0" + "version": "==4.67.1" }, "typing-extensions": { "hashes": [ @@ -1180,11 +1114,11 @@ }, "uvicorn": { "hashes": [ - "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82", - "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e" + "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", + "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9" ], "index": "pypi", - "version": "==0.32.0" + "version": "==0.34.0" }, "w3lib": { "hashes": [ @@ -1289,95 +1223,87 @@ "develop": { "anyio": { "hashes": [ - "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", - "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d" + "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", + "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352" ], "markers": "python_version >= '3.9'", - "version": "==4.6.2.post1" + "version": "==4.7.0" }, "certifi": { "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", + "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" ], "markers": "python_version >= '3.6'", - "version": "==2024.8.30" + "version": "==2024.12.14" }, "coverage": { "hashes": [ - "sha256:0266b62cbea568bd5e93a4da364d05de422110cbed5056d69339bd5af5685433", - "sha256:0573f5cbf39114270842d01872952d301027d2d6e2d84013f30966313cadb529", - "sha256:0ddcb70b3a3a57581b450571b31cb774f23eb9519c2aaa6176d3a84c9fc57671", - "sha256:108bb458827765d538abcbf8288599fee07d2743357bdd9b9dad456c287e121e", - "sha256:14045b8bfd5909196a90da145a37f9d335a5d988a83db34e80f41e965fb7cb42", - "sha256:1a5407a75ca4abc20d6252efeb238377a71ce7bda849c26c7a9bece8680a5d99", - "sha256:2bc3e45c16564cc72de09e37413262b9f99167803e5e48c6156bccdfb22c8327", - "sha256:2d608a7808793e3615e54e9267519351c3ae204a6d85764d8337bd95993581a8", - "sha256:34d23e28ccb26236718a3a78ba72744212aa383141961dd6825f6595005c8b06", - "sha256:37a15573f988b67f7348916077c6d8ad43adb75e478d0910957394df397d2874", - "sha256:3c0317288f032221d35fa4cbc35d9f4923ff0dfd176c79c9b356e8ef8ef2dff4", - "sha256:3c42ec2c522e3ddd683dec5cdce8e62817afb648caedad9da725001fa530d354", - "sha256:3c6b24007c4bcd0b19fac25763a7cac5035c735ae017e9a349b927cfc88f31c1", - "sha256:40cca284c7c310d622a1677f105e8507441d1bb7c226f41978ba7c86979609ab", - "sha256:46f21663e358beae6b368429ffadf14ed0a329996248a847a4322fb2e35d64d3", - "sha256:49ed5ee4109258973630c1f9d099c7e72c5c36605029f3a91fe9982c6076c82b", - "sha256:5c95e0fa3d1547cb6f021ab72f5c23402da2358beec0a8e6d19a368bd7b0fb37", - "sha256:5dd4e4a49d9c72a38d18d641135d2fb0bdf7b726ca60a103836b3d00a1182acd", - "sha256:5e444b8e88339a2a67ce07d41faabb1d60d1004820cee5a2c2b54e2d8e429a0f", - "sha256:60dcf7605c50ea72a14490d0756daffef77a5be15ed1b9fea468b1c7bda1bc3b", - "sha256:623e6965dcf4e28a3debaa6fcf4b99ee06d27218f46d43befe4db1c70841551c", - "sha256:673184b3156cba06154825f25af33baa2671ddae6343f23175764e65a8c4c30b", - "sha256:6cf96ceaa275f071f1bea3067f8fd43bec184a25a962c754024c973af871e1b7", - "sha256:70a56a2ec1869e6e9fa69ef6b76b1a8a7ef709972b9cc473f9ce9d26b5997ce3", - "sha256:77256ad2345c29fe59ae861aa11cfc74579c88d4e8dbf121cbe46b8e32aec808", - "sha256:796c9b107d11d2d69e1849b2dfe41730134b526a49d3acb98ca02f4985eeff7a", - "sha256:7c07de0d2a110f02af30883cd7dddbe704887617d5c27cf373362667445a4c76", - "sha256:7e61b0e77ff4dddebb35a0e8bb5a68bf0f8b872407d8d9f0c726b65dfabe2469", - "sha256:82c809a62e953867cf57e0548c2b8464207f5f3a6ff0e1e961683e79b89f2c55", - "sha256:850cfd2d6fc26f8346f422920ac204e1d28814e32e3a58c19c91980fa74d8289", - "sha256:87ea64b9fa52bf395272e54020537990a28078478167ade6c61da7ac04dc14bc", - "sha256:90746521206c88bdb305a4bf3342b1b7316ab80f804d40c536fc7d329301ee13", - "sha256:951aade8297358f3618a6e0660dc74f6b52233c42089d28525749fc8267dccd2", - "sha256:963e4a08cbb0af6623e61492c0ec4c0ec5c5cf74db5f6564f98248d27ee57d30", - "sha256:987a8e3da7da4eed10a20491cf790589a8e5e07656b6dc22d3814c4d88faf163", - "sha256:9c2eb378bebb2c8f65befcb5147877fc1c9fbc640fc0aad3add759b5df79d55d", - "sha256:a1ab9763d291a17b527ac6fd11d1a9a9c358280adb320e9c2672a97af346ac2c", - "sha256:a3b925300484a3294d1c70f6b2b810d6526f2929de954e5b6be2bf8caa1f12c1", - "sha256:acbb8af78f8f91b3b51f58f288c0994ba63c646bc1a8a22ad072e4e7e0a49f1c", - "sha256:ad32a981bcdedb8d2ace03b05e4fd8dace8901eec64a532b00b15217d3677dd2", - "sha256:aee9cf6b0134d6f932d219ce253ef0e624f4fa588ee64830fcba193269e4daa3", - "sha256:af05bbba896c4472a29408455fe31b3797b4d8648ed0a2ccac03e074a77e2314", - "sha256:b6cce5c76985f81da3769c52203ee94722cd5d5889731cd70d31fee939b74bf0", - "sha256:bb684694e99d0b791a43e9fc0fa58efc15ec357ac48d25b619f207c41f2fd384", - "sha256:c132b5a22821f9b143f87446805e13580b67c670a548b96da945a8f6b4f2efbb", - "sha256:c296263093f099da4f51b3dff1eff5d4959b527d4f2f419e16508c5da9e15e8c", - "sha256:c973b2fe4dc445cb865ab369df7521df9c27bf40715c837a113edaa2aa9faf45", - "sha256:cdd94501d65adc5c24f8a1a0eda110452ba62b3f4aeaba01e021c1ed9cb8f34a", - "sha256:d79d4826e41441c9a118ff045e4bccb9fdbdcb1d02413e7ea6eb5c87b5439d24", - "sha256:dbba8210f5067398b2c4d96b4e64d8fb943644d5eb70be0d989067c8ca40c0f8", - "sha256:df002e59f2d29e889c37abd0b9ee0d0e6e38c24f5f55d71ff0e09e3412a340ec", - "sha256:dfd14bcae0c94004baba5184d1c935ae0d1231b8409eb6c103a5fd75e8ecdc56", - "sha256:e25bacb53a8c7325e34d45dddd2f2fbae0dbc230d0e2642e264a64e17322a777", - "sha256:e2c8e3384c12dfa19fa9a52f23eb091a8fad93b5b81a41b14c17c78e23dd1d8b", - "sha256:e5f2a0f161d126ccc7038f1f3029184dbdf8f018230af17ef6fd6a707a5b881f", - "sha256:e69ad502f1a2243f739f5bd60565d14a278be58be4c137d90799f2c263e7049a", - "sha256:ead9b9605c54d15be228687552916c89c9683c215370c4a44f1f217d2adcc34d", - "sha256:f07ff574986bc3edb80e2c36391678a271d555f91fd1d332a1e0f4b5ea4b6ea9", - "sha256:f2c7a045eef561e9544359a0bf5784b44e55cefc7261a20e730baa9220c83413", - "sha256:f3e8796434a8106b3ac025fd15417315d7a58ee3e600ad4dbcfddc3f4b14342c", - "sha256:f63e21ed474edd23f7501f89b53280014436e383a14b9bd77a648366c81dce7b", - "sha256:fd49c01e5057a451c30c9b892948976f5d38f2cbd04dc556a82743ba8e27ed8c" + "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4", + "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c", + "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f", + "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b", + "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6", + "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae", + "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692", + "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4", + "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4", + "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717", + "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d", + "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198", + "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1", + "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3", + "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb", + "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", + "sha256:4e12ae8cc979cf83d258acb5e1f1cf2f3f83524d1564a49d20b8bec14b637f08", + "sha256:592ac539812e9b46046620341498caf09ca21023c41c893e1eb9dbda00a70cbf", + "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b", + "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710", + "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c", + "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae", + "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077", + "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00", + "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb", + "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664", + "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014", + "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9", + "sha256:8e3c3e38930cfb729cb8137d7f055e5a473ddaf1217966aa6238c88bd9fd50e6", + "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e", + "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9", + "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa", + "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611", + "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b", + "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a", + "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8", + "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030", + "sha256:a27801adef24cc30871da98a105f77995e13a25a505a0161911f6aafbd66e678", + "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015", + "sha256:adb697c0bd35100dc690de83154627fbab1f4f3c0386df266dded865fc50a902", + "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97", + "sha256:b9389a429e0e5142e69d5bf4a435dd688c14478a19bb901735cdf75e57b13845", + "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419", + "sha256:bb5555cff66c4d3d6213a296b360f9e1a8e323e74e0426b6c10ed7f4d021e464", + "sha256:be57b6d56e49c2739cdf776839a92330e933dd5e5d929966fbbd380c77f060be", + "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9", + "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7", + "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be", + "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1", + "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba", + "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5", + "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073", + "sha256:e28bf44afa2b187cc9f41749138a64435bf340adfcacb5b2290c070ce99839d4", + "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a", + "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a", + "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3", + "sha256:f1592791f8204ae9166de22ba7e6705fa4ebd02936c09436a1bb85aabca3e599", + "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0", + "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b", + "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec", + "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1", + "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3" ], "index": "pypi", - "version": "==7.6.7" - }, - "exceptiongroup": { - "hashes": [ - "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", - "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" - ], - "markers": "python_version < '3.11'", - "version": "==1.2.2" + "version": "==7.6.9" }, "h11": { "hashes": [ @@ -1397,11 +1323,11 @@ }, "httpx": { "hashes": [ - "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", - "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2" + "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", + "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" ], "index": "pypi", - "version": "==0.27.2" + "version": "==0.28.1" }, "idna": { "hashes": [ @@ -1499,27 +1425,27 @@ }, "ruff": { "hashes": [ - "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", - "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", - "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", - "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", - "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", - "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", - "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", - "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", - "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", - "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", - "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", - "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", - "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", - "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", - "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", - "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", - "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", - "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452" + "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", + "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", + "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", + "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", + "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", + "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", + "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", + "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", + "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", + "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", + "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", + "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", + "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", + "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", + "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", + "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", + "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", + "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936" ], "index": "pypi", - "version": "==0.7.4" + "version": "==0.8.3" }, "sentinels": { "hashes": [ @@ -1535,14 +1461,6 @@ "markers": "python_version >= '3.7'", "version": "==1.3.1" }, - "tomli": { - "hashes": [ - "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", - "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391" - ], - "markers": "python_version < '3.11'", - "version": "==2.1.0" - }, "types-requests": { "hashes": [ "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", diff --git a/api/account/account.py b/api/account/account.py index bc37f4ec1..80086cd63 100644 --- a/api/account/account.py +++ b/api/account/account.py @@ -1,4 +1,6 @@ import requests +import os +import pandas from apis import BinbotApi from tools.handle_error import ( handle_binance_errors, @@ -9,8 +11,6 @@ from database.db import setup_db from requests_cache import CachedSession, MongoCache from pymongo import MongoClient -import os -import pandas from decimal import Decimal @@ -253,8 +253,7 @@ def matching_engine(self, symbol: str, order_side: bool, qty=None): @param: qty - quantity wanted to be bought/sold """ - params = [("symbol", symbol)] - res = requests.get(url=self.order_book_url, params=params) + res = requests.get(url=self.order_book_url, params={"symbol": symbol}) data = handle_binance_errors(res) if order_side: diff --git a/api/account/assets.py b/api/account/assets.py index b2ed17641..9cdc72dd8 100644 --- a/api/account/assets.py +++ b/api/account/assets.py @@ -2,9 +2,10 @@ from bson.objectid import ObjectId from fastapi.responses import JSONResponse from account.controller import AssetsController -from autotrade.controller import AutotradeSettingsController -from bots.schemas import BotSchema -from deals.controllers import CreateDealController +from database.models.bot_table import BotTable +from database.autotrade_crud import AutotradeCrud +from bots.models import BotModel +from deals.factory import DealFactory from tools.handle_error import json_response, json_response_error, json_response_message from tools.round_numbers import round_numbers from tools.exceptions import BinanceErrors, LowBalanceCleanupError @@ -15,7 +16,7 @@ class Assets(AssetsController): def __init__(self): self.usd_balance = 0 self.exception_list = [] - self.fiat = AutotradeSettingsController().get_settings().balance_to_use + self.fiat = AutotradeCrud().get_settings().balance_to_use def get_pnl(self, days=7): current_time = datetime.now() @@ -346,8 +347,9 @@ def one_click_liquidation(self, pair: str) -> JSONResponse: bot = self._db.bots.find_one({"status": Status.active, "pair": pair}) if not bot: return bot - active_bot = BotSchema.model_validate(bot) - deal = CreateDealController(active_bot, db_collection="bots") + active_bot = BotModel.model_validate(bot) + deal = DealFactory(active_bot, db_table=BotTable) + if active_bot.strategy == Strategy.margin_short: deal.margin_liquidation(pair) diff --git a/api/alembic/env.py b/api/alembic/env.py index 79ae1749e..89adf7826 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -5,7 +5,7 @@ from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context -from database.api_db import db_url +from database.utils import db_url from database.models import SQLModel diff --git a/api/autotrade/routes.py b/api/autotrade/routes.py index 7c91afecd..8d706d041 100644 --- a/api/autotrade/routes.py +++ b/api/autotrade/routes.py @@ -1,9 +1,9 @@ from typing import Annotated from sqlmodel import Session +from database.autotrade_crud import AutotradeCrud from tools.enum_definitions import AutotradeSettingsDocument from database.utils import get_session -from autotrade.controller import AutotradeSettingsController from autotrade.schemas import AutotradeSettingsResponse, AutotradeSettingsSchema from fastapi import APIRouter, Depends, HTTPException from pydantic import ValidationError @@ -22,7 +22,7 @@ def edit_settings( these use real money and real Binance transactions """ try: - result = AutotradeSettingsController(session=session).edit_settings(item) + result = AutotradeCrud(session=session).edit_settings(item) if not result: raise HTTPException(status_code=404, detail="Autotrade settings not found") return json_response({"message": "Successfully updated settings"}) @@ -37,7 +37,7 @@ def edit_settings( @autotrade_settings_blueprint.get("/bots", tags=["autotrade settings"]) def get_settings(session: Session = Depends(get_session)): try: - deserialized_data = AutotradeSettingsController(session=session).get_settings() + deserialized_data = AutotradeCrud(session=session).get_settings() return json_response( { "message": "Successfully retrieved settings", @@ -57,7 +57,7 @@ def get_test_autotrade_settings( session: Session = Depends(get_session), ): try: - deserialized_data = AutotradeSettingsController( + deserialized_data = AutotradeCrud( document_id=AutotradeSettingsDocument.test_autotrade_settings, session=session, ).get_settings() @@ -77,7 +77,7 @@ def edit_test_autotrade_settings( session: Session = Depends(get_session), ): try: - data = AutotradeSettingsController( + data = AutotradeCrud( document_id=AutotradeSettingsDocument.test_autotrade_settings, session=session, ).edit_settings(item) diff --git a/api/bots/controllers.py b/api/bots/controllers.py deleted file mode 100644 index 1379db542..000000000 --- a/api/bots/controllers.py +++ /dev/null @@ -1,338 +0,0 @@ -from pymongo import ReturnDocument -from time import time -from datetime import datetime -from bson.objectid import ObjectId -from fastapi.exceptions import RequestValidationError -from account.account import Account -from database.db import Database -from deals.models import BinanceOrderModel, DealModel -from base_producer import BaseProducer -from tools.enum_definitions import BinbotEnums, DealType, Status, Strategy -from tools.handle_error import json_response, json_response_message, json_response_error -from typing import List -from fastapi import Query -from bots.schemas import BotSchema, ErrorsRequestBody -from deals.controllers import CreateDealController -from tools.exceptions import BinanceErrors, InsufficientBalance - - -class Bot(Database, Account): - def __init__(self, collection_name="bots"): - super().__init__() - self.db_collection = self._db[collection_name] - self.base_producer = BaseProducer() - self.producer = self.base_producer.start_producer() - self.deal: CreateDealController | None = None - - def get_active_pairs(self, symbol: str | None = None): - """ - Get distinct (non-repeating) bots by status active - """ - params = {"status": Status.active.value} - if symbol: - params["pair"] = symbol - - bots = list(self.db_collection.distinct("pair", params)) - return bots - - def get(self, status, start_date=None, end_date=None, no_cooldown=False): - """ - Get all bots in the db except archived - Args: - - archive=false - - filter_by: string - last-week, last-month, all - """ - params = {} - - if status and status in BinbotEnums.statuses: - params["status"] = status - - if start_date: - try: - float(start_date) - except ValueError: - resp = json_response( - {"message": "start_date must be a timestamp float", "data": []} - ) - return resp - - obj_start_date = datetime.fromtimestamp(int(float(start_date) / 1000)) - gte_tp_id = ObjectId.from_datetime(obj_start_date) - try: - params["_id"]["$gte"] = gte_tp_id - except KeyError: - params["_id"] = {"$gte": gte_tp_id} - - if end_date: - try: - float(end_date) - except ValueError as e: - resp = json_response( - {"message": f"end_date must be a timestamp float: {e}", "data": []} - ) - return resp - - obj_end_date = datetime.fromtimestamp(int(float(end_date) / 1000)) - lte_tp_id = ObjectId.from_datetime(obj_end_date) - params["_id"]["$lte"] = lte_tp_id - - # Only retrieve active and cooldown bots - # These bots will be removed from signals - if status and no_cooldown: - params = { - "$or": [ - {"status": status}, - { - "$where": """function () { - if (this.deal !== undefined) { - return new Date().getTime() - this.deal.sell_timestamp < (this.cooldown * 1000) - } else { - return (new Date().getTime() - this.created_at < (this.cooldown * 1000)) - } - }""" - }, - ] - } - - try: - bot = list( - self.db_collection.find(params).sort( - [("_id", -1), ("status", 1), ("pair", 1)] - ) - ) - resp = json_response({"message": "Sucessfully found bots!", "data": bot}) - except Exception as error: - resp = json_response_message(error) - - return resp - - def get_one(self, bot_id=None, symbol=None, status: Status = None): - if bot_id: - params = {"id": bot_id} - elif symbol: - params = {"pair": symbol} - else: - raise ValueError("id or symbol is required to find bot") - - if status: - params["status"] = status - - bot = self.db_collection.find_one(params) - return bot - - def create(self, data: BotSchema): - """ - Always creates new document - """ - try: - bot = data.model_dump() - bot["id"] = str(ObjectId()) - - self.db_collection.insert_one(bot) - resp = json_response( - { - "message": "Successfully created bot!", - "botId": str(bot["id"]), - } - ) - self.base_producer.update_required(self.producer, "CREATE_BOT") - - except RequestValidationError as error: - resp = json_response_error(f"Failed to create new bot: {error}") - pass - - return resp - - def edit(self, botId, data: BotSchema): - if not botId: - return json_response_message("id is required to update bot") - - try: - # Merge new data with old data - initial_bot_data = self.db_collection.find_one({"id": botId}) - # Ensure if client-side updated current_price is not overwritten - # Client side most likely has most up to date current_price because of websockets single pair update in BotDetail - if data.deal.current_price: - initial_bot_data["deal"]["current_price"] = data.deal.current_price - data.deal = initial_bot_data["deal"] - data.orders = initial_bot_data["orders"] - data.created_at = initial_bot_data["created_at"] - data.total_commission = initial_bot_data["total_commission"] - data.updated_at = round(time() * 1000) - bot = data.model_dump() - if "id" in bot: - bot.pop("id") - self.db_collection.update_one({"id": botId}, {"$set": bot}) - resp = json_response( - {"message": "Successfully updated bot", "botId": str(botId)} - ) - except RequestValidationError as e: - resp = json_response_error(f"Failed validation: {e}") - pass - - self.base_producer.update_required(self.producer, "EDIT_BOT") - return resp - - def delete(self, bot_ids: List[str] = Query(...)): - """ - Delete by multiple ids. - For a single id, pass one id in a list - """ - - try: - self.db_collection.delete_many({"id": {"$in": [id for id in bot_ids]}}) - resp = json_response_message("Successfully deleted bot(s)") - self.base_producer.update_required(self.producer, "DELETE_BOT") - except Exception as error: - resp = json_response_error(f"Failed to delete bot(s) {error}") - - return resp - - def activate(self, bot: dict | BotSchema): - if isinstance(bot, dict): - self.active_bot = BotSchema.model_validate(bot) - else: - self.active_bot = bot - - CreateDealController(self.active_bot, db_collection="bots").open_deal() - self.base_producer.update_required(self.producer, "ACTIVATE_BOT") - return bot - - def deactivate(self, bot: BotSchema) -> dict: - """ - DO NOT USE, LEGACY CODE NEEDS TO BE REVAMPED - Close all deals, sell pair and deactivate - 1. Close all deals - 2. Sell Coins - 3. Delete bot - """ - # Close all active orders - if len(bot.orders) > 0: - for d in bot.orders: - if d.status == "NEW" or d.status == "PARTIALLY_FILLED": - order_id = d.order_id - try: - self.delete_opened_order(bot.pair, order_id) - except BinanceErrors as error: - if error.code == -2011: - self.update_deal_logs( - "Order not found. Most likely not completed", bot - ) - pass - - if not bot.deal.buy_total_qty or bot.deal.buy_total_qty == 0: - msg = "Not enough balance to close and sell" - self.update_deal_logs(msg, bot) - raise InsufficientBalance(msg) - - deal_controller = CreateDealController(bot, db_collection="bots") - - if bot.strategy == Strategy.margin_short: - order_res = deal_controller.margin_liquidation(bot.pair) - panic_close_order = BinanceOrderModel( - timestamp=order_res["transactTime"], - deal_type=DealType.panic_close, - order_id=order_res["orderId"], - pair=order_res["symbol"], - order_side=order_res["side"], - order_type=order_res["type"], - price=order_res["price"], - qty=order_res["origQty"], - time_in_force=order_res["timeInForce"], - status=order_res["status"], - ) - - bot.total_commission = self.calculate_total_commissions(order_res["fills"]) - - bot.orders.append(panic_close_order) - else: - try: - res = deal_controller.spot_liquidation(bot.pair) - except InsufficientBalance as error: - self.update_deal_logs(error.message, bot) - bot.status = Status.completed - bot = self.save_bot_streaming(bot) - return bot.model_dump() - - panic_close_order = BinanceOrderModel( - timestamp=res["transactTime"], - order_id=res["orderId"], - deal_type=DealType.panic_close, - pair=res["symbol"], - order_side=res["side"], - order_type=res["type"], - price=res["price"], - qty=res["origQty"], - time_in_force=res["timeInForce"], - status=res["status"], - ) - - for chunk in res["fills"]: - bot.total_commission += float(chunk["commission"]) - - bot.orders.append(panic_close_order) - - bot.deal = DealModel( - buy_timestamp=res["transactTime"], - buy_price=res["price"], - buy_total_qty=res["origQty"], - current_price=res["price"], - ) - - bot.status = Status.completed - bot_obj = bot.model_dump() - if "_id" in bot_obj: - bot_obj.pop("_id") - - document = self.db_collection.find_one_and_update( - {"id": bot.id}, - {"$set": bot}, - return_document=ReturnDocument.AFTER, - ) - - return document - - def put_archive(self, botId): - """ - Change status to archived - """ - bot = self.db_collection.find_one({"id": botId}) - if bot["status"] == "active": - return json_response( - {"message": "Cannot archive an active bot!", "botId": botId} - ) - - if bot["status"] == "archived": - status = "inactive" - else: - status = "archived" - - try: - self.db_collection.update_one({"id": botId}, {"$set": {"status": status}}) - resp = json_response( - {"message": "Successfully archived bot", "botId": botId} - ) - return resp - except Exception as error: - resp = json_response({"message": f"Failed to archive bot {error}"}) - - return resp - - def post_errors_by_id(self, bot_id: str, reported_error: ErrorsRequestBody): - """ - Directly post errors to Bot - which should show in the BotForm page in Web - - Similar to update_deal_errors - but without a bot instance. - """ - operation = {"$push": {"errors": reported_error}} - if isinstance(reported_error, list): - operation = {"$push": {"errors": {"$each": reported_error}}} - elif isinstance(reported_error, str): - operation = {"$push": {"errors": reported_error}} - else: - raise ValueError("reported_error must be a list") - - self.db_collection.update_one({"id": bot_id}, operation) - pass diff --git a/api/bots/models.py b/api/bots/models.py new file mode 100644 index 000000000..9b5e15fa8 --- /dev/null +++ b/api/bots/models.py @@ -0,0 +1,165 @@ +from typing import List, Optional +from uuid import uuid4, UUID +from tools.enum_definitions import ( + BinanceKlineIntervals, + CloseConditions, + Status, + Strategy, +) +from deals.models import DealModel +from pydantic import ( + BaseModel, + Field, + Json, + field_validator, +) +from database.utils import timestamp +from tools.handle_error import StandardResponse, IResponseBase +from tools.enum_definitions import DealType, OrderType + + +class OrderModel(BaseModel): + order_type: OrderType + time_in_force: str + timestamp: Optional[int] + order_id: int + order_side: str + pair: str + qty: float + status: str + price: float + deal_type: DealType + + +class BotBase(BaseModel): + id: Optional[UUID] = Field(default_factory=uuid4) + pair: str + fiat: str = Field(default="USDC") + base_order_size: float = Field( + default=15, description="Min Binance 0.0001 BNB approx 15USD" + ) + candlestick_interval: BinanceKlineIntervals = Field( + default=BinanceKlineIntervals.fifteen_minutes, + ) + close_condition: CloseConditions = Field( + default=CloseConditions.dynamic_trailling, + ) + cooldown: int = Field( + default=0, + description="cooldown period in minutes before opening next bot with same pair", + ) + created_at: float = Field(default_factory=timestamp) + updated_at: float = Field(default_factory=timestamp) + dynamic_trailling: bool = Field(default=False) + logs: list[Json[str]] = Field(default=[]) + mode: str = Field(default="manual") + name: str = Field(default="Default bot") + status: Status = Field(default=Status.inactive) + stop_loss: float = Field( + default=0, description="If stop_loss > 0, allow for reversal" + ) + margin_short_reversal: bool = Field(default=False) + take_profit: float = Field(default=0) + trailling: bool = Field(default=False) + trailling_deviation: float = Field( + default=0, + ge=-1, + le=101, + description="Trailling activation (first take profit hit)", + ) + trailling_profit: float = Field(default=0) + strategy: Strategy = Field(default=Strategy.long) + total_commission: float = Field( + default=0, description="autoswitch to short_strategy" + ) + + @field_validator("id") + def deserialize_id(cls, v): + if isinstance(v, UUID): + return str(v) + return True + + +class BotModel(BotBase): + """ + The way SQLModel works causes a lot of errors + if we combine (with inheritance) both Pydantic models + and SQLModels. they are not compatible. Thus the duplication + """ + + deal: DealModel = Field(default_factory=DealModel) + orders: List[OrderModel] = Field(default=[]) + + model_config = { + "from_attributes": True, + "use_enum_values": True, + "json_schema_extra": { + "description": "Most fields are optional. Deal field is generated internally, orders are filled up by Exchange", + "examples": [ + { + "pair": "BNBUSDT", + "fiat": "USDC", + "base_order_size": 15, + "candlestick_interval": "15m", + "cooldown": 0, + "logs": [], + # Manual is triggered by the terminal dashboard, autotrade by research app, + "mode": "manual", + "name": "Default bot", + "orders": [], + "status": "inactive", + "stop_loss": 0, + "take_profit": 2.3, + "trailling": "true", + "trailling_deviation": 0.63, + "trailling_profit": 2.3, + "strategy": "long", + "short_buy_price": 0, + "short_sell_price": 0, + "total_commission": 0, + } + ], + }, + } + + +class BotResponse(StandardResponse): + data: Optional[BotModel] = None + + +class ActivePairsResponse(IResponseBase): + data: Optional[list[str]] + + +class BotListResponse(IResponseBase): + """ + Model exclusively used to serialize + list of bots. + + Has to be converted to BotModel to be able to + serialize nested table objects (deal, orders) + """ + + data: Optional[List[BotModel]] = Field(default=[]) + + +class ErrorsRequestBody(BaseModel): + errors: str | list[str] + + @field_validator("errors") + @classmethod + def check_names_not_empty(cls, v): + if isinstance(v, list): + assert len(v) != 0, "List of errors is empty." + if isinstance(v, str): + assert v != "", "Empty pair field." + return v + + +class GetBotParams(BaseModel): + status: str | None = None + start_date: float | None = None + end_date: float | None = None + no_cooldown: bool = True + limit: int = 100 + offset: int = 0 diff --git a/api/bots/routes.py b/api/bots/routes.py index 0510faf45..ddf1c40f1 100644 --- a/api/bots/routes.py +++ b/api/bots/routes.py @@ -1,75 +1,148 @@ -from fastapi import APIRouter -from tools.handle_error import json_response, json_response_error, json_response_message -from bots.controllers import Bot -from bots.schemas import BotSchema, BotListResponse, ErrorsRequestBody +from fastapi import APIRouter, Depends +from pydantic import ValidationError, TypeAdapter +from sqlmodel import Session +from tools.enum_definitions import Status +from database.bot_crud import BotTableCrud +from deals.factory import DealFactory +from database.utils import get_session +from bots.models import ( + BotModel, + BotResponse, + ErrorsRequestBody, + BotBase, + BotListResponse, + IResponseBase, + ActivePairsResponse, +) from typing import List from tools.exceptions import BinanceErrors, BinbotErrors -from tools.enum_definitions import Status - bot_blueprint = APIRouter() @bot_blueprint.get("/bot", response_model=BotListResponse, tags=["bots"]) def get( - status: str | None = None, + status: Status | None = None, start_date: float | None = None, end_date: float | None = None, - no_cooldown: bool = True, + no_cooldown=False, + limit: int = 200, + offset: int = 0, + session: Session = Depends(get_session), +): + try: + bots = BotTableCrud(session=session).get( + status, start_date, end_date, no_cooldown, limit, offset + ) + # Has to be converted to BotModel to + # be able to serialize nested objects + ta = TypeAdapter(List[BotModel]) + data = ta.dump_python(bots) + return BotListResponse[List](message="Successfully found bots!", data=data) + except ValidationError as error: + return BotResponse(message="Failed to find bots!", data=error.json(), error=1) + + +@bot_blueprint.get( + "/bot/active-pairs", response_model=ActivePairsResponse, tags=["bots"] +) +def get_active_pairs( + session: Session = Depends(get_session), ): - return Bot(collection_name="bots").get(status, start_date, end_date, no_cooldown) - - -@bot_blueprint.get("/bot/active-pairs", tags=["bots"]) -def get_active_pairs(): try: - bot = Bot(collection_name="bots").get_active_pairs() - return json_response({"message": "Active pairs found!", "data": bot}) - except ValueError as error: - return json_response_error(error) + bot = BotTableCrud(session=session).get_active_pairs() + if not bot: + return BotResponse(message="Bot not found.", error=1) + else: + ta = TypeAdapter(BotModel) + data = ta.dump_python(bot) + return ActivePairsResponse( + message="Successfully retrieved active pairs.", data=data + ) + except ValidationError as error: + return BotResponse( + data=error.json(), error=1, message="Failed to find active pairs." + ) -@bot_blueprint.get("/bot/{id}", tags=["bots"]) -def get_one_by_id(id: str): + +@bot_blueprint.get("/bot/{id}", response_model=BotResponse, tags=["bots"]) +def get_one_by_id(id: str, session: Session = Depends(get_session)): try: - bot = Bot(collection_name="bots").get_one(bot_id=id, symbol=None) + bot = BotTableCrud(session=session).get_one(bot_id=id) if not bot: - return json_response_error("Bot not found.") + return BotResponse(message="Bot not found.", error=1) else: - return json_response({"message": "Bot found", "data": bot}) - except ValueError as error: - return json_response_error(error) + ta = TypeAdapter(BotModel) + data = ta.dump_python(bot) + return BotResponse(message="Successfully found one bot.", data=data) + except ValidationError as error: + return BotResponse(message="Bot not found.", error=1, data=error.json()) -@bot_blueprint.get("/bot/{symbol}", tags=["bots"]) -def get_one_by_symbol(symbol: str): +@bot_blueprint.get("/bot/symbol/{symbol}", tags=["bots"]) +def get_one_by_symbol(symbol: str, session: Session = Depends(get_session)): try: - bot = Bot(collection_name="bots").get_one(bot_id=None, symbol=symbol) - return json_response({"message": "Bot found", "data": bot}) - except ValueError as error: - return json_response_error(error) + bot = BotTableCrud(session=session).get_one(bot_id=None, symbol=symbol) + if not bot: + return BotResponse(message="Bot not found.", error=1) + else: + ta = TypeAdapter(BotModel) + data = ta.dump_python(bot) + return BotResponse(message="Successfully found one bot.", data=data) + except ValidationError as error: + return BotResponse(message="Bot not found.", error=1, data=error.json()) -@bot_blueprint.post("/bot", tags=["bots"]) -def create(bot_item: BotSchema): - return Bot(collection_name="bots").create(bot_item) +@bot_blueprint.post("/bot", tags=["bots"], response_model=BotResponse) +def create( + bot_item: BotBase, + session: Session = Depends(get_session), +): + try: + bot = BotTableCrud(session=session).create(bot_item) + ta = TypeAdapter(BotModel) + data = ta.dump_python(bot) + return BotResponse(message="Successfully created one bot.", data=data) + except ValidationError as error: + return BotResponse( + message="Failed to create new bot", data=error.json(), error=1 + ) @bot_blueprint.put("/bot/{id}", tags=["bots"]) -def edit(id: str, bot_item: BotSchema): - return Bot(collection_name="bots").edit(id, bot_item) +def edit( + id: str, + bot_item: BotModel, + session: Session = Depends(get_session), +): + try: + bot_item.id = id + bot = BotTableCrud(session=session).save(bot_item) + ta = TypeAdapter(BotModel) + data = ta.dump_python(bot) + return BotResponse(message="Sucessfully edited bot", data=data) + except ValidationError as error: + return BotResponse(message="Failed to edit bot", data=error.json(), error=1) @bot_blueprint.delete("/bot", tags=["bots"]) -def delete(id: List[str]): +def delete( + id: List[str], + session: Session = Depends(get_session), +): """ Delete bots, given a list of ids """ - return Bot(collection_name="bots").delete(id) + try: + BotTableCrud(session=session).delete(id) + return IResponseBase(message="Sucessfully deleted bot.") + except ValidationError as error: + return BotResponse(message="Failed to delete bot", data=error.json(), error=1) @bot_blueprint.get("/bot/activate/{id}", tags=["bots"]) -async def activate_by_id(id: str): +def activate_by_id(id: str, session: Session = Depends(get_session)): """ Activate bot @@ -77,61 +150,60 @@ async def activate_by_id(id: str): - If changes were made, it will override DB data - Because botId is received from endpoint, it will be a str not a PyObjectId """ - bot_instance = Bot(collection_name="bots") - bot = bot_instance.get_one(id) - if bot: - try: - bot_instance.activate(bot) - return json_response_message("Successfully activated bot!") - except BinbotErrors as error: - bot_instance.post_errors_by_id(id, error.message) - return json_response_error(error.message) - except BinanceErrors as error: - bot_instance.post_errors_by_id(id, error.message) - return json_response_error(error.message) - - else: - return json_response_error("Bot not found.") - - -@bot_blueprint.delete("/bot/deactivate/{id}", tags=["bots"]) -def deactivation(id: str): + bot = BotTableCrud(session=session).get_one(bot_id=id) + if not bot: + return BotResponse(message="Bot not found.") + + bot_model = BotModel.model_construct(**bot.model_dump()) + bot_instance = DealFactory(bot_model) + + try: + data = bot_instance.open_deal() + return BotResponse(message="Successfully activated bot!", data=data) + except BinbotErrors as error: + bot_instance.controller.update_logs(bot_id=id, log_message=error.message) + return BotResponse(message=error.message, error=1) + except BinanceErrors as error: + bot_instance.controller.update_logs(bot_id=id, log_message=error.message) + return BotResponse(message=error.message, error=1) + + +@bot_blueprint.delete("/bot/deactivate/{id}", response_model=BotResponse, tags=["bots"]) +def deactivation(id: str, session: Session = Depends(get_session)): """ Deactivation means closing all deals and selling to fiat. This is often used to prevent losses """ - botModel = Bot(collection_name="bots") - bot = botModel.db_collection.find_one({"id": id, "status": Status.active}) - bot = BotSchema.model_validate(bot) - if not bot: - return json_response_message("No active bot found.") - else: - response = botModel.deactivate(bot) - if response: - return json_response_message( - "Active orders closed, sold base asset, deactivated" - ) - else: - return json_response_error("Error deactivating bot.") + bot_table = BotTableCrud(session=session).get_one(bot_id=id) + if not bot_table: + return BotResponse(message="No active bot found.") - -@bot_blueprint.put("/bot/archive/{id}", tags=["bots"]) -def archive(id: str): - return Bot(collection_name="bots").put_archive(id) + bot_model = BotModel.model_construct(**bot_table.model_dump()) + deal_instance = DealFactory(bot_model) + try: + data = deal_instance.close_all() + return BotResponse(message="Active orders closed, sold base asset, deactivated", data=data) + except BinbotErrors as error: + return BotResponse(message=error.message, error=1) -@bot_blueprint.post("/bot/errors/{bot_id}", tags=["bots"]) -def bot_errors(bot_id: str, bot_errors: ErrorsRequestBody): +@bot_blueprint.post("/bot/errors/{bot_id}", response_model=BotResponse, tags=["bots"]) +def bot_errors( + bot_id: str, bot_errors: ErrorsRequestBody, session: Session = Depends(get_session) +): """ POST errors to a bot - If error(s) is received from endpoint, get it from request body - Else use `post_errors_by_id` method for internal calls """ - request_body = bot_errors.model_dump(mode="python") - bot_errors = request_body.get("errors", None) try: - Bot(collection_name="bots").post_errors_by_id(bot_id, bot_errors) - except Exception as error: - return json_response_error(f"Error posting errors: {error}") - return json_response_message("Errors posted successfully.") + request_body = ErrorsRequestBody.model_dump(bot_errors) + errors = request_body.get("errors", None) + bot = BotTableCrud(session=session).update_logs( + log_message=errors, bot_id=bot_id + ) + data = BotModel.model_construct(**bot.model_dump()) + return BotResponse(message="Errors posted successfully.", data=data) + except ValidationError as error: + return BotResponse(message="Failed to post errors", data=error.json(), error=1) diff --git a/api/bots/schemas.py b/api/bots/schemas.py deleted file mode 100644 index ee169c10b..000000000 --- a/api/bots/schemas.py +++ /dev/null @@ -1,161 +0,0 @@ -from time import time -from typing import Optional - -from bson.objectid import ObjectId -from deals.models import BinanceOrderModel, DealModel -from pydantic import BaseModel, Field, field_validator -from tools.enum_definitions import ( - BinanceKlineIntervals, - BinbotEnums, - CloseConditions, - Status, - Strategy, -) -from tools.handle_error import StandardResponse - - -class BotSchema(BaseModel): - id: str | None = "" - pair: str - balance_size_to_use: str | float = 1 - # New table field fiat replaces balance_to_use - fiat: str = "USDC" - balance_to_use: str = "USDC" - base_order_size: float | int = 15 # Min Binance 0.0001 BNB - candlestick_interval: BinanceKlineIntervals = Field(default=BinanceKlineIntervals.fifteen_minutes) - close_condition: CloseConditions = Field(default=CloseConditions.dynamic_trailling) - # cooldown period in minutes before opening next bot with same pair - cooldown: int = 0 - deal: DealModel = Field(default_factory=DealModel) - dynamic_trailling: bool = False - errors: list[str] = [] # Event logs - # to deprecate in new db - locked_so_funds: Optional[float] = 0 # funds locked by Safety orders - mode: str = "manual" - name: str = "Default bot" - orders: list[BinanceOrderModel] = [] # Internal - status: Status = Field(default=Status.inactive) - stop_loss: float = 0 - margin_short_reversal: bool = False # If stop_loss > 0, allow for reversal - take_profit: float = 0 - trailling: bool = True - trailling_deviation: float = 0 - trailling_profit: float = 0 # Trailling activation (first take profit hit) - strategy: str = Field(default=Strategy.long) - short_buy_price: float = 0 - short_sell_price: float = 0 # autoswitch to short_strategy - # Deal and orders are internal, should never be updated by outside data - total_commission: float = 0 - created_at: float = time() * 1000 - updated_at: float = time() * 1000 - - model_config = { - "arbitrary_types_allowed": True, - "use_enum_values": True, - "json_encoders": {ObjectId: str}, - "json_schema_extra": { - "description": "Most fields are optional. Deal field is generated internally, orders are filled up by Exchange", - "examples": [ - { - "pair": "BNBUSDT", - "balance_size_to_use": 1, - "fiat": "USDC", - "base_order_size": 15, - "candlestick_interval": "15m", - "cooldown": 0, - "errors": [], - # Manual is triggered by the terminal dashboard, autotrade by research app, - "mode": "manual", - "name": "Default bot", - "orders": [], - "status": "inactive", - "stop_loss": 0, - "take_profit": 2.3, - "trailling": "true", - "trailling_deviation": 0.63, - "trailling_profit": 2.3, - "strategy": "long", - "short_buy_price": 0, - "short_sell_price": 0, - "total_commission": 0, - } - ], - }, - } - - @field_validator("pair", "candlestick_interval") - @classmethod - def check_names_not_empty(cls, v): - assert v != "", "Empty pair field." - return v - - @field_validator("balance_size_to_use", "base_order_size", "base_order_size", mode="before") - @classmethod - def countables(cls, v): - if isinstance(v, float): - return v - elif isinstance(v, str): - return float(v) - elif isinstance(v, int): - return float(v) - else: - raise ValueError(f"{v} must be a number (float, int or string)") - - @field_validator( - "stop_loss", "take_profit", "trailling_deviation", "trailling_profit", mode="before" - ) - @classmethod - def check_percentage(cls, v): - if 0 <= float(v) < 100: - return v - else: - raise ValueError(f"{v} must be a percentage") - - @field_validator("mode") - @classmethod - def check_mode(cls, v: str): - if v not in BinbotEnums.mode: - raise ValueError(f'Status must be one of {", ".join(BinbotEnums.mode)}') - return v - - @field_validator("strategy") - @classmethod - def check_strategy(cls, v: str): - if v not in BinbotEnums.strategy: - raise ValueError(f'Status must be one of {", ".join(BinbotEnums.strategy)}') - return v - - @field_validator("trailling") - @classmethod - def string_booleans(cls, v: str | bool): - if isinstance(v, str) and v.lower() == "false": - return False - if isinstance(v, str) and v.lower() == "true": - return True - return v - - @field_validator("errors") - @classmethod - def check_errors_format(cls, v: list[str]): - if not isinstance(v, list): - raise ValueError("Errors must be a list of strings") - return v - - -class BotListResponse(StandardResponse): - data: list[BotSchema] - - -class ErrorsRequestBody(BaseModel): - errors: str | list[str] - - @field_validator("errors") - @classmethod - def check_names_not_empty(cls, v): - if isinstance(v, list): - assert len(v) != 0, "List of errors is empty." - if isinstance(v, str): - assert v != "", "Empty pair field." - return v - - return v diff --git a/api/charts/controllers.py b/api/charts/controllers.py index f3bc8aa30..5a828ed2b 100644 --- a/api/charts/controllers.py +++ b/api/charts/controllers.py @@ -2,7 +2,7 @@ from bson import ObjectId from pymongo import DESCENDING, ReturnDocument from pymongo.errors import CollectionInvalid -from autotrade.controller import AutotradeSettingsController +from database.autotrade_crud import AutotradeCrud from charts.models import MarketDominationSeriesStore from apis import BinbotApi from database.db import Database, setup_db, setup_kafka_db @@ -116,7 +116,7 @@ class MarketDominationController(Database, BinbotApi): def __init__(self) -> None: super().__init__() self.collection = self.kafka_db.market_domination - self.autotrade_db = AutotradeSettingsController() + self.autotrade_db = AutotradeCrud() self.autotrade_settings = self.autotrade_db.get_settings() def mkdm_migration(self): @@ -248,5 +248,14 @@ def top_gainers(self): fiat = self.autotrade_db.get_fiat() ticket_data = self.ticker_24() - fiat_market_data = sorted((item for item in ticket_data if item["symbol"].endswith(fiat) and float(item["priceChangePercent"]) > 0), key=lambda x: x["priceChangePercent"], reverse=True) + fiat_market_data = sorted( + ( + item + for item in ticket_data + if item["symbol"].endswith(fiat) + and float(item["priceChangePercent"]) > 0 + ), + key=lambda x: x["priceChangePercent"], + reverse=True, + ) return fiat_market_data[:10] diff --git a/api/database/api_db.py b/api/database/api_db.py index 4fcdd9581..6d981d6e5 100644 --- a/api/database/api_db.py +++ b/api/database/api_db.py @@ -1,15 +1,11 @@ import logging import os -from database.models import ( - BotTable, - DealTable, - ExchangeOrderTable, - UserTable, - PaperTradingTable, -) from database.models.autotrade_table import AutotradeTable, TestAutotradeTable -from sqlalchemy import create_engine +from database.models.deal_table import DealTable +from database.models.order_table import ExchangeOrderTable +from database.models.user_table import UserTable +from database.models.bot_table import BotTable, PaperTradingTable from sqlmodel import Session, SQLModel, select from tools.enum_definitions import ( AutotradeSettingsDocument, @@ -21,19 +17,11 @@ ) from alembic.config import Config from alembic import command - -# This allows testing/Github action dummy envs -db_url = f'postgresql://{os.getenv("POSTGRES_USER", "postgres")}:{os.getenv("POSTGRES_PASSWORD", "postgres")}@{os.getenv("POSTGRES_HOSTNAME", "localhost")}:{os.getenv("POSTGRES_PORT", 5432)}/{os.getenv("POSTGRES_DB", "postgres")}' -engine = create_engine( - url=db_url, -) +from database.utils import engine class ApiDb: def __init__(self): - self.engine = create_engine( - url=db_url, - ) self.session = Session(engine) pass @@ -130,34 +118,33 @@ def create_dummy_bot(self): results = self.session.exec(statement) if results.first(): return - orders = [ - ExchangeOrderTable( - id=1, - order_type="market", - time_in_force="GTC", - timestamp=0, - order_side="buy", - pair="BTCUSDT", - qty=0.000123, - status="filled", - price=1.222, - deal_type=DealType.base_order, - total_commission=0, - ), - ExchangeOrderTable( - id=2, - order_type="limit", - time_in_force="GTC", - timestamp=0, - order_side="sell", - pair="BTCUSDT", - qty=0.000123, - status="filled", - price=1.222, - deal_type=DealType.take_profit, - total_commission=0, - ), - ] + self.session.close() + base_order = ExchangeOrderTable( + order_id=123, + order_type="market", + time_in_force="GTC", + timestamp=0, + order_side="buy", + pair="BTCUSDT", + qty=0.000123, + status="filled", + price=1.222, + deal_type=DealType.base_order, + total_commission=0, + ) + take_profit_order = ExchangeOrderTable( + order_id=456, + order_type="limit", + time_in_force="GTC", + timestamp=0, + order_side="sell", + pair="BTCUSDT", + qty=0.000123, + status="filled", + price=1.222, + deal_type=DealType.take_profit, + total_commission=0, + ) deal = DealTable( buy_price=0, buy_total_qty=0, @@ -191,18 +178,17 @@ def create_dummy_bot(self): margin_short_sell_timestamp=0, margin_short_loan_timestamp=0, ) - self.session.add(deal) bot = BotTable( pair="BTCUSDT", balance_size_to_use="1", - balance_to_use=1, + fiat="USDC", base_order_size=15, - deal_id=deal.id, + deal=deal, cooldown=0, - logs='["Bot created"]', + logs=["Bot created"], mode="manual", name="Dummy bot", - orders=orders, + orders=[base_order, take_profit_order], status=Status.inactive, stop_loss=0, take_profit=2.3, @@ -214,19 +200,17 @@ def create_dummy_bot(self): short_sell_price=0, total_commission=0, ) - self.session.add(bot) - self.session.commit() paper_trading_bot = PaperTradingTable( pair="BTCUSDT", balance_size_to_use=1, balance_to_use=1, base_order_size=15, - deal_id=deal.id, + deal=deal, cooldown=0, - logs='["Paper trading bot created"]', + logs=["Paper trading bot created"], mode="manual", name="Dummy bot", - orders=orders, + orders=[base_order, take_profit_order], status=Status.inactive, stop_loss=0, take_profit=2.3, @@ -238,8 +222,11 @@ def create_dummy_bot(self): short_sell_price=0, total_commission=0, ) + self.session.add(bot) self.session.add(paper_trading_bot) self.session.commit() + self.session.refresh(bot) + self.session.refresh(paper_trading_bot) return bot def select_bot(self, pair): diff --git a/api/autotrade/controller.py b/api/database/autotrade_crud.py similarity index 97% rename from api/autotrade/controller.py rename to api/database/autotrade_crud.py index 30bee09f4..71c893458 100644 --- a/api/autotrade/controller.py +++ b/api/database/autotrade_crud.py @@ -7,9 +7,9 @@ from tools.enum_definitions import AutotradeSettingsDocument -class AutotradeSettingsController: +class AutotradeCrud: """ - Autotrade settings + Database operations for Autotrade settings """ def __init__( diff --git a/api/database/bot_crud.py b/api/database/bot_crud.py new file mode 100644 index 000000000..28c01235c --- /dev/null +++ b/api/database/bot_crud.py @@ -0,0 +1,253 @@ +from typing import List, Optional +from uuid import UUID +from fastapi import Query +from sqlmodel import Session, asc, desc, or_, select, case +from time import time +from bots.models import BotModel +from database.models.bot_table import BotTable +from database.models.deal_table import DealTable +from database.utils import independent_session +from tools.enum_definitions import BinbotEnums, Status +from bots.models import BotBase +from collections.abc import Sequence + + +class BotTableCrud: + """ + CRUD and database operations for the SQL API DB + bot_table table. + + Use for lower level APIs that require a session + e.g. + client-side -> receive json -> bots.routes -> BotModelCrud + """ + + def __init__( + self, + # Some instances of AutotradeSettingsController are used outside of the FastAPI context + # this is designed this way for reusability + session: Session | None = None, + ): + if session is None: + session = independent_session() + self.session = session + + def update_logs( + self, + log_message: str, + bot: Optional[BotModel] = None, + bot_id: str | None = None, + ) -> BotTable: + """ + Update logs for a bot + + Args: + - bot_id: str + - bot: BotModel + + Either id or bot has to be passed + """ + if bot_id: + bot_obj = self.session.get(BotTable, bot_id) + if not bot_obj: + raise ValueError("Bot not found") + # No validation needed, this is a trusted source + bot = BotModel.model_construct(**bot_obj.model_dump()) + elif not bot: + raise ValueError("Bot id or BotModel object is required") + + current_logs: list[str] = bot.logs + if len(current_logs) == 0: + current_logs = [log_message] + elif len(current_logs) > 0: + current_logs.append(log_message) + + bot_table_model = BotTable.model_validate(bot.model_dump()) + bot_table_model.logs = current_logs + + # db operations + self.session.add(bot_table_model) + self.session.commit() + self.session.refresh(bot_table_model) + self.session.close() + return bot_table_model + + def get( + self, + status: Status | None = None, + start_date: float | None = None, + end_date: float | None = None, + no_cooldown=False, + limit: int = 200, + offset: int = 0, + ) -> Sequence[BotTable]: + """ + Get all bots in the db except archived + Args: + - status: Status enum + - start_date and end_date are timestamps in milliseconds + - no_cooldown: bool - filter out bots that are in cooldown + - limit and offset for pagination + """ + statement = select(BotTable) + + if status and status in BinbotEnums.statuses: + statement.where(BotTable.status == status) + + if start_date: + statement.where(BotTable.created_at >= start_date) + + if end_date: + statement.where(BotTable.created_at <= end_date) + + if status and no_cooldown: + current_timestamp = time() + cooldown_condition = cooldown_condition = or_( + BotTable.status == status, + case( + ( + (DealTable.sell_timestamp > 0), + current_timestamp - DealTable.sell_timestamp + < (BotTable.cooldown * 1000), + ), + else_=( + current_timestamp - BotTable.created_at + < (BotTable.cooldown * 1000) + ), + ), + ) + + statement.where(cooldown_condition) + + # sorting + statement.order_by( + desc(BotTable.created_at), + case((BotTable.status == Status.active, 1), else_=2), + asc(BotTable.pair), + ) + + # pagination + statement.limit(limit).offset(offset) + + bots = self.session.exec(statement).all() + self.session.close() + return bots + + def get_one( + self, + bot_id: str | None = None, + symbol: str | None = None, + status: Status | None = None, + ) -> BotTable: + """ + Get one bot by id or symbol + """ + if bot_id: + santize_uuid = UUID(bot_id) + bot = self.session.get(BotTable, santize_uuid) + if not bot: + raise ValueError("Bot not found") + return bot + elif symbol: + if status: + bot = self.session.exec( + select(BotTable).where( + BotTable.pair == symbol, BotTable.status == status + ) + ).first() + else: + bot = self.session.exec( + select(BotTable).where(BotTable.pair == symbol) + ).first() + if not bot: + raise ValueError("Bot not found") + return bot + else: + raise ValueError("Invalid bot id or symbol") + + def create(self, data: BotBase) -> BotModel: + """ + Create a new bot + + It's crucial to reset fields, so bot can trigger base orders + and start trailling. + + Args: + - data: BotBase includes only flat properties (excludes deal and orders which are generated internally) + """ + bot = BotModel.model_construct(**data.model_dump()) + deal = bot.deal + + # db operations + serialised_bot = BotTable.model_validate(data) + serialised_deal = DealTable.model_validate(deal) + + self.session.add(serialised_bot) + self.session.add(serialised_deal) + + self.session.commit() + self.session.refresh(serialised_bot) + self.session.refresh(serialised_deal) + self.session.close() + resulted_bot = self.session.get(BotTable, data.id) + if resulted_bot: + bot_model = BotModel.model_validate(resulted_bot.model_dump()) + else: + bot_model = bot + return bot_model + + def save(self, data: BotModel) -> BotTable: + """ + Save bot + + This can be an edit of an entire object + or just a few fields + """ + bot = self.session.get(BotTable, data.id) + if not bot: + raise ValueError("Bot not found") + + # due to incompatibility of SQLModel and Pydantic + dumped_bot = data.model_dump() + bot.sqlmodel_update(dumped_bot) + self.session.add(bot) + self.session.commit() + self.session.refresh(bot) + self.session.close() + resulted_bot = self.get_one(bot_id=dumped_bot["id"]) + return resulted_bot + + def delete(self, bot_ids: List[str] = Query(...)): + """ + Delete by multiple ids. + For a single id, pass one id in a list + """ + statement = select(BotTable) + for id in bot_ids: + statement.where(BotTable.id == id) + bots = self.session.exec(statement).all() + self.session.commit() + self.session.close() + return bots + + def update_status(self, bot: BotModel, status: Status) -> BotModel: + """ + Mostly as a replacement of the previous "activate" and "deactivate" + although any Status can be passed now + """ + bot.status = status + self.save(bot) + return bot + + def get_active_pairs(self) -> list: + """ + Get all active pairs + + a replacement of the previous "distinct pairs" query + """ + statement = ( + select(BotTable.pair).where(BotTable.status == Status.active).distinct() + ) + pairs = self.session.exec(statement).all() + self.session.close() + return list(pairs) diff --git a/api/database/db.py b/api/database/db.py index 958d525ca..fe3f4c995 100644 --- a/api/database/db.py +++ b/api/database/db.py @@ -1,11 +1,5 @@ import os -from time import time -from bson import ObjectId -from pymongo import MongoClient, ReturnDocument -from tools.enum_definitions import Status -from tools.handle_error import encode_json -from deals.models import DealModel -from bots.schemas import BotSchema +from pymongo import MongoClient def get_mongo_client(): @@ -46,80 +40,3 @@ class Database: _db = setup_db() kafka_db = setup_kafka_db() - - def save_bot_streaming( - self, active_bot: BotSchema, db_collection_name: str = "bots" - ) -> BotSchema: - """ - MongoDB query to save bot using Pydantic - - This function differs from usual save query in that - it returns the saved bot, thus called streaming, it's - specifically for streaming saves - - Returns: - dict: The saved bot - """ - - bot = BotSchema.model_dump(active_bot) - if "_id" in bot: - bot.pop("_id") - - response = self._db[db_collection_name].find_one_and_update( - {"id": active_bot.id}, - { - "$set": bot, - }, - return_document=ReturnDocument.AFTER, - ) - - active_bot = BotSchema.model_validate(response) - return active_bot - - def update_deal_logs( - self, message, active_bot: BotSchema, db_collection_name: str = "bots" - ): - """ - Use this function if independently updating Event logs (deal.errors list) - especially useful if a certain operation might fail in an exception - and the error needs to be stored in the logs - - However, if save_bot_streaming is used later, - and there is almost no chance of failure, - best to this.active_bot.errors.append(str(msg)) so we can save - some DB calls. - """ - result = self._db[db_collection_name].find_one_and_update( - {"id": active_bot.id}, - {"$push": {"errors": str(message)}}, - return_document=ReturnDocument.AFTER, - ) - active_bot.errors = result["errors"] - return result - - def create_new_bot_streaming( - self, active_bot: BotSchema, db_collection_name: str = "bots" - ): - """ - Resets bot to initial state and saves it to DB - - This function differs from usual create_bot in that - it needs to set strategy first (reversal) - clear orders, deal and errors, - which are not required in new bots, - as they initialize with empty values - """ - active_bot.id = str(ObjectId()) - active_bot.orders = [] - active_bot.errors = [] - active_bot.created_at = time() * 1000 - active_bot.updated_at = time() * 1000 - active_bot.status = Status.inactive - active_bot.deal = DealModel() - - bot = encode_json(active_bot) - self._db[db_collection_name].insert_one(bot) - new_bot = self._db[db_collection_name].find_one({"id": bot["id"]}) - new_bot_class = BotSchema(**new_bot) - - return new_bot_class diff --git a/api/database/models/__init__.py b/api/database/models/__init__.py index 62bfa43ad..f3aa96784 100644 --- a/api/database/models/__init__.py +++ b/api/database/models/__init__.py @@ -3,5 +3,4 @@ from .order_table import * # noqa from .deal_table import * # noqa from .bot_table import * # noqa -from .paper_trading_table import * # noqa from .autotrade_table import * # noqa diff --git a/api/database/models/autotrade_table.py b/api/database/models/autotrade_table.py index b1fe240bb..4eeeebb3e 100644 --- a/api/database/models/autotrade_table.py +++ b/api/database/models/autotrade_table.py @@ -49,8 +49,30 @@ class AutotradeTable(SQLModel, table=True): sa_column=Column(Enum(CloseConditions)), ) - class Config: - arbitrary_types_allowed = True + model_config = { + "from_attributes": True, + "use_enum_values": True, + "json_schema_extra": { + "description": "Autotrade global settings used by Binquant", + "examples": [ + { + "autotrade": True, + "base_order_size": 15, + "candlestick_interval": "15m", + "trailling": False, + "trailling_deviation": 3, + "trailling_profit": 2.4, + "stop_loss": 0, + "take_profit": 2.3, + "fiat": "USDC", + "max_request": 950, + "telegram_signals": True, + "max_active_autotrade_bots": 1, + "close_condition": "dynamic_trailling", + } + ], + }, + } class TestAutotradeTable(SQLModel, table=True): @@ -80,5 +102,27 @@ class TestAutotradeTable(SQLModel, table=True): telegram_signals: bool = Field(default=True) max_active_autotrade_bots: int = Field(default=1) - class Config: - arbitrary_types_allowed = True + model_config = { + "from_attributes": True, + "use_enum_values": True, + "json_schema_extra": { + "description": "Autotrade global settings used by Binquant", + "examples": [ + { + "autotrade": True, + "base_order_size": 15, + "candlestick_interval": "15m", + "trailling": False, + "trailling_deviation": 3, + "trailling_profit": 2.4, + "stop_loss": 0, + "take_profit": 2.3, + "fiat": "USDC", + "max_request": 950, + "telegram_signals": True, + "max_active_autotrade_bots": 1, + "close_condition": "dynamic_trailling", + } + ], + }, + } diff --git a/api/database/models/bot_table.py b/api/database/models/bot_table.py index afde02a60..7338df6cd 100644 --- a/api/database/models/bot_table.py +++ b/api/database/models/bot_table.py @@ -1,7 +1,8 @@ from uuid import uuid4, UUID -from time import time -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Optional, List +from pydantic import Json from sqlalchemy import JSON, Column, Enum +from database.utils import timestamp from tools.enum_definitions import ( BinanceKlineIntervals, CloseConditions, @@ -24,9 +25,10 @@ class BotTable(SQLModel, table=True): default_factory=uuid4, primary_key=True, index=True, nullable=False, unique=True ) pair: str = Field(index=True) - fiat: str = Field(default="USDC") - # Min Binance 0.0001 BNB - base_order_size: float = Field(default=15) + fiat: str = Field(default="USDC", index=True) + base_order_size: float = Field( + default=15, description="Min Binance 0.0001 BNB approx 15USD" + ) candlestick_interval: BinanceKlineIntervals = Field( default=BinanceKlineIntervals.fifteen_minutes, sa_column=Column(Enum(BinanceKlineIntervals)), @@ -35,31 +37,117 @@ class BotTable(SQLModel, table=True): default=CloseConditions.dynamic_trailling, sa_column=Column(Enum(CloseConditions)), ) - # cooldown period in minutes before opening next bot with same pair - cooldown: int = Field(default=0) - created_at: float = Field(default_factory=lambda: time() * 1000) + cooldown: int = Field( + default=0, + description="cooldown period in minutes before opening next bot with same pair", + ) + created_at: float = Field(default_factory=timestamp) + updated_at: float = Field(default_factory=timestamp) + dynamic_trailling: bool = Field(default=False) + logs: List[Json[str]] = Field(default=[], sa_column=Column(JSON)) + mode: str = Field(default="manual") + name: str = Field(default="Default bot") + status: Status = Field(default=Status.inactive, sa_column=Column(Enum(Status))) + stop_loss: float = Field( + default=0, description="If stop_loss > 0, allow for reversal" + ) + margin_short_reversal: bool = Field(default=False) + take_profit: float = Field(default=0) + trailling: bool = Field(default=False) + trailling_deviation: float = Field( + default=0, + ge=-1, + le=101, + description="Trailling activation (first take profit hit)", + ) + trailling_profit: float = Field(default=0) + strategy: Strategy = Field(default=Strategy.long, sa_column=Column(Enum(Strategy))) + total_commission: float = Field( + default=0, description="autoswitch to short_strategy" + ) + + # Table relationships filled up internally + orders: Optional[list["ExchangeOrderTable"]] = Relationship(back_populates="bot") deal: Optional["DealTable"] = Relationship(back_populates="bot") + + model_config = { + "from_attributes": True, + "use_enum_values": True, + } + + +class PaperTradingTable(SQLModel, table=True): + """ + Fake bots + + these trade without actual money, so qty + is usually 0 or 1. Orders are simulated + + This cannot inherit from a SQLModel base + because errors with candlestick_interval + already assigned to BotTable error + """ + + __tablename__ = "paper_trading" + + id: Optional[UUID] = Field( + default_factory=uuid4, primary_key=True, index=True, nullable=False, unique=True + ) + pair: str = Field(index=True) + fiat: str = Field(default="USDC", index=True) + base_order_size: float = Field( + default=15, description="Min Binance 0.0001 BNB approx 15USD" + ) + candlestick_interval: BinanceKlineIntervals = Field( + default=BinanceKlineIntervals.fifteen_minutes, + sa_column=Column(Enum(BinanceKlineIntervals)), + ) + close_condition: CloseConditions = Field( + default=CloseConditions.dynamic_trailling, + sa_column=Column(Enum(CloseConditions)), + ) + cooldown: int = Field( + default=0, + description="cooldown period in minutes before opening next bot with same pair", + ) + created_at: float = Field(default_factory=timestamp) + updated_at: float = Field(default_factory=timestamp) dynamic_trailling: bool = Field(default=False) - logs: JSON = Field(default="[]", sa_column=Column(JSON)) + logs: list[Json[str]] = Field(default=[], sa_column=Column(JSON)) mode: str = Field(default="manual") name: str = Field(default="Default bot") - # filled up internally - orders: Optional[List["ExchangeOrderTable"]] = Relationship(back_populates="bot") - status: str = Field(default=Status.inactive, sa_column=Column(Enum(Status))) - stop_loss: float = Field(default=0, gt=0) - # If stop_loss > 0, allow for reversal + status: Status = Field(default=Status.inactive, sa_column=Column(Enum(Status))) + stop_loss: float = Field( + default=0, description="If stop_loss > 0, allow for reversal" + ) margin_short_reversal: bool = Field(default=False) - take_profit: float = Field(default=0, gt=0) + take_profit: float = Field(default=0) trailling: bool = Field(default=False) - trailling_deviation: float = Field(default=0, gt=0) - # Trailling activation (first take profit hit) - trailling_profit: float = Field(default=0, gt=0) - strategy: str = Field(default=Strategy.long, sa_column=Column(Enum(Strategy))) - short_buy_price: float = Field(default=0) - # autoswitch to short_strategy - short_sell_price: float = Field(default=0) - total_commission: float = Field(default=0) - updated_at: float = Field(default_factory=lambda: time() * 1000) + trailling_deviation: float = Field( + default=0, + ge=-1, + le=101, + description="Trailling activation (first take profit hit)", + ) + trailling_profit: float = Field(default=0) + strategy: Strategy = Field(default=Strategy.long, sa_column=Column(Enum(Strategy))) + short_buy_price: float = Field( + default=0, description="autoswitch to short_strategy" + ) + short_sell_price: float = Field( + default=0, description="autoswitch to short_strategy" + ) + total_commission: float = Field( + default=0, description="autoswitch to short_strategy" + ) + + # Table relationships filled up internally + deal: "DealTable" = Relationship(back_populates="paper_trading") + orders: Optional[list["ExchangeOrderTable"]] = Relationship( + back_populates="paper_trading" + ) - class Config: - arbitrary_types_allowed = True + model_config = { + "from_attributes": True, + "use_enum_values": True, + } diff --git a/api/database/models/deal_table.py b/api/database/models/deal_table.py index 44ebda92c..f44ae203c 100644 --- a/api/database/models/deal_table.py +++ b/api/database/models/deal_table.py @@ -1,23 +1,15 @@ -import uuid +from uuid import uuid4, UUID from time import time from sqlmodel import SQLModel, Field, Relationship from typing import Optional, TYPE_CHECKING # avoids circular imports if TYPE_CHECKING: - from database.models.bot_table import BotTable - from database.models.paper_trading_table import PaperTradingTable + from database.models.bot_table import BotTable, PaperTradingTable -class DealTable(SQLModel, table=True): - """ - Data model that is used for operations, - so it should all be numbers (int or float) - """ - - __tablename__ = "deal" - - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) +class DealBase(SQLModel): + id: UUID = Field(default_factory=uuid4, primary_key=True) buy_price: float = Field(default=0) buy_total_qty: float = Field(default=0) buy_timestamp: float = time() * 1000 @@ -56,9 +48,20 @@ class DealTable(SQLModel, table=True): margin_short_loan_timestamp: int = Field(default=0) # Relationships - bot_id: Optional[uuid.UUID] = Field(default=None, foreign_key="bot.id") + # bot_id: Optional[UUID] = Field(default=None, foreign_key="bot.id") + # paper_trading_id: Optional[UUID] = Field(default=None, foreign_key="paper_trading.id") + +class DealTable(DealBase, table=True): + """ + Data model that is used for operations, + so it should all be numbers (int or float) + """ + + __tablename__ = "deal" + + # Relationships + bot_id: Optional[UUID] = Field(default=None, foreign_key="bot.id") + paper_trading_id: Optional[UUID] = Field(default=None, foreign_key="paper_trading.id") bot: Optional["BotTable"] = Relationship(back_populates="deal") - paper_trading_id: Optional[uuid.UUID] = Field( - default=None, foreign_key="paper_trading.id" - ) paper_trading: Optional["PaperTradingTable"] = Relationship(back_populates="deal") + pass diff --git a/api/database/models/order_table.py b/api/database/models/order_table.py index 8e1a98d1e..c3d9ac5d0 100644 --- a/api/database/models/order_table.py +++ b/api/database/models/order_table.py @@ -1,15 +1,39 @@ -from typing import Optional -from uuid import UUID - +from typing import TYPE_CHECKING, Optional from pydantic import ValidationInfo, field_validator from sqlalchemy import Column, Enum -from database.models.paper_trading_table import PaperTradingTable -from database.models.bot_table import BotTable -from tools.enum_definitions import DealType +from tools.enum_definitions import DealType, OrderType from sqlmodel import Field, Relationship, SQLModel +from uuid import UUID, uuid4 + + +if TYPE_CHECKING: + from database.models.bot_table import BotTable, PaperTradingTable -class ExchangeOrderTable(SQLModel, table=True): +class OrderBase(SQLModel): + order_type: OrderType + time_in_force: str + timestamp: Optional[int] + order_id: int = Field(nullable=False) + order_side: str + pair: str + qty: float + status: str + price: float + deal_type: DealType + + # Relationships + bot_id: Optional[UUID] = Field(default=None, foreign_key="bot.id") + paper_trading_id: Optional[UUID] = Field( + default=None, foreign_key="paper_trading.id" + ) + + model_config = { + "use_enum_values": True, + } + + +class ExchangeOrderTable(OrderBase, table=True): """ Data provided by Crypto Exchange, therefore they should be all be strings @@ -23,27 +47,18 @@ class ExchangeOrderTable(SQLModel, table=True): __tablename__ = "exchange_order" - id: int = Field(primary_key=True) - order_type: str = Field(nullable=True) - time_in_force: str = Field(nullable=True) - timestamp: int = Field(nullable=True) - order_side: str = Field(nullable=True) - pair: str = Field(nullable=True) - qty: float = Field(nullable=True) - status: str = Field(nullable=True) + id: UUID = Field( + primary_key=True, default_factory=uuid4, nullable=False, unique=True, index=True + ) price: float = Field(nullable=True) deal_type: DealType = Field(sa_column=Column(Enum(DealType))) total_commission: float = Field(nullable=True, default=0) # Relationships - bot_id: Optional[UUID] = Field(default=None, foreign_key="bot.id") bot: Optional["BotTable"] = Relationship(back_populates="orders") - paper_trading_id: Optional[UUID] = Field( - default=None, foreign_key="paper_trading.id" - ) paper_trading: Optional["PaperTradingTable"] = Relationship(back_populates="orders") - @field_validator("price", "qty", mode="before") + @field_validator("price", "qty") @classmethod def validate_str_numbers(cls, v, info: ValidationInfo): if isinstance(v, str): diff --git a/api/database/models/paper_trading_table.py b/api/database/models/paper_trading_table.py deleted file mode 100644 index 05ffcd777..000000000 --- a/api/database/models/paper_trading_table.py +++ /dev/null @@ -1,74 +0,0 @@ -from uuid import uuid4, UUID -from time import time -from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import JSON, Column, Enum -from tools.enum_definitions import ( - BinanceKlineIntervals, - CloseConditions, - Status, - Strategy, -) -from sqlmodel import Relationship, SQLModel, Field - -# avoids circular imports -# https://sqlmodel.tiangolo.com/tutorial/code-structure/#hero-model-file -if TYPE_CHECKING: - from database.models.deal_table import DealTable - from database.models.order_table import ExchangeOrderTable - - -class PaperTradingTable(SQLModel, table=True): - """ - Fake bots - - these trade without actual money, so qty - is usually 0 or 1. Orders are simualted - """ - - __tablename__ = "paper_trading" - - id: Optional[UUID] = Field( - default_factory=uuid4, primary_key=True, index=True, nullable=False, unique=True - ) - pair: str = Field(index=True) - fiat: str = Field(default="USDC") - # No need to respect min exchange balance - base_order_size: float = Field(default=1) - candlestick_interval: BinanceKlineIntervals = Field( - default=BinanceKlineIntervals.fifteen_minutes, - sa_column=Column(Enum(BinanceKlineIntervals)), - ) - close_condition: CloseConditions = Field( - default=CloseConditions.dynamic_trailling, - sa_column=Column(Enum(CloseConditions)), - ) - # cooldown period in minutes before opening next bot with same pair - cooldown: int = Field(default=0) - created_at: float = Field(default_factory=lambda: time() * 1000) - deal: Optional["DealTable"] = Relationship(back_populates="paper_trading") - dynamic_trailling: bool = Field(default=False) - logs: JSON = Field(default="[]", sa_column=Column(JSON)) - mode: str = Field(default="manual") - name: str = Field(default="Default bot") - # filled up internally - orders: Optional[List["ExchangeOrderTable"]] = Relationship( - back_populates="paper_trading" - ) - status: str = Field(default=Status.inactive, sa_column=Column(Enum(Status))) - stop_loss: float = Field(default=0, gt=0) - # If stop_loss > 0, allow for reversal - margin_short_reversal: bool = Field(default=False) - take_profit: float = Field(default=0, gt=0) - trailling: bool = Field(default=False) - trailling_deviation: float = Field(default=0, gt=0) - # Trailling activation (first take profit hit) - trailling_profit: float = Field(default=0, gt=0) - strategy: str = Field(default=Strategy.long, sa_column=Column(Enum(Strategy))) - short_buy_price: float = Field(default=0) - # autoswitch to short_strategy - short_sell_price: float = Field(default=0) - total_commission: float = Field(default=0) - updated_at: float = Field(default_factory=lambda: time() * 1000) - - class Config: - arbitrary_types_allowed = True diff --git a/api/database/paper_trading_crud.py b/api/database/paper_trading_crud.py new file mode 100644 index 000000000..c30a77b56 --- /dev/null +++ b/api/database/paper_trading_crud.py @@ -0,0 +1,214 @@ +from time import time +from typing import Union +from sqlmodel import Session, or_, select, case, desc, asc +from database.models.bot_table import PaperTradingTable +from bots.models import BotModel, BotBase +from database.models.deal_table import DealTable +from database.utils import independent_session +from tools.enum_definitions import BinbotEnums, Status +from collections.abc import Sequence + + +class PaperTradingTableCrud: + def __init__(self, session: Session | None = None): + if session is None: + session = independent_session() + self.session = session + pass + + def update_logs( + self, log_message: str, bot: BotModel = None, bot_id: str | None = None + ) -> BotModel: + """ + Update logs for a bot + + Args: + - bot_id: str + - bot: BotModel + + Either id or bot has to be passed + """ + if bot_id: + bot_obj = self.session.get(PaperTradingTable, bot_id) + bot = BotModel.model_validate(bot_obj) + elif not bot: + raise ValueError("Bot id or BotModel object is required") + + current_logs: list[str] = bot.logs + if len(current_logs) == 0: + current_logs = [log_message] + elif len(current_logs) > 0: + current_logs.append(log_message) + + bot.logs = current_logs + + # db operations + self.session.add(bot) + self.session.commit() + self.session.refresh(bot) + self.session.close() + return bot + + def create(self, data: BotBase) -> BotModel: + """ + Create a new paper trading account + """ + bot = BotModel.model_validate(data) + + # Ensure values are reset + bot.orders = [] + bot.logs = [] + bot.status = Status.inactive + + # db operations + self.session.add(bot) + self.session.commit() + resulted_bot = self.session.get(PaperTradingTable, bot.id) + self.session.close() + data = BotModel.model_validate(resulted_bot) + return data + + def save(self, data: BotModel) -> BotModel: + """ + Save operation + This can be editing a bot, or saving the object, + or updating a single field. + """ + bot = self.session.get(PaperTradingTable, data.id) + if not bot: + raise ValueError("Bot not found") + + # double check orders and deal are not overwritten + dumped_bot = data.model_dump(exclude_unset=True) + bot.sqlmodel_update(dumped_bot) + self.session.add(bot) + self.session.commit() + resulted_bot = self.session.get(PaperTradingTable, bot.id) + self.session.close() + data = BotModel.model_validate(resulted_bot) + return data + + def delete(self, id: Union[list[str], str]) -> bool: + """ + Delete a paper trading account by id + """ + data = self.session.get(PaperTradingTable, id) + if not data: + return False + + self.session.delete(data) + self.session.commit() + self.session.refresh(data) + self.session.close() + return True + + def get( + self, + status: Status | None = None, + start_date: float | None = None, + end_date: float | None = None, + no_cooldown=False, + limit: int = 200, + offset: int = 0, + ) -> Sequence[PaperTradingTable]: + """ + Get all bots in the db except archived + Args: + - status: Status enum + - start_date and end_date are timestamps in milliseconds + - no_cooldown: bool - filter out bots that are in cooldown + - limit and offset for pagination + """ + statement = select(PaperTradingTable) + + if status and status in BinbotEnums.statuses: + statement.where(PaperTradingTable.status == status) + + if start_date: + statement.where(PaperTradingTable.created_at >= start_date) + + if end_date: + statement.where(PaperTradingTable.created_at <= end_date) + + if status and no_cooldown: + current_timestamp = time() + cooldown_condition = cooldown_condition = or_( + PaperTradingTable.status == status, + case( + ( + (DealTable.sell_timestamp > 0), + current_timestamp - DealTable.sell_timestamp + < (PaperTradingTable.cooldown * 1000), + ), + else_=( + current_timestamp - PaperTradingTable.created_at + < (PaperTradingTable.cooldown * 1000) + ), + ), + ) + + statement.where(cooldown_condition) + + # sorting + statement.order_by( + desc(PaperTradingTable.created_at), + case((PaperTradingTable.status == Status.active, 1), else_=2), + asc(PaperTradingTable.pair), + ) + + # pagination + statement.limit(limit).offset(offset) + + bots = self.session.exec(statement).all() + self.session.close() + return bots + + def update_status(self, paper_trading: BotModel, status: Status) -> BotModel: + """ + Activate a paper trading account + """ + paper_trading.status = status + self.session.add(paper_trading) + self.session.commit() + self.session.close() + return paper_trading + + def get_one( + self, + bot_id: str | None = None, + symbol: str | None = None, + status: Status | None = None, + ) -> BotModel: + """ + Get one bot by id or symbol + """ + if bot_id: + bot = self.session.get(PaperTradingTable, bot_id) + elif symbol: + if status: + bot = self.session.exec( + select(PaperTradingTable).where( + PaperTradingTable.pair == symbol, + PaperTradingTable.status == status, + ) + ).first() + else: + bot = self.session.exec( + select(PaperTradingTable).where(PaperTradingTable.pair == symbol) + ).first() + else: + raise ValueError("Invalid bot id or symbol") + + self.session.close() + data = BotModel.model_validate(bot) + return data + + def get_active_pairs(self): + """ + Get all active bots + """ + bots = self.session.exec( + select(PaperTradingTable).where(PaperTradingTable.status == Status.active) + ).all() + self.session.close() + return bots diff --git a/api/database/utils.py b/api/database/utils.py index 1da3ce926..ccf43caaa 100644 --- a/api/database/utils.py +++ b/api/database/utils.py @@ -1,5 +1,13 @@ +import os +from sqlalchemy import create_engine from sqlmodel import Session -from database.api_db import engine +from time import time + +# This allows testing/Github action dummy envs +db_url = f'postgresql://{os.getenv("POSTGRES_USER", "postgres")}:{os.getenv("POSTGRES_PASSWORD", "postgres")}@{os.getenv("POSTGRES_HOSTNAME", "localhost")}:{os.getenv("POSTGRES_PORT", 5432)}/{os.getenv("POSTGRES_DB", "postgres")}' +engine = create_engine( + url=db_url, +) def get_session(): @@ -12,3 +20,7 @@ def independent_session() -> Session: Used outside of FastAPI context """ return Session(engine) + + +def timestamp() -> float: + return int(round(time() * 1000)) diff --git a/api/deals/base.py b/api/deals/base.py index bd4f015ce..4e1b02327 100644 --- a/api/deals/base.py +++ b/api/deals/base.py @@ -1,20 +1,19 @@ -from typing import Tuple -import uuid -from time import time -from pymongo import ReturnDocument +from typing import Tuple, Type, Union from datetime import datetime -from deals.models import BinanceOrderModel, DealModel +from bots.models import BotModel +from database.models.bot_table import BotTable, PaperTradingTable +from database.bot_crud import BotTableCrud +from database.paper_trading_crud import PaperTradingTableCrud from orders.controller import OrderController -from bots.schemas import BotSchema from tools.round_numbers import round_numbers, supress_notation, round_numbers_ceiling -from tools.handle_error import encode_json from tools.exceptions import ( BinanceErrors, DealCreationError, InsufficientBalance, MarginLoanNotFound, ) -from tools.enum_definitions import DealType, Status, Strategy +from tools.enum_definitions import Status, Strategy +from base_producer import BaseProducer # To be removed one day en commission endpoint found that provides this value @@ -23,7 +22,7 @@ class BaseDeal(OrderController): """ - Base Deal class to share with CreateDealController and MarginDeal. + Base Deal class to share with DealFactory and MarginDeal. Deals should always deal with the same symbol at instance creation level, since it needs @@ -31,15 +30,24 @@ class BaseDeal(OrderController): self.symbol is always the same. """ - def __init__(self, bot, db_collection_name): - if not isinstance(bot, BotSchema): - self.active_bot = BotSchema(**bot) + def __init__( + self, + bot: BotModel, + db_table: Type[Union[PaperTradingTable, BotTable]] = BotTable, + ): + db_controller: Type[Union[PaperTradingTableCrud, BotTableCrud]] + if db_table == PaperTradingTable: + db_controller = PaperTradingTableCrud else: - self.active_bot = bot - self.db_collection = self._db[db_collection_name] - self.market_domination_reversal = None + db_controller = BotTableCrud + + self.controller = db_controller() + self.active_bot = bot + self.market_domination_reversal: bool | None = None self.price_precision = self.calculate_price_precision(bot.pair) self.qty_precision = self.calculate_qty_precision(bot.pair) + self.base_producer = BaseProducer() + self.producer = self.base_producer.start_producer() if self.active_bot.strategy == Strategy.margin_short: self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) @@ -50,13 +58,9 @@ def __repr__(self) -> str: """ return f"BaseDeal({self.__dict__})" - def generate_id(self): - return uuid.uuid4() - def compute_qty(self, pair): """ Helper function to compute buy_price. - Previous qty = bot.deal["buy_total_qty"] """ asset = self.find_baseAsset(pair) @@ -101,50 +105,6 @@ def compute_margin_buy_back(self) -> Tuple[float | int, float | int]: return qty, free - def simulate_order(self, pair, qty, side): - """ - Price is determined by market - to help trigger the order immediately - """ - price = float(self.matching_engine(pair, True, qty)) - order = { - "symbol": pair, - "orderId": self.generate_id().int, - "orderListId": -1, - "clientOrderId": self.generate_id().hex, - "transactTime": time() * 1000, - "price": price, - "origQty": qty, - "executedQty": qty, - "cummulativeQuoteQty": qty, - "status": "FILLED", - "timeInForce": "GTC", - "type": "LIMIT", - "side": side, - "fills": [], - } - return order - - def simulate_response_order(self, pair, qty, side): - price = float(self.matching_engine(pair, True, qty)) - response_order = { - "symbol": pair, - "orderId": self.generate_id().int, - "orderListId": -1, - "clientOrderId": self.generate_id().hex, - "transactTime": time() * 1000, - "price": price, - "origQty": qty, - "executedQty": qty, - "cummulativeQuoteQty": qty, - "status": "FILLED", - "timeInForce": "GTC", - "type": "LIMIT", - "side": side, - "fills": [], - } - return response_order - def replace_order(self, cancel_order_id): payload = { "symbol": self.active_bot.pair, @@ -175,9 +135,9 @@ def close_open_orders(self, symbol): payload={"symbol": symbol, "orderId": order["orderId"]}, ) for order in self.active_bot.orders: - if order.order_id == order["orderId"]: + if order.id == order["orderId"]: self.active_bot.orders.remove(order) - self.active_bot.errors.append( + self.controller.update_logs( "base_order not executed, therefore cancelled" ) self.active_bot.status = Status.error @@ -204,88 +164,13 @@ def verify_deal_close_order(self): return None - def base_order(self): - """ - Required initial order to trigger long strategy bot. - Other orders require this to execute, - therefore should fail if not successful - - 1. Initial base purchase - 2. Set take_profit - """ - - # Long position does not need qty in take_profit - # initial price with 1 qty should return first match - price = float(self.matching_engine(self.active_bot.pair, True)) - qty = round_numbers( - (float(self.active_bot.base_order_size) / float(price)), - self.qty_precision, - ) - # setup stop_loss_price - stop_loss_price: float = 0 - if float(self.active_bot.stop_loss) > 0: - stop_loss_price = price - (price * (float(self.active_bot.stop_loss) / 100)) - - if self.db_collection.name == "paper_trading": - res = self.simulate_order( - self.active_bot.pair, - qty, - "BUY", - ) - else: - res = self.buy_order( - symbol=self.active_bot.pair, - qty=qty, - price=supress_notation(price, self.price_precision), - ) - - order_data = BinanceOrderModel( - timestamp=res["transactTime"], - order_id=res["orderId"], - deal_type=DealType.base_order, - pair=res["symbol"], - order_side=res["side"], - order_type=res["type"], - price=res["price"], - qty=res["origQty"], - time_in_force=res["timeInForce"], - status=res["status"], - ) - - self.active_bot.orders.append(order_data) - tp_price = float(res["price"]) * 1 + (float(self.active_bot.take_profit) / 100) - - self.active_bot.deal = DealModel( - buy_timestamp=res["transactTime"], - buy_price=res["price"], - buy_total_qty=res["origQty"], - current_price=res["price"], - take_profit_price=tp_price, - stop_loss_price=stop_loss_price, - ) - - # Activate bot - self.active_bot.status = Status.active - - bot = encode_json(self.active_bot) - if "_id" in bot: - bot.pop("_id") # _id is what causes conflict not id - - document = self.db_collection.find_one_and_update( - {"id": self.active_bot.id}, - {"$set": bot}, - return_document=ReturnDocument.AFTER, - ) - - return document - def margin_liquidation(self, pair: str): """ Emulate Binance Dashboard One click liquidation function Args: - pair: a.k.a symbol, quote asset + base asset - - qty_precision: to round numbers for Binance API. Passed optionally to + - qty_precision: to round numbers for Binance Passed optionally to reduce number of requests to avoid rate limit. """ self.isolated_balance = self.get_isolated_balance(pair) @@ -385,7 +270,7 @@ def margin_liquidation(self, pair: str): def spot_liquidation(self, pair: str): qty = self.compute_qty(pair) - if qty: + if qty > 0: order_res = self.sell_order(pair, qty) return order_res else: diff --git a/api/deals/controllers.py b/api/deals/controllers.py deleted file mode 100644 index d41267bed..000000000 --- a/api/deals/controllers.py +++ /dev/null @@ -1,312 +0,0 @@ -from orders.controller import OrderController -from bots.schemas import BotSchema -from deals.base import BaseDeal -from deals.margin import MarginDeal -from deals.models import BinanceOrderModel -from pymongo import ReturnDocument -from tools.enum_definitions import DealType, Status, Strategy -from tools.exceptions import TakeProfitError -from tools.handle_error import ( - encode_json, - handle_binance_errors, -) -from tools.round_numbers import round_numbers - - -class CreateDealController(BaseDeal): - """ - Centralized deal controller. - - This is the first step that comes after a bot is saved - 1. Save bot - 2. Open deal (deal controller) - 3. Update deals (deal update controller) - - - db_collection = ["bots", "paper_trading"]. - paper_trading uses simulated orders and bot uses real binance orders - """ - - def __init__(self, bot: BotSchema, db_collection="paper_trading"): - # Inherit from parent class - super().__init__(bot, db_collection) - self.active_bot = bot - - def compute_qty(self, pair): - """ - Helper function to compute buy_price. - Previous qty = bot.deal["buy_total_qty"] - """ - - asset = self.find_baseAsset(pair) - balance = self.get_raw_balance(asset) - if not balance or len(balance) == 0: - return None - qty = round_numbers(balance[0], self.qty_precision) - return qty - - def take_profit_order(self) -> BotSchema: - """ - take profit order (Binance take_profit) - - We only have stop_price, because there are no book bids/asks in t0 - - take_profit order can ONLY be executed once base order is filled (on Binance) - """ - - deal_buy_price = self.active_bot.deal.buy_price - buy_total_qty = self.active_bot.deal.buy_total_qty - price = (1 + (float(self.active_bot.take_profit) / 100)) * float(deal_buy_price) - - if self.db_collection.name == "paper_trading": - qty = self.active_bot.deal.buy_total_qty - else: - qty = self.compute_qty(self.active_bot.pair) - - qty = round_numbers(buy_total_qty, self.qty_precision) - price = round_numbers(price, self.price_precision) - - if self.db_collection.name == "paper_trading": - res = self.simulate_order(self.active_bot.pair, qty, "SELL") - if price: - res = self.simulate_order( - self.active_bot.pair, - qty, - "SELL", - ) - else: - price = (1 + (float(self.active_bot.take_profit) / 100)) * float( - deal_buy_price - ) - res = self.simulate_order( - self.active_bot.pair, - qty, - "SELL", - ) - else: - qty = round_numbers(qty, self.qty_precision) - price = round_numbers(price, self.price_precision) - res = self.sell_order(symbol=self.active_bot.pair, qty=qty) - - # If error pass it up to parent function, can't continue - if "error" in res: - raise TakeProfitError(res["error"]) - - order_data = BinanceOrderModel( - timestamp=res["transactTime"], - order_id=res["orderId"], - deal_type="take_profit", - pair=res["symbol"], - order_side=res["side"], - order_type=res["type"], - price=res["price"], - qty=res["origQty"], - time_in_force=res["timeInForce"], - status=res["status"], - ) - - self.active_bot.total_commission = self.calculate_total_commissions( - res["fills"] - ) - - self.active_bot.orders.append(order_data) - self.active_bot.deal.take_profit_price = res["price"] - self.active_bot.deal.sell_price = res["price"] - self.active_bot.deal.sell_qty = res["origQty"] - self.active_bot.deal.sell_timestamp = res["transactTime"] - self.active_bot.status = Status.completed - msg = "Completed take profit" - self.active_bot.errors.append(msg) - - try: - bot = encode_json(self.active_bot) - if "_id" in bot: - bot.pop("_id") - - bot = self.db_collection.find_one_and_update( - {"id": self.active_bot.id}, - { - "$set": bot, - }, - return_document=ReturnDocument.AFTER, - ) - except Exception as error: - raise TakeProfitError(error) - - return bot - - def close_all(self) -> None: - """ - Close all deals and sell pair - 1. Close all deals - 2. Sell Coins - 3. Delete bot - """ - orders = self.active_bot.orders - - # Close all active orders - if len(orders) > 0: - for d in orders: - if d.status == "NEW" or d.status == "PARTIALLY_FILLED": - self.update_deal_logs( - "Failed to close all active orders (status NEW), retrying...", - self.active_bot, - ) - self.replace_order(d.order_id) - - # Sell everything - pair = self.active_bot.pair - base_asset = self.find_baseAsset(pair) - balance = self.get_raw_balance(base_asset) - if balance: - qty = round_numbers(balance[0], self.qty_precision) - price: float = float(self.matching_engine(pair, True, qty)) - price = round_numbers(price, self.price_precision) - self.sell_order(symbol=self.active_bot.pair, qty=qty, price=price) - - return - - def update_take_profit(self, order_id) -> None: - """ - Update take profit after websocket order endpoint triggered - - Close current opened take profit order - - Create new take profit order - - Update database by replacing old take profit deal with new take profit deal - """ - bot = self.active_bot - if bot.deal: - find_base_order = next( - (order.order_id == order_id for order in bot.orders), None - ) - if find_base_order: - so_deal_price = bot.deal.buy_price - # Create new take profit order - new_tp_price = float(so_deal_price) + ( - float(so_deal_price) * float(bot.take_profit) / 100 - ) - asset = self.find_baseAsset(bot.pair) - - # First cancel old order to unlock balance - OrderController().delete_order(bot.pair, order_id) - - raw_balance = self.get_raw_balance(asset) - qty = round_numbers(raw_balance[0], self.qty_precision) - res = self.sell_order( - symbol=self.active_bot.pair, - qty=qty, - price=round_numbers(new_tp_price, self.price_precision), - ) - - # New take profit order successfully created - order = handle_binance_errors(res) - - # Replace take_profit order - take_profit_order = BinanceOrderModel( - timestamp=order["transactTime"], - order_id=order["orderId"], - deal_type=DealType.take_profit, - pair=order["symbol"], - order_side=order["side"], - order_type=order["type"], - price=order["price"], - qty=order["origQty"], - time_in_force=order["timeInForce"], - status=order["status"], - ) - - total_commission = self.calculate_total_commissions(res["fills"]) - # Build new deals list - new_deals = [] - for d in bot.orders: - if d.deal_type != DealType.take_profit: - new_deals.append(d) - - # Append now new take_profit deal - new_deals.append(take_profit_order) - self.active_bot.orders = new_deals - self.active_bot.total_commission = total_commission - self.active_bot.errors.append("take_profit deal successfully updated") - self.db.bots.update_one( - {"id": self.active_bot.id}, - {"$set": self.active_bot.model_dump()}, - ) - return - else: - self.update_deal_logs( - "Error: Bot does not contain a base order deal", self.active_bot - ) - - def open_deal(self) -> None: - """ - Mandatory deals section - - - If base order deal is not executed, bot is not activated - """ - - # If there is already a base order do not execute - base_order_deal = next( - ( - bo_deal - for bo_deal in self.active_bot.orders - if bo_deal.deal_type == DealType.base_order - ), - None, - ) - - if not base_order_deal: - if self.active_bot.strategy == Strategy.margin_short: - self.active_bot = MarginDeal( - bot=self.active_bot, db_collection_name=self.db_collection.name - ).margin_short_base_order() - else: - bot = self.base_order() - self.active_bot = BotSchema(**bot) - - """ - Optional deals section - - The following functionality is triggered according to the options set in the bot - """ - - # Update stop loss regarless of base order - if float(self.active_bot.stop_loss) > 0: - if self.active_bot.strategy == Strategy.margin_short: - self.active_bot = MarginDeal( - bot=self.active_bot, db_collection_name=self.db_collection.name - ).set_margin_short_stop_loss() - else: - buy_price = float(self.active_bot.deal.buy_price) - stop_loss_price = buy_price - ( - buy_price * float(self.active_bot.stop_loss) / 100 - ) - self.active_bot.deal.stop_loss_price = round_numbers( - stop_loss_price, self.price_precision - ) - - # Margin short Take profit - if ( - float(self.active_bot.take_profit) > 0 - and self.active_bot.strategy == Strategy.margin_short - ): - self.active_bot = MarginDeal( - bot=self.active_bot, db_collection_name=self.db_collection.name - ).set_margin_take_profit() - - # Keep trailling_stop_loss_price up to date in case of failure to update in autotrade - # if we don't do this, the trailling stop loss will trigger - if self.active_bot.deal and ( - self.active_bot.deal.trailling_stop_loss_price > 0 - or self.active_bot.deal.trailling_stop_loss_price - < self.active_bot.deal.buy_price - ): - take_profit_price = float(self.active_bot.deal.buy_price) * ( - 1 + (float(self.active_bot.take_profit) / 100) - ) - self.active_bot.deal.take_profit_price = take_profit_price - # Update trailling_stop_loss - self.active_bot.deal.trailling_stop_loss_price = 0 - - self.active_bot.status = Status.active - bot = self.active_bot.model_dump() - if "_id" in bot: - bot.pop("_id") - - self.db_collection.update_one({"id": self.active_bot.id}, {"$set": bot}) - return diff --git a/api/deals/factory.py b/api/deals/factory.py new file mode 100644 index 000000000..43f0d2e8a --- /dev/null +++ b/api/deals/factory.py @@ -0,0 +1,358 @@ +from typing import Type, Union +from database.paper_trading_crud import PaperTradingTableCrud +from database.models.bot_table import BotTable, PaperTradingTable +from bots.models import BotModel, OrderModel +from deals.margin import MarginDeal +from deals.spot import SpotLongDeal +from deals.models import DealModel +from tools.enum_definitions import DealType, OrderSide, Status, Strategy +from tools.exceptions import TakeProfitError +from tools.handle_error import ( + handle_binance_errors, +) +from tools.round_numbers import round_numbers, supress_notation + + +class DealFactory: + """ + Centralized deal controller. + + This is the first step that comes after a bot is saved + 1. Save bot + 2. Open deal (deal controller) + 3. Update deals (deal update controller) + + - db_collection = ["bots", "paper_trading"]. + paper_trading uses simulated orders and bot uses real binance orders. + PaperTradingTable is implemented, PaperTradingController with the db operations is not. + - bot: BotModel (at some point to refactor into BotTable as they are both pydantic models) + """ + + def __init__( + self, + bot: BotModel, + db_table: Type[Union[PaperTradingTable, BotTable]] = BotTable, + ): + self.active_bot = bot + self.db_table = db_table + if bot.strategy == Strategy.margin_short: + self.dealer = MarginDeal(bot, db_table) + else: + self.dealer = SpotLongDeal(bot, db_table) + + def base_order(self): + """ + Required initial order to trigger long strategy bot. + Other orders require this to execute, + therefore should fail if not successful + + 1. Initial base purchase + 2. Set take_profit + """ + + # Long position does not need qty in take_profit + # initial price with 1 qty should return first match + price = float(self.dealer.matching_engine(self.active_bot.pair, True)) + qty = round_numbers( + (float(self.active_bot.base_order_size) / float(price)), + self.dealer.qty_precision, + ) + # setup stop_loss_price + stop_loss_price: float = 0 + if float(self.active_bot.stop_loss) > 0: + stop_loss_price = price - (price * (float(self.active_bot.stop_loss) / 100)) + + if self.dealer.controller == PaperTradingTableCrud: + res = self.dealer.simulate_order( + self.active_bot.pair, + OrderSide.buy, + ) + else: + res = self.dealer.buy_order( + symbol=self.active_bot.pair, + qty=qty, + price=supress_notation(price, self.dealer.price_precision), + ) + + order_data = OrderModel( + timestamp=res["transactTime"], + order_id=res["orderId"], + deal_type=DealType.base_order, + pair=res["symbol"], + order_side=res["side"], + order_type=res["type"], + price=res["price"], + qty=res["origQty"], + time_in_force=res["timeInForce"], + status=res["status"], + ) + + self.active_bot.orders.append(order_data) + tp_price = float(res["price"]) * 1 + (float(self.active_bot.take_profit) / 100) + + self.active_bot.deal = DealModel( + buy_timestamp=res["transactTime"], + buy_price=res["price"], + buy_total_qty=res["origQty"], + current_price=res["price"], + take_profit_price=tp_price, + stop_loss_price=stop_loss_price, + ) + + # Activate bot + document = self.open_deal() + # do this after db operations in case there is rollback + # avoids sending unnecessary signals + self.dealer.base_producer.update_required(self.dealer.producer, "ACTIVATE_BOT") + return document + + def take_profit_order(self) -> BotModel: + """ + take profit order (Binance take_profit) + - We only have stop_price, because there are no book bids/asks in t0 + - take_profit order can ONLY be executed once base order is filled (on Binance) + """ + + deal_buy_price = self.active_bot.deal.buy_price + buy_total_qty = self.active_bot.deal.buy_total_qty + price = (1 + (float(self.active_bot.take_profit) / 100)) * float(deal_buy_price) + + if self.db_table == PaperTradingTable: + qty = self.active_bot.deal.buy_total_qty + else: + qty = self.dealer.compute_qty(self.active_bot.pair) + + qty = round_numbers(buy_total_qty, self.dealer.qty_precision) + price = round_numbers(price, self.dealer.price_precision) + + if self.db_table == PaperTradingTable: + res = self.dealer.simulate_order(self.active_bot.pair, OrderSide.sell) + else: + qty = round_numbers(qty, self.dealer.qty_precision) + price = round_numbers(price, self.dealer.price_precision) + res = self.dealer.sell_order(symbol=self.active_bot.pair, qty=qty) + + # If error pass it up to parent function, can't continue + if "error" in res: + raise TakeProfitError(res["error"]) + + order_data = OrderModel( + timestamp=res["transactTime"], + order_id=res["orderId"], + deal_type="take_profit", + pair=res["symbol"], + order_side=res["side"], + order_type=res["type"], + price=res["price"], + qty=res["origQty"], + time_in_force=res["timeInForce"], + status=res["status"], + ) + + self.active_bot.total_commission = self.dealer.calculate_total_commissions( + res["fills"] + ) + + self.active_bot.orders.append(order_data) + self.active_bot.deal.take_profit_price = res["price"] + self.active_bot.deal.sell_price = res["price"] + self.active_bot.deal.sell_qty = res["origQty"] + self.active_bot.deal.sell_timestamp = res["transactTime"] + self.active_bot.status = Status.completed + + bot = self.dealer.controller.save(self.active_bot) + bot = BotModel.model_construct(**bot.model_dump()) + self.dealer.controller.update_logs("Completed take profit", self.active_bot) + + return bot + + def close_all(self) -> BotModel: + """ + Close all deals and sell pair + 1. Close all deals + 2. Sell Coins + 3. Delete bot + """ + orders = self.active_bot.orders + + # Close all active orders + if len(orders) > 0: + for d in orders: + if d.status == "NEW" or d.status == "PARTIALLY_FILLED": + self.dealer.controller.update_logs( + "Failed to close all active orders (status NEW), retrying...", + self.active_bot, + ) + self.dealer.replace_order(d.order_id) + + # Sell everything + pair = self.active_bot.pair + base_asset = self.dealer.find_baseAsset(pair) + balance = self.dealer.get_raw_balance(base_asset) + if balance: + qty = round_numbers(balance[0], self.dealer.qty_precision) + price: float = float(self.dealer.matching_engine(pair, True, qty)) + price = round_numbers(price, self.dealer.price_precision) + self.dealer.sell_order(symbol=self.active_bot.pair, qty=qty, price=price) + + self.dealer.controller.update_logs( + "Panic sell triggered. All active orders closed", self.active_bot + ) + self.dealer.controller.update_status(self.active_bot, Status.completed) + + return self.active_bot + + def update_take_profit(self, order_id: int) -> BotModel: + """ + Update take profit after websocket order endpoint triggered + - Close current opened take profit order + - Create new take profit order + - Update database by replacing old take profit deal with new take profit deal + """ + bot = self.active_bot + if bot.deal: + find_base_order = next( + (order.order_id == order_id for order in bot.orders), None + ) + if find_base_order: + so_deal_price = bot.deal.buy_price + # Create new take profit order + new_tp_price = float(so_deal_price) + ( + float(so_deal_price) * float(bot.take_profit) / 100 + ) + asset = self.dealer.find_baseAsset(bot.pair) + + # First cancel old order to unlock balance + self.dealer.delete_order(bot.pair, order_id) + + raw_balance = self.dealer.get_raw_balance(asset) + qty = round_numbers(raw_balance[0], self.dealer.qty_precision) + res = self.dealer.sell_order( + symbol=self.active_bot.pair, + qty=qty, + price=round_numbers(new_tp_price, self.dealer.price_precision), + ) + + # New take profit order successfully created + order = handle_binance_errors(res) + + # Replace take_profit order + take_profit_order = OrderModel( + timestamp=order["transactTime"], + order_id=order["orderId"], + deal_type=DealType.take_profit, + pair=order["symbol"], + order_side=order["side"], + order_type=order["type"], + price=order["price"], + qty=order["origQty"], + time_in_force=order["timeInForce"], + status=order["status"], + ) + + total_commission = self.dealer.calculate_total_commissions(res["fills"]) + # Build new deals list + new_deals = [] + for d in bot.orders: + if d.deal_type != DealType.take_profit: + new_deals.append(d) + + # Append now new take_profit deal + new_deals.append(take_profit_order) + self.active_bot.orders = new_deals + self.active_bot.total_commission = total_commission + self.dealer.controller.save(self.active_bot) + self.dealer.controller.update_logs( + "take_profit deal successfully updated" + ) + return self.active_bot + else: + self.dealer.controller.update_logs( + "Error: Bot does not contain a base order deal", self.active_bot + ) + raise ValueError("Bot does not contain a base order deal") + return self.active_bot + + def open_deal(self) -> BotModel: + """ + Bot activation requires: + + 1. Opening a new deal, which entails opening orders + 2. Updating stop loss and take profit + 3. Updating trailling + 4. Save in db + + - If bot DOES have a base order, we still need to update stop loss and take profit and trailling + """ + + base_order_deal = next( + ( + bo_deal + for bo_deal in self.active_bot.orders + if bo_deal.deal_type == DealType.base_order + ), + None, + ) + + if not base_order_deal: + if self.active_bot.strategy == Strategy.margin_short: + self.dealer.margin_short_base_order() + else: + bot = self.base_order() + self.active_bot = BotModel.model_validate(bot) + + """ + Optional deals section + + The following functionality is triggered according to the options set in the bot + """ + + # Update stop loss regarless of base order + if float(self.active_bot.stop_loss) > 0: + if ( + self.active_bot.strategy == Strategy.margin_short + and self.active_bot.stop_loss > 0 + ): + price = self.active_bot.deal.margin_short_sell_price + self.active_bot.deal.stop_loss_price = price + ( + price * (float(self.active_bot.stop_loss) / 100) + ) + else: + buy_price = float(self.active_bot.deal.buy_price) + stop_loss_price = buy_price - ( + buy_price * float(self.active_bot.stop_loss) / 100 + ) + self.active_bot.deal.stop_loss_price = round_numbers( + stop_loss_price, self.dealer.price_precision + ) + + # Margin short Take profit + if ( + self.active_bot.take_profit > 0 + and self.active_bot.strategy == Strategy.margin_short + ): + if self.active_bot.take_profit: + price = float(self.active_bot.deal.margin_short_sell_price) + take_profit_price = price - ( + price * (self.active_bot.take_profit) / 100 + ) + self.active_bot.deal.take_profit_price = take_profit_price + + # Keep trailling_stop_loss_price up to date in case of failure to update in autotrade + # if we don't do this, the trailling stop loss will trigger + if ( + self.active_bot.deal.trailling_stop_loss_price > 0 + or self.active_bot.deal.trailling_stop_loss_price + < self.active_bot.deal.buy_price + ): + take_profit_price = float(self.active_bot.deal.buy_price) * ( + 1 + (float(self.active_bot.take_profit) / 100) + ) + self.active_bot.deal.take_profit_price = take_profit_price + # Update trailling_stop_loss + self.active_bot.deal.trailling_stop_loss_price = 0 + + self.active_bot.status = Status.active + bot = self.dealer.controller.save(self.active_bot) + self.dealer.controller.update_logs("Bot activated", bot) + return bot diff --git a/api/deals/margin.py b/api/deals/margin.py index d8e49ee20..115099b10 100644 --- a/api/deals/margin.py +++ b/api/deals/margin.py @@ -1,46 +1,26 @@ import logging -from time import time +from typing import Type, Union from urllib.error import HTTPError -from deals.models import BinanceOrderModel -from base_producer import BaseProducer -from tools.enum_definitions import CloseConditions, DealType, Strategy -from bots.schemas import BotSchema +from database.bot_crud import BotTableCrud +from database.models.bot_table import BotTable, PaperTradingTable +from database.paper_trading_crud import PaperTradingTableCrud +from tools.enum_definitions import CloseConditions, DealType, OrderSide, Strategy +from bots.models import BotModel, OrderModel from tools.enum_definitions import Status -from deals.base import BaseDeal from tools.exceptions import BinanceErrors, MarginShortError from tools.round_numbers import round_numbers, supress_notation, round_numbers_ceiling +from deals.base import BaseDeal class MarginDeal(BaseDeal): - def __init__(self, bot, db_collection_name) -> None: - self.active_bot: BotSchema - # Inherit from parent class - super().__init__(bot, db_collection_name=db_collection_name) - self.base_producer = BaseProducer() - self.producer = self.base_producer.start_producer() - - def simulate_margin_order(self, qty, side): - price = float(self.matching_engine(self.active_bot.pair, True, qty)) - order = { - "symbol": self.active_bot.pair, - "orderId": self.generate_id().int, - "orderListId": -1, - "clientOrderId": self.generate_id().hex, - "transactTime": time() * 1000, - "price": price, - "origQty": qty, - "executedQty": qty, - "cummulativeQuoteQty": qty, - "status": "FILLED", - "timeInForce": "GTC", - "type": "LIMIT", - "side": side, - "marginBuyBorrowAmount": 5, - "marginBuyBorrowAsset": "BTC", - "isIsolated": "true", - "fills": [], - } - return order + def __init__( + self, + bot: BotModel, + db_table: Type[Union[PaperTradingTable, BotTable]] = BotTable, + ): + super().__init__(bot, db_table) + self.active_bot = bot + self.db_table = db_table def get_remaining_assets(self) -> tuple[float, float]: """ @@ -51,14 +31,14 @@ def get_remaining_assets(self) -> tuple[float, float]: """ if float(self.isolated_balance[0]["quoteAsset"]["borrowed"]) > 0: - self.update_deal_logs( + self.controller.update_logs( f'Borrowed {self.isolated_balance[0]["quoteAsset"]["asset"]} still remaining, please clear out manually', self.active_bot, ) self.active_bot.status = Status.error if float(self.isolated_balance[0]["baseAsset"]["borrowed"]) > 0: - self.update_deal_logs( + self.controller.update_logs( f'Borrowed {self.isolated_balance[0]["baseAsset"]["asset"]} still remaining, please clear out manually', self.active_bot, ) @@ -71,7 +51,7 @@ def get_remaining_assets(self) -> tuple[float, float]: base_asset, self.qty_precision ) - def cancel_open_orders(self, deal_type): + def cancel_open_orders(self, deal_type: DealType) -> BotModel: """ Given an order deal_type i.e. take_profit, stop_loss etc cancel currently open orders to unblock funds @@ -88,23 +68,23 @@ def cancel_open_orders(self, deal_type): try: # First cancel old order to unlock balance self.cancel_margin_order(symbol=self.active_bot.pair, order_id=order_id) - self.update_deal_logs( + self.controller.update_logs( "Old take profit order cancelled", self.active_bot ) except HTTPError: - self.update_deal_logs( + self.controller.update_logs( "Take profit order not found, no need to cancel", self.active_bot ) - return + return self.active_bot except BinanceErrors as error: # Most likely old error out of date orderId if error.code == -2011: - return + return self.active_bot - return + return self.active_bot - def terminate_failed_transactions(self): + def terminate_failed_transactions(self) -> BotModel: """ Transfer back from isolated account to spot account Disable isolated pair (so we don't reach the limit) @@ -112,12 +92,13 @@ def terminate_failed_transactions(self): self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) qty = self.isolated_balance[0]["quoteAsset"]["free"] self.transfer_isolated_margin_to_spot( - asset=self.active_bot.balance_to_use, + asset=self.active_bot.fiat, symbol=self.active_bot.pair, amount=qty, ) + return self.active_bot - def init_margin_short(self, initial_price): + def init_margin_short(self, initial_price: float) -> BotModel: """ Pre-tasks for db_collection = bots These tasks are not necessary for paper_trading @@ -126,10 +107,12 @@ def init_margin_short(self, initial_price): 2. create loan with qty given by market 3. borrow 2.5x to do base order """ - self.update_deal_logs("Initializating margin_short tasks", self.active_bot) + self.controller.update_logs( + "Initializating margin_short tasks", self.active_bot + ) # Check margin account balance first balance = float(self.isolated_balance[0]["quoteAsset"]["free"]) - asset = self.active_bot.pair.replace(self.active_bot.balance_to_use, "") + asset = self.active_bot.pair.replace(self.active_bot.fiat, "") # always enable, it doesn't cause errors try: self.enable_isolated_margin_account(symbol=self.active_bot.pair) @@ -137,13 +120,13 @@ def init_margin_short(self, initial_price): asset=asset, isolated_symbol=self.active_bot.pair ) error_msg = f"Checking borrowable amount: {borrow_res['amount']} (amount), {borrow_res['borrowLimit']} (limit)" - self.update_deal_logs(error_msg, self.active_bot) + self.controller.update_logs(error_msg, self.active_bot) except BinanceErrors as error: - self.update_deal_logs(error.message, self.active_bot) + self.controller.update_logs(error.message, self.active_bot) if error.code == -11001 or error.code == -3052: # Isolated margin account needs to be activated with a transfer self.transfer_spot_to_isolated_margin( - asset=self.active_bot.balance_to_use, + asset=self.active_bot.fiat, symbol=self.active_bot.pair, amount=1, ) @@ -162,7 +145,7 @@ def init_margin_short(self, initial_price): try: # transfer self.transfer_spot_to_isolated_margin( - asset=self.active_bot.balance_to_use, + asset=self.active_bot.fiat, symbol=self.active_bot.pair, amount=self.active_bot.base_order_size, ) @@ -195,9 +178,9 @@ def init_margin_short(self, initial_price): ) self.active_bot.deal.margin_short_base_order = qty - return + return self.active_bot - def terminate_margin_short(self, buy_back_fiat: bool = True): + def terminate_margin_short(self, buy_back_fiat: bool = True) -> BotModel: """ Args: @@ -240,13 +223,13 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): ) except BinanceErrors as error: if error.code == -3041: - self.active_bot.errors.append(error.message) + self.controller.update_logs(error.message) pass if error.code == -3015: # false alarm pass except Exception as error: - self.update_deal_logs(error, self.active_bot) + logging.error(error) # Continue despite errors to avoid losses # most likely it is still possible to update bot pass @@ -280,7 +263,7 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): res = self.sell_margin_order( symbol=self.active_bot.pair, qty=sell_back_qty ) - sell_back_order = BinanceOrderModel( + sell_back_order = OrderModel( timestamp=res["transactTime"], deal_type=DealType.take_profit, order_id=res["orderId"], @@ -300,15 +283,15 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): self.active_bot.orders.append(sell_back_order) self.active_bot.deal.buy_total_qty = res["origQty"] self.active_bot.status = Status.completed - self.active_bot.errors.append( + self.controller.update_logs( "Margin_short bot repaid, deal completed." ) else: - self.active_bot.errors.append("Loan not found for this bot.") + self.controller.update_logs("Loan not found for this bot.") # Save in two steps, because it takes time for Binance to process repayments - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) try: # get new balance @@ -316,7 +299,7 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): if float(self.isolated_balance[0]["quoteAsset"]["free"]) != 0: # transfer back to SPOT account self.transfer_isolated_margin_to_spot( - asset=self.active_bot.balance_to_use, + asset=self.active_bot.fiat, symbol=self.active_bot.pair, amount=self.isolated_balance[0]["quoteAsset"]["free"], ) @@ -329,17 +312,19 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): except Exception as error: error_msg = f"Failed to transfer isolated assets to spot: {error}" logging.error(error_msg) - self.active_bot.errors.append(error_msg) - return + self.controller.update_logs(error_msg) + return self.active_bot - completion_msg = f"{self.active_bot.pair} ISOLATED margin funds transferred back to SPOT." self.active_bot.status = Status.completed - self.active_bot.errors.append(completion_msg) + self.controller.update_logs( + f"{self.active_bot.pair} ISOLATED margin funds transferred back to SPOT.", + self.active_bot, + ) - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) return self.active_bot - def margin_short_base_order(self): + def margin_short_base_order(self) -> BotModel: """ Same functionality as usual base_order with a few more fields. This is used during open_deal @@ -349,23 +334,18 @@ def margin_short_base_order(self): """ initial_price = float(self.matching_engine(self.active_bot.pair, False)) - if self.db_collection.name == "bots": + if self.controller == BotTableCrud: self.init_margin_short(initial_price) - try: - order_res = self.sell_margin_order( - symbol=self.active_bot.pair, - qty=self.active_bot.deal.margin_short_base_order, - ) - except BinanceErrors as error: - if error.code == -3052: - print(error) - return + order_res = self.sell_margin_order( + symbol=self.active_bot.pair, + qty=self.active_bot.deal.margin_short_base_order, + ) else: - # Simulate Margin sell - # qty doesn't matter in paper bots - order_res = self.simulate_margin_order(1, "SELL") + order_res = self.simulate_margin_order( + pair=self.active_bot, side=OrderSide.sell + ) - order_data = BinanceOrderModel( + order_data = OrderModel( timestamp=order_res["transactTime"], order_id=order_res["orderId"], deal_type=DealType.base_order, @@ -391,9 +371,10 @@ def margin_short_base_order(self): # Activate bot self.active_bot.status = Status.active + self.controller.save(self.active_bot) return self.active_bot - def streaming_updates(self, close_price: str): + def streaming_updates(self, close_price: str) -> BotModel: """ Margin_short streaming updates """ @@ -418,14 +399,20 @@ def streaming_updates(self, close_price: str): ) * float(self.active_bot.deal.hourly_interest_rate) # bugs, normally this should be set at deal opening - if self.active_bot.deal.take_profit_price == 0: - self.set_margin_take_profit() + if ( + self.active_bot.deal.take_profit_price == 0 + and self.active_bot.take_profit > 0 + ): + price = self.active_bot.deal.margin_short_sell_price + self.active_bot.deal.take_profit_price = price - ( + price * (float(self.active_bot.take_profit) / 100) + ) logging.debug( f"margin_short streaming updating {self.active_bot.pair} @ {self.active_bot.deal.stop_loss_price} and interests {self.active_bot.deal.margin_short_loan_interest}" ) - self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) # Direction 1.1: downward trend (short) # Breaking trailling @@ -438,11 +425,11 @@ def streaming_updates(self, close_price: str): self.active_bot.trailling == "true" or self.active_bot.trailling ) and self.active_bot.deal.margin_short_sell_price > 0: self.update_trailling_profit(price) - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) else: # Execute the usual non-trailling take_profit - self.update_deal_logs( + self.controller.update_logs( f"Executing margin_short take_profit after hitting take_profit_price {self.active_bot.deal.stop_loss_price}", self.active_bot, ) @@ -457,7 +444,7 @@ def streaming_updates(self, close_price: str): if float(self.active_bot.deal.trailling_stop_loss_price) > 0 and float( close_price ) >= float(self.active_bot.deal.trailling_stop_loss_price): - self.update_deal_logs( + self.controller.update_logs( f"Hit trailling_stop_loss_price {self.active_bot.deal.trailling_stop_loss_price}. Selling {self.active_bot.pair}", self.active_bot, ) @@ -487,24 +474,9 @@ def streaming_updates(self, close_price: str): self.producer, "EXECUTE_MARGIN_SWITCH_TO_LONG" ) - return - - def set_margin_short_stop_loss(self): - """ - Sets stop_loss for margin_short at initial activation - """ - price = float(self.active_bot.deal.margin_short_sell_price) - if ( - hasattr(self.active_bot, "stop_loss") - and float(self.active_bot.stop_loss) > 0 - ): - self.active_bot.deal.stop_loss_price = price + ( - price * (float(self.active_bot.stop_loss) / 100) - ) - return self.active_bot - def set_margin_take_profit(self): + def set_margin_take_profit(self) -> BotModel: """ Sets take_profit for margin_short at initial activation """ @@ -520,21 +492,23 @@ def set_margin_take_profit(self): return self.active_bot - def execute_stop_loss(self): + def execute_stop_loss(self) -> BotModel: """ Execute stop loss when price is hit This is used during streaming updates """ # Margin buy (buy back) - if self.db_collection.name == "paper_trading": - res = self.simulate_margin_order(self.active_bot.deal.buy_total_qty, "BUY") + if self.controller == PaperTradingTableCrud: + res = self.simulate_margin_order( + self.active_bot.deal.buy_total_qty, OrderSide.buy + ) else: # Cancel orders first # paper_trading doesn't have real orders so no need to check self.cancel_open_orders(DealType.stop_loss) res = self.margin_liquidation(self.active_bot.pair) - stop_loss_order = BinanceOrderModel( + stop_loss_order = OrderModel( timestamp=res["transactTime"], deal_type=DealType.stop_loss, order_id=res["orderId"], @@ -560,13 +534,13 @@ def execute_stop_loss(self): self.active_bot.deal.margin_short_buy_back_timestamp = res["transactTime"] msg = "Completed Stop loss order" - self.active_bot.errors.append(msg) + self.controller.update_logs(msg) self.active_bot.status = Status.completed - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) - return + return self.active_bot - def execute_take_profit(self): + def execute_take_profit(self) -> BotModel: """ Execute take profit when price is hit. This can be a simple take_profit order when take_profit_price is hit or @@ -578,19 +552,21 @@ def execute_take_profit(self): - Buy back asset sold """ - if self.db_collection.name == "bots": - self.cancel_open_orders("take_profit") + if self.controller == BotTableCrud: + self.cancel_open_orders(DealType.take_profit) # Margin buy (buy back) - if self.db_collection.name == "paper_trading": - res = self.simulate_margin_order(self.active_bot.deal.buy_total_qty, "BUY") + if self.controller == PaperTradingTableCrud: + res = self.simulate_margin_order( + self.active_bot.deal.buy_total_qty, OrderSide.buy + ) else: res = self.margin_liquidation(self.active_bot.pair) if res: # No res means it wasn't properly closed/completed - take_profit_order = BinanceOrderModel( + take_profit_order = OrderModel( timestamp=res["transactTime"], deal_type=DealType.take_profit, order_id=res["orderId"], @@ -616,13 +592,13 @@ def execute_take_profit(self): else: msg = "Re-completed take profit" - self.active_bot.errors.append(msg) + self.controller.update_logs(msg) self.active_bot.status = Status.completed - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) - return + return self.active_bot - def switch_to_long_bot(self): + def switch_to_long_bot(self) -> BotModel: """ Switch to long strategy. Doing some parts of open_deal from scratch @@ -634,34 +610,35 @@ def switch_to_long_bot(self): 2. Calculate take_profit_price and stop_loss_price as usual 3. Create deal """ - self.update_deal_logs( + self.controller.update_logs( "Switching margin_short to long strategy", self.active_bot ) self.active_bot.strategy = Strategy.long - self.active_bot = self.create_new_bot_streaming(active_bot=self.active_bot) + self.controller.save(self.active_bot) bot = self.base_order() - self.active_bot = BotSchema(**bot) + self.active_bot = BotModel.model_validate(bot) # Keep bot up to date in the DB # this avoid unsyched bots when errors ocurr in other functions - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) return self.active_bot - def update_trailling_profit(self, close_price): + def update_trailling_profit(self, close_price: float) -> BotModel: # Fix potential bugs in bot updates if self.active_bot.deal.take_profit_price == 0: self.margin_short_base_order() # Direction: downward trend (short) # Breaking trailling_stop_loss if self.active_bot.deal.trailling_stop_loss_price == 0: + trailling_take_profit: float = 0 trailling_take_profit = float( self.active_bot.deal.margin_short_sell_price ) - ( self.active_bot.deal.margin_short_sell_price * ((self.active_bot.take_profit) / 100) ) - stop_loss_trailling_price = float(trailling_take_profit) - ( + stop_loss_trailling_price = trailling_take_profit - ( trailling_take_profit * ((self.active_bot.trailling_deviation) / 100) ) # If trailling_stop_loss not below initial margin_short_sell price @@ -669,8 +646,8 @@ def update_trailling_profit(self, close_price): self.active_bot.deal.trailling_stop_loss_price = ( stop_loss_trailling_price ) - self.active_bot = self.save_bot_streaming(self.active_bot) - self.update_deal_logs( + self.controller.save(self.active_bot) + self.controller.update_logs( f"{self.active_bot.pair} Setting trailling_stop_loss (short) and saved to DB", self.active_bot, ) @@ -693,24 +670,25 @@ def update_trailling_profit(self, close_price): and self.active_bot.deal.trailling_stop_loss_price < self.active_bot.deal.margin_short_sell_price ): - self.active_bot.deal.trailling_stop_loss_price = float( + self.active_bot.deal.trailling_stop_loss_price = ( self.active_bot.deal.trailling_profit_price - ) * (1 + ((self.active_bot.trailling_deviation) / 100)) + * (1 + ((self.active_bot.trailling_deviation) / 100)) + ) # Reset stop_loss_price to avoid confusion in front-end self.active_bot.deal.stop_loss_price = 0 - self.update_deal_logs( + self.controller.update_logs( f"{self.active_bot.pair} Updating after broken first trailling_profit (short)", self.active_bot, ) # Direction 1 (downward): breaking the current trailling - if float(close_price) <= float(self.active_bot.deal.trailling_profit_price): - new_take_profit = float(close_price) - ( + if float(close_price) <= self.active_bot.deal.trailling_profit_price: + new_take_profit: float = float(close_price) - ( float(close_price) * (float(self.active_bot.take_profit) / 100) ) new_trailling_stop_loss = float(close_price) * ( - 1 + (float(self.active_bot.trailling_deviation) / 100) + 1 + (self.active_bot.trailling_deviation / 100) ) # Update deal take_profit self.active_bot.deal.take_profit_price = new_take_profit @@ -725,7 +703,9 @@ def update_trailling_profit(self, close_price): # Update trailling_stop_loss self.active_bot.deal.trailling_stop_loss_price = new_trailling_stop_loss - def close_conditions(self, current_price): + return self.active_bot + + def close_conditions(self, current_price: float): """ Check if there is a market reversal @@ -738,7 +718,7 @@ def close_conditions(self, current_price): self.market_domination_reversal and current_price > self.active_bot.deal.buy_price ): - self.update_deal_logs( + self.controller.update_logs( f"Closing bot according to close_condition: {self.active_bot.close_condition}", self.active_bot, ) diff --git a/api/deals/models.py b/api/deals/models.py index 4c371d795..6dac0c2e3 100644 --- a/api/deals/models.py +++ b/api/deals/models.py @@ -1,37 +1,4 @@ -from time import time -from pydantic import BaseModel, field_validator -from tools.enum_definitions import DealType - - -class BinanceOrderModel(BaseModel): - """ - Data model given by Binance, - therefore it should be strings - """ - - order_type: str - time_in_force: str - timestamp: int - order_id: int - order_side: str - pair: str - qty: float - status: str - price: float - deal_type: DealType - - @field_validator("timestamp", "order_id", "price", "qty", "order_id") - @classmethod - def validate_str_numbers(cls, v): - if isinstance(v, float): - return v - elif isinstance(v, int): - return v - elif isinstance(v, str): - return float(v) - else: - raise ValueError(f"{v} must be a number") - +from pydantic import BaseModel, Field, field_validator class DealModel(BaseModel): """ @@ -39,40 +6,41 @@ class DealModel(BaseModel): so it should all be numbers (int or float) """ - buy_price: float = 0 - buy_total_qty: float = 0 - buy_timestamp: float = time() * 1000 - current_price: float = 0 - sd: float = 0 - avg_buy_price: float = 0 - take_profit_price: float = 0 - sell_timestamp: float = 0 - sell_price: float = 0 - sell_qty: float = 0 - trailling_stop_loss_price: float = 0 - # take_profit but for trailling, to avoid confusion, trailling_profit_price always be > trailling_stop_loss_price - trailling_profit_price: float = 0 - stop_loss_price: float = 0 - trailling_profit: float = 0 - so_prices: float = 0 - post_closure_current_price: float = 0 - original_buy_price: float = 0 # historical buy_price after so trigger - short_sell_price: float = 0 - short_sell_qty: float = 0 - short_sell_timestamp: float = time() * 1000 + buy_price: float = Field(default=0) + buy_total_qty: float = Field(default=0) + buy_timestamp: float = Field(default=0) + current_price: float = Field(default=0) + sd: float = Field(default=0) + avg_buy_price: float = Field(default=0) + take_profit_price: float = Field(default=0) + sell_timestamp: float = Field(default=0) + sell_price: float = Field(default=0) + sell_qty: float = Field(default=0) + trailling_stop_loss_price: float = Field(default=0, description="take_profit but for trailling, to avoid confusion, trailling_profit_price always be > trailling_stop_loss_price") + trailling_profit_price: float = Field(default=0) + stop_loss_price: float = Field(default=0) + trailling_profit: float = Field(default=0) + so_prices: float = Field(default=0) + original_buy_price: float = Field( + default=0, + description="historical buy_price after so trigger" + ) + short_sell_price: float = Field(default=0) + short_sell_qty: float = Field(default=0) + short_sell_timestamp: float = Field(default=0) # fields for margin trading - margin_short_loan_principal: float = 0 - margin_loan_id: float = 0 - hourly_interest_rate: float = 0 - margin_short_sell_price: float = 0 - margin_short_loan_interest: float = 0 - margin_short_buy_back_price: float = 0 - margin_short_sell_qty: float = 0 + margin_short_loan_principal: float = Field(default=0) + margin_loan_id: float = Field(default=0) + hourly_interest_rate: float = Field(default=0) + margin_short_sell_price: float = Field(default=0) + margin_short_loan_interest: float = Field(default=0) + margin_short_buy_back_price: float = Field(default=0) + margin_short_sell_qty: float = Field(default=0) margin_short_buy_back_timestamp: int = 0 - margin_short_base_order: float = 0 - margin_short_sell_timestamp: int = 0 - margin_short_loan_timestamp: int = 0 + margin_short_base_order: float = Field(default=0) + margin_short_sell_timestamp: int = Field(default=0) + margin_short_loan_timestamp: int = Field(default=0) @field_validator( "buy_price", diff --git a/api/deals/spot.py b/api/deals/spot.py index 2148c698a..04d5dda76 100644 --- a/api/deals/spot.py +++ b/api/deals/spot.py @@ -1,11 +1,18 @@ import logging - -from base_producer import BaseProducer +from typing import Type, Union +from database.bot_crud import BotTableCrud +from database.models.bot_table import BotTable, PaperTradingTable +from database.paper_trading_crud import PaperTradingTableCrud from deals.base import BaseDeal from deals.margin import MarginDeal -from deals.models import BinanceOrderModel -from tools.enum_definitions import CloseConditions, DealType, Status, Strategy -from bots.schemas import BotSchema +from tools.enum_definitions import ( + CloseConditions, + DealType, + OrderSide, + Status, + Strategy, +) +from bots.models import BotModel, OrderModel class SpotLongDeal(BaseDeal): @@ -14,15 +21,20 @@ class SpotLongDeal(BaseDeal): during streaming """ - def __init__(self, bot, db_collection_name: str) -> None: - # Inherit from parent class - self.db_collection_name = db_collection_name - super().__init__(bot, db_collection_name) - self.base_producer = BaseProducer() - self.producer = self.base_producer.start_producer() - self.active_bot: BotSchema + def __init__( + self, bot, db_table: Type[Union[PaperTradingTable, BotTable]] = BotTable + ) -> None: + db_controller: Type[Union[PaperTradingTableCrud, BotTableCrud]] + if db_table == PaperTradingTable: + db_controller = PaperTradingTableCrud + else: + db_controller = BotTableCrud + + super().__init__(bot, db_controller) + self.active_bot: BotModel = bot + self.db_table = db_table - def switch_margin_short(self): + def switch_margin_short(self) -> BotModel: """ Switch to short strategy. Doing some parts of open_deal from scratch @@ -34,20 +46,20 @@ def switch_margin_short(self): 2. Calculate take_profit_price and stop_loss_price as usual 3. Create deal """ - self.update_deal_logs( + self.controller.update_logs( "Resetting bot for margin_short strategy...", self.active_bot ) self.active_bot.strategy = Strategy.margin_short - self.active_bot = self.create_new_bot_streaming(active_bot=self.active_bot) + self.active_bot = self.controller.create(data=self.active_bot) self.active_bot = MarginDeal( - bot=self.active_bot, db_collection_name=self.db_collection_name + bot=self.active_bot, db_table=self.db_table ).margin_short_base_order() - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) return self.active_bot - def execute_stop_loss(self): + def execute_stop_loss(self) -> BotModel: """ Update stop limit after websocket @@ -55,44 +67,46 @@ def execute_stop_loss(self): - Close current opened take profit order - Deactivate bot """ - self.update_deal_logs("Executing stop loss...", self.active_bot) - if self.db_collection.name == "paper_trading": + self.controller.update_logs("Executing stop loss...", self.active_bot) + if self.controller == PaperTradingTableCrud: qty = self.active_bot.deal.buy_total_qty else: qty = self.compute_qty(self.active_bot.pair) # If for some reason, the bot has been closed already (e.g. transacted on Binance) # Inactivate bot - if not qty: + if qty > 0: closed_orders = self.close_open_orders(self.active_bot.pair) if not closed_orders: order = self.verify_deal_close_order() if order: - self.active_bot.errors.append( - "Execute stop loss previous order found! Appending..." + self.controller.update_logs( + "Execute stop loss previous order found! Appending...", + self.active_bot, ) self.active_bot.orders.append(order) else: - self.update_deal_logs( + self.controller.update_logs( "No quantity in balance, no closed orders. Cannot execute update stop limit.", self.active_bot, ) self.active_bot.status = Status.error - self.active_bot = self.save_bot_streaming(self.active_bot) - return + self.controller.save(self.active_bot) + return self.active_bot # Dispatch fake order - if self.db_collection.name == "paper_trading": - res = self.simulate_order(self.active_bot.pair, qty, "SELL") + if self.controller == PaperTradingTableCrud: + res = self.simulate_order(pair=self.active_bot.pair, side=OrderSide.sell) else: - self.active_bot.errors.append( - "Dispatching sell order for trailling profit..." + self.controller.update_logs( + "Dispatching sell order for trailling profit...", + self.active_bot, ) # Dispatch real order res = self.sell_order(symbol=self.active_bot.pair, qty=qty) - stop_loss_order = BinanceOrderModel( + stop_loss_order = OrderModel( timestamp=res["transactTime"], deal_type=DealType.stop_loss, order_id=int(res["orderId"]), @@ -113,54 +127,56 @@ def execute_stop_loss(self): self.active_bot.deal.sell_price = res["price"] self.active_bot.deal.sell_qty = res["origQty"] self.active_bot.deal.sell_timestamp = res["transactTime"] - msg = "Completed Stop loss. " + msg = "Completed Stop loss." if self.active_bot.margin_short_reversal: - msg += "Scheduled to switch strategy" - self.active_bot.errors.append(msg) + msg += " Scheduled to switch strategy" + + self.controller.update_logs(msg) self.active_bot.status = Status.completed + self.controller.save(self.active_bot) - self.active_bot = self.save_bot_streaming(self.active_bot) return self.active_bot - def trailling_profit(self) -> BotSchema | None: + def trailling_profit(self) -> BotModel | None: """ Sell at take_profit price, because prices will not reach trailling """ - if self.db_collection.name == "paper_trading": + if self.controller == PaperTradingTableCrud: qty = self.active_bot.deal.buy_total_qty else: qty = self.compute_qty(self.active_bot.pair) # Already sold? - if not qty: + if qty > 0: closed_orders = self.close_open_orders(self.active_bot.pair) if not closed_orders: order = self.verify_deal_close_order() if order: - self.active_bot.errors.append( - "Execute trailling profit previous order found! Appending..." + self.controller.update_logs( + "Execute trailling profit previous order found! Appending...", + self.active_bot, ) self.active_bot.orders.append(order) else: - self.update_deal_logs( + self.controller.update_logs( "No quantity in balance, no closed orders. Cannot execute update trailling profit.", self.active_bot, ) self.active_bot.status = Status.error - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) return self.active_bot # Dispatch fake order - if self.db_collection.name == "paper_trading": + if self.controller == PaperTradingTableCrud: res = self.simulate_order( self.active_bot.pair, - qty, - "SELL", + OrderSide.sell, ) else: - self.active_bot.errors.append( - "Dispatching sell order for trailling profit..." + self.controller.update_logs( + "Dispatching sell order for trailling profit...", + self.active_bot, ) # Dispatch real order # No price means market order @@ -169,7 +185,7 @@ def trailling_profit(self) -> BotSchema | None: qty=qty, ) - order_data = BinanceOrderModel( + order_data = OrderModel( timestamp=res["transactTime"], order_id=res["orderId"], deal_type=DealType.take_profit, @@ -194,23 +210,24 @@ def trailling_profit(self) -> BotSchema | None: self.active_bot.deal.sell_qty = res["origQty"] self.active_bot.deal.sell_timestamp = res["transactTime"] self.active_bot.status = Status.completed - msg = f"Completed take profit after failing to break trailling {self.active_bot.pair}" - self.active_bot.errors.append(msg) - - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) + self.controller.update_logs( + f"Completed take profit after failing to break trailling {self.active_bot.pair}" + ) return self.active_bot def streaming_updates(self, close_price, open_price): close_price = float(close_price) - self.close_conditions(float(close_price)) + self.close_conditions(close_price) self.active_bot.deal.current_price = close_price - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) # Stop loss - if float(self.active_bot.stop_loss) > 0 and float( - self.active_bot.deal.stop_loss_price - ) > float(close_price): + if ( + self.active_bot.stop_loss > 0 + and self.active_bot.deal.stop_loss_price > close_price + ): self.execute_stop_loss() self.base_producer.update_required(self.producer, "EXECUTE_SPOT_STOP_LOSS") if self.active_bot.margin_short_reversal: @@ -218,14 +235,14 @@ def streaming_updates(self, close_price, open_price): self.base_producer.update_required( self.producer, "EXECUTE_SWITCH_MARGIN_SHORT" ) - self.update_deal_logs( + self.controller.update_logs( "Completed switch to margin short bot", self.active_bot ) return # Take profit trailling - if (self.active_bot.trailling) and float(self.active_bot.deal.buy_price) > 0: + if self.active_bot.trailling and float(self.active_bot.deal.buy_price) > 0: # If current price didn't break take_profit (first time hitting take_profit or trailling_stop_loss lower than base_order buy_price) if self.active_bot.deal.trailling_stop_loss_price == 0: trailling_price = float(self.active_bot.deal.buy_price) * ( @@ -239,13 +256,12 @@ def streaming_updates(self, close_price, open_price): self.active_bot.deal.trailling_profit_price = trailling_price # Direction 1 (upward): breaking the current trailling - if float(close_price) >= float(trailling_price): - new_take_profit = float(close_price) * ( - 1 + (float(self.active_bot.take_profit) / 100) + if close_price >= float(trailling_price): + new_take_profit = close_price * ( + 1 + ((self.active_bot.take_profit) / 100) ) - new_trailling_stop_loss = float(close_price) - ( - float(close_price) - * (float(self.active_bot.trailling_deviation) / 100) + new_trailling_stop_loss: float = close_price - ( + close_price * ((self.active_bot.trailling_deviation) / 100) ) # Update deal take_profit self.active_bot.deal.take_profit_price = new_take_profit @@ -265,11 +281,11 @@ def streaming_updates(self, close_price, open_price): new_trailling_stop_loss ) - self.update_deal_logs( + self.controller.update_logs( f"Updated {self.active_bot.pair} trailling_stop_loss_price {self.active_bot.deal.trailling_stop_loss_price}", self.active_bot, ) - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) # Direction 2 (downward): breaking the trailling_stop_loss # Make sure it's red candlestick, to avoid slippage loss @@ -277,19 +293,15 @@ def streaming_updates(self, close_price, open_price): if ( float(self.active_bot.deal.trailling_stop_loss_price) > 0 # Broken stop_loss - and float(close_price) - < float(self.active_bot.deal.trailling_stop_loss_price) + and close_price < float(self.active_bot.deal.trailling_stop_loss_price) # Red candlestick - and (float(open_price) > float(close_price)) + and (float(open_price) > close_price) ): - self.update_deal_logs( + self.controller.update_logs( f"Hit trailling_stop_loss_price {self.active_bot.deal.trailling_stop_loss_price}. Selling {self.active_bot.pair}", self.active_bot, ) self.trailling_profit() - self.base_producer.update_required( - self.producer, "EXECUTE_SPOT_TRAILLING_PROFIT" - ) # Update unfilled orders unupdated_order = next( @@ -312,7 +324,11 @@ def streaming_updates(self, close_price, open_price): self.active_bot.orders[i].qty = order_response["origQty"] self.active_bot.orders[i].status = order_response["status"] - self.active_bot = self.save_bot_streaming(self.active_bot) + self.controller.save(self.active_bot) + + self.base_producer.update_required( + self.producer, "EXECUTE_SPOT_STREAMING_UPDATES" + ) def close_conditions(self, current_price): """ diff --git a/api/main.py b/api/main.py index 2248d1a06..7b8c67ff4 100644 --- a/api/main.py +++ b/api/main.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +import logging from fastapi import FastAPI, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError @@ -20,8 +21,12 @@ @asynccontextmanager async def lifespan(app: FastAPI): - api_db = ApiDb() - api_db.init_db() + try: + api_db = ApiDb() + api_db.init_db() + except Exception as error: + logging.error(f"Error initializing database: {error}") + pass yield diff --git a/api/mypy.ini b/api/mypy.ini index c6cf4fad0..195ec722b 100644 --- a/api/mypy.ini +++ b/api/mypy.ini @@ -2,10 +2,11 @@ implicit_optional = True ignore_missing_imports = True plugins = pydantic.mypy +warn_redundant_casts = True +check_untyped_defs = True +allow_redefinition = True show_error_codes = True follow_imports = silent -warn_redundant_casts = True warn_unused_ignores = True -check_untyped_defs = True implicit_reexport = True -explicit_package_bases = True \ No newline at end of file +explicit_package_bases = True diff --git a/api/orders/controller.py b/api/orders/controller.py index 048b5ada5..d4906be34 100644 --- a/api/orders/controller.py +++ b/api/orders/controller.py @@ -1,16 +1,20 @@ +from time import time +from uuid import uuid4 from account.account import Account from tools.exceptions import DeleteOrderError -from database.db import Database from tools.enum_definitions import OrderType, TimeInForce, OrderSide from tools.handle_error import json_response, json_response_message -from tools.round_numbers import supress_notation +from tools.round_numbers import supress_notation, zero_remainder -class OrderController(Database, Account): +class OrderController(Account): """ Always GTC and limit orders limit/market orders will be decided by matching_engine PRICE_FILTER decimals + + Methods and attributes here are all unrelated to database operations + this is highly tied to the Binance API """ def __init__(self) -> None: @@ -20,14 +24,84 @@ def __init__(self) -> None: self.qty_precision: int pass - def zero_remainder(self, x): - number = x + def generate_id(self): + return uuid4() + + def simulate_order(self, pair, side, qty=1): + """ + Price is determined by market + to help trigger the order immediately + """ + price = float(self.matching_engine(pair, True, qty)) + order = { + "symbol": pair, + "orderId": self.generate_id().int, + "orderListId": -1, + "clientOrderId": self.generate_id().hex, + "transactTime": time() * 1000, + "price": price, + "origQty": qty, + "executedQty": qty, + "cummulativeQuoteQty": qty, + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": side, + "fills": [], + } + return order - while True: - if number % x == 0: - return number - else: - number += x + def simulate_response_order(self, pair, side, qty=1): + price = float(self.matching_engine(pair, True, qty)) + response_order = { + "symbol": pair, + "orderId": self.generate_id().int, + "orderListId": -1, + "clientOrderId": self.generate_id().hex, + "transactTime": time() * 1000, + "price": price, + "origQty": qty, + "executedQty": qty, + "cummulativeQuoteQty": qty, + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": side, + "fills": [], + } + return response_order + + def simulate_margin_order(self, pair, side: OrderSide): + """ + Quantity doesn't matter, as it is not a real order that needs + to be process by the exchange + + Args: + - symbol and pair are used interchangably + - side: buy or sell + """ + qty = 1 + price = float(self.matching_engine(pair, True, qty)) + order = { + "symbol": pair, + "orderId": self.generate_id().int, + "orderListId": -1, + "clientOrderId": self.generate_id().hex, + "transactTime": time() * 1000, + "price": price, + "origQty": qty, + "executedQty": qty, + "cummulativeQuoteQty": qty, + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": side, + "marginBuyBorrowAmount": 5, + "marginBuyBorrowAsset": "BTC", + "isIsolated": "true", + "fills": [], + } + return order def sell_order(self, symbol, qty, price=None): """ @@ -82,7 +156,7 @@ def buy_order(self, symbol, qty, price=None): # If price is not provided by matching engine, # create iceberg orders if not book_price: - payload["iceberg_qty"] = self.zero_remainder(qty) + payload["iceberg_qty"] = zero_remainder(qty) payload["price"] = supress_notation(book_price, self.price_precision) else: @@ -107,7 +181,7 @@ def buy_order(self, symbol, qty, price=None): return data - def delete_order(self, symbol: str, orderId: str): + def delete_order(self, symbol: str, orderId: int): """ Cancels single order by symbol - Optimal for open orders table diff --git a/api/paper_trading/routes.py b/api/paper_trading/routes.py index 66a948262..47364240f 100644 --- a/api/paper_trading/routes.py +++ b/api/paper_trading/routes.py @@ -1,6 +1,13 @@ -from fastapi import APIRouter, HTTPException, Query -from bots.controllers import Bot -from bots.schemas import BotSchema +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session +from tools.enum_definitions import Status +from database.models.bot_table import PaperTradingTable +from database.paper_trading_crud import PaperTradingTableCrud +from database.utils import get_session +from deals.factory import DealFactory +from tools.exceptions import BinanceErrors, BinbotErrors +from tools.handle_error import api_response +from bots.models import BotModel from typing import List @@ -8,69 +15,109 @@ @paper_trading_blueprint.get( - "/paper-trading", response_model=list[BotSchema], tags=["paper trading"] + "/paper-trading", response_model=list[BotModel], tags=["paper trading"] ) def get( - status: str | None = None, + status: Status | None = None, start_date: float | None = None, end_date: float | None = None, - no_cooldown: bool = True, + no_cooldown=False, + limit: int = 200, + offset: int = 0, + session: Session = Depends(get_session), ): - return Bot(collection_name="paper_trading").get( - status, start_date, end_date, no_cooldown - ) + try: + bot = PaperTradingTableCrud(session=session).get( + status, start_date, end_date, no_cooldown, limit, offset + ) + return api_response(detail="Paper trading bots found!", data=bot) + + except BinbotErrors as error: + return api_response(detail=error.message, error=1) @paper_trading_blueprint.get("/paper-trading/{id}", tags=["paper trading"]) -def get_one(id: str): - return Bot(collection_name="paper_trading").get_one(id) +def get_one( + id: str, + session: Session = Depends(get_session), +): + try: + bot = PaperTradingTableCrud(session=session).get_one(bot_id=id, symbol=None) + if not bot: + return api_response(detail="Bot not found.") + else: + return api_response(detail="Bot found", data=bot) + except ValueError as error: + return api_response(detail=error.args[0], error=1) @paper_trading_blueprint.post("/paper-trading", tags=["paper trading"]) -def create(bot_item: BotSchema): - return Bot(collection_name="paper_trading").create(bot_item) +def create(bot_item: BotModel, session: Session = Depends(get_session)): + try: + bot = PaperTradingTableCrud(session=session).create(bot_item) + return api_response(detail="Bot created", data=bot) + except BinbotErrors as error: + return api_response(detail=error.message, error=1) @paper_trading_blueprint.put("/paper-trading/{id}", tags=["paper trading"]) -def edit(id: str, data: BotSchema): - return Bot(collection_name="paper_trading").edit(id, data) +def edit(id: str, bot_item: BotModel, session: Session = Depends(get_session)): + try: + bot = PaperTradingTableCrud(session=session).save(bot_item) + return api_response(detail="Bot updated", data=bot) + except BinbotErrors as error: + return api_response(detail=error.message, error=1) @paper_trading_blueprint.delete("/paper-trading", tags=["paper trading"]) -def delete(id: List[str] = Query(...)): +def delete(id: List[str] = Query(...), session: Session = Depends(get_session)): """ Receives a list of `id=a1b2c3&id=b2c3d4` """ - return Bot(collection_name="paper_trading").delete(id) + try: + PaperTradingTableCrud(session=session).delete(id) + return api_response(detail="Successfully deleted bot!") + except BinbotErrors as error: + return api_response(detail=error.message, error=1) @paper_trading_blueprint.get("/paper-trading/activate/{id}", tags=["paper trading"]) -def activate(id: str): - bot_instance = Bot(collection_name="paper_trading") - bot = bot_instance.get_one(id) +def activate(id: str, session: Session = Depends(get_session)): + bot = PaperTradingTableCrud(session=session).get_one(bot_id=id) if not bot: - raise HTTPException( - status_code=404, detail="Could not activate a bot that doesn't exist" - ) + return api_response("Bot not found.") + + bot_instance = DealFactory(bot, db_table=PaperTradingTable) - botSchema = BotSchema.model_validate(bot) - return Bot(collection_name="paper_trading").activate(botSchema) + try: + bot_instance.open_deal() + return api_response("Successfully activated bot!") + + except BinbotErrors as error: + bot_instance.controller.update_logs(bot_id=id, log_message=error.message) + return api_response(error.message, error=1) + except BinanceErrors as error: + bot_instance.controller.update_logs(bot_id=id, log_message=error.message) + return api_response(error.message, error=1) @paper_trading_blueprint.delete( "/paper-trading/deactivate/{id}", tags=["paper trading"] ) -def deactivate(id: str): +def deactivate(id: str, session: Session = Depends(get_session)): """ Deactivation means closing all deals and selling to GBP Otherwise losses will be incurred """ - bot_instance = Bot(collection_name="paper_trading") - bot = bot_instance.get_one(id) - if not bot: - raise HTTPException( - status_code=404, detail="Could not deactivate a bot that doesn't exist" - ) - - botSchema = BotSchema.model_validate(bot) - return Bot(collection_name="paper_trading").deactivate(botSchema) + bot_model = PaperTradingTableCrud(session=session).get_one(bot_id=id) + if not bot_model: + return api_response("No active bot found. Can't deactivate") + + bot_instance = DealFactory(bot_model, PaperTradingTable) + try: + bot_instance.close_all() + return api_response("Active orders closed, sold base asset, deactivated") + + except BinbotErrors as error: + bot_instance.controller.update_logs(bot_id=id, log_message=error.message) + return api_response(error.message, error=1) diff --git a/api/streaming/streaming_controller.py b/api/streaming/streaming_controller.py index 41329809d..690ee0035 100644 --- a/api/streaming/streaming_controller.py +++ b/api/streaming/streaming_controller.py @@ -1,112 +1,90 @@ import json import logging -import typing - +from typing import Type, Union, no_type_check +from kafka import KafkaConsumer +from bots.models import BotModel +from database.autotrade_crud import AutotradeCrud +from database.models.bot_table import BotTable, PaperTradingTable +from database.paper_trading_crud import PaperTradingTableCrud +from database.bot_crud import BotTableCrud +from deals.factory import DealFactory from tools.round_numbers import round_numbers from streaming.models import SignalsConsumer -from bots.schemas import BotSchema -from autotrade.controller import AutotradeSettingsController -from bots.controllers import Bot from tools.enum_definitions import Status, Strategy -from database.db import Database from deals.margin import MarginDeal from deals.spot import SpotLongDeal from tools.exceptions import BinanceErrors -class BaseStreaming(Database): - def get_current_bot(self, symbol): - current_bot = Bot(collection_name="bots").get_one( - symbol=symbol, status=Status.active - ) +class BaseStreaming: + def __init__(self) -> None: + self.bot_controller = BotTableCrud() + self.paper_trading_controller = PaperTradingTableCrud() + + def get_current_bot(self, symbol: str) -> BotModel: + current_bot = self.bot_controller.get_one(symbol=symbol, status=Status.active) return current_bot - def get_current_test_bot(self, symbol): - current_test_bot = Bot(collection_name="paper_trading").get_one( + def get_current_test_bot(self, symbol: str) -> BotModel: + current_test_bot = self.paper_trading_controller.get_one( symbol=symbol, status=Status.active ) - current_test_bot = current_test_bot return current_test_bot class StreamingController(BaseStreaming): - def __init__(self, consumer): + def __init__(self, consumer: KafkaConsumer) -> None: super().__init__() - self.streaming_db = self._db # Gets any signal to restart streaming self.consumer = consumer - self.autotrade_controller = AutotradeSettingsController() + self.autotrade_controller = AutotradeCrud() self.load_data_on_start() - def load_data_on_start(self): + def load_data_on_start(self) -> None: """ New function to replace get_klines without websockets """ # Load real bot settings - bot_controller = Bot(collection_name="bots") - self.list_bots = bot_controller.get_active_pairs() + self.list_bots = self.bot_controller.get_active_pairs() # Load paper trading bot settings - paper_trading_controller_paper = Bot(collection_name="paper_trading") - self.list_paper_trading_bots = paper_trading_controller_paper.get_active_pairs() + self.list_paper_trading_bots = self.paper_trading_controller.get_active_pairs() return def execute_strategies( self, - current_bot, + current_bot: BotModel, close_price: str, open_price: str, - db_collection_name, - ): + db_table: Type[Union[PaperTradingTable, BotTable]] = BotTable, + ) -> None: """ Processes the deal market websocket price updates It updates the bots deals, safety orders, trailling orders, stop loss for both paper trading test bots and real bots """ - if len(current_bot["orders"]) > 0: + if len(current_bot.orders) > 0: try: - int(current_bot["orders"][0]["order_id"]) + int(current_bot.orders[0].order_id) except Exception: - print(current_bot["orders"][0]["order_id"]) + print(current_bot.orders[0].order_id) pass - try: - active_bot = BotSchema(**current_bot) - pass - except Exception as error: - logging.info(error) - return + + active_bot = BotModel.model_validate(current_bot) + # Margin short if active_bot.strategy == Strategy.margin_short: - margin_deal = MarginDeal(active_bot, db_collection_name) - try: - margin_deal.streaming_updates(close_price) - except BinanceErrors as error: - if error.code in (-2010, -1013): - margin_deal.update_deal_logs(error.message, active_bot) - except Exception as error: - logging.info(error) - margin_deal.update_deal_logs(error, active_bot) - pass + margin_deal = MarginDeal(active_bot, db_table=db_table) + margin_deal.streaming_updates(close_price) else: # Long strategy starts if active_bot.strategy == Strategy.long: - spot_long_deal = SpotLongDeal(active_bot, db_collection_name) - try: - spot_long_deal.streaming_updates(close_price, open_price) - except BinanceErrors as error: - if error.code in (-2010, -1013): - spot_long_deal.update_deal_logs(error.message, active_bot) - active_bot.status = Status.error - active_bot = self.save_bot_streaming(active_bot) - except Exception as error: - logging.info(error) - spot_long_deal.update_deal_logs(error, active_bot) - pass - + spot_long_deal = SpotLongDeal(active_bot, db_table=db_table) + spot_long_deal.streaming_updates(close_price, open_price) pass - def process_klines(self, message): + def process_klines(self, message: str) -> None: """ Updates deals with klines websockets, when price and symbol match existent deal @@ -121,46 +99,57 @@ def process_klines(self, message): # temporary test that we get enough streaming update signals logging.info(f"Streaming update for {symbol}") - if current_bot: - self.execute_strategies( - current_bot, - close_price, - open_price, - "bots", - ) - if current_test_bot: - self.execute_strategies( - current_test_bot, - close_price, - open_price, - "paper_trading", - ) + try: + if current_bot: + create_deal_controller = DealFactory( + bot=current_bot, db_table=BotTable + ) + self.execute_strategies( + current_bot, + close_price, + open_price, + db_table=BotTable, + ) + elif current_test_bot: + create_deal_controller = DealFactory( + bot=current_bot, db_table=PaperTradingTable + ) + self.execute_strategies( + current_test_bot, + close_price, + open_price, + db_table=PaperTradingTable, + ) + except BinanceErrors as error: + if error.code in (-2010, -1013): + bot = current_bot if current_bot else current_test_bot + create_deal_controller.controller.update_logs(error.message, bot) + bot.status = Status.error + create_deal_controller.controller.save(bot) return class BbspreadsUpdater(BaseStreaming): - def __init__(self): - self.current_bot: BotSchema | None = None - self.current_test_bot: BotSchema | None = None + def __init__(self) -> None: + self.current_bot: BotModel | None = None + self.current_test_bot: BotModel | None = None - def load_current_bots(self, symbol): + def load_current_bots(self, symbol: str) -> None: current_bot_payload = self.get_current_bot(symbol) if current_bot_payload: - self.current_bot = BotSchema(**current_bot_payload) + self.current_bot = BotModel.model_validate(current_bot_payload) current_test_bot_payload = self.get_current_test_bot(symbol) if current_test_bot_payload: - self.current_test_bot = BotSchema(**current_test_bot_payload) - - def reactivate_bot(self, bot: BotSchema, collection_name="bots"): - bot_instance = Bot(collection_name=collection_name) - activated_bot = bot_instance.activate(bot) - return activated_bot + self.current_test_bot = BotModel.model_validate(current_test_bot_payload) def update_bots_parameters( - self, bot: BotSchema, bb_spreads, collection_name="bots" - ): + self, + bot: BotModel, + bb_spreads: dict, + create_deal_controller: DealFactory, + ) -> None: # multiplied by 1000 to get to the same scale stop_loss top_spread = round_numbers( ( @@ -207,7 +196,7 @@ def update_bots_parameters( # too much risk, reduce stop loss bot.trailling_deviation = bottom_spread # reactivate includes saving - self.reactivate_bot(bot, collection_name=collection_name) + create_deal_controller.open_deal() # No need to continue # Bots can only be either long or short @@ -222,10 +211,10 @@ def update_bots_parameters( if bot.trailling_deviation > bottom_spread: bot.trailling_deviation = top_spread # reactivate includes saving - self.reactivate_bot(bot, collection_name=collection_name) + create_deal_controller.open_deal() # To find a better interface for bb_xx once mature - @typing.no_type_check + @no_type_check def update_close_conditions(self, message): """ Update bot with dynamic trailling enabled to update @@ -233,7 +222,7 @@ def update_close_conditions(self, message): dynamic movements in the market """ data = json.loads(message) - signalsData = SignalsConsumer(**data) + signalsData = SignalsConsumer.model_validate(data) # Check if it matches any active bots self.load_current_bots(signalsData.symbol) @@ -249,8 +238,20 @@ def update_close_conditions(self, message): and bb_spreads["bb_mid"] ): if self.current_bot: - self.update_bots_parameters(self.current_bot, bb_spreads) + create_deal_controller = DealFactory( + bot=self.current_bot, controller=BotTableCrud + ) + self.update_bots_parameters( + self.current_bot, + bb_spreads, + create_deal_controller=create_deal_controller, + ) if self.current_test_bot: + create_deal_controller = DealFactory( + bot=self.current_test_bot, controller=PaperTradingTableCrud + ) self.update_bots_parameters( - self.current_test_bot, bb_spreads, collection_name="paper_trading" + self.current_test_bot, + bb_spreads, + create_deal_controller=create_deal_controller, ) diff --git a/api/tests/AutotradeSettingsMock.py b/api/tests/AutotradeSettingsMock.py deleted file mode 100644 index 4740f72e3..000000000 --- a/api/tests/AutotradeSettingsMock.py +++ /dev/null @@ -1,27 +0,0 @@ -from time import time -from typing import Optional -from pydantic import BaseModel -from tools.enum_definitions import BinanceKlineIntervals, Strategy - - -class AutotradeSettingsMock(BaseModel): - autotrade: bool = False - updated_at: float = time() * 1000 - # Assuming 10 USDC is the minimum, adding a bit more to avoid MIN_NOTIONAL fail - base_order_size: float = 15 - candlestick_interval: BinanceKlineIntervals = BinanceKlineIntervals.fifteen_minutes - # Deprecated, this is now up to binquant to set - strategy: Optional[Strategy] = Strategy.long - test_autotrade: bool = False - trailling: bool = False - trailling_deviation: float = 3 - trailling_profit: float = 2.4 - stop_loss: float = 0 - take_profit: float = 2.3 - balance_to_use: str = "USDC" - balance_size_to_use: float = 100 - max_request: int = 950 - system_logs: list[str] = [] - # Number of times update is requested - telegram_signals: bool = True - max_active_autotrade_bots: int = 1 diff --git a/api/tests/active_bots.py b/api/tests/active_bots.py new file mode 100644 index 000000000..7c229420f --- /dev/null +++ b/api/tests/active_bots.py @@ -0,0 +1,34 @@ +# for some reason this test blocks vscode testing +from unittest.mock import MagicMock +from fastapi.testclient import TestClient +from database.utils import get_session +from main import app +from pytest import fixture +from tests.model_mocks import ( + active_pairs, +) +from tests.table_mocks import mocked_db_data + + +@fixture() +def client() -> TestClient: + session_mock = MagicMock() + session_mock.exec.return_value.first.return_value = mocked_db_data + session_mock.exec.return_value.all.return_value = [mocked_db_data] + session_mock.get.return_value = mocked_db_data + session_mock.add.return_value = MagicMock(return_value=None) + session_mock.refresh.return_value = MagicMock(return_value=None) + session_mock.commit.return_value = MagicMock(return_value=None) + app.dependency_overrides.clear() + app.dependency_overrides[get_session] = lambda: session_mock + client = TestClient(app) + return client + + +def test_active_pairs(): + client = TestClient(app) + response = client.get("/bot/active-pairs") + + assert response.status_code == 200 + content = response.json() + assert content["data"] == active_pairs diff --git a/api/tests/model_mocks.py b/api/tests/model_mocks.py new file mode 100644 index 000000000..0964fc12d --- /dev/null +++ b/api/tests/model_mocks.py @@ -0,0 +1,170 @@ +from bots.models import BotModel, OrderModel +from deals.models import DealModel + +id = "02031768-fbb9-4cc7-b549-642f15ab787b" +ts = 1733973560249.0 + +active_pairs = ["BTCUSDT", "ETHUSDT", "ADAUSDT"] + +deal_model = DealModel( + buy_price=1.3, + buy_total_qty=0, + buy_timestamp=0, + current_price=0, + sd=0, + avg_buy_price=0, + take_profit_price=0, + sell_timestamp=0, + sell_price=0, + sell_qty=0, + trailling_stop_loss_price=0, + trailling_profit_price=0, + stop_loss_price=0, + trailling_profit=0, + so_prices=0, + original_buy_price=0, + short_sell_price=0, + short_sell_qty=0, + short_sell_timestamp=0, + margin_short_loan_principal=0, + margin_loan_id=0, + hourly_interest_rate=0, + margin_short_sell_price=0, + margin_short_loan_interest=0, + margin_short_buy_back_price=0, + margin_short_sell_qty=0, + margin_short_buy_back_timestamp=0, + margin_short_base_order=0, + margin_short_sell_timestamp=0, + margin_short_loan_timestamp=0, +) + +initial_deal_model = DealModel( + buy_price=0, + buy_total_qty=0, + buy_timestamp=0, + current_price=0, + sd=0, + avg_buy_price=0, + take_profit_price=0, + sell_timestamp=0, + sell_price=0, + sell_qty=0, + trailling_stop_loss_price=0, + trailling_profit_price=0, + stop_loss_price=0, + trailling_profit=0, + so_prices=0, + original_buy_price=0, + short_sell_price=0, + short_sell_qty=0, + short_sell_timestamp=0, + margin_short_loan_principal=0, + margin_loan_id=0, + hourly_interest_rate=0, + margin_short_sell_price=0, + margin_short_loan_interest=0, + margin_short_buy_back_price=0, + margin_short_sell_qty=0, + margin_short_buy_back_timestamp=0, + margin_short_base_order=0, + margin_short_sell_timestamp=0, + margin_short_loan_timestamp=0, +) + +orders_model = [ + OrderModel( + id=1, + order_id=123, + order_type="MARKET", + time_in_force="GTC", + timestamp=0, + order_side="buy", + pair="BTCUSDT", + qty=0.000123, + status="filled", + price=1.222, + deal_type="base_order", + total_commission=0, + ), + OrderModel( + id=2, + order_id=321, + order_type="LIMIT", + time_in_force="GTC", + timestamp=0, + order_side="sell", + pair="BTCUSDT", + qty=0.000123, + status="filled", + price=1.222, + deal_type="take_profit", + total_commission=0, + ), +] + +mock_model_data = BotModel( + id=id, + pair="ADXUSDC", + fiat="USDC", + base_order_size=15, + candlestick_interval="15m", + dynamic_trailling=False, + close_condition="dynamic_trailling", + cooldown=360, + created_at=ts, + status="inactive", + margin_short_reversal=False, + logs=[], + mode="manual", + name="coinrule_fast_and_slow_macd_2024-04-20T22:28", + stop_loss=3.0, + take_profit=2.3, + trailling=True, + trailling_deviation=3.0, + trailling_profit=0, + strategy="long", + updated_at=ts, + orders=orders_model, + deal=deal_model, +) + +# new bots don't have orders because they are not activated +mock_model_data_without_orders = BotModel( + id=id, + pair="ADXUSDC", + fiat="USDC", + base_order_size=15, + candlestick_interval="15m", + status="inactive", + margin_short_reversal=False, + dynamic_trailling=False, + close_condition="dynamic_trailling", + cooldown=360, + created_at=ts, + logs=[], + mode="manual", + name="coinrule_fast_and_slow_macd_2024-04-20T22:28", + stop_loss=3.0, + take_profit=2.3, + trailling=True, + trailling_deviation=3.0, + trailling_profit=0, + strategy="long", + updated_at=ts, + orders=[], + deal=initial_deal_model, +) + + +class DealFactoryMock: + def __init__(self, bot: BotModel): + pass + + def open_deal(self): + bot_model = BotModel(**mock_model_data.model_dump()) + return bot_model + + def close_all(self): + bot_model = BotModel(**mock_model_data.model_dump()) + return bot_model diff --git a/api/tests/table_mocks.py b/api/tests/table_mocks.py new file mode 100644 index 000000000..79cc8e4cd --- /dev/null +++ b/api/tests/table_mocks.py @@ -0,0 +1,99 @@ +from database.models import BotTable, DealTable, ExchangeOrderTable +from tools.enum_definitions import DealType, OrderType +from uuid import UUID + +ts = 1733973560249.0 +id = "02031768-fbb9-4cc7-b549-642f15ab787b" + +orders = [ + ExchangeOrderTable( + id=id, + order_id=123, + order_type=OrderType.market, + time_in_force="GTC", + timestamp=0, + order_side="buy", + pair="BTCUSDT", + qty=0.000123, + status="filled", + price=1.222, + deal_type=DealType.base_order, + total_commission=0, + ), + ExchangeOrderTable( + id=id, + order_id=321, + order_type=OrderType.limit, + time_in_force="GTC", + timestamp=0, + order_side="sell", + pair="BTCUSDT", + qty=0.000123, + status="filled", + price=1.222, + deal_type=DealType.take_profit, + total_commission=0, + ), +] + + +deal_table = DealTable( + buy_price=1.3, + buy_total_qty=0, + buy_timestamp=0, + current_price=0, + sd=0, + avg_buy_price=0, + take_profit_price=0, + sell_timestamp=0, + sell_price=0, + sell_qty=0, + trailling_stop_loss_price=0, + trailling_profit_price=0, + stop_loss_price=0, + trailling_profit=0, + so_prices=0, + original_buy_price=0, + short_sell_price=0, + short_sell_qty=0, + short_sell_timestamp=0, + margin_short_loan_principal=0, + margin_loan_id=0, + hourly_interest_rate=0, + margin_short_sell_price=0, + margin_short_loan_interest=0, + margin_short_buy_back_price=0, + margin_short_sell_qty=0, + margin_short_buy_back_timestamp=0, + margin_short_base_order=0, + margin_short_sell_timestamp=0, + margin_short_loan_timestamp=0, +) + + +mocked_db_data = BotTable( + id=UUID(id), + pair="ADXUSDC", + fiat="USDC", + base_order_size=15, + buy_price=1.222, + candlestick_interval="15m", + close_condition="dynamic_trailling", + dynamic_trailling=False, + cooldown=360, + created_at=ts, + logs=[], + mode="manual", + name="coinrule_fast_and_slow_macd_2024-04-20T22:28", + stop_loss=3.0, + take_profit=2.3, + trailling=True, + trailling_deviation=3.0, + trailling_profit=0.0, + strategy="long", + updated_at=ts, + status="inactive", + margin_short_reversal=False, + deal=deal_table, + orders=orders, +) diff --git a/api/tests/test_assets.py b/api/tests/test_assets.py index 632ba2947..350d0ee3f 100644 --- a/api/tests/test_assets.py +++ b/api/tests/test_assets.py @@ -10,8 +10,8 @@ app_client = TestClient(app) -MockAutotradeSettingsController = MagicMock() -MockAutotradeSettingsController.return_value.get_settings.return_value.balance_to_use = "USDC" +MockAutotradeCrud = MagicMock() +MockAutotradeCrud.return_value.get_settings.return_value.balance_to_use = "USDC" @pytest.fixture() @@ -105,7 +105,7 @@ def patch_store_balance(monkeypatch): ) -@patch("account.assets.AutotradeSettingsController", MockAutotradeSettingsController) +@patch("account.assets.AutotradeCrud", MockAutotradeCrud) def test_get_raw_balance(patch_database, patch_raw_balances): """ Test get all raw_balances @@ -123,7 +123,7 @@ def test_get_raw_balance(patch_database, patch_raw_balances): assert content["data"] == expected_result -@patch("account.assets.AutotradeSettingsController", MockAutotradeSettingsController) +@patch("account.assets.AutotradeCrud", MockAutotradeCrud) def test_total_fiat(patch_total_fiat): """ Test get balance estimates @@ -136,7 +136,7 @@ def test_total_fiat(patch_total_fiat): assert content["data"] == 20 -@patch("account.assets.AutotradeSettingsController", MockAutotradeSettingsController) +@patch("account.assets.AutotradeCrud", MockAutotradeCrud) def test_available_fiat(patch_database, patch_raw_balances): """ Test available fiat @@ -149,7 +149,7 @@ def test_available_fiat(patch_database, patch_raw_balances): assert content["data"] == 5.2 -@patch("account.assets.AutotradeSettingsController", MockAutotradeSettingsController) +@patch("account.assets.AutotradeCrud", MockAutotradeCrud) def test_store_balance(patch_database, patch_store_balance): """ Test store balance as an endpoint diff --git a/api/tests/test_autotrade_settings.py b/api/tests/test_autotrade_settings.py index df02c05ec..4b8e1a1df 100644 --- a/api/tests/test_autotrade_settings.py +++ b/api/tests/test_autotrade_settings.py @@ -7,28 +7,29 @@ from database.models.autotrade_table import AutotradeTable from main import app +mocked_db_data = AutotradeTable( + id="autotrade_settings", + base_order_size=15.0, + test_autotrade=False, + trailling_deviation=0.63, + stop_loss=0.0, + balance_to_use="USDC", + telegram_signals=True, + close_condition="dynamic_trailling", + autotrade=True, + candlestick_interval="15m", + updated_at=1732388868477.8518, + trailling=True, + trailling_profit=2.3, + take_profit=2.3, + max_request=500, + max_active_autotrade_bots=1, +) + @fixture() def client() -> TestClient: session_mock = MagicMock() - mocked_db_data = AutotradeTable( - id="autotrade_settings", - base_order_size=15.0, - test_autotrade=False, - trailling_deviation=0.63, - stop_loss=0.0, - balance_to_use="USDC", - telegram_signals=True, - close_condition="dynamic_trailling", - autotrade=True, - candlestick_interval="15m", - updated_at=1732388868477.8518, - trailling=True, - trailling_profit=2.3, - take_profit=2.3, - max_request=500, - max_active_autotrade_bots=1, - ) session_mock.exec.return_value.first.return_value = mocked_db_data session_mock.get.return_value = mocked_db_data session_mock.add.return_value = MagicMock(return_value=None) @@ -38,36 +39,14 @@ def client() -> TestClient: return client -def test_get_autotrade_settings(client) -> None: +def test_get_autotrade_settings(client: TestClient) -> None: r = client.get("/autotrade-settings/bots") assert r.status_code == 200 result = r.json() - assert result == { - "message": "Successfully retrieved settings", - "data": { - "id": "autotrade_settings", - "base_order_size": 15.0, - "test_autotrade": False, - "trailling_deviation": 0.63, - "stop_loss": 0.0, - "fiat": "USDC", - # Below to be deprecated - "balance_to_use": "USDC", - "telegram_signals": True, - "close_condition": "dynamic_trailling", - "autotrade": True, - "candlestick_interval": "15m", - "updated_at": 1732388868477.8518, - "trailling": True, - "trailling_profit": 2.3, - "take_profit": 2.3, - "max_request": 500, - "max_active_autotrade_bots": 1, - }, - } + assert result["data"] == mocked_db_data.model_dump() -@patch("autotrade.controller.AsyncBaseProducer", MockAsyncBaseProducer) +@patch("database.autotrade_crud.AsyncBaseProducer", MockAsyncBaseProducer) def test_edit_autotrade_settings(client: TestClient) -> None: r = client.put( "/autotrade-settings/bots", diff --git a/api/tests/test_bots.py b/api/tests/test_bots.py index e4f71fb88..e710f8ded 100644 --- a/api/tests/test_bots.py +++ b/api/tests/test_bots.py @@ -1,115 +1,177 @@ +from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient -import pytest -from bots.controllers import Bot +from database.utils import get_session from main import app -from mongomock import MongoClient +from pytest import fixture +from tests.model_mocks import ( + mock_model_data, + id, + mock_model_data_without_orders, + DealFactoryMock, +) +from tests.table_mocks import mocked_db_data +from fastapi.encoders import jsonable_encoder + + +@fixture() +def client(pairs=False) -> TestClient: + session_mock = MagicMock() + session_mock.exec.return_value.first.return_value = mocked_db_data + session_mock.exec.return_value.all.return_value = [mocked_db_data] + session_mock.get.return_value = mocked_db_data + session_mock.add.return_value = MagicMock(return_value=None) + session_mock.refresh.return_value = MagicMock(return_value=None) + session_mock.commit.return_value = MagicMock(return_value=None) + app.dependency_overrides[get_session] = lambda: session_mock + client = TestClient(app) + return client + + +def test_get_one_by_id(client: TestClient): + response = client.get(f"/bot/{id}") + + assert response.status_code == 200 + content = response.json() + assert content["data"] == jsonable_encoder(mock_model_data.model_dump()) + + +def test_get_one_by_symbol(client: TestClient): + symbol = "ADXUSDC" + response = client.get(f"/bot/symbol/{symbol}") + + assert response.status_code == 200 + content = response.json() + assert content["data"] == jsonable_encoder(mock_model_data.model_dump()) + + +def test_get_bots(client: TestClient): + response = client.get("/bot") + + assert response.status_code == 200 + content = response.json() + mock_data = jsonable_encoder(mock_model_data.model_dump()) + # Avoid testing internal objects + assert content["data"] == [mock_data] + + +def test_create_bot(client: TestClient): + payload = { + "pair": "ADXUSDC", + "fiat": "USDC", + "base_order_size": 15, + "candlestick_interval": "15m", + "close_condition": "dynamic_trailling", + "cooldown": 360, + "created_at": 1733973560249.0, + "updated_at": 1733973560249.0, + "dynamic_trailling": False, + "logs": [], + "mode": "manual", + "name": "Default bot", + "status": "inactive", + "stop_loss": 3.0, + "margin_short_reversal": False, + "take_profit": 2.3, + "trailling": True, + "trailling_deviation": 3.0, + "trailling_profit": 0.0, + "strategy": "long", + "total_commission": 0.0, + } + + response = client.post("/bot", json=payload) + + assert response.status_code == 200 + content = response.json() + assert content["data"] == mock_model_data_without_orders.model_dump() -@pytest.fixture() -def patch_bot(monkeypatch): - bot = { - "id": "6624255433c3cf9806d0a70e", - "pair": "ADXUSDT", - "balance_size_to_use": "0.0", - "balance_to_use": "USDC", - "base_order_size": "50", +def test_edit_bot(client: TestClient): + payload = { + "pair": "ADXUSDC", + "fiat": "USDC", + "base_order_size": 15, "candlestick_interval": "15m", "close_condition": "dynamic_trailling", "cooldown": 360, - "created_at": 1713643305854.368, - "deal": { - "buy_price": 0.2257, - "base_order_price": 0.0, - "buy_timestamp": 1713644889534.0, - "buy_total_qty": 221.0, - }, - "errors": [], - "mode": "autotrade", + "created_at": 1733973560249.0, + "updated_at": 1733973560249.0, + "dynamic_trailling": False, + "logs": [], + "mode": "manual", "name": "coinrule_fast_and_slow_macd_2024-04-20T22:28", - "orders": [ - { - "order_type": "LIMIT", - "time_in_force": "GTC", - "timestamp": 1713644889534.0, - "pair": "ADXUSDT", - "qty": "221.00000000", - "order_side": "BUY", - "order_id": 1, - "fills": [], - "price": 0.2257, - "status": "NEW", - "deal_type": "base_order", - } - ], - "status": "active", + "status": "inactive", "stop_loss": 3.0, + "margin_short_reversal": False, "take_profit": 2.3, - "trailling": "true", + "trailling": True, "trailling_deviation": 3.0, "trailling_profit": 0.0, "strategy": "long", - "updated_at": 1713643305854.435, + "total_commission": 0.0, } - def new_init(self, collection_name="bots"): - mongo_client: MongoClient = MongoClient() - self.db = mongo_client.db - self.db_collection = self.db[collection_name] + response = client.put(f"/bot/{id}", json=payload) - monkeypatch.setattr(Bot, "__init__", new_init) - monkeypatch.setattr(Bot, "get_one", lambda self, bot_id, symbol: bot) + assert response.status_code == 200 + content = response.json() + assert content["data"] == jsonable_encoder(mock_model_data.model_dump()) - return bot +def test_delete_bot(): + # Fix missing json arg for delete tests + class CustomTestClient(TestClient): + def delete_with_payload(self, **kwargs): + return self.request(method="DELETE", **kwargs) -@pytest.fixture() -def patch_active_pairs(monkeypatch): - active_pairs = ["BNBUSDT", "BTCUSDT"] + client = CustomTestClient(app) + payload = [id] + response = client.delete_with_payload(url="/bot", json=payload) - def new_init(self, collection_name="bots"): - mongo_client: MongoClient = MongoClient() - self.db = mongo_client.db - self.db_collection = self.db[collection_name] + assert response.status_code == 200 + content = response.json() + assert content["message"] == "Sucessfully deleted bot." - monkeypatch.setattr(Bot, "__init__", new_init) - monkeypatch.setattr(Bot, "get_active_pairs", lambda self: active_pairs) - return active_pairs +@patch("bots.routes.DealFactory", DealFactoryMock) +def test_activate_by_id(client: TestClient): + response = client.get(f"/bot/activate/{id}") + assert response.status_code == 200 + content = response.json() + assert content["data"] == mock_model_data.model_dump() -def test_get_one_by_id(patch_bot): - client = TestClient(app) - test_id = "6624255433c3cf9806d0a70e" - response = client.get(f"/bot/{test_id}") - # Assert the expected result - expected_result = patch_bot +@patch("bots.routes.DealFactory", DealFactoryMock) +def test_deactivate(client: TestClient): + response = client.delete(f"/bot/deactivate/{id}") assert response.status_code == 200 content = response.json() - assert content["data"] == expected_result + assert content["data"] == mock_model_data.model_dump() -def test_get_one_by_symbol(patch_bot): - client = TestClient(app) - symbol = "ADXUSDT" - response = client.get(f"/bot/{symbol}") +def test_post_bot_errors_str(client: TestClient): + """ + Test submitting bot errors with a single string + """ + payload = {"errors": "failed to create bot"} - # Assert the expected result - expected_result = patch_bot + response = client.post(f"/bot/errors/{id}", json=payload) assert response.status_code == 200 content = response.json() - assert content["data"] == expected_result + assert content["message"] == "Errors posted successfully." -def test_active_pairs(patch_active_pairs): - client = TestClient(app) - response = client.get("/bot/active-pairs") +def test_post_bot_errors_list(client: TestClient): + """ + Test submitting bot errors with a list of strings + """ + payload = {"errors": ["failed to create bot", "failed to create deal"]} - # Assert the expected result - expected_result = patch_active_pairs + response = client.post(f"/bot/errors/{id}", json=payload) assert response.status_code == 200 content = response.json() - assert content["data"] == expected_result + assert content["message"] == "Errors posted successfully." diff --git a/api/tests/test_market_domination.py b/api/tests/test_market_domination.py index d41496fc0..c1253d3b5 100644 --- a/api/tests/test_market_domination.py +++ b/api/tests/test_market_domination.py @@ -2,10 +2,14 @@ import pytest from fastapi.testclient import TestClient from mongomock import MongoClient -from tests.AutotradeSettingsMock import AutotradeSettingsMock from charts.controllers import MarketDominationController from main import app from datetime import datetime +from time import time +from typing import Optional +from pydantic import BaseModel +from tools.enum_definitions import BinanceKlineIntervals, Strategy + client: MongoClient = MongoClient() db = client.db @@ -13,6 +17,29 @@ app_client = TestClient(app) +class AutotradeSettingsMock(BaseModel): + autotrade: bool = False + updated_at: float = time() * 1000 + # Assuming 10 USDC is the minimum, adding a bit more to avoid MIN_NOTIONAL fail + base_order_size: float = 15 + candlestick_interval: BinanceKlineIntervals = BinanceKlineIntervals.fifteen_minutes + # Deprecated, this is now up to binquant to set + strategy: Optional[Strategy] = Strategy.long + test_autotrade: bool = False + trailling: bool = False + trailling_deviation: float = 3 + trailling_profit: float = 2.4 + stop_loss: float = 0 + take_profit: float = 2.3 + balance_to_use: str = "USDC" + balance_size_to_use: float = 100 + max_request: int = 950 + system_logs: list[str] = [] + # Number of times update is requested + telegram_signals: bool = True + max_active_autotrade_bots: int = 1 + + @pytest.fixture() def patch_database(monkeypatch): ticker_24 = [ diff --git a/api/tools/exceptions.py b/api/tools/exceptions.py index f1fd7ba10..22ae0001d 100644 --- a/api/tools/exceptions.py +++ b/api/tools/exceptions.py @@ -27,7 +27,7 @@ def __str__(self) -> str: return f"Binbot error: {self.message}" -class CreateDealControllerError(BinbotErrors): +class DealFactoryError(BinbotErrors): """ Code: 1 Message: "Bot already active" diff --git a/api/tools/handle_error.py b/api/tools/handle_error.py index 8de3a8f17..047acf8dc 100644 --- a/api/tools/handle_error.py +++ b/api/tools/handle_error.py @@ -2,9 +2,10 @@ import os import logging from time import sleep +from typing import Any, Union, TypeVar, Generic from bson import json_util from fastapi.responses import JSONResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from requests import Response, put from requests.exceptions import HTTPError from fastapi.encoders import jsonable_encoder @@ -25,6 +26,27 @@ def post_error(msg): return +def api_response(detail: str, data: Any = None, error: Union[str, int] = 0, status=200): + """ + Custom Fast API response + + Args: + - detail: the message of the response + - data: Pydantic model returned + """ + body = {"message": detail} + if data: + body["data"] = data + + if error: + body["error"] = str(error) + + return JSONResponse( + status_code=status, + content=body, + ) + + def json_response(content, status=200): content = json.loads(json_util.dumps(content)) # Objectid serialization response = JSONResponse( @@ -62,7 +84,7 @@ def handle_binance_errors(response: Response) -> dict: sleep(120) if response.status_code == 418 or response.status_code == 429: - print("Request weight limit hit, ban will come soon, waiting 1 hour") + logging.warning("Request weight limit hit, ban will come soon, waiting 1 hour") sleep(3600) # Cloudfront 403 error @@ -128,5 +150,13 @@ def encode_json(raw): class StandardResponse(BaseModel): + message: str + error: int = Field(default=0) + + +DataType = TypeVar("DataType") + + +class IResponseBase(BaseModel, Generic[DataType]): message: str error: int = 0 diff --git a/api/tools/round_numbers.py b/api/tools/round_numbers.py index 6fed0b93d..d1fc2c49f 100644 --- a/api/tools/round_numbers.py +++ b/api/tools/round_numbers.py @@ -83,3 +83,13 @@ def format_ts(time: datetime.datetime) -> str: to human-readable date """ return time.strftime("%Y-%m-%d %H:%M:%S.%f") + + +def zero_remainder(x): + number = x + + while True: + if number % x == 0: + return number + else: + number += x diff --git a/api/user/models/user.py b/api/user/models/user.py index 512a4f0b3..212b92294 100644 --- a/api/user/models/user.py +++ b/api/user/models/user.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, EmailStr, SecretStr, field_validator from sqlmodel import Field from tools.enum_definitions import UserRoles from tools.handle_error import StandardResponse @@ -22,7 +22,7 @@ class CreateUser(BaseModel): is_active: bool = True role: UserRoles = Field(default=UserRoles.admin) full_name: Optional[str] = Field(default="") - password: str = Field(min_length=8, max_length=40) + password: SecretStr = Field(min_length=8, max_length=40) # Email is the main identifier username: Optional[str] = Field(default="") bio: Optional[str] = Field(default="") diff --git a/binquant b/binquant index 381181d12..0e7671bc3 160000 --- a/binquant +++ b/binquant @@ -1 +1 @@ -Subproject commit 381181d122b94e03e39abb86a816871dea033f3e +Subproject commit 0e7671bc3e46d8306fd162e5cc782c65c590f2c2 diff --git a/terminal/src/features/bots/profits.ts b/terminal/src/features/bots/profits.ts index 2a8129ecf..2d82b77db 100644 --- a/terminal/src/features/bots/profits.ts +++ b/terminal/src/features/bots/profits.ts @@ -43,7 +43,7 @@ function getInterestsShortMargin(bot) { * by using input data from that individual bot as opposed to computeTotalProfit * function which uses an accumulator function to aggregate all profits of all bots * - * @param { BotSchema } bot + * @param { BotModel } bot * @param { number } realTimeCurrPrice * @returns { number } */