├── .gitignore ├── LICENSE ├── Makefile ├── Pipfile ├── README.md ├── ftx ├── __init__.py ├── authentication.py ├── client.py ├── enums.py ├── exceptions.py ├── streams.py ├── threaded_stream.py └── timer.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,virtualenv,visualstudio,visualstudiocode,linux 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,virtualenv,visualstudio,visualstudiocode,linux 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### Python ### 21 | # Byte-compiled / optimized / DLL files 22 | __pycache__/ 23 | *.py[cod] 24 | *$py.class 25 | 26 | # C extensions 27 | *.so 28 | 29 | # Distribution / packaging 30 | .Python 31 | build/ 32 | develop-eggs/ 33 | dist/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | sdist/ 41 | var/ 42 | wheels/ 43 | share/python-wheels/ 44 | *.egg-info/ 45 | .installed.cfg 46 | *.egg 47 | MANIFEST 48 | 49 | # PyInstaller 50 | # Usually these files are written by a python script from a template 51 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 52 | *.manifest 53 | *.spec 54 | 55 | # Installer logs 56 | pip-log.txt 57 | pip-delete-this-directory.txt 58 | 59 | # Unit test / coverage reports 60 | htmlcov/ 61 | .tox/ 62 | .nox/ 63 | .coverage 64 | .coverage.* 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | *.cover 69 | *.py,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | cover/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Django stuff: 79 | *.log 80 | local_settings.py 81 | db.sqlite3 82 | db.sqlite3-journal 83 | 84 | # Flask stuff: 85 | instance/ 86 | .webassets-cache 87 | 88 | # Scrapy stuff: 89 | .scrapy 90 | 91 | # Sphinx documentation 92 | docs/_build/ 93 | 94 | # PyBuilder 95 | .pybuilder/ 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # IPython 102 | profile_default/ 103 | ipython_config.py 104 | 105 | # pyenv 106 | # For a library or package, you might want to ignore these files since the code is 107 | # intended to run in multiple environments; otherwise, check them in: 108 | # .python-version 109 | 110 | # pipenv 111 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 112 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 113 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 114 | # install all needed dependencies. 115 | #Pipfile.lock 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | ### VirtualEnv ### 161 | # Virtualenv 162 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 163 | [Bb]in 164 | [Ii]nclude 165 | [Ll]ib 166 | [Ll]ib64 167 | [Ll]ocal 168 | [Ss]cripts 169 | pyvenv.cfg 170 | pip-selfcheck.json 171 | 172 | ### VisualStudioCode ### 173 | .vscode/* 174 | !.vscode/settings.json 175 | !.vscode/tasks.json 176 | !.vscode/launch.json 177 | !.vscode/extensions.json 178 | *.code-workspace 179 | 180 | # Local History for Visual Studio Code 181 | .history/ 182 | 183 | ### VisualStudioCode Patch ### 184 | # Ignore all local history of files 185 | .history 186 | .ionide 187 | 188 | ### VisualStudio ### 189 | ## Ignore Visual Studio temporary files, build results, and 190 | ## files generated by popular Visual Studio add-ons. 191 | ## 192 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 193 | 194 | # User-specific files 195 | *.rsuser 196 | *.suo 197 | *.user 198 | *.userosscache 199 | *.sln.docstates 200 | 201 | # User-specific files (MonoDevelop/Xamarin Studio) 202 | *.userprefs 203 | 204 | # Mono auto generated files 205 | mono_crash.* 206 | 207 | # Build results 208 | [Dd]ebug/ 209 | [Dd]ebugPublic/ 210 | [Rr]elease/ 211 | [Rr]eleases/ 212 | x64/ 213 | x86/ 214 | [Ww][Ii][Nn]32/ 215 | [Aa][Rr][Mm]/ 216 | [Aa][Rr][Mm]64/ 217 | bld/ 218 | [Bb]in/ 219 | [Oo]bj/ 220 | [Ll]og/ 221 | [Ll]ogs/ 222 | 223 | # Visual Studio 2015/2017 cache/options directory 224 | .vs/ 225 | # Uncomment if you have tasks that create the project's static files in wwwroot 226 | #wwwroot/ 227 | 228 | # Visual Studio 2017 auto generated files 229 | Generated\ Files/ 230 | 231 | # MSTest test Results 232 | [Tt]est[Rr]esult*/ 233 | [Bb]uild[Ll]og.* 234 | 235 | # NUnit 236 | *.VisualState.xml 237 | TestResult.xml 238 | nunit-*.xml 239 | 240 | # Build Results of an ATL Project 241 | [Dd]ebugPS/ 242 | [Rr]eleasePS/ 243 | dlldata.c 244 | 245 | # Benchmark Results 246 | BenchmarkDotNet.Artifacts/ 247 | 248 | # .NET Core 249 | project.lock.json 250 | project.fragment.lock.json 251 | artifacts/ 252 | 253 | # ASP.NET Scaffolding 254 | ScaffoldingReadMe.txt 255 | 256 | # StyleCop 257 | StyleCopReport.xml 258 | 259 | # Files built by Visual Studio 260 | *_i.c 261 | *_p.c 262 | *_h.h 263 | *.ilk 264 | *.meta 265 | *.obj 266 | *.iobj 267 | *.pch 268 | *.pdb 269 | *.ipdb 270 | *.pgc 271 | *.pgd 272 | *.rsp 273 | *.sbr 274 | *.tlb 275 | *.tli 276 | *.tlh 277 | *.tmp 278 | *.tmp_proj 279 | *_wpftmp.csproj 280 | *.tlog 281 | *.vspscc 282 | *.vssscc 283 | .builds 284 | *.pidb 285 | *.svclog 286 | *.scc 287 | 288 | # Chutzpah Test files 289 | _Chutzpah* 290 | 291 | # Visual C++ cache files 292 | ipch/ 293 | *.aps 294 | *.ncb 295 | *.opendb 296 | *.opensdf 297 | *.sdf 298 | *.cachefile 299 | *.VC.db 300 | *.VC.VC.opendb 301 | 302 | # Visual Studio profiler 303 | *.psess 304 | *.vsp 305 | *.vspx 306 | *.sap 307 | 308 | # Visual Studio Trace Files 309 | *.e2e 310 | 311 | # TFS 2012 Local Workspace 312 | $tf/ 313 | 314 | # Guidance Automation Toolkit 315 | *.gpState 316 | 317 | # ReSharper is a .NET coding add-in 318 | _ReSharper*/ 319 | *.[Rr]e[Ss]harper 320 | *.DotSettings.user 321 | 322 | # TeamCity is a build add-in 323 | _TeamCity* 324 | 325 | # DotCover is a Code Coverage Tool 326 | *.dotCover 327 | 328 | # AxoCover is a Code Coverage Tool 329 | .axoCover/* 330 | !.axoCover/settings.json 331 | 332 | # Coverlet is a free, cross platform Code Coverage Tool 333 | coverage*.json 334 | coverage*.xml 335 | coverage*.info 336 | 337 | # Visual Studio code coverage results 338 | *.coverage 339 | *.coveragexml 340 | 341 | # NCrunch 342 | _NCrunch_* 343 | .*crunch*.local.xml 344 | nCrunchTemp_* 345 | 346 | # MightyMoose 347 | *.mm.* 348 | AutoTest.Net/ 349 | 350 | # Web workbench (sass) 351 | .sass-cache/ 352 | 353 | # Installshield output folder 354 | [Ee]xpress/ 355 | 356 | # DocProject is a documentation generator add-in 357 | DocProject/buildhelp/ 358 | DocProject/Help/*.HxT 359 | DocProject/Help/*.HxC 360 | DocProject/Help/*.hhc 361 | DocProject/Help/*.hhk 362 | DocProject/Help/*.hhp 363 | DocProject/Help/Html2 364 | DocProject/Help/html 365 | 366 | # Click-Once directory 367 | publish/ 368 | 369 | # Publish Web Output 370 | *.[Pp]ublish.xml 371 | *.azurePubxml 372 | # Note: Comment the next line if you want to checkin your web deploy settings, 373 | # but database connection strings (with potential passwords) will be unencrypted 374 | *.pubxml 375 | *.publishproj 376 | 377 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 378 | # checkin your Azure Web App publish settings, but sensitive information contained 379 | # in these scripts will be unencrypted 380 | PublishScripts/ 381 | 382 | # NuGet Packages 383 | *.nupkg 384 | # NuGet Symbol Packages 385 | *.snupkg 386 | # The packages folder can be ignored because of Package Restore 387 | **/[Pp]ackages/* 388 | # except build/, which is used as an MSBuild target. 389 | !**/[Pp]ackages/build/ 390 | # Uncomment if necessary however generally it will be regenerated when needed 391 | #!**/[Pp]ackages/repositories.config 392 | # NuGet v3's project.json files produces more ignorable files 393 | *.nuget.props 394 | *.nuget.targets 395 | 396 | # Nuget personal access tokens and Credentials 397 | nuget.config 398 | 399 | # Microsoft Azure Build Output 400 | csx/ 401 | *.build.csdef 402 | 403 | # Microsoft Azure Emulator 404 | ecf/ 405 | rcf/ 406 | 407 | # Windows Store app package directories and files 408 | AppPackages/ 409 | BundleArtifacts/ 410 | Package.StoreAssociation.xml 411 | _pkginfo.txt 412 | *.appx 413 | *.appxbundle 414 | *.appxupload 415 | 416 | # Visual Studio cache files 417 | # files ending in .cache can be ignored 418 | *.[Cc]ache 419 | # but keep track of directories ending in .cache 420 | !?*.[Cc]ache/ 421 | 422 | # Others 423 | ClientBin/ 424 | ~$* 425 | *.dbmdl 426 | *.dbproj.schemaview 427 | *.jfm 428 | *.pfx 429 | *.publishsettings 430 | orleans.codegen.cs 431 | 432 | # Including strong name files can present a security risk 433 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 434 | #*.snk 435 | 436 | # Since there are multiple workflows, uncomment next line to ignore bower_components 437 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 438 | #bower_components/ 439 | 440 | # RIA/Silverlight projects 441 | Generated_Code/ 442 | 443 | # Backup & report files from converting an old project file 444 | # to a newer Visual Studio version. Backup files are not needed, 445 | # because we have git ;-) 446 | _UpgradeReport_Files/ 447 | Backup*/ 448 | UpgradeLog*.XML 449 | UpgradeLog*.htm 450 | ServiceFabricBackup/ 451 | *.rptproj.bak 452 | 453 | # SQL Server files 454 | *.mdf 455 | *.ldf 456 | *.ndf 457 | 458 | # Business Intelligence projects 459 | *.rdl.data 460 | *.bim.layout 461 | *.bim_*.settings 462 | *.rptproj.rsuser 463 | *- [Bb]ackup.rdl 464 | *- [Bb]ackup ([0-9]).rdl 465 | *- [Bb]ackup ([0-9][0-9]).rdl 466 | 467 | # Microsoft Fakes 468 | FakesAssemblies/ 469 | 470 | # GhostDoc plugin setting file 471 | *.GhostDoc.xml 472 | 473 | # Node.js Tools for Visual Studio 474 | .ntvs_analysis.dat 475 | node_modules/ 476 | 477 | # Visual Studio 6 build log 478 | *.plg 479 | 480 | # Visual Studio 6 workspace options file 481 | *.opt 482 | 483 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 484 | *.vbw 485 | 486 | # Visual Studio LightSwitch build output 487 | **/*.HTMLClient/GeneratedArtifacts 488 | **/*.DesktopClient/GeneratedArtifacts 489 | **/*.DesktopClient/ModelManifest.xml 490 | **/*.Server/GeneratedArtifacts 491 | **/*.Server/ModelManifest.xml 492 | _Pvt_Extensions 493 | 494 | # Paket dependency manager 495 | .paket/paket.exe 496 | paket-files/ 497 | 498 | # FAKE - F# Make 499 | .fake/ 500 | 501 | # CodeRush personal settings 502 | .cr/personal 503 | 504 | # Python Tools for Visual Studio (PTVS) 505 | *.pyc 506 | 507 | # Cake - Uncomment if you are using it 508 | # tools/** 509 | # !tools/packages.config 510 | 511 | # Tabs Studio 512 | *.tss 513 | 514 | # Telerik's JustMock configuration file 515 | *.jmconfig 516 | 517 | # BizTalk build output 518 | *.btp.cs 519 | *.btm.cs 520 | *.odx.cs 521 | *.xsd.cs 522 | 523 | # OpenCover UI analysis results 524 | OpenCover/ 525 | 526 | # Azure Stream Analytics local run output 527 | ASALocalRun/ 528 | 529 | # MSBuild Binary and Structured Log 530 | *.binlog 531 | 532 | # NVidia Nsight GPU debugger configuration file 533 | *.nvuser 534 | 535 | # MFractors (Xamarin productivity tool) working folder 536 | .mfractor/ 537 | 538 | # Local History for Visual Studio 539 | .localhistory/ 540 | 541 | # BeatPulse healthcheck temp database 542 | healthchecksdb 543 | 544 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 545 | MigrationBackup/ 546 | 547 | # Ionide (cross platform F# VS Code tools) working folder 548 | .ionide/ 549 | 550 | # Fody - auto-generated XML schema 551 | FodyWeavers.xsd 552 | 553 | # VS Code files for those working on multiple tools 554 | 555 | # Local History for Visual Studio Code 556 | 557 | # Windows Installer files from build outputs 558 | *.cab 559 | *.msi 560 | *.msix 561 | *.msm 562 | *.msp 563 | 564 | # JetBrains Rider 565 | .idea/ 566 | *.sln.iml 567 | 568 | ### VisualStudio Patch ### 569 | # Additional files built by Visual Studio 570 | 571 | # End of https://www.toptal.com/developers/gitignore/api/python,virtualenv,visualstudio,visualstudiocode,linux 572 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2021] [TinghsuWan] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all install test clean 2 | 3 | test: 4 | tox 5 | 6 | lint: 7 | black -l 120 *.py */*.py 8 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | aiohttp = "*" 8 | dateparser = "*" 9 | websockets = "==9.0" 10 | black = "*" 11 | requests = "*" 12 | twine = "*" 13 | loguru = "*" 14 | 15 | [dev-packages] 16 | 17 | [requires] 18 | python_version = "3.7" 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | FTX RIP... 2 | -------------------------------------------------------------------------------- /ftx/__init__.py: -------------------------------------------------------------------------------- 1 | from ftx.timer import PerpetualTimer 2 | from ftx.exceptions import FtxValueError, FtxAPIException, FtxWebsocketUnableToConnect 3 | from ftx.authentication import signature, ws_signature 4 | from ftx.client import Client, AsyncClient 5 | from ftx.streams import ThreadedWebsocketManager 6 | -------------------------------------------------------------------------------- /ftx/authentication.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import json 3 | 4 | 5 | def signature(ts: str, method: str, path_url: str, secret: str, post_body=None): 6 | signature_payload = f"{ts}{method.upper()}/api{path_url}".encode() 7 | if post_body is not None: 8 | signature_payload += json.dumps(post_body).encode() 9 | signature = hmac.new(secret.encode(), signature_payload, "sha256").hexdigest() 10 | return signature 11 | 12 | 13 | def ws_signature(ts: str, secret: str): 14 | signature_payload = f"{ts}websocket_login".encode() 15 | signature = hmac.new(secret.encode(), signature_payload, "sha256").hexdigest() 16 | return signature 17 | -------------------------------------------------------------------------------- /ftx/client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | import aiohttp 4 | import asyncio 5 | import requests 6 | from loguru import logger 7 | from ftx import signature 8 | from urllib.parse import urlencode 9 | from typing import Dict, Optional, List, Tuple 10 | from ftx import FtxAPIException, FtxValueError 11 | from concurrent.futures._base import TimeoutError 12 | 13 | 14 | class BaseClient: 15 | API_URL = "https://ftx.com/api" 16 | 17 | def __init__(self, api: Optional[str] = None, secret: Optional[str] = None): 18 | self.API_KEY, self.API_SECRET = api, secret 19 | self.session = self._init_session() 20 | self.header = {} 21 | self.TIMEOUT = 45 22 | 23 | def _get_header(self) -> Dict: 24 | header = { 25 | "FTX-KEY": "", 26 | "FTX-SIGN": "", 27 | "FTX-TS": "", 28 | } 29 | if self.API_KEY: 30 | assert self.API_KEY 31 | header["FTX-KEY"] = self.API_KEY 32 | return header 33 | 34 | def _init_session(self): 35 | raise NotImplementedError 36 | 37 | def _init_url(self): 38 | self.api_url = self.API_URL 39 | 40 | def _handle_response(self, response: requests.Response): 41 | code = response.status_code 42 | if code == 200: 43 | return response.json() 44 | else: 45 | try: 46 | resp_json = response.json() 47 | raise FtxAPIException(resp_json, code) 48 | except ValueError: 49 | raise FtxValueError(response) 50 | 51 | 52 | class Client(BaseClient): 53 | def __init__(self, api: Optional[str], secret: Optional[str], subaccount=None): 54 | super().__init__(api=api, secret=secret) 55 | self.subaccount = subaccount 56 | 57 | def _init_session(self) -> requests.Session: 58 | self.header = self._get_header() 59 | session = requests.session() 60 | session.headers.update(self.header) 61 | return session 62 | 63 | def _get(self, path: str, params=None): 64 | return self._request("get", path, params) 65 | 66 | def _post(self, path: str, params=None) -> Dict: 67 | return self._request("post", path, params) 68 | 69 | def _put(self, path: str, params=None) -> Dict: 70 | return self._request("put", path, params) 71 | 72 | def _delete(self, path: str, params=None) -> Dict: 73 | return self._request("delete", path, params) 74 | 75 | def _request(self, method, path: str, params: Dict): 76 | try: 77 | ts = str(int(time.time() * 1000)) 78 | uri = f"{self.API_URL}{path}" 79 | sig = signature(ts, method, path, self.API_SECRET, params) 80 | 81 | self.header["FTX-KEY"] = self.API_KEY 82 | self.header["FTX-SIGN"] = sig 83 | self.header["FTX-TS"] = ts 84 | if self.subaccount is not None: 85 | self.header["FTX-SUBACCOUNT"] = self.subaccount 86 | self.session.headers.update(self.header) 87 | if method == "get": 88 | self.response = getattr(self.session, method)(uri, params=params) 89 | else: 90 | self.response = getattr(self.session, method)(uri, json=params) 91 | return self._handle_response(self.response) 92 | except Exception as e: 93 | logger.error(f"[ERROR] Request failed!") 94 | logger.error(self.response.text) 95 | 96 | def get_markets(self) -> Dict: 97 | return self._get("/markets") 98 | 99 | def get_funding_rate(self, **kwargs) -> Dict: 100 | return self._get("/funding_rates", params=kwargs) 101 | 102 | def get_klines(self, market: str, resolution: int, start_time=None, end_time=None) -> Dict: 103 | params = {} 104 | params["resolution"] = resolution 105 | if start_time is not None: 106 | params["start_time"] = start_time 107 | if end_time is not None: 108 | params["end_time"] = end_time 109 | 110 | return self._get(f"/markets/{market}/candles", params=params) 111 | 112 | def get_account_info(self) -> Dict: 113 | return self._get("/account") 114 | 115 | def get_all_balances(self) -> Dict: 116 | return self._get("/wallet/all_balances") 117 | 118 | def get_balances(self) -> Dict: 119 | return self._get("/wallet/balances") 120 | 121 | def get_positions(self, **kwargs) -> Dict: 122 | if kwargs: 123 | return self._get("/positions", params=kwargs) 124 | else: 125 | return self._get("/positions") 126 | 127 | def send_order(self, **kwargs) -> Dict: 128 | try: 129 | return self._post("/orders", params=kwargs) 130 | except: 131 | time.sleep(0.2) 132 | return self._post("/orders", params=kwargs) 133 | 134 | def set_leverage(self, **kwargs): 135 | return self._post("/account/leverage", kwargs) 136 | 137 | def request_withdrawal(self, **kwargs): 138 | return self._post("/wallet/withdrawals", kwargs) 139 | 140 | def cancel_order(self, order_id): 141 | return self._delete(f"/orders/{order_id}") 142 | 143 | 144 | class AsyncClient(BaseClient): 145 | def __init__( 146 | self, 147 | api: Optional[str], 148 | secret: Optional[str], 149 | subaccount=None, 150 | loop=None, 151 | ): 152 | self.loop = loop or asyncio.get_event_loop() 153 | self.subaccount = subaccount 154 | super().__init__( 155 | api=api, 156 | secret=secret, 157 | ) 158 | 159 | @classmethod 160 | async def create( 161 | cls, 162 | api: Optional[str], 163 | secret: Optional[str], 164 | loop=None, 165 | ): 166 | self = cls(api, secret, loop) 167 | return self 168 | 169 | def _init_session(self) -> aiohttp.ClientSession: 170 | session = aiohttp.ClientSession(loop=self.loop, headers=self._get_header()) 171 | return session 172 | 173 | async def close_connection(self): 174 | if self.session: 175 | assert self.session 176 | await self.session.close() 177 | 178 | async def _request(self, method, uri: str, path: str, params: Dict): 179 | try: 180 | ts = str(int(time.time() * 1000)) 181 | uri = f"{self.API_URL}{path}" 182 | 183 | sig = signature(ts, method, path, self.API_SECRET) 184 | self.header["FTX-KEY"] = self.API_KEY 185 | self.header["FTX-SIGN"] = sig 186 | self.header["FTX-TS"] = ts 187 | if self.subaccount is not None: 188 | self.header["FTX-SUBACCOUNT"] = self.subaccount 189 | self.session.headers.update(self.header) 190 | 191 | async with getattr(self.session, method)(uri, params=params) as response: 192 | self.response = response 193 | return await self._handle_response(response) 194 | 195 | except Exception as e: 196 | logger.error(f"[ERROR] Request failed!") 197 | logger.error(e) 198 | 199 | async def _handle_response(self, response: requests.Response): 200 | code = response.status_code 201 | if code == 200: 202 | return response.json() 203 | else: 204 | try: 205 | resp_json = response.json() 206 | raise FtxAPIException(resp_json, code) 207 | except ValueError: 208 | raise FtxValueError(response) 209 | 210 | async def _get(self, path: str, params=None): 211 | return await self._request("get", path, params) 212 | 213 | async def _post(self, path: str, params=None) -> Dict: 214 | return await self._request("post", path, params) 215 | 216 | async def _put(self, path: str, params=None) -> Dict: 217 | return await self._request("put", path, params) 218 | 219 | async def _delete(self, path: str, params=None) -> Dict: 220 | return await self._request("delete", path, params) 221 | 222 | async def get_available_symbol(self): 223 | return await self._get("public/info") 224 | -------------------------------------------------------------------------------- /ftx/enums.py: -------------------------------------------------------------------------------- 1 | SYMBOL_TYPE_SPOT = "SPOT" 2 | 3 | ORDER_STATUS_NEW = "NEW" 4 | ORDER_STATUS_PARTIALLY_FILLED = "PARTIAL_FILLED" 5 | ORDER_STATUS_FILLED = "FILLED" 6 | ORDER_STATUS_CANCELED = "CANCELLED" 7 | ORDER_STATUS_REJECTED = "REJECTED" 8 | ORDER_STATUS_INCOMPLETE = "INCOMPLETE" 9 | ORDER_STATUS_COMPLETED = "COMPLETED" 10 | 11 | KLINE_INTERVAL_1MINUTE = "1m" 12 | KLINE_INTERVAL_5MINUTE = "5m" 13 | KLINE_INTERVAL_15MINUTE = "15m" 14 | KLINE_INTERVAL_30MINUTE = "30m" 15 | KLINE_INTERVAL_1HOUR = "1h" 16 | KLINE_INTERVAL_1DAY = "1d" 17 | KLINE_INTERVAL_1WEEK = "1w" 18 | KLINE_INTERVAL_1MONTH = "1M" 19 | 20 | SIDE_BUY = "BUY" 21 | SIDE_SELL = "SELL" 22 | 23 | ORDER_TYPE_LIMIT = "LIMIT" 24 | ORDER_TYPE_MARKET = "MARKET" 25 | ORDER_TYPE_IOC = "IOC" 26 | ORDER_TYPE_FOK = "FOK" 27 | ORDER_TYPE_POST_ONLY = "POST_ONLY" 28 | ORDER_TYPE_ASK = "ASK" 29 | ORDER_TYPE_BID = "BID" 30 | -------------------------------------------------------------------------------- /ftx/exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class FtxAPIException(Exception): 5 | def __init__(self, resp_json, status_code): 6 | self.message = resp_json["message"] 7 | self.code = status_code 8 | self.api_code = resp_json["code"] 9 | 10 | def __str__(self): 11 | return f"APIError(code={self.api_code}): {self.message}" 12 | 13 | 14 | class FtxValueError: 15 | def __init__(self, response) -> None: 16 | self.response = response 17 | 18 | def __str__(self): 19 | return f"Invalid Response: {self.response.text}" 20 | 21 | 22 | class FtxWebsocketUnableToConnect(Exception): 23 | pass 24 | -------------------------------------------------------------------------------- /ftx/streams.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import gzip 3 | import json 4 | from loguru import logger 5 | from asyncio import sleep 6 | import time 7 | from enum import Enum 8 | from random import random 9 | from typing import Optional, List, Callable 10 | 11 | from socket import gaierror 12 | from .exceptions import FtxWebsocketUnableToConnect 13 | from websockets.exceptions import ConnectionClosedError 14 | 15 | import websockets as ws 16 | from ftx import AsyncClient 17 | from ftx import ws_signature 18 | from .threaded_stream import ThreadedApiManager 19 | 20 | KEEPALIVE_TIMEOUT = 5 * 60 # 5 minutes 21 | 22 | 23 | class WSListenerState(Enum): 24 | INITIALISING = "Initialising" 25 | STREAMING = "Streaming" 26 | RECONNECTING = "Reconnecting" 27 | EXITING = "Exiting" 28 | 29 | 30 | class ReconnectingWebsocket: 31 | MAX_RECONNECTS = 5 32 | MAX_RECONNECT_SECONDS = 60 33 | MIN_RECONNECT_WAIT = 0.1 34 | TIMEOUT = 10 35 | NO_MESSAGE_RECONNECT_TIMEOUT = 60 36 | MAX_QUEUE_SIZE = 10000 37 | 38 | def __init__( 39 | self, 40 | loop, 41 | url: str, 42 | subscription: List, 43 | name: Optional[str] = None, 44 | is_binary: bool = False, 45 | exit_coro=None, 46 | ): 47 | self._loop = loop or asyncio.get_event_loop() 48 | self._name = name 49 | self._url = url 50 | self._exit_coro = exit_coro 51 | self._reconnects = 0 52 | self._is_binary = is_binary 53 | self._conn = None 54 | self.ws: Optional[ws.WebSocketClientProtocol] = None 55 | self.ws_state = WSListenerState.INITIALISING 56 | self._queue = asyncio.Queue(loop=self._loop) 57 | self._handle_read_loop = None 58 | self.subscription = subscription 59 | 60 | async def __aenter__(self): 61 | await self.connect() 62 | return self 63 | 64 | async def __aexit__(self, exc_type, exc_val, exc_tb): 65 | if self._exit_coro: 66 | await self._exit_coro(self._name) 67 | self.ws_state = WSListenerState.EXITING 68 | if self.ws: 69 | self.ws.fail_connection() 70 | if self._conn and hasattr(self._conn, "protocol"): 71 | await self._conn.__aexit__(exc_type, exc_val, exc_tb) 72 | self.ws = None 73 | if not self._handle_read_loop: 74 | logger.error("CANCEL read_loop") 75 | await self._kill_read_loop() 76 | 77 | async def connect(self): 78 | await self._before_connect() 79 | assert self._name 80 | self.ws_state = WSListenerState.STREAMING 81 | self._conn = ws.connect(self._url, close_timeout=0.1) 82 | try: 83 | self.ws = await self._conn.__aenter__() 84 | except: # noqa 85 | await self._reconnect() 86 | return 87 | self._reconnects = 0 88 | await self._after_connect() 89 | if not self._handle_read_loop: 90 | self._handle_read_loop = self._loop.call_soon_threadsafe(asyncio.create_task, self._read_loop()) 91 | 92 | async def _kill_read_loop(self): 93 | self.ws_state = WSListenerState.EXITING 94 | while self._handle_read_loop: 95 | await sleep(0.1) 96 | 97 | async def send_msg(self, msg): 98 | wait_count = 0 99 | while not self.ws: 100 | time.sleep(0.1) 101 | wait_count = wait_count + 1 102 | if wait_count > 20: 103 | raise Exception("send_msg() failed, cannot resend subscription") 104 | 105 | await self.ws.send(json.dumps(msg)) 106 | 107 | async def _before_connect(self): 108 | pass 109 | 110 | async def _after_connect(self): 111 | pass 112 | 113 | def _handle_message(self, evt): 114 | if self._is_binary: 115 | try: 116 | evt = gzip.decompress(evt) 117 | except (ValueError, OSError): 118 | return None 119 | try: 120 | return json.loads(evt) 121 | except ValueError: 122 | logger.debug(f"error parsing evt json:{evt}") 123 | return None 124 | 125 | async def _read_loop(self): 126 | try: 127 | while True: 128 | try: 129 | if self.ws_state == WSListenerState.RECONNECTING: 130 | await self._run_reconnect() 131 | if not self.ws or self.ws_state != WSListenerState.STREAMING: 132 | await self._wait_for_reconnect() 133 | break 134 | elif self.ws_state == WSListenerState.EXITING: 135 | break 136 | elif self.ws.state == ws.protocol.State.CLOSING: 137 | await asyncio.sleep(0.1) 138 | continue 139 | elif self.ws.state == ws.protocol.State.CLOSED: 140 | await self._reconnect() 141 | elif self.ws_state == WSListenerState.STREAMING: 142 | res = await asyncio.wait_for(self.ws.recv(), timeout=self.TIMEOUT) 143 | res = self._handle_message(res) 144 | if res: 145 | if self._queue.qsize() < self.MAX_QUEUE_SIZE: 146 | await self._queue.put(res) 147 | else: 148 | logger.debug(f"Queue overflow {self.MAX_QUEUE_SIZE}. Message not filled") 149 | await self._queue.put({"e": "error", "m": "Queue overflow. Message not filled"}) 150 | raise FtxWebsocketUnableToConnect 151 | except asyncio.TimeoutError: 152 | logger.debug(f"no message in {self.TIMEOUT} seconds") 153 | except asyncio.CancelledError as e: 154 | logger.debug(f"cancelled error {e}") 155 | break 156 | except asyncio.IncompleteReadError as e: 157 | logger.debug(f"incomplete read error ({e})") 158 | except ConnectionClosedError as e: 159 | logger.debug(f"connection close error ({e})") 160 | except gaierror as e: 161 | logger.debug(f"DNS Error ({e})") 162 | except FtxWebsocketUnableToConnect as e: 163 | logger.debug(f"FtxWebsocketUnableToConnect ({e})") 164 | break 165 | except Exception as e: 166 | logger.debug(f"Unknown exception ({e})") 167 | continue 168 | finally: 169 | self._handle_read_loop = None 170 | self._reconnects = 0 171 | 172 | async def _after_reconnect(self): 173 | for msg in self.subscription: 174 | await self.send_msg(msg) 175 | 176 | async def _run_reconnect(self): 177 | await self.before_reconnect() 178 | if self._reconnects < self.MAX_RECONNECTS: 179 | reconnect_wait = self._get_reconnect_wait(self._reconnects) 180 | logger.debug( 181 | f"websocket reconnecting. {self.MAX_RECONNECTS - self._reconnects} reconnects left - " 182 | f"waiting {reconnect_wait} seconds to start." 183 | ) 184 | await asyncio.sleep(reconnect_wait) 185 | await self.connect() 186 | await self._after_reconnect() 187 | else: 188 | logger.error(f"Max reconnections {self.MAX_RECONNECTS} reached:") 189 | # Signal the error 190 | await self._queue.put({"e": "error", "m": "Max reconnect retries reached"}) 191 | raise FtxWebsocketUnableToConnect 192 | 193 | async def recv(self): 194 | res = None 195 | while not res: 196 | try: 197 | res = await asyncio.wait_for(self._queue.get(), timeout=self.TIMEOUT) 198 | except asyncio.TimeoutError: 199 | logger.debug(f"no message in {self.TIMEOUT} seconds") 200 | return res 201 | 202 | async def _wait_for_reconnect(self): 203 | while self.ws_state != WSListenerState.STREAMING and self.ws_state != WSListenerState.EXITING: 204 | await sleep(0.1) 205 | 206 | def _get_reconnect_wait(self, attempts: int) -> int: 207 | expo = 2 ** attempts 208 | return round(random() * min(self.MAX_RECONNECT_SECONDS, expo - 1) + 1) 209 | 210 | async def before_reconnect(self): 211 | if self.ws: 212 | await self._conn.__aexit__(None, None, None) 213 | self.ws = None 214 | self._reconnects += 1 215 | 216 | def _no_message_received_reconnect(self): 217 | logger.debug("No message received, reconnecting") 218 | self.ws_state = WSListenerState.RECONNECTING 219 | 220 | async def _reconnect(self): 221 | self.ws_state = WSListenerState.RECONNECTING 222 | 223 | 224 | class FtxSocketManager: 225 | WS_URL = "wss://ftx.com/ws/" 226 | 227 | def __init__( 228 | self, 229 | client: AsyncClient, 230 | loop=None, 231 | ): 232 | self._conns = {} 233 | self._loop = loop or asyncio.get_event_loop() 234 | self._client = client 235 | self.subscription = [] 236 | 237 | def _init_stream_url(self): 238 | self.ws_url = self.STREAM_URL 239 | self.private_ws_url = self.PSTREAM_URL 240 | 241 | def _get_socket(self, socket_name: str, is_binary: bool = False) -> str: 242 | if socket_name not in self._conns: 243 | self._conns[socket_name] = ReconnectingWebsocket( 244 | loop=self._loop, 245 | name=socket_name, 246 | url=self.WS_URL, 247 | exit_coro=self._exit_socket, 248 | is_binary=is_binary, 249 | subscription=self.subscription, 250 | ) 251 | 252 | return self._conns[socket_name] 253 | 254 | async def subscribe(self, socket_name: str, **params): 255 | try: 256 | await self._conns[socket_name].send_msg(params) 257 | if params not in self.subscription: 258 | self.subscription.append(params) 259 | except KeyError: 260 | logger.warning(f"Connection name: <{socket_name}> not create or start!") 261 | 262 | async def _exit_socket(self, name: str): 263 | await self._stop_socket(name) 264 | 265 | def get_socket(self, socket_name): 266 | return self._get_socket(socket_name) 267 | 268 | async def _stop_socket(self, conn_key): 269 | if conn_key not in self._conns: 270 | return 271 | 272 | del self._conns[conn_key] 273 | 274 | 275 | class ThreadedWebsocketManager(ThreadedApiManager): 276 | def __init__( 277 | self, 278 | api_key: Optional[str] = None, 279 | api_secret: Optional[str] = None, 280 | subaccount: str = None, 281 | ): 282 | super().__init__(api_key, api_secret) 283 | self._fsm: Optional[FtxSocketManager] = None 284 | self.api = api_key 285 | self.secret = api_secret 286 | self.subaccount = subaccount 287 | 288 | async def _before_socket_listener_start(self): 289 | assert self._client 290 | self._fsm = FtxSocketManager(client=self._client, loop=self._loop) 291 | 292 | def _start_socket( 293 | self, 294 | callback: Callable, 295 | socket_name: str, 296 | ) -> str: 297 | while not self._fsm: 298 | time.sleep(0.1) 299 | 300 | socket = getattr(self._fsm, "get_socket")(socket_name) 301 | name = socket._name 302 | self._socket_running[name] = True 303 | self._loop.call_soon_threadsafe( 304 | asyncio.create_task, 305 | self.start_listener(socket, socket._name, callback, self.ping), 306 | ) 307 | 308 | return socket 309 | 310 | def start_socket( 311 | self, 312 | callback: Callable, 313 | socket_name: str, 314 | ) -> str: 315 | socket = self._start_socket( 316 | callback=callback, 317 | socket_name=socket_name, 318 | ) 319 | self.ping(socket_name) 320 | return socket 321 | 322 | def subscribe(self, socket_name: str, **params): 323 | while not self._fsm: 324 | time.sleep(0.1) 325 | try: 326 | loop = asyncio.get_running_loop() 327 | except RuntimeError: 328 | loop = None 329 | if loop and loop.is_running(): 330 | loop.create_task(self._fsm.subscribe(socket_name, **params)) 331 | else: 332 | asyncio.run(self._fsm.subscribe(socket_name, **params)) 333 | 334 | def login(self, socket_name: str): 335 | if not socket_name: 336 | return 337 | ts = int(time.time() * 1000) 338 | sign = ws_signature(ts, self.secret) 339 | args = {} 340 | args["key"] = self.api 341 | args["sign"] = sign 342 | args["time"] = ts 343 | if self.subaccount != None: 344 | args["subaccount"] = self.subaccount 345 | 346 | self.subscribe(socket_name=socket_name, args=args, op="login") 347 | 348 | def ping(self, name): 349 | self.subscribe(name, op="ping") 350 | -------------------------------------------------------------------------------- /ftx/threaded_stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | from ftx import PerpetualTimer 4 | from typing import Callable, Optional, Dict 5 | from ftx import AsyncClient 6 | 7 | 8 | class ThreadedApiManager(threading.Thread): 9 | def __init__( 10 | self, 11 | api_key: Optional[str] = None, 12 | api_secret: Optional[str] = None, 13 | ): 14 | """Initialise the FtxSocketManager""" 15 | super().__init__() 16 | self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() 17 | self._client: Optional[AsyncClient] = None 18 | self._running: bool = True 19 | self._socket_running: Dict[str, bool] = {} 20 | self._client_params = { 21 | "api": api_key, 22 | "secret": api_secret, 23 | } 24 | 25 | async def _before_socket_listener_start(self): 26 | ... 27 | 28 | async def socket_listener(self): 29 | self._client = await AsyncClient.create(loop=self._loop, **self._client_params) 30 | await self._before_socket_listener_start() 31 | while self._running: 32 | await asyncio.sleep(0.2) 33 | while self._socket_running: 34 | await asyncio.sleep(0.2) 35 | 36 | async def start_listener(self, socket, name: str, callback, ping: Optional[Callable] = None): 37 | async with socket as s: 38 | while self._socket_running[name]: 39 | try: 40 | msg = await asyncio.wait_for(s.recv(), 3) 41 | except asyncio.TimeoutError: 42 | ... 43 | continue 44 | if not msg: 45 | continue 46 | if "type" in msg and msg["type"] == "pong": 47 | ping(name) 48 | continue 49 | callback(msg) 50 | del self._socket_running[name] 51 | 52 | def run(self): 53 | self._loop.run_until_complete(self.socket_listener()) 54 | 55 | def stop_socket(self, socket_name): 56 | if socket_name in self._socket_running: 57 | self._socket_running[socket_name] = False 58 | 59 | async def stop_client(self): 60 | await self._client.close_connection() 61 | 62 | def stop(self): 63 | if not self._running: 64 | return 65 | self._running = False 66 | self._loop.call_soon(asyncio.create_task, self.stop_client()) 67 | for socket_name in self._socket_running.keys(): 68 | self._socket_running[socket_name] = False 69 | -------------------------------------------------------------------------------- /ftx/timer.py: -------------------------------------------------------------------------------- 1 | from threading import Timer 2 | 3 | 4 | class PerpetualTimer: 5 | def __init__(self, t, hFunction): 6 | self.t = t 7 | self.hFunction = hFunction 8 | self.thread = Timer(self.t, self.handle_function) 9 | 10 | def handle_function(self): 11 | self.hFunction() 12 | self.thread = Timer(self.t, self.handle_function) 13 | self.thread.start() 14 | 15 | def start(self): 16 | self.thread.start() 17 | 18 | def cancel(self): 19 | self.thread.cancel() 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from setuptools import setup, find_packages 3 | import sys 4 | 5 | setup( 6 | name="python-ftx", 7 | version="0.1.2", 8 | author="doublewan", 9 | author_email="wanth1997@gmail.com", 10 | description="FTX python API SDK", 11 | license="MIT", 12 | url="https://github.com/wanth1997/python-ftx", 13 | packages=["ftx"], 14 | include_package_data=True, 15 | classifiers=[ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ], 20 | install_requires=["requests", "aiohttp", "websockets", "loguru"], 21 | zip_safe=True, 22 | ) 23 | --------------------------------------------------------------------------------