diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml new file mode 100644 index 0000000..6668d30 --- /dev/null +++ b/.github/workflows/pythonpackage.yml @@ -0,0 +1,40 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions + +name: Python package + +on: + push: + branches: + - main + pull_request: + branches: + - main + - rc-** + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -e . + - name: Run black to review standard code format + run: | + black --check --diff . + - name: Test with unittest + run: | + python3 -m unittest tests/*/test_*.py -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ee4a205 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release Distributables + +on: + release: + types: + - published + +jobs: + build-n-upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.13" + - run: | + python3 -m pip install --upgrade build + pip install -U pip setuptools + - name: Build new distributables + run: python3 -m build + - name: Upload distributables to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + - name: Post release cleaning + run: | + rm dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ef2bb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Config File +config.ini \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f..212db6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,6 @@ # Changelog + +## 1.0.0 - 2025-03-24 + +### Added +- First release, details in the `README.md` file \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..971ef75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Binance + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index f3c1bfc..6499851 100644 --- a/README.md +++ b/README.md @@ -1 +1,136 @@ -# Binance FIX API Connector in Python \ No newline at end of file +# Binance FIX API Connector in Python + +This is a simple Python library that provides access to Binance Financial Information eXchange (FIX) [SPOT messages](https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#message-components) using the FIX protocol. +It allows you to perform key operations such as placing orders, canceling orders, and querying current limit usage. + +## Prerequisites + +Before using or testing the library, ensure that the necessary dependencies are installed. You can do this by running the following command: +``` +pip install binance-fix-connector +``` + +**Notes:** +- FIX API only support Ed25519 keys. Please refer to this [tutorial](https://www.binance.com/en/support/faq/how-to-generate-an-ed25519-key-pair-to-send-api-requests-on-binance-6b9a63f1e3384cf48a2eedb82767a69a) for setting up an Ed25519 key pair on the mainnet, and this one for the [testnet](https://testnet.binance.vision/). +- Ensure that your API key has the appropriate Fix API permissions for the Testnet environment before you begin testing. + +## Example + +All the FIX messages can be created with the `BinanceFixConnector` class. The following example demonstrates how to create a simple order using the FIX API: +```python +import time +import os +from pathlib import Path + +from binance_fix_connector.fix_connector import create_order_entry_session +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join( + Path(__file__).parent.resolve(), "..", "config.ini" +) +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URL +FIX_OE_URL = "tcp+tls://fix-oe.testnet.binance.vision:9000" + +# Response types +ORD_STATUS = { + "0": "NEW", + "1": "PARTIALLY_FILLED", + "2": "FILLED", + "4": "CANCELED", + "6": "PENDING_CANCEL", + "8": "REJECTED", + "A": "PENDING_NEW", + "C": "EXPIRED", +} +ORD_TYPES = {"1": "MARKET", "2": "LIMIT", "3": "STOP", "4": "STOP_LIMIT"} +SIDES = {"1": "BUY", "2": "SELL"} +TIME_IN_FORCE = { + "1": "GOOD_TILL_CANCEL", + "3": "IMMEDIATE_OR_CANCEL", + "4": "FILL_OR_KILL", +} +ORD_REJECT_REASON = {"99": "OTHER"} + +# Parameter +INSTRUMENT = "BNBUSDT" + +client_oe = create_order_entry_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_OE_URL, +) +client_oe.retrieve_messages_until(message_type="A") + +example = "This example shows how to place a single order. Order type LIMIT.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#newordersingled for additional types." +client_oe.logger.info(example) + +# PLACING SIMPLE ORDER +msg = client_oe.create_fix_message_with_basic_header("D") +msg.append_pair(38, 1) # ORD QTY +msg.append_pair(40, 2) # ORD TYPE +msg.append_pair(11, str(time.time_ns())) # CL ORD ID +msg.append_pair(44, 730) # PRICE +msg.append_pair(54, 2) # SIDE +msg.append_pair(55, INSTRUMENT) # SYMBOL +msg.append_pair(59, 1) # TIME IN FORCE +client_oe.send_message(msg) + + +responses = client_oe.retrieve_messages_until(message_type="8") +resp = next( + (x for x in responses if x.message_type.decode("utf-8") == "8"), + None, +) +client_oe.logger.info("Parsing response Execution Report (8) for an order LIMIT type.") + +cl_ord_id = None if not resp.get(11) else resp.get(11).decode("utf-8") +order_qty = None if not resp.get(38) else resp.get(38).decode("utf-8") +ord_type = None if not resp.get(40) else resp.get(40).decode("utf-8") +side = None if not resp.get(54) else resp.get(54).decode("utf-8") +symbol = None if not resp.get(55) else resp.get(55).decode("utf-8") +price = None if not resp.get(44) else resp.get(44).decode("utf-8") +time_in_force = None if not resp.get(59) else resp.get(59).decode("utf-8") +cum_qty = None if not resp.get(14) else resp.get(14).decode("utf-8") +last_qty = None if not resp.get(32) else resp.get(32).decode("utf-8") +ord_status = None if not resp.get(39) else resp.get(39).decode("utf-8") +ord_rej_reason = None if not resp.get(103) else resp.get(103).decode("utf-8") +error_code = None if not resp.get(25016) else resp.get(25016).decode("utf-8") +text = None if not resp.get(58) else resp.get(58).decode("utf-8") + + +client_oe.logger.info(f"Client order ID: {cl_ord_id}") +client_oe.logger.info(f"Symbol: {symbol}") +client_oe.logger.info( + f"Order -> Type: {ORD_TYPES.get(ord_type, ord_type)} | Side: {SIDES.get(side, side)} | TimeInForce: {TIME_IN_FORCE.get(time_in_force,time_in_force)}", +) +client_oe.logger.info( + f"Price: {price} | Quantity: {order_qty} | cum qty: {cum_qty} | last qty: {last_qty}" +) +client_oe.logger.info( + f"Status: {ORD_STATUS.get(ord_status,ord_status)} | Msg: {ORD_REJECT_REASON.get(ord_rej_reason,ord_rej_reason)}", +) +client_oe.logger.info(f"Error code: {error_code} | Reason: {text}") + + +# LOGOUT +client_oe.logger.info("LOGOUT (5)") +client_oe.logout() +client_oe.retrieve_messages_until(message_type="5") +client_oe.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_oe.disconnect() +``` + +Please look at [`examples`](./examples) folder to test the examples. +To try the examples, follow the indications written on the [`examples/config.ini.example`](./examples/config.ini.example) file. + +## Documentation + +For more information, have a look at the Binance documentation on [Fix API](https://developers.binance.com/docs/binance-spot-api-docs/fix-api). + +## License +MIT \ No newline at end of file diff --git a/examples/config.ini.example b/examples/config.ini.example new file mode 100644 index 0000000..3109643 --- /dev/null +++ b/examples/config.ini.example @@ -0,0 +1,9 @@ +# Make a copy of this file, and save to a file called `config.ini` in the example folder +# Enter your API and the path of your pem file for use in the example files; +# No need to add double quote or single quote. Here is an example with demo key that doesn't work on production: +# api_key=vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A +# PATH_TO_PRIVATE_KEY_PEM_FILE=/Users/Documents/program/python-fix-connector/key.pem + +[keys] +API_KEY = +PATH_TO_PRIVATE_KEY_PEM_FILE = \ No newline at end of file diff --git a/examples/general/current_messages_limit_rate.py b/examples/general/current_messages_limit_rate.py new file mode 100644 index 0000000..50ebec3 --- /dev/null +++ b/examples/general/current_messages_limit_rate.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import os +from pathlib import Path + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, + create_order_entry_session, +) +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URLs +FIX_OE_URL = "tcp+tls://fix-oe.testnet.binance.vision:9000" +FIX_MD_URL = "tcp+tls://fix-md.testnet.binance.vision:9000" + +# Response types +RESOLUTIONS = {"s": "SECOND", "m": "MINUTE", "h": "HOUR", "d": "DAY"} +LIMIT_TYPES = {"1": "ORDER_LIMIT", "2": "MESSAGE_LIMIT", "3": "SUBSCRIPTION_LIMIT"} + + +def show_rendered_limit_session(client: BinanceFixConnector) -> None: + """Show the current LIMITS the session has.""" + responses = client.retrieve_messages_until(message_type="XLR") + for msg in responses: + if msg.message_type.decode("utf-8") == "XLR": + limits = 0 if not msg.get(25003) else int(msg.get(25003).decode("utf-8")) + client.logger.info("Parsing response LimitResponse (XLR)") + _info_header = f"Limits: ({limits})" + client.logger.info(_info_header) + for i in range(limits): + limit_type = ( + None + if not msg.get(25004, i + 1) + else msg.get(25004, i + 1).decode("utf-8") + ) + limit_count = ( + None + if not msg.get(25005, i + 1) + else msg.get(25005, i + 1).decode("utf-8") + ) + limit_max = ( + None + if not msg.get(25006, i + 1) + else msg.get(25006, i + 1).decode("utf-8") + ) + interval = ( + None + if not msg.get(25007, i + 1) + else msg.get(25007, i + 1).decode("utf-8") + ) + interval_res = ( + None + if not msg.get(25008, i + 1) + else msg.get(25008, i + 1).decode("utf-8") + ) + interval_str = ( + "" + if not interval + else f"| Interval: {interval} {RESOLUTIONS.get(interval_res, interval_res)}" + ) + _info_body = f"Type: {LIMIT_TYPES.get(limit_type, limit_type)} | Count: {limit_count} | Max: {limit_max} {interval_str}" + client.logger.info(_info_body) + + +# FIX OE +client_oe = create_order_entry_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_OE_URL, +) +client_oe.retrieve_messages_until(message_type="A") + +example = "This example shows how to query for current session limits and how to parse it's data. Check https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#limitqueryxlq for additional information." +client_oe.logger.info(example) + +msg = client_oe.create_fix_message_with_basic_header("XLQ") +msg.append_pair(6136, "current_message_rate") +client_oe.logger.info("LimitQuery (XLQ)") +client_oe.send_message(msg) +show_rendered_limit_session(client_oe) + +# LOGOUT +client_oe.logger.info("LOGOUT (5)") +client_oe.logout() +client_oe.retrieve_messages_until(message_type="5") +client_oe.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_oe.disconnect() + +# FIX MD +client_md = create_market_data_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_MD_URL, +) +client_md.retrieve_messages_until(message_type="A") + +example = "This example shows how to query for current session limits and how to parse it's data. Check https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md for additional information." +client_md.logger.info(example) + +msg = client_md.create_fix_message_with_basic_header("XLQ") +msg.append_pair(6136, "current_message_rate") +client_md.logger.info("LimitQuery (XLQ)") +client_md.send_message(msg) +show_rendered_limit_session(client_md) + +# LOGOUT +client_md.logger.info("LOGOUT (5)") +client_md.logout() +client_md.retrieve_messages_until(message_type="5") +client_md.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_md.disconnect() diff --git a/examples/general/instrument_list.py b/examples/general/instrument_list.py new file mode 100644 index 0000000..1c1c3a9 --- /dev/null +++ b/examples/general/instrument_list.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import time +import os +from pathlib import Path + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, +) +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URL +FIX_MD_URL = "tcp+tls://fix-md.testnet.binance.vision:9000" + +# Parameters +INSTRUMENT = "BNBUSDT" + + +def show_rendered_instrument_list(client: BinanceFixConnector) -> None: + """Show the instrument list messages received.""" + for _ in range(client.queue_msg_received.qsize()): + msg = client.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "y": + instrument_req_id = ( + None if not msg.get(320) else msg.get(320).decode("utf-8") + ) + symbols = 0 if not msg.get(146) else int(msg.get(146).decode("utf-8")) + for i in range(symbols): + symbol = ( + None + if not msg.get(55, i + 1) + else msg.get(55, i + 1).decode("utf-8") + ) + currency = ( + None + if not msg.get(15, i + 1) + else msg.get(15, i + 1).decode("utf-8") + ) + header = f"Symbol: {symbol}, Currency: {currency}" + min_trade_vol = ( + None + if not msg.get(562, i + 1) + else msg.get(562, i + 1).decode("utf-8") + ) + max_trade_vol = ( + None + if not msg.get(1140, i + 1) + else msg.get(1140, i + 1).decode("utf-8") + ) + min_qty = ( + None + if not msg.get(25039, i + 1) + else msg.get(25039, i + 1).decode("utf-8") + ) + min_price_inc = ( + None + if not msg.get(969, i + 1) + else msg.get(969, i + 1).decode("utf-8") + ) + body1 = f"Min trade vol: {min_trade_vol} | Max trade vol: {max_trade_vol} | Min Qty: {min_qty} | Min price inc: {min_price_inc}" + market_min_trade_vol = ( + None + if not msg.get(25040, i + 1) + else msg.get(25040, i + 1).decode("utf-8") + ) + market_max_trade_vol = ( + None + if not msg.get(25041, i + 1) + else msg.get(25041, i + 1).decode("utf-8") + ) + market_min_qty = ( + None + if not msg.get(25042, i + 1) + else msg.get(25042, i + 1).decode("utf-8") + ) + body2 = f"Market Min trade vol: {market_min_trade_vol} | Market Max trade vol: {market_max_trade_vol} | Market Min Qty: {market_min_qty}" + + client.logger.info(header) + client.logger.info(body1) + client.logger.info(body2) + + +client_md = create_market_data_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_MD_URL, +) +client_md.retrieve_messages_until(message_type="A") + +example = "This example shows how to query information about active instruments.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#instrumentlistrequestx for additional types." +client_md.logger.info(example) + + +msg = client_md.create_fix_message_with_basic_header("x") +msg.append_pair(320, "GetInstrumentList") # md req id +msg.append_pair(559, 0) # InstrumentListRequestType: Single symbol +msg.append_pair(55, "BNBUSDT") # Symbol + + +client_md.logger.info("*" * 50) +client_md.logger.info("INSTRUMENT_LIST_REQUEST (x): Single symbol") +client_md.logger.info("*" * 50) +client_md.send_message(msg) + +time.sleep(1) +show_rendered_instrument_list(client_md) + +# LOGOUT +client_md.logger.info("LOGOUT (5)") +client_md.logout() +client_md.retrieve_messages_until(message_type="5") +client_md.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_md.disconnect() diff --git a/examples/maket_stream/depth_stream.py b/examples/maket_stream/depth_stream.py new file mode 100644 index 0000000..002b8d0 --- /dev/null +++ b/examples/maket_stream/depth_stream.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +import time +import os +from pathlib import Path +from datetime import datetime, timedelta + + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, +) +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URLs +FIX_MD_URL = "tcp+tls://fix-md.testnet.binance.vision:9000" + +# Response types +UPDATE = {"0": "BID", "1": "OFFER", "2": "TRADE"} +ACTION = {"0": "NEW", "1": "CHANGE", "2": "DELETE"} + +# Parameters +INSTRUMENT = "BNBUSDT" +TIMEOUT_SECONDS = 20 + + +def show_rendered_snapshot_message(client: BinanceFixConnector) -> None: + """Show the snapshot message received.""" + responses = client.retrieve_messages_until(message_type="W") + for msg in responses: + if msg.message_type.decode("utf-8") == "W": + client.logger.info("Parsing a MarketDataSnapshot (W) ...") + subscription_id = None if not msg.get(262) else msg.get(262).decode("utf-8") + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + last_book_id = ( + None if not msg.get(25044) else msg.get(25044).decode("utf-8") + ) + header = f"Snapshot: {subscription_id} -> {updates} updates received for Symbol: {symbol} and LastBookId: {last_book_id}" + client.logger.info(header) + for i in range(updates): + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + update_type = f"Update type: {UPDATE.get(update_type,update_type)}" + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + body = f"{update_type} | Price: {price} | Qty: {qty}" + client.logger.info(body) + + +def show_rendered_market_depth_stream(client: BinanceFixConnector) -> None: + """Show the current DEPTH stream messages received.""" + for _ in range(client.queue_msg_received.qsize()): + msg = client.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "X": + subscription_id = None if not msg.get(262) else msg.get(262).decode("utf-8") + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + first_book_id = ( + None if not msg.get(25043) else msg.get(25043).decode("utf-8") + ) + last_book_id = ( + None if not msg.get(25044) else msg.get(25044).decode("utf-8") + ) + header = f"Subscription: {subscription_id} -> {updates} updates received for Symbol: {symbol} between FirstBookId: {first_book_id} and LastBookId: {last_book_id}" + client.logger.info(header) + qty_index = 0 + for i in range(updates): + action = ( + None + if not msg.get(279, i + 1) + else msg.get(279, i + 1).decode("utf-8") + ) + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1 - qty_index) + else msg.get(271, i + 1 - qty_index).decode("utf-8") + ) + qty_str = f"| Qty: {qty}" + if action == "2": + qty_str = "" + qty_index += 1 + + body = f"Action: {ACTION.get(action, action)} | Update: {UPDATE.get(update_type,update_type)} | Price: {price} {qty_str}" + client.logger.info(body) + + +client_md = create_market_data_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_MD_URL, +) +client_md.retrieve_messages_until(message_type="A") + +example = "This example shows how to subscribe to a book depth stream.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#diffdepthstream for additional types." +client_md.logger.info(example) + + +msg = client_md.create_fix_message_with_basic_header("V") +msg.append_pair(262, "DEPTH_STREAM") # md req id +msg.append_pair(263, 1) # Subscription type + +msg.append_pair(264, 50) # market depth +msg.append_pair(266, "Y") # aggregated book +msg.append_pair(146, 1) # NoSymbols +msg.append_pair(55, INSTRUMENT) # Symbol +msg.append_pair(267, 2) # NoMDEntries +msg.append_pair(269, 0) # MDEntry +msg.append_pair(269, 1) # MDEntry + +client_md.logger.info("*" * 50) +client_md.logger.info("MARKET_DATA_REQUEST (V): SUBSCRIBING") +client_md.logger.info("*" * 50) +client_md.send_message(msg) +client_md.logger.info( + f"Subscribed to the Depth stream, showing stream for {TIMEOUT_SECONDS} seconds." +) + +show_rendered_snapshot_message(client_md) +timeout = datetime.now() + timedelta(seconds=TIMEOUT_SECONDS) +while datetime.now() < timeout: + time.sleep(0.01) + show_rendered_market_depth_stream(client_md) + +msg = client_md.create_fix_message_with_basic_header("V") +msg.append_pair(262, "DEPTH_STREAM") # md req id +msg.append_pair(263, 2) # Subscription type + +msg.append_pair(264, 1) # market depth +msg.append_pair(266, "Y") # aggregated book +msg.append_pair(146, 1) # NoSymbols +msg.append_pair(55, INSTRUMENT) # Symbol +msg.append_pair(267, 1) # NoMDEntries +msg.append_pair(269, 2) # MDEntry + +client_md.logger.info("*" * 50) +client_md.logger.info("MARKET_DATA_REQUEST (V): UNSUBSCRIBING") +client_md.logger.info("*" * 50) +client_md.send_message(msg) + +# LOGOUT +client_md.logger.info("LOGOUT (5)") +client_md.logout() +client_md.retrieve_messages_until(message_type="5") +client_md.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_md.disconnect() diff --git a/examples/maket_stream/ticker_stream.py b/examples/maket_stream/ticker_stream.py new file mode 100644 index 0000000..5f5525b --- /dev/null +++ b/examples/maket_stream/ticker_stream.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +import time +import os +from pathlib import Path +from datetime import datetime, timedelta + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, +) +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URL +FIX_MD_URL = "tcp+tls://fix-md.testnet.binance.vision:9000" + +# Response types +UPDATE = {"0": "BID", "1": "OFFER", "2": "TRADE"} + +# Parameters +INSTRUMENT = "BNBUSDT" +TIMEOUT_SECONDS = 20 + + +def show_rendered_snapshot_message(client: BinanceFixConnector) -> None: + """Show the snapshot message received.""" + responses = client.retrieve_messages_until(message_type="W") + for msg in responses: + if msg.message_type.decode("utf-8") == "W": + client.logger.info("Parsing a MarketDataSnapshot (W) ...") + subscription_id = None if not msg.get(262) else msg.get(262).decode("utf-8") + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + last_book_id = ( + None if not msg.get(25044) else msg.get(25044).decode("utf-8") + ) + header = f"Snapshot: {subscription_id} -> {updates} updates received for Symbol: {symbol} and LastBookId: {last_book_id}" + client.logger.info(header) + for i in range(updates): + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + update_type = f"Update type: {UPDATE.get(update_type,update_type)}" + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + body = f"{update_type} | Price: {price} | Qty: {qty}" + client.logger.info(body) + + +def show_rendered_market_book_ticker_stream(client: BinanceFixConnector) -> None: + """Show the current BOOK TICKER stream messages received.""" + for _ in range(client.queue_msg_received.qsize()): + msg = client.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "X": + subscription_id = None if not msg.get(262) else msg.get(262).decode("utf-8") + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + header = f"Subscription: {subscription_id} -> {updates} updates received for Symbol: {symbol}" + client.logger.info(header) + for i in range(updates): + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + update_type = f"Update type: {UPDATE.get(update_type,update_type)}" + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + last_book_id = ( + None + if not msg.get(25044, i + 1) + else msg.get(25044, i + 1).decode("utf-8") + ) + last_book_id_str = ( + "" if not last_book_id else f"| Last Book ID: {last_book_id}" + ) + body = f"{update_type} | Price: {price} | Qty: {qty} {last_book_id_str}" + client.logger.info(body) + + +client_md = create_market_data_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_MD_URL, + recv_window=100, +) +client_md.retrieve_messages_until(message_type="A") + +example = "This example shows how to subscribe to a book ticker stream.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#symbolbooktickerstream for additional types." +client_md.logger.info(example) + + +msg = client_md.create_fix_message_with_basic_header("V") +msg.append_pair(262, "BOOK_TICKER_STREAM") # md req id +msg.append_pair(263, 1) # Subscription type + +msg.append_pair(264, 1) # market depth +msg.append_pair(266, "Y") # aggregated book +msg.append_pair(146, 1) # NoSymbols +msg.append_pair(55, INSTRUMENT) # Symbol +msg.append_pair(267, 2) # NoMDEntries +msg.append_pair(269, 0) # MDEntry +msg.append_pair(269, 1) # MDEntry + +client_md.logger.info("*" * 50) +client_md.logger.info("MARKET_DATA_REQUEST (V): SUBSCRIBING") +client_md.logger.info("*" * 50) +client_md.send_message(msg) +client_md.logger.info( + f"Subscribed to the Book Ticker stream, showing stream for {TIMEOUT_SECONDS} seconds." +) + +show_rendered_snapshot_message(client_md) +timeout = datetime.now() + timedelta(seconds=TIMEOUT_SECONDS) +while datetime.now() < timeout: + time.sleep(0.01) + show_rendered_market_book_ticker_stream(client_md) + +msg = client_md.create_fix_message_with_basic_header("V") +msg.append_pair(262, "BOOK_TICKER_STREAM") # md req id +msg.append_pair(263, 2) # Subscription type + +msg.append_pair(264, 1) # market depth +msg.append_pair(266, "Y") # aggregated book +msg.append_pair(146, 1) # NoSymbols +msg.append_pair(55, INSTRUMENT) # Symbol +msg.append_pair(267, 1) # NoMDEntries +msg.append_pair(269, 2) # MDEntry + +client_md.logger.info("*" * 50) +client_md.logger.info("MARKET_DATA_REQUEST (V): UNSUBSCRIBING") +client_md.logger.info("*" * 50) +client_md.send_message(msg) + +# LOGOUT +client_md.logger.info("LOGOUT (5)") +client_md.logout() +client_md.retrieve_messages_until(message_type="5") +client_md.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_md.disconnect() diff --git a/examples/maket_stream/trade_stream.py b/examples/maket_stream/trade_stream.py new file mode 100644 index 0000000..47ecd80 --- /dev/null +++ b/examples/maket_stream/trade_stream.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +import time +import os +from pathlib import Path +from datetime import datetime, timedelta + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, +) +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URL +FIX_MD_URL = "tcp+tls://fix-md.testnet.binance.vision:9000" + +# Response types +UPDATE = {"0": "BID", "1": "OFFER", "2": "TRADE"} +AGGRESSOR_SIDE = {"1": "BUY", "2": "SELL"} + +# Parameters +INSTRUMENT = "BNBUSDT" +TIMEOUT_SECONDS = 20 + + +def show_rendered_market_trade_stream(client: BinanceFixConnector) -> None: + """Show the current TRADE stream messages received.""" + for _ in range(client.queue_msg_received.qsize()): + msg = client.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "X": + subscription_id = None if not msg.get(262) else msg.get(262).decode("utf-8") + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + header = f"Subscription: {subscription_id} -> {updates} updates received for Symbol: {symbol}" + client.logger.info(header) + for i in range(updates): + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + update_type = f"Update type: {UPDATE.get(update_type, update_type)}" + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + trade_id = ( + None + if not msg.get(1003, i + 1) + else msg.get(1003, i + 1).decode("utf-8") + ) + transact_time = ( + None + if not msg.get(60, i + 1) + else msg.get(60, i + 1).decode("utf-8") + ) + aggressor_side = ( + None + if not msg.get(2446, i + 1) + else msg.get(2446, i + 1).decode("utf-8") + ) + aggressor_side = f"Aggressor side: {AGGRESSOR_SIDE.get(aggressor_side ,aggressor_side)}" + body = f"{update_type} | trade_id: {trade_id} | Transaction time: {transact_time} | Price: {price} | Qty: {qty} | {aggressor_side}" + client.logger.info(body) + + +client_md = create_market_data_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_MD_URL, +) +client_md.retrieve_messages_until(message_type="A") + +example = "This example shows how to subscribe to a trade stream.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#tradestream for additional types." +client_md.logger.info(example) + + +msg = client_md.create_fix_message_with_basic_header("V") +msg.append_pair(262, "TRADE_STREAM") # md req id +msg.append_pair(263, 1) # Subscription type + +msg.append_pair(264, 1) # market depth +msg.append_pair(266, "Y") # aggregated book +msg.append_pair(146, 1) # NoSymbols +msg.append_pair(55, INSTRUMENT) # Symbol +msg.append_pair(267, 1) # NoMDEntries +msg.append_pair(269, 2) # MDEntry + +client_md.logger.info("*" * 50) +client_md.logger.info("MARKET_DATA_REQUEST (V): SUBSCRIBING") +client_md.logger.info("*" * 50) +client_md.send_message(msg) + + +client_md.logger.info( + f"Subscribed to the Trade stream, showing stream for {TIMEOUT_SECONDS} seconds." +) +timeout = datetime.now() + timedelta(seconds=TIMEOUT_SECONDS) +while datetime.now() < timeout: + time.sleep(0.01) + show_rendered_market_trade_stream(client_md) + +client_md.logger.info( + f"Subscribed to the Trade stream, showing stream for {TIMEOUT_SECONDS} seconds." +) + +msg = client_md.create_fix_message_with_basic_header("V") +msg.append_pair(262, "TRADE_STREAM") # md req id +msg.append_pair(263, 2) # Subscription type + +msg.append_pair(264, 1) # market depth +msg.append_pair(266, "Y") # aggregated book +msg.append_pair(146, 1) # NoSymbols +msg.append_pair(55, INSTRUMENT) # Symbol +msg.append_pair(267, 1) # NoMDEntries +msg.append_pair(269, 2) # MDEntry + +client_md.logger.info("*" * 50) +client_md.logger.info("MARKET_DATA_REQUEST (V): UNSUBSCRIBING") +client_md.logger.info("*" * 50) +client_md.send_message(msg) + +# LOGOUT +client_md.logger.info("LOGOUT (5)") +client_md.logout() +client_md.retrieve_messages_until(message_type="5") +client_md.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_md.disconnect() diff --git a/examples/trade/new_list_OTO_order.py b/examples/trade/new_list_OTO_order.py new file mode 100644 index 0000000..7db6f7c --- /dev/null +++ b/examples/trade/new_list_OTO_order.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 + +import time +import os +from pathlib import Path + +from binance_fix_connector.fix_connector import create_order_entry_session +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URL +FIX_OE_URL = "tcp+tls://fix-oe.testnet.binance.vision:9000" + +# Response types +ORD_STATUS = { + "0": "NEW", + "1": "PARTIALLY_FILLED", + "2": "FILLED", + "4": "CANCELED", + "6": "PENDING_CANCEL", + "8": "REJECTED", + "A": "PENDING_NEW", + "C": "EXPIRED", +} +ORD_TYPES = {"1": "MARKET", "2": "LIMIT", "3": "STOP", "4": "STOP_LIMIT"} +SIDES = {"1": "BUY", "2": "SELL"} +TIME_IN_FORCE = { + "1": "GOOD_TILL_CANCEL", + "3": "IMMEDIATE_OR_CANCEL", + "4": "FILL_OR_KILL", +} +ORD_REJECT_REASON = {"99": "OTHER"} +LIST_STATUS = {"2": "RESPONSE", "4": "EXEC_STARTED", "5": "ALL_DONE"} +LIST_ORD_STATUS = {"3": "EXECUTING", "6": "ALL_DONE", "7": "REJECT"} +LIST_ORD_TYPE = {"1": "ONE_CANCELS_THE_OTHER", "2": "ONE_TRIGGERS_THE_OTHER"} +LIST_TRIG_TYPE = {"ACTIVATED": "1", "PARTIALLY_FILLED": "2", "FILLED": "3"} +LIST_TRIG_ACTION = {"RELEASE": "1", "CANCEL": "2"} + +# Parameters +INSTRUMENT = "BNBUSDT" + +client_oe = create_order_entry_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_OE_URL, +) + + +client_oe.retrieve_messages_until(message_type="A") +client_oe.get_all_new_messages_received() + +example = "This example shows how to place a list order type OTO, where both legs are Order type LIMIT.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#neworderliste for additional types." +client_oe.logger.info(example) + +# PLACING OTO ORDER +msg = client_oe.create_fix_message_with_basic_header("E") +identifier = f"{time.time_ns()}" +working_leg_id = f"w{identifier}" +pending_leg_id = f"p{identifier}" + +msg.append_pair(73, 2) +msg.append_pair(11, working_leg_id) +msg.append_pair(55, INSTRUMENT) +msg.append_pair(54, 2) # SELL +msg.append_pair(38, 1) # QTY +msg.append_pair(40, 2) # LIMIT +msg.append_pair(44, 730) # PRICE +msg.append_pair(59, 1) # GTC +msg.append_pair(11, pending_leg_id) +msg.append_pair(55, INSTRUMENT) +msg.append_pair(54, 2) # SELL +msg.append_pair(38, 1) # QTY +msg.append_pair(40, 2) # LIMIT +msg.append_pair(44, 735) # PRICE +msg.append_pair(59, 1) # GTC +msg.append_pair(25010, 1) +msg.append_pair(25011, 3) +msg.append_pair(25012, 0) +msg.append_pair(25013, 1) +msg.append_pair(1385, 2) # OTO +msg.append_pair(25014, f"{identifier}") +client_oe.send_message(msg) + +responses = client_oe.retrieve_messages_until(message_type="N") +resp = next( + (x for x in responses if x.message_type.decode("utf-8") == "N"), + None, +) +client_oe.logger.info("*" * 50) +client_oe.logger.info("Parsing response List status (N) for an OTO type.") +client_oe.logger.info("*" * 50) + + +symbol = None if not resp.get(55) else resp.get(55).decode("utf-8") +list_status_type = None if not resp.get(429) else resp.get(429).decode("utf-8") +list_ord_status = None if not resp.get(431) else resp.get(431).decode("utf-8") +cl_list_id = None if not resp.get(25014) else resp.get(25014).decode("utf-8") +contingency = None if not resp.get(1385) else resp.get(1385).decode("utf-8") +header = f"Symbol: {symbol} | List status: {LIST_STATUS.get(list_status_type,list_status_type)} | List order status: {LIST_ORD_STATUS.get(list_ord_status,list_ord_status)}" +header_2 = f"Client list id: {cl_list_id} | List type: {LIST_ORD_TYPE.get(contingency,contingency)} | " +client_oe.logger.info(header) +client_oe.logger.info(header_2) + +orders = 0 if not resp.get(73) else int(resp.get(73).decode("utf-8")) +for i in range(orders): + cl_ord_id = None if not resp.get(11, i + 1) else resp.get(11, i + 1).decode("utf-8") + symbol = None if not resp.get(55, i + 1) else resp.get(55, i + 1).decode("utf-8") + ord_rej_reason = ( + None if not resp.get(103, i + 1) else resp.get(103, i + 1).decode("utf-8") + ) + error_code = ( + None if not resp.get(25016, i + 1) else resp.get(25016, i + 1).decode("utf-8") + ) + text = None if not resp.get(58, i + 1) else resp.get(58, i + 1).decode("utf-8") + body = f"Client order ID: {cl_ord_id} | Symbol: {symbol}" + body_2 = f"Reason: {ORD_REJECT_REASON.get(ord_rej_reason,ord_rej_reason)} | Error code: {error_code} | Msg: {text}" + + client_oe.logger.info(body) + client_oe.logger.info(body_2) + + +# +++++++++++++++++++++++++++ +responses = client_oe.retrieve_messages_until(message_type="8") +responses.extend(client_oe.retrieve_messages_until(message_type="8")) +resp = next( + ( + x + for x in responses + if x.message_type.decode("utf-8") == "8" + and x.get(11) + and x.get(11).decode("utf-8") == working_leg_id + ), + None, +) + +client_oe.logger.info("*" * 50) +client_oe.logger.info("Parsing response Execution Report (8) for the working leg order") +client_oe.logger.info("*" * 50) + +cl_ord_id = None if not resp.get(11) else resp.get(11).decode("utf-8") +order_qty = None if not resp.get(38) else resp.get(38).decode("utf-8") +ord_type = None if not resp.get(40) else resp.get(40).decode("utf-8") +side = None if not resp.get(54) else resp.get(54).decode("utf-8") +symbol = None if not resp.get(55) else resp.get(55).decode("utf-8") +price = None if not resp.get(44) else resp.get(44).decode("utf-8") +time_in_force = None if not resp.get(59) else resp.get(59).decode("utf-8") +cum_qty = None if not resp.get(14) else resp.get(14).decode("utf-8") +last_qty = None if not resp.get(32) else resp.get(32).decode("utf-8") +ord_status = None if not resp.get(39) else resp.get(39).decode("utf-8") +ord_rej_reason = None if not resp.get(103) else resp.get(103).decode("utf-8") +error_code = None if not resp.get(25016) else resp.get(25016).decode("utf-8") +text = None if not resp.get(58) else resp.get(58).decode("utf-8") + + +client_oe.logger.info(f"Client order ID: {cl_ord_id}") +client_oe.logger.info(f"Symbol: {symbol}") +client_oe.logger.info( + f"Order -> Type: {ORD_TYPES.get(ord_type, ord_type)} | Side: {SIDES.get(side, side)} | TimeInForce: {TIME_IN_FORCE.get(time_in_force,time_in_force)}", +) +client_oe.logger.info( + f"Price: {price} | Quantity: {order_qty} | cum qty: {cum_qty} | last qty: {last_qty}" +) +client_oe.logger.info( + f"Status: {ORD_STATUS.get(ord_status,ord_status)} | Reason: {ORD_REJECT_REASON.get(ord_rej_reason,ord_rej_reason)}", +) +client_oe.logger.info(f"Error code: {error_code} | Reason: {text}") + + +resp = next( + ( + x + for x in responses + if x.message_type.decode("utf-8") == "8" + and x.get(11) + and x.get(11).decode("utf-8") == working_leg_id + ), + None, +) + +client_oe.logger.info("*" * 50) +client_oe.logger.info("Parsing response Execution Report (8) for the pending leg order") +client_oe.logger.info("*" * 50) + +cl_ord_id = None if not resp.get(11) else resp.get(11).decode("utf-8") +order_qty = None if not resp.get(38) else resp.get(38).decode("utf-8") +ord_type = None if not resp.get(40) else resp.get(40).decode("utf-8") +side = None if not resp.get(54) else resp.get(54).decode("utf-8") +symbol = None if not resp.get(55) else resp.get(55).decode("utf-8") +price = None if not resp.get(44) else resp.get(44).decode("utf-8") +time_in_force = None if not resp.get(59) else resp.get(59).decode("utf-8") +cum_qty = None if not resp.get(14) else resp.get(14).decode("utf-8") +last_qty = None if not resp.get(32) else resp.get(32).decode("utf-8") +ord_status = None if not resp.get(39) else resp.get(39).decode("utf-8") +ord_rej_reason = None if not resp.get(103) else resp.get(103).decode("utf-8") +error_code = None if not resp.get(25016) else resp.get(25016).decode("utf-8") +text = None if not resp.get(58) else resp.get(58).decode("utf-8") + + +client_oe.logger.info(f"Client order ID: {cl_ord_id}") +client_oe.logger.info(f"Symbol: {symbol}") +client_oe.logger.info( + f"Order -> Type: {ORD_TYPES.get(ord_type, ord_type)} | Side: {SIDES.get(side, side)} | TimeInForce: {TIME_IN_FORCE.get(time_in_force,time_in_force)}", +) +client_oe.logger.info( + f"Price: {price} | Quantity: {order_qty} | cum qty: {cum_qty} | last qty: {last_qty}" +) +client_oe.logger.info( + f"Status: {ORD_STATUS.get(ord_status,ord_status)} | Reason: {ORD_REJECT_REASON.get(ord_rej_reason,ord_rej_reason)}", +) +client_oe.logger.info(f"Error code: {error_code} | Reason: {text}") + + +# LOGOUT +client_oe.logger.info("LOGOUT (5)") +client_oe.logout() +client_oe.retrieve_messages_until(message_type="5") +client_oe.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_oe.disconnect() diff --git a/examples/trade/new_order.py b/examples/trade/new_order.py new file mode 100644 index 0000000..7b14ebd --- /dev/null +++ b/examples/trade/new_order.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 + +import time +import os +from pathlib import Path + +from binance_fix_connector.fix_connector import create_order_entry_session +from binance_fix_connector.utils import get_api_key, get_private_key + +# Credentials +path = config_path = os.path.join(Path(__file__).parent.resolve(), "..", "config.ini") +API_KEY, PATH_TO_PRIVATE_KEY_PEM_FILE = get_api_key(path) + +# FIX URL +FIX_OE_URL = "tcp+tls://fix-oe.testnet.binance.vision:9000" + +# Response types +ORD_STATUS = { + "0": "NEW", + "1": "PARTIALLY_FILLED", + "2": "FILLED", + "4": "CANCELED", + "6": "PENDING_CANCEL", + "8": "REJECTED", + "A": "PENDING_NEW", + "C": "EXPIRED", +} +ORD_TYPES = {"1": "MARKET", "2": "LIMIT", "3": "STOP", "4": "STOP_LIMIT"} +SIDES = {"1": "BUY", "2": "SELL"} +TIME_IN_FORCE = { + "1": "GOOD_TILL_CANCEL", + "3": "IMMEDIATE_OR_CANCEL", + "4": "FILL_OR_KILL", +} +ORD_REJECT_REASON = {"99": "OTHER"} + +# Parameter +INSTRUMENT = "BNBUSDT" + +client_oe = create_order_entry_session( + api_key=API_KEY, + private_key=get_private_key(PATH_TO_PRIVATE_KEY_PEM_FILE), + endpoint=FIX_OE_URL, +) +client_oe.retrieve_messages_until(message_type="A") + +example = "This example shows how to place a single order. Order type LIMIT.\nCheck https://github.com/binance/binance-spot-api-docs/blob/master/fix-api.md#newordersingled for additional types." +client_oe.logger.info(example) + +# PLACING SIMPLE ORDER +msg = client_oe.create_fix_message_with_basic_header("D") +msg.append_pair(38, 1) # ORD QTY +msg.append_pair(40, 2) # ORD TYPE +msg.append_pair(11, str(time.time_ns())) # CL ORD ID +msg.append_pair(44, 730) # PRICE +msg.append_pair(54, 2) # SIDE +msg.append_pair(55, INSTRUMENT) # SYMBOL +msg.append_pair(59, 1) # TIME IN FORCE +client_oe.send_message(msg) + + +responses = client_oe.retrieve_messages_until(message_type="8") +resp = next( + (x for x in responses if x.message_type.decode("utf-8") == "8"), + None, +) +client_oe.logger.info("Parsing response Execution Report (8) for an order LIMIT type.") + +cl_ord_id = None if not resp.get(11) else resp.get(11).decode("utf-8") +order_qty = None if not resp.get(38) else resp.get(38).decode("utf-8") +ord_type = None if not resp.get(40) else resp.get(40).decode("utf-8") +side = None if not resp.get(54) else resp.get(54).decode("utf-8") +symbol = None if not resp.get(55) else resp.get(55).decode("utf-8") +price = None if not resp.get(44) else resp.get(44).decode("utf-8") +time_in_force = None if not resp.get(59) else resp.get(59).decode("utf-8") +cum_qty = None if not resp.get(14) else resp.get(14).decode("utf-8") +last_qty = None if not resp.get(32) else resp.get(32).decode("utf-8") +ord_status = None if not resp.get(39) else resp.get(39).decode("utf-8") +ord_rej_reason = None if not resp.get(103) else resp.get(103).decode("utf-8") +error_code = None if not resp.get(25016) else resp.get(25016).decode("utf-8") +text = None if not resp.get(58) else resp.get(58).decode("utf-8") + + +client_oe.logger.info(f"Client order ID: {cl_ord_id}") +client_oe.logger.info(f"Symbol: {symbol}") +client_oe.logger.info( + f"Order -> Type: {ORD_TYPES.get(ord_type, ord_type)} | Side: {SIDES.get(side, side)} | TimeInForce: {TIME_IN_FORCE.get(time_in_force,time_in_force)}", +) +client_oe.logger.info( + f"Price: {price} | Quantity: {order_qty} | cum qty: {cum_qty} | last qty: {last_qty}" +) +client_oe.logger.info( + f"Status: {ORD_STATUS.get(ord_status,ord_status)} | Msg: {ORD_REJECT_REASON.get(ord_rej_reason,ord_rej_reason)}", +) +client_oe.logger.info(f"Error code: {error_code} | Reason: {text}") + + +# LOGOUT +client_oe.logger.info("LOGOUT (5)") +client_oe.logout() +client_oe.retrieve_messages_until(message_type="5") +client_oe.logger.info( + "Closing the connection with server as we already sent the logout message" +) +client_oe.disconnect() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..463e313 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "binance_fix_connector" +version = "1.0.0" +authors = [{name = "Binance"}] +description = "This is a simple Python library that provides access to Binance Financial Information eXchange (FIX) SPOT messages using the FIX protocol." +readme = "README.md" +requires-python = ">=3.9" +classifiers = [ + "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "simplefix", + "cryptography", + "black" +] +license = {file = "LICENSE"} +keywords = ["binance", "fix", "connector"] \ No newline at end of file diff --git a/src/binance_fix_connector/__init__.py b/src/binance_fix_connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/binance_fix_connector/fix_connector.py b/src/binance_fix_connector/fix_connector.py new file mode 100644 index 0000000..9d9caf4 --- /dev/null +++ b/src/binance_fix_connector/fix_connector.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import base64 +import contextlib +import logging +import socket +import ssl +import sys +import threading +import time +from datetime import datetime, timedelta, timezone +from queue import Queue +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from simplefix import FixMessage + +if TYPE_CHECKING: + from cryptography.hazmat.primitives.asymmetric import ed25519 + +_SOH_ = "\x01" +GREEN = "\033[32m" +BLUE = "\u001b[34m" +RESET = "\x1b[0m" +MAX_BUFFER_SIZE = 4096 +MAX_SENDER_ID_LENGTH = 8 +MIN_FIX_MESSAGE_LENGTH = 37 +TRAILER_SIZE = 6 +FIX_MD_URL = "tcp+tls://fix-md.binance.com:9000" +FIX_OE_URL = "tcp+tls://fix-oe.binance.com:9000" +FIX_DC_URL = "tcp+tls://fix-dc.binance.com:9000" + + +class FixMsgTypes: + HEARTBEAT = "0" + TEST_REQUEST = "1" + LOGOUT = "5" + LOGON = "A" + REJECT = "3" + + +class FixTags: + BEGIN_STRING = "8" + BODY_LENGTH = "9" + CHECKSUM = "10" + MSG_SEQ_NUM = "34" + MSG_TYPE = "35" + SENDER_COMP_ID = "49" + SENDING_TIME = "52" + TARGET_COMP_ID = "56" + TEXT = "58" + RAW_DATA_LENGTH = "95" + RAW_DATA = "96" + ENCRYPT_METHOD = "98" + HEART_BT_INT = "108" + TEST_REQ_ID = "112" + RESET_SEQ_NUM_FLAG = "141" + USERNAME = "553" + DROP_COPY_FLAG = "9406" + + RECV_WINDOW = "25000" + MESSAGE_HANDLING = "25035" + RESPONSE_MODE = "25036" + + +def __create_session( + api_key: str, + private_key: ed25519.Ed25519PrivateKey, + endpoint: str, + sender_comp_id: str, + *, + target_comp_id: str = "SPOT", + fix_version: str = "FIX.4.4", + socket_buffer_size: int = MAX_BUFFER_SIZE, + heart_bt_int: int = 30, + reset_seq_num_flag: bool = True, + encrypt_method: int = 0, + message_handling: int = 2, + response_mode: int | None = None, + drop_copy_flag: bool | None = None, + recv_window: int | None = None, +) -> BinanceFixConnector: + session = BinanceFixConnector( + endpoint=endpoint, + api_key=api_key, + private_key=private_key, + sender_comp_id=sender_comp_id, + target_comp_id=target_comp_id, + fix_version=fix_version, + socket_buffer_size=socket_buffer_size, + heart_bt_int=heart_bt_int, + reset_seq_num_flag=reset_seq_num_flag, + encrypt_method=encrypt_method, + message_handling=message_handling, + response_mode=response_mode, + drop_copy_flag=drop_copy_flag, + ) + session.connect() + session.logon(recv_window=recv_window) + return session + + +def create_market_data_session( + api_key: str, + private_key: ed25519.Ed25519PrivateKey, + endpoint: str = FIX_MD_URL, + sender_comp_id: str = "WATCH", + target_comp_id: str = "SPOT", + fix_version: str = "FIX.4.4", + heart_bt_int: int = 30, + message_handling: int = 2, + recv_window: int | None = None, +) -> BinanceFixConnector: + """ + Create a session to the FIX market data service. + + Message handling: 1->UNORDERED + 2->SEQUENTIAL + """ + return __create_session( + endpoint=endpoint, + api_key=api_key, + private_key=private_key, + sender_comp_id=("BMD" + sender_comp_id)[0:MAX_SENDER_ID_LENGTH], + target_comp_id=target_comp_id, + fix_version=fix_version, + heart_bt_int=heart_bt_int, + socket_buffer_size=MAX_BUFFER_SIZE, + reset_seq_num_flag="Y", + encrypt_method=0, + message_handling=message_handling, + recv_window=recv_window, + ) + + +def create_order_entry_session( + api_key: str, + private_key: ed25519.Ed25519PrivateKey, + endpoint: str = FIX_OE_URL, + sender_comp_id: str = "TRADE", + target_comp_id: str = "SPOT", + fix_version: str = "FIX.4.4", + heart_bt_int: int = 30, + message_handling: int = 2, + response_mode: int = 1, + recv_window: int | None = None, +) -> BinanceFixConnector: + """ + Create a session to the FIX order-entry service. + + Response mode: 1->EVERYTHING + 2->ONLY_ACKS + Message handling: 1->UNORDERED + 2->SEQUENTIAL + """ + return __create_session( + endpoint=endpoint, + api_key=api_key, + private_key=private_key, + sender_comp_id=("BOE" + sender_comp_id)[0:MAX_SENDER_ID_LENGTH], + target_comp_id=target_comp_id, + fix_version=fix_version, + heart_bt_int=heart_bt_int, + socket_buffer_size=MAX_BUFFER_SIZE, + reset_seq_num_flag="Y", + encrypt_method=0, + response_mode=response_mode, + message_handling=message_handling, + drop_copy_flag="N", + recv_window=recv_window, + ) + + +def create_drop_copy_session( + api_key: str, + private_key: ed25519.Ed25519PrivateKey, + endpoint: str = FIX_DC_URL, + sender_comp_id: str = "TECH", + target_comp_id: str = "SPOT", + fix_version: str = "FIX.4.4", + heart_bt_int: int = 30, + message_handling: int = 2, + response_mode: int = 1, + recv_window: int | None = None, +) -> BinanceFixConnector: + """ + Create a session to the FIX drop-copy service. + + Response mode: 1->EVERYTHING + 2->ONLY_ACKS + Message handling: 1->UNORDERED + 2->SEQUENTIAL + """ + return __create_session( + endpoint=endpoint, + api_key=api_key, + private_key=private_key, + sender_comp_id=("BDC" + sender_comp_id)[0:MAX_SENDER_ID_LENGTH], + target_comp_id=target_comp_id, + fix_version=fix_version, + heart_bt_int=heart_bt_int, + socket_buffer_size=MAX_BUFFER_SIZE, + auto_reconnect=False, + auto_reconnect_attempts=3, + reset_seq_num_flag="Y", + encrypt_method=0, + response_mode=response_mode, + message_handling=message_handling, + drop_copy_flag="Y", + recv_window=recv_window, + ) + + +class BinanceFixConnector: + def __init__( + self, + endpoint: str, + api_key: str, + private_key: ed25519.Ed25519PrivateKey, + sender_comp_id: str, + *, + target_comp_id: str = "SPOT", + fix_version: str = "FIX.4.4", + socket_buffer_size: int = MAX_BUFFER_SIZE, + heart_bt_int: int = 30, + reset_seq_num_flag: bool = True, + encrypt_method: int = 0, + message_handling: int = 2, + response_mode: int = 1, + drop_copy_flag: bool = False, + ) -> None: + """ + Create a fix session. + + Args: + ---- + endpoint (str): The server endpoint + api_key (str): The api key registered for the user + private_key (ed25519.Ed25519PrivateKey): the ed25519 private key used to register the api key + sender_comp_id (str): the sender id (client) + target_comp_id (str, optional):The target id (server). Defaults to "SPOT". + fix_version (str, optional): The fix version protocol used. Defaults to "FIX.4.4". + socket_buffer_size (int, optional): The socket buffer when receiving messages from server. Defaults to 4096. + + heart_bt_int (int, optional): The heartbeat interval. Defaults to 30 + reset_seq_num_flag (bool, optional): The reset seq num flag. Defaults to True. + + encrypt_method (int, optional): The encrypt method. Defaults to 0 (None). + message_handling (int, optional): The message handling. Defaults to 2 (SEQUENTIAL). + response_mode (int, optional): The response mode. Defaults to 1 (EVERYTHING). + drop_copy_flag (bool, optional): The drop copy flag. Defaults to False. + + + Raises: + ------ + ValueError: Raised when some mandatory arguments are not sent + + """ + error_message = "" + if not endpoint: + error_message += "endpoint can not be None or empty\n" + self.endpoint = endpoint + + if not api_key: + error_message += "api_key can not be None or empty\n" + self.api_key = api_key + + if not private_key: + error_message += "private_key can not be None or empty\n" + self.private_key = private_key + + if not sender_comp_id: + error_message += "sender_comp_id can not be None or empty\n" + elif len(sender_comp_id) > MAX_SENDER_ID_LENGTH: + error_message += "sender_comp_id can not be longer than 8 characters\n" + self.sender_comp_id = str(sender_comp_id) + + if error_message: + raise ValueError(error_message) + + self.target_comp_id = str(target_comp_id) + self.fix_version = str(fix_version) + + self.heart_bt_int = heart_bt_int + self.reset_seq_num_flag = reset_seq_num_flag + self.encrypt_method = encrypt_method + self.message_handling = message_handling + self.response_mode = response_mode + self.drop_copy_flag = drop_copy_flag + + self.socket_buffer_size: int = socket_buffer_size + + self.lock = threading.Lock() + self.priv_key: ed25519.Ed25519PrivateKey = None + + self.sock = None + self.ssl_sock = None + self.receive_thread = None + self.is_connected: bool = False + + self.msg_seq_num: int = 1 + self.queue_msg_received: Queue[FixMessage] = Queue() + self.messages_sent: list[FixMessage] = [] + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(stream=sys.stdout)], + ) + + self.logger = logging.getLogger("BinanceFixConnector") + self.__data: bytes = b"" + + def current_utc_time(self) -> str: + """ + Return the current utc time which will be used for signature and fix message header. + + Returns + ------- + - datetime in string format YYYYmmdd-HH:MM:SS.ffffff + + """ + return datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f") + + def get_next_seq_num(self) -> str: + """ + Return next seq num to be used for the fix message to be sent to server. + + Returns + ------- + str: next seq num valid + + """ + with self.lock: + self.msg_seq_num += 1 + return str(self.msg_seq_num) + + def generate_signature( + self, + sender_comp_id: str, + target_comp_id: str, + msg_seq_num: int, + sending_time: str, + ) -> str: + """ + Generate the signature required to login in the server. + + Args: + ---- + sender_comp_id (str): the sender comp id + target_comp_id (str): the target comp id + msg_seq_num (int): the msq seq num + sending_time (str): the sending time + + Raises: + ------ + ValueError: When the private key is not provided + + Returns: + ------- + signed_signature: signature ready to be used. + + """ + if not self.private_key: + msg = "Please provide a ed25219 key" + raise ValueError(msg) + signed_headers = f"A{_SOH_}{sender_comp_id}{_SOH_}{target_comp_id}{_SOH_}{msg_seq_num}{_SOH_}{sending_time}" + signature = self.private_key.sign(bytes(signed_headers, "ASCII")) + return base64.b64encode(signature).decode("ASCII") + + def parse_server_response(self) -> list[FixMessage]: + """ + Parse the response from the server and create a fix message for every message serve has sent. + + Returns + ------- + list[FixMessage]: The list of (FIX) messages server has sent. + + """ + if len(self.__data) < MIN_FIX_MESSAGE_LENGTH: + return [] + raw_data = self.__data.decode("utf-8") + msg = raw_data if raw_data.startswith(_SOH_) else f"{_SOH_}{raw_data}" + raw_messages = [f"8={x}" for x in msg.split(f"{_SOH_}8=") if x] + messages: list[FixMessage] = [] + for i in range(len(raw_messages)): + tag_values = [x for x in raw_messages[i].split(_SOH_) if x != ""] + if ( + len(tag_values) > 1 + and tag_values[0] == "8=" + and tag_values[1] == self.fix_version + ): + tag_values.pop(0) + tag_values[0] = f"8={self.fix_version}" + if tag_values[-1].startswith("10=") and len(tag_values[-1]) >= TRAILER_SIZE: + fix_msg = FixMessage() + fix_msg.append_strings(tag_values) + messages.append(fix_msg) + else: # uncompleted message + self.__data = bytes(f"{_SOH_}".join(raw_messages[i:]).encode("ASCII")) + return messages + + self.__data = b"" + return messages + + def connect(self) -> None: + """Create a socket connection between the client and the server.""" + try: + if self.sock: + self.sock.close() + self.sock = None + url = urlparse(self.endpoint) + sock = socket.create_connection((url.hostname, url.port)) + context = ssl.create_default_context() + self.sock = context.wrap_socket(sock, server_hostname=url.hostname) + self.logger.info("-" * 100) + self.logger.info( + "FIX Client (%s:%s): Connected to %s", + self.sock.getsockname()[0], + self.sock.getsockname()[1], + self.endpoint, + ) + self.logger.info("-" * 100) + self.logger.info("LOGIN (A)") + self.is_connected = True + if self.receive_thread is None or not self.receive_thread.is_alive(): + self.receive_thread = threading.Thread( + target=self.__receive_messages, daemon=True + ) + self.receive_thread.start() + + except Exception: + self.logger.exception("Error connecting") + raise + + def __receive_messages(self) -> None: + """Read the data sent from server and process the messages accordingly.""" + messages: list[FixMessage] = [] + while self.is_connected: + try: + data = self.sock.recv(self.socket_buffer_size) + self.__data += data + if not data: + break + messages = self.parse_server_response() + if messages: + self.__data = b"" + for msg in messages: + clean_message = msg.encode().decode("utf-8").replace(_SOH_, "|") + self.logger.info( + "%sServer=>Client: %s%s", GREEN, clean_message, RESET + ) + self.on_message_received(messages) + + except OSError: + break + except Exception: + self.logger.exception("Error receiving message") + self.disconnect() + raise + + def on_message_received(self, messages: list[FixMessage]) -> None: + """ + Process every message received from server. + + Args: + ---- + messages (list[FixMessage]): The messages to be processed + + """ + with self.lock: + for msg in messages: + self.queue_msg_received.put(msg) + for message in messages: + msg_type = ( + None + if not message.get(FixTags.MSG_TYPE) + else message.get(FixTags.MSG_TYPE).decode("utf-8") + ) + if msg_type == FixMsgTypes.TEST_REQUEST: + test_req_resp_id = ( + None + if not message.get("112") + else message.get("112").decode("utf-8") + ) + + if test_req_resp_id is None: + self.logger.error( + "Error: TestReqID (112) not found in the message." + ) + return + self.logger.debug( + "Sending a heartbeat message as we received a TestRequest message from server" + ) + self.heartbeat(test_req_resp_id) + + def get_all_new_messages_received(self) -> list[FixMessage]: + """ + Return all the FIX messages received from the server until now. + If no new messages received, it returns []. + + Returns + ------- + list[FixMessage]: The list of fix messages received from server. + + """ + with self.lock: + return [ + self.queue_msg_received.get() + for _ in range(self.queue_msg_received.qsize()) + ] + + def retrieve_messages_until( + self, + message_type: str, + timeout_seconds: int = 3, + ) -> list[FixMessage]: + """Return all the FIX messages received from the server until message of desired type is received.""" + # with self.lock: + messages: list[FixMessage] = [] + timeout = datetime.now() + timedelta(seconds=timeout_seconds) + while datetime.now() < timeout: + for _ in range(self.queue_msg_received.qsize()): + msg = self.queue_msg_received.get() + messages.append(msg) + if message_type and msg.get("35").decode("utf-8") == message_type: + return messages + + time.sleep(0.001) + return messages + + def send_message(self, message: FixMessage, *, raw: bool = False) -> None: + """ + Send the Fix Message to the server. + + Unless 'raw' is set, this function will calculate and + correctly set the BodyLength (9) and Checksum (10) fields, and + ensure that the BeginString (8), Body Length (9), Message Type + (35) and Checksum (10) fields are in the right positions. + + This function does no further validation of the message content. + + Args: + ---- + message (FixMessage): The message + raw (bool, optional): If True, encode pairs exactly as provided. + + """ + with self.lock: # save the logon message for future auto_reconnects + self.messages_sent.append(message) + + if not self.sock: + self.logger.error("Error: No connection established. can't send message.") + return + try: + self.sock.sendall(message.encode(raw)) + clean_message = message.encode().decode("utf-8").replace(chr(1), "|") + self.logger.info("%sClient=>Server: %s%s", BLUE, clean_message, RESET) + except Exception: + self.logger.exception("Error sending message") + + def create_fix_message_with_basic_header( + self, + msg_type: str, + recv_window: str | None = None, + ) -> FixMessage: + """ + Return a basic FixMessage with the mandatory headers required for a valid message. + + Args: + ---- + msg_type (str): The msg type + recv_window (str | None, optional): The recv window. + + Returns: + ------- + FixMessage: the fix message ready to be filled with the body tags + + """ + msg = FixMessage() + + msg.append_pair(FixTags.BEGIN_STRING, self.fix_version, header=True) + msg.append_pair(FixTags.MSG_TYPE, msg_type, header=True) + msg.append_pair(FixTags.SENDER_COMP_ID, self.sender_comp_id, header=True) + msg.append_pair(FixTags.TARGET_COMP_ID, self.target_comp_id, header=True) + msg.append_pair(FixTags.MSG_SEQ_NUM, self.get_next_seq_num(), header=True) + msg.append_pair(FixTags.SENDING_TIME, self.current_utc_time(), header=True) + msg.append_pair(FixTags.RECV_WINDOW, recv_window, header=True) + + return msg + + def logon( + self, + recv_window: str | None = None, + ) -> None: + """ + Logon method. + + Args: + ---- + recv_window (str | None, optional): The recv window. Defaults to None. + + """ + self.msg_seq_num = 0 + msg = self.create_fix_message_with_basic_header(FixMsgTypes.LOGON, recv_window) + signature = self.generate_signature( + self.sender_comp_id, + self.target_comp_id, + self.msg_seq_num, + msg.get(FixTags.SENDING_TIME).decode("utf-8"), + ) + + msg.append_pair(FixTags.ENCRYPT_METHOD, self.encrypt_method, header=False) + msg.append_pair(FixTags.HEART_BT_INT, self.heart_bt_int, header=False) + msg.append_data( + FixTags.RAW_DATA_LENGTH, FixTags.RAW_DATA, signature, header=False + ) + + msg.append_pair( + FixTags.RESET_SEQ_NUM_FLAG, self.reset_seq_num_flag, header=False + ) + + msg.append_pair(FixTags.USERNAME, self.api_key, header=False) + msg.append_pair(FixTags.MESSAGE_HANDLING, self.message_handling, header=False) + msg.append_pair(FixTags.RESPONSE_MODE, self.response_mode, header=False) + msg.append_pair(FixTags.DROP_COPY_FLAG, self.drop_copy_flag, header=False) + + self.send_message(msg) + + def logout(self, text: str | None = None, recv_window: str | None = None) -> None: + """ + Logout method. + + Args: + ---- + text (str | None, optional): The reason to logout. Defaults to None. + recv_window (str | None, optional): The recv window. Defaults to None. + + """ + msg = self.create_fix_message_with_basic_header(FixMsgTypes.LOGOUT, recv_window) + msg.append_pair(FixTags.TEXT, text, header=False) + + self.send_message(msg) + + def heartbeat( + self, test_req_id: str | None = None, recv_window: str | None = None + ) -> None: + """ + Heartbeat method. + + Args: + ---- + test_req_id (str | None, optional): The identifier for a test request. Defaults to None. + recv_window (str | None, optional): The recv window. Defaults to None. + + """ + msg = self.create_fix_message_with_basic_header( + FixMsgTypes.HEARTBEAT, recv_window + ) + msg.append_pair(FixTags.TEST_REQ_ID, test_req_id, header=False) + + self.send_message(msg) + + def test_request( + self, test_req_id: str | None = None, recv_window: str | None = None + ) -> None: + """ + Test request method. + + Args: + ---- + test_req_id (str | None, optional): The identifier for a test request. Defaults to None. + recv_window (str | None, optional): The recv window. Defaults to None. + + """ + msg = self.create_fix_message_with_basic_header( + FixMsgTypes.TEST_REQUEST, recv_window + ) + msg.append_pair(FixTags.TEST_REQ_ID, test_req_id, header=False) + + self.send_message(msg) + + def disconnect(self) -> None: + """Stop the connection with the server by shuting down the socket connection.""" + self.is_connected = False + if self.sock: + with contextlib.suppress(OSError): + self.sock.shutdown(socket.SHUT_RDWR) + self.sock.close() diff --git a/src/binance_fix_connector/utils.py b/src/binance_fix_connector/utils.py new file mode 100644 index 0000000..c95fc68 --- /dev/null +++ b/src/binance_fix_connector/utils.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path + +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from configparser import ConfigParser + + +def get_private_key(key_path: str): + if not key_path: + raise ValueError("Private key path is required") + with Path(key_path).open("rb") as f: + private_key_from_file = f.read() + return load_pem_private_key(private_key_from_file, password=None) + + +def get_api_key(config_path: str): + if not config_path: + raise ValueError("Config path is required") + config = ConfigParser() + config.read(config_path) + return config["keys"]["API_KEY"], config["keys"]["PATH_TO_PRIVATE_KEY_PEM_FILE"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/general/test_current_messages_limit_rate.py b/tests/general/test_current_messages_limit_rate.py new file mode 100644 index 0000000..8e2bb44 --- /dev/null +++ b/tests/general/test_current_messages_limit_rate.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_URL = "tcp+tls://localhost:1234" +INSTRUMENT = "BNBUSDT" + + +class TestCurrentMessagesLimitRate(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.sentMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.sentMessage += 1 + if self.sentMessage % 2 == 0: + return b"" + else: + if not self.logOutSent: + return b"8=FIX.4.4\x019=166\x0135=XLR\x0149=SPOT\x0156=BMDWATCH\x0134=2\x0152=20250301-01:00:00.001000\x016136=current_message_rate\x0125003=2\x0125004=2\x0125005=1\x0125006=10000\x0125007=60\x0125008=s\x0110=212\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0134=4\x0149=SPOT\x0152=20250301-01:00:00.002000\x0156=GhQHzrLR\x0158=Logout acknowledgment.\x0110=212\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_current_messages_limit_rate( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client = create_market_data_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_URL, + recv_window=100, + ) + + # Assert the logon request + self.assertEqual(1, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=204\x0135=A\x0149=BMDWATCH\x0156=SPOT\x0134=1\x0152=20250301-01:00:00.000000\x0125000=100\x0198=0\x01108=30\x0195=88\x0196=23rxWHmfUGML7o9O9lPkedNBj53wcilb9pFuIxc9Q1pGxHFQaBQnuPwj0ueH09t5Gzl3ybOc67wXGzh9Qcl/Aw==\x01141=Y\x01553=API_KEY\x0125035=2\x0110=219\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Create instrument list message + msg = client.create_fix_message_with_basic_header("XLQ") + msg.append_pair(6136, "current_message_rate") + client.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=86\x0135=XLQ\x0149=BMDWATCH\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x016136=current_message_rate\x0110=254\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + for _ in range(client.queue_msg_received.qsize()): + msg = client.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "y": + req_id = ( + None if not msg.get(6136) else int(msg.get(6136).decode("utf-8")) + ) + limit_indicator = ( + 0 if not msg.get(25003) else int(msg.get(25003).decode("utf-8")) + ) + + self.assertEqual("current_message_rate", req_id) + self.assertEqual("1", limit_indicator) + for i in range(limit_indicator): + limit_type = ( + None + if not msg.get(25004, i + 1) + else msg.get(25004, i + 1).decode("utf-8") + ) + limit_count = ( + None + if not msg.get(25005, i + 1) + else msg.get(25005, i + 1).decode("utf-8") + ) + limit_max = ( + None + if not msg.get(25006, i + 1) + else msg.get(25006, i + 1).decode("utf-8") + ) + limit_reset_interval = ( + None + if not msg.get(25007, i + 1) + else msg.get(25007, i + 1).decode("utf-8") + ) + interval_res = ( + None + if not msg.get(25008, i + 1) + else msg.get(25008, i + 1).decode("utf-8") + ) + + self.assertEqual("2", limit_type) + self.assertEqual("1", limit_count) + self.assertEqual("10000", limit_max) + self.assertEqual("60", limit_reset_interval) + self.assertEqual("s", interval_res) + + # Logout process + client.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(3, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BMDWATCH\x0156=SPOT\x0134=3\x0152=20250301-01:00:00.000000\x0110=222\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client.retrieve_messages_until(message_type="5", timeout_seconds=1) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client.disconnect() diff --git a/tests/general/test_instrument_list.py b/tests/general/test_instrument_list.py new file mode 100644 index 0000000..f47adec --- /dev/null +++ b/tests/general/test_instrument_list.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_MD_URL = "tcp+tls://localhost:1234" +INSTRUMENT = "BNBUSDT" + + +class TestInstrumentList(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.sentMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.sentMessage += 1 + if self.sentMessage % 2 == 0: + return b"" + else: + if not self.logOutSent: + return b"8=FIX.4.4\x019=227\x0135=y\x0149=SPOT\x0156=BMDWATCH\x0134=2\x0152=20250301-01:00:00.001000\x01320=GetInstrumentList\x01146=1\x0155=BNBUSDT\x0115=USDT\x01562=0.00100000\x011140=900000.00000000\x0125039=0.00100000\x0125040=0.00000001\x0125041=6629.33313692\x0125042=0.00000001\x01969=0.01000000\x0110=110\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0134=4\x0149=SPOT\x0152=20250301-01:00:00.002000\x0156=GhQHzrLR\x0158=Logout acknowledgment.\x0110=212\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_instrument_list( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client_md = create_market_data_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_MD_URL, + recv_window=100, + ) + + # Assert the logon request + self.assertEqual(1, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=204\x0135=A\x0149=BMDWATCH\x0156=SPOT\x0134=1\x0152=20250301-01:00:00.000000\x0125000=100\x0198=0\x01108=30\x0195=88\x0196=23rxWHmfUGML7o9O9lPkedNBj53wcilb9pFuIxc9Q1pGxHFQaBQnuPwj0ueH09t5Gzl3ybOc67wXGzh9Qcl/Aw==\x01141=Y\x01553=API_KEY\x0125035=2\x0110=219\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Create instrument list message + msg = client_md.create_fix_message_with_basic_header("x") + msg.append_pair(320, "GetInstrumentList") + msg.append_pair(559, 0) + msg.append_pair(55, "BNBUSDT") + client_md.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=97\x0135=x\x0149=BMDWATCH\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x01320=GetInstrumentList\x01559=0\x0155=BNBUSDT\x0110=182\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + for _ in range(client_md.queue_msg_received.qsize()): + msg = client_md.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "y": + instrument_req_id = ( + None if not msg.get(320) else msg.get(320).decode("utf-8") + ) + message_seq_num = ( + None if not msg.get(34) else msg.get(34).decode("utf-8") + ) + num_symbols = ( + 0 if not msg.get(146) else int(msg.get(146).decode("utf-8")) + ) + + self.assertEqual("GetInstrumentList", instrument_req_id) + self.assertEqual("2", message_seq_num) + self.assertEqual(1, num_symbols) + for i in range(num_symbols): + symbol = ( + None + if not msg.get(55, i + 1) + else msg.get(55, i + 1).decode("utf-8") + ) + currency = ( + None + if not msg.get(15, i + 1) + else msg.get(15, i + 1).decode("utf-8") + ) + min_trade_vol = ( + None + if not msg.get(562, i + 1) + else msg.get(562, i + 1).decode("utf-8") + ) + max_trade_vol = ( + None + if not msg.get(1140, i + 1) + else msg.get(1140, i + 1).decode("utf-8") + ) + min_qty = ( + None + if not msg.get(25039, i + 1) + else msg.get(25039, i + 1).decode("utf-8") + ) + min_price_inc = ( + None + if not msg.get(969, i + 1) + else msg.get(969, i + 1).decode("utf-8") + ) + market_min_trade_vol = ( + None + if not msg.get(25040, i + 1) + else msg.get(25040, i + 1).decode("utf-8") + ) + market_max_trade_vol = ( + None + if not msg.get(25041, i + 1) + else msg.get(25041, i + 1).decode("utf-8") + ) + market_min_qty = ( + None + if not msg.get(25042, i + 1) + else msg.get(25042, i + 1).decode("utf-8") + ) + + self.assertEqual("BNBUSDT", symbol) + self.assertEqual("USDT", currency) + self.assertEqual("0.00100000", min_trade_vol) + self.assertEqual("900000.00000000", max_trade_vol) + self.assertEqual("0.00100000", min_qty) + self.assertEqual("0.01000000", min_price_inc) + self.assertEqual("0.00000001", market_min_trade_vol) + self.assertEqual("6629.33313692", market_max_trade_vol) + self.assertEqual("0.00000001", market_min_qty) + + # Logout process + client_md.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(3, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BMDWATCH\x0156=SPOT\x0134=3\x0152=20250301-01:00:00.000000\x0110=222\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client_md.retrieve_messages_until( + message_type="5", timeout_seconds=1 + ) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client_md.disconnect() diff --git a/tests/market_stream/test_book_depth_stream.py b/tests/market_stream/test_book_depth_stream.py new file mode 100644 index 0000000..bd476a9 --- /dev/null +++ b/tests/market_stream/test_book_depth_stream.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_MD_URL = "tcp+tls://localhost:1234" +INSTRUMENT = "BNBUSDT" + + +class TestBookDepthStream(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.sentMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.sentMessage += 1 + if self.sentMessage % 2 == 0: + return b"" + else: + if not self.logOutSent: + return b"8=FIX.4.4\x019=0000165\x0135=X\x0149=SPOT\x0156=BMDWATCH\x0134=3\x0152=20250301-01:00:00.001000\x01262=DEPTH_STREAM\x01268=1\x01279=1\x01269=0\x01270=638.54000000\x01271=11.76700000\x0155=BNBUSDT\x0125043=7517775\x0125044=7517775\x0110=021\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0134=4\x0149=SPOT\x0152=20250301-01:00:00.002000\x0156=GhQHzrLR\x0158=Logout acknowledgment.\x0110=212\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_depth_stream( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client_md = create_market_data_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_MD_URL, + recv_window=100, + ) + + # Assert the logon request + self.assertEqual(1, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=204\x0135=A\x0149=BMDWATCH\x0156=SPOT\x0134=1\x0152=20250301-01:00:00.000000\x0125000=100\x0198=0\x01108=30\x0195=88\x0196=23rxWHmfUGML7o9O9lPkedNBj53wcilb9pFuIxc9Q1pGxHFQaBQnuPwj0ueH09t5Gzl3ybOc67wXGzh9Qcl/Aw==\x01141=Y\x01553=API_KEY\x0125035=2\x0110=219\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Send a depth_stream request + msg = client_md.create_fix_message_with_basic_header("V") + msg.append_pair(262, "DEPTH_STREAM") # md req id + msg.append_pair(263, 1) # Subscription type + + msg.append_pair(264, 50) # market depth + msg.append_pair(266, "Y") # aggregated book + msg.append_pair(146, 1) # NoSymbols + msg.append_pair(55, INSTRUMENT) # Symbol + msg.append_pair(267, 2) # NoMDEntries + msg.append_pair(269, 0) # MDEntry + msg.append_pair(269, 1) # MDEntry + client_md.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=129\x0135=V\x0149=BMDWATCH\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x01262=DEPTH_STREAM\x01263=1\x01264=50\x01266=Y\x01146=1\x0155=BNBUSDT\x01267=2\x01269=0\x01269=1\x0110=021\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Consume from the internal queue and assert + for _ in range(client_md.queue_msg_received.qsize()): + msg = client_md.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "X": + subscription_id = ( + None if not msg.get(262) else msg.get(262).decode("utf-8") + ) + message_seq_num = ( + None if not msg.get(34) else msg.get(34).decode("utf-8") + ) + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + + self.assertEqual("DEPTH_STREAM", subscription_id) + self.assertEqual("3", message_seq_num) + self.assertEqual(1, updates) + self.assertEqual("BNBUSDT", symbol) + + for i in range(updates): + action = ( + None + if not msg.get(279, i + 1) + else msg.get(279, i + 1).decode("utf-8") + ) + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + last_book_id = ( + None + if not msg.get(25044, i + 1) + else msg.get(25044, i + 1).decode("utf-8") + ) + + self.assertEqual("1", action) + self.assertEqual("0", update_type) + self.assertEqual("638.54000000", price) + self.assertEqual("11.76700000", qty) + self.assertEqual("7517775", last_book_id) + + # Logout process + client_md.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(3, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BMDWATCH\x0156=SPOT\x0134=3\x0152=20250301-01:00:00.000000\x0110=222\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client_md.retrieve_messages_until( + message_type="5", timeout_seconds=1 + ) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client_md.disconnect() diff --git a/tests/market_stream/test_book_ticker_stream.py b/tests/market_stream/test_book_ticker_stream.py new file mode 100644 index 0000000..dba0161 --- /dev/null +++ b/tests/market_stream/test_book_ticker_stream.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_MD_URL = "tcp+tls://localhost:1234" +INSTRUMENT = "BNBUSDT" + + +class TestBookTickerStream(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.sentMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.sentMessage += 1 + if self.sentMessage % 2 == 0: + return b"" + else: + if not self.logOutSent: + return b"8=FIX.4.4\x019=0000156\x0135=X\x0149=SPOT\x0156=BMDWATCH\x0134=3\x0152=20250224-10:12:53.515372\x01262=BOOK_TICKER_STREAM\x01268=1\x01279=1\x01269=1\x01270=641.67000000\x01271=2.72700000\x0155=BNBUSDT\x0125044=7495113\x0110=241\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0134=4\x0149=SPOT\x0152=20250301-01:00:00.002000\x0156=GhQHzrLR\x0158=Logout acknowledgment.\x0110=212\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_ticker_stream( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client_md = create_market_data_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_MD_URL, + recv_window=100, + ) + + # Assert the logon request + self.assertEqual(1, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=204\x0135=A\x0149=BMDWATCH\x0156=SPOT\x0134=1\x0152=20250301-01:00:00.000000\x0125000=100\x0198=0\x01108=30\x0195=88\x0196=23rxWHmfUGML7o9O9lPkedNBj53wcilb9pFuIxc9Q1pGxHFQaBQnuPwj0ueH09t5Gzl3ybOc67wXGzh9Qcl/Aw==\x01141=Y\x01553=API_KEY\x0125035=2\x0110=219\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Send a book_ticker request + msg = client_md.create_fix_message_with_basic_header("V") + msg.append_pair(262, "BOOK_TICKER_STREAM") # md req id + msg.append_pair(263, 1) # Subscription type + + msg.append_pair(264, 1) # market depth + msg.append_pair(266, "Y") # aggregated book + msg.append_pair(146, 1) # NoSymbols + msg.append_pair(55, INSTRUMENT) # Symbol + msg.append_pair(267, 2) # NoMDEntries + msg.append_pair(269, 0) # MDEntry + msg.append_pair(269, 1) # MDEntry + client_md.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=134\x0135=V\x0149=BMDWATCH\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x01262=BOOK_TICKER_STREAM\x01263=1\x01264=1\x01266=Y\x01146=1\x0155=BNBUSDT\x01267=2\x01269=0\x01269=1\x0110=180\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Consume from the internal queue and assert + for _ in range(client_md.queue_msg_received.qsize()): + msg = client_md.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "X": + subscription_id = ( + None if not msg.get(262) else msg.get(262).decode("utf-8") + ) + message_seq_num = ( + None if not msg.get(34) else msg.get(34).decode("utf-8") + ) + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + + self.assertEqual("BOOK_TICKER_STREAM", subscription_id) + self.assertEqual("3", message_seq_num) + self.assertEqual("BNBUSDT", symbol) + self.assertEqual(1, updates) + for i in range(updates): + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + last_book_id = ( + None + if not msg.get(25044, i + 1) + else msg.get(25044, i + 1).decode("utf-8") + ) + + self.assertEqual("1", update_type) + self.assertEqual("641.67000000", price) + self.assertEqual("2.72700000", qty) + self.assertEqual("7495113", last_book_id) + + # Logout process + client_md.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(3, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BMDWATCH\x0156=SPOT\x0134=3\x0152=20250301-01:00:00.000000\x0110=222\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client_md.retrieve_messages_until( + message_type="5", timeout_seconds=1 + ) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client_md.disconnect() diff --git a/tests/market_stream/test_book_trade_stream.py b/tests/market_stream/test_book_trade_stream.py new file mode 100644 index 0000000..fe94911 --- /dev/null +++ b/tests/market_stream/test_book_trade_stream.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_market_data_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_MD_URL = "tcp+tls://localhost:1234" +INSTRUMENT = "BNBUSDT" + + +class TestBookTradeStream(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.sentMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.sentMessage += 1 + if self.sentMessage % 2 == 0: + return b"" + else: + if not self.logOutSent: + return b"8=FIX.4.4\x019=0000183\x0135=X\x0149=SPOT\x0156=BMDWATCH\x0134=3\x0152=20250224-11:31:20.047857\x01262=TRADE_STREAM\x01268=1\x01279=0\x01269=2\x01270=640.09000000\x01271=3.13200000\x0155=BNBUSDT\x011003=760268\x0160=20250101-01:00:01.000001\x012446=2\x0110=063\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0134=4\x0149=SPOT\x0152=20250301-01:00:00.002000\x0156=GhQHzrLR\x0158=Logout acknowledgment.\x0110=212\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_trade_stream( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client_md = create_market_data_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_MD_URL, + recv_window=100, + ) + + # Assert the logon request + self.assertEqual(1, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=204\x0135=A\x0149=BMDWATCH\x0156=SPOT\x0134=1\x0152=20250301-01:00:00.000000\x0125000=100\x0198=0\x01108=30\x0195=88\x0196=23rxWHmfUGML7o9O9lPkedNBj53wcilb9pFuIxc9Q1pGxHFQaBQnuPwj0ueH09t5Gzl3ybOc67wXGzh9Qcl/Aw==\x01141=Y\x01553=API_KEY\x0125035=2\x0110=219\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Send a trade_stream request + msg = client_md.create_fix_message_with_basic_header("V") + msg.append_pair(262, "TRADE_STREAM") # md req id + msg.append_pair(263, 2) # Subscription type + + msg.append_pair(264, 1) # market depth + msg.append_pair(266, "Y") # aggregated book + msg.append_pair(146, 1) # NoSymbols + msg.append_pair(55, INSTRUMENT) # Symbol + msg.append_pair(267, 1) # NoMDEntries + msg.append_pair(269, 2) # MDEntry + client_md.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=122\x0135=V\x0149=BMDWATCH\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x01262=TRADE_STREAM\x01263=2\x01264=1\x01266=Y\x01146=1\x0155=BNBUSDT\x01267=1\x01269=2\x0110=199\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Consume from the internal queue and assert + for _ in range(client_md.queue_msg_received.qsize()): + msg = client_md.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "X": + subscription_id = ( + None if not msg.get(262) else msg.get(262).decode("utf-8") + ) + message_seq_num = ( + None if not msg.get(34) else msg.get(34).decode("utf-8") + ) + updates = 0 if not msg.get(268) else int(msg.get(268).decode("utf-8")) + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + + self.assertEqual("TRADE_STREAM", subscription_id) + self.assertEqual("3", message_seq_num) + self.assertEqual(1, updates) + self.assertEqual("BNBUSDT", symbol) + for i in range(updates): + update_type = ( + None + if not msg.get(269, i + 1) + else msg.get(269, i + 1).decode("utf-8") + ) + price = ( + None + if not msg.get(270, i + 1) + else msg.get(270, i + 1).decode("utf-8") + ) + qty = ( + None + if not msg.get(271, i + 1) + else msg.get(271, i + 1).decode("utf-8") + ) + trade_id = ( + None + if not msg.get(1003, i + 1) + else msg.get(1003, i + 1).decode("utf-8") + ) + transact_time = ( + None + if not msg.get(60, i + 1) + else msg.get(60, i + 1).decode("utf-8") + ) + + self.assertEqual("2", update_type) + self.assertEqual("640.09000000", price) + self.assertEqual("3.13200000", qty) + self.assertEqual("760268", trade_id) + self.assertEqual("20250101-01:00:01.000001", transact_time) + + # Logout process + client_md.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_md._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(3, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BMDWATCH\x0156=SPOT\x0134=3\x0152=20250301-01:00:00.000000\x0110=222\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client_md.retrieve_messages_until( + message_type="5", timeout_seconds=1 + ) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client_md.disconnect() diff --git a/tests/trade/test_list_OTO_order.py b/tests/trade/test_list_OTO_order.py new file mode 100644 index 0000000..bbceacc --- /dev/null +++ b/tests/trade/test_list_OTO_order.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_order_entry_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_OE_URL = "tcp+tls://localhost:1234" +ORD_STATUS = { + "0": "NEW", + "1": "PARTIALLY_FILLED", + "2": "FILLED", + "4": "CANCELED", + "6": "PENDING_CANCEL", + "8": "REJECTED", + "A": "PENDING_NEW", + "C": "EXPIRED", +} +ORD_TYPES = {"1": "MARKET", "2": "LIMIT", "3": "STOP", "4": "STOP_LIMIT"} +SIDES = {"1": "BUY", "2": "SELL"} +TIME_IN_FORCE = { + "1": "GOOD_TILL_CANCEL", + "3": "IMMEDIATE_OR_CANCEL", + "4": "FILL_OR_KILL", +} +ORD_REJECT_REASON = {"99": "OTHER"} +ORD_EXEC_TYPE = { + "0": "NEW", + "4": "CANCELED", + "5": "REPLACED", + "8": "REJECTED", + "F": "TRADE", + "C": "EXPIRED", +} +SELF_TRADE_PREVENTION_MODE = { + "1": "NONE", + "2": "EXPIRE_TAKER", + "3": "EXPIRE_MAKER", + "4": "EXPIRE_BOTH", +} + +LIST_STATUS = {"2": "RESPONSE", "4": "EXEC_STARTED", "5": "ALL_DONE"} +LIST_ORD_STATUS = {"3": "EXECUTING", "6": "ALL_DONE", "7": "REJECT"} +LIST_ORD_TYPE = {"1": "ONE_CANCELS_THE_OTHER", "2": "ONE_TRIGGERS_THE_OTHER"} +LIST_TRIG_TYPE = {"ACTIVATED": "1", "PARTIALLY_FILLED": "2", "FILLED": "3"} +LIST_TRIG_ACTION = {"RELEASE": "1", "CANCEL": "2"} +INSTRUMENT = "BNBUSDT" + + +class TestInstrumentList(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.receivedMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.receivedMessage += 1 + if self.receivedMessage % 4 == 0: + return b"" + else: + if not self.logOutSent: + if self.receivedMessage == 1: + return b"8=FIX.4.4\x019=306\x0135=N\x0149=SPOT\x0156=BOETRADE\x0134=2\x0152=20250301-01:00:00.000001\x0155=BNBUSDT\x0166=16889\x01429=4\x01431=3\x0125014=1740758400000001000\x0125015=1740758400000001000\x0160=20250301-01:00:00.000001\x011385=2\x0173=2\x0111=w1740758400000001000\x0155=BNBUSDT\x0137=5279178\x0111=p1740758400000001000\x0155=BNBUSDT\x0137=5279179\x0125010=1\x0125011=3\x0125012=0\x0125013=1\x0110=194\x01" + if self.receivedMessage == 2: + return b"8=FIX.4.4\x019=352\x0135=8\x0149=SPOT\x0156=BOETRADE\x0134=3\x0152=20250301-01:00:00.000001\x0117=11493629\x0111=w1740758400000001000\x0137=5279178\x0138=1.00000000\x0140=2\x0154=1\x0155=BNBUSDT\x0144=730.00000000\x0159=1\x0160=20250301-01:00:00.000001\x0125018=20250301-01:00:00.000001\x0166=16889\x0125001=3\x01150=0\x0114=0.00000000\x01151=1.00000000\x0125017=0.00000000\x011057=Y\x0132=0.00000000\x0139=0\x01636=Y\x0125023=20250329-08:00:00.000001\x0110=079\x01" + elif self.receivedMessage == 3: + return b"8=FIX.4.4\x019=315\x0135=8\x0149=SPOT\x0156=BOETRADE\x0134=4\x0152=20250301-01:00:00.000001\x0117=11493630\x0111=p1740758400000001000\x0137=5279179\x0138=1.00000000\x0140=2\x0154=2\x0155=BNBUSDT\x0144=735.00000000\x0159=1\x0160=20250301-01:00:00.000001\x0125018=20250301-01:00:00.000001\x0166=16889\x0125001=3\x01150=0\x0114=0.00000000\x01151=1.00000000\x0125017=0.00000000\x011057=Y\x0132=0.00000000\x0139=A\x0110=024\x01" + elif self.receivedMessage == 5: + return b"8=FIX.4.4\x019=376\x0135=8\x0149=SPOT\x0156=BOETRADE\x0134=5\x0152=20250301-01:00:00.000001\x0117=11493631\x0111=w1740758400000001000\x0137=5279178\x0138=1.00000000\x0140=2\x0154=1\x0155=BNBUSDT\x0144=730.00000000\x0159=1\x0160=20250301-01:00:00.000001\x0125018=20250301-01:00:00.000001\x0166=16889\x0125001=3\x01150=F\x0114=1.00000000\x01151=0.00000000\x0125017=576.12000000\x011057=Y\x011003=956281\x0131=576.12000000\x0132=1.00000000\x0139=2\x0125023=20250329-08:00:00.000001\x0110=224\x01" + else: + return b"8=FIX.4.4\x019=352\x0135=8\x0149=SPOT\x0156=BOETRADE\x0134=6\x0152=20250301-01:00:00.000001\x0117=11493633\x0111=p1740758400000001000\x0137=5279179\x0138=1.00000000\x0140=2\x0154=2\x0155=BNBUSDT\x0144=735.00000000\x0159=1\x0160=20250301-01:00:00.000001\x0125018=20250301-01:00:00.000001\x0166=16889\x0125001=3\x01150=0\x0114=0.00000000\x01151=1.00000000\x0125017=0.00000000\x011057=Y\x0132=0.00000000\x0139=0\x01636=Y\x0125023=20250329-08:00:00.000001\x0110=072\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0149=SPOT\x0156=BOETRADE\x0134=5\x0152=20250301-01:00:00.000005\x0158=Logout acknowledgment.\x0110=088\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_list_OTO_order( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client_oe = create_order_entry_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_OE_URL, + ) + + # Assert the logon request + self.assertEqual(1, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=209\x0135=A\x0149=BOETRADE\x0156=SPOT\x0134=1\x0152=20250301-01:00:00.000000\x0198=0\x01108=30\x0195=88\x0196=eI7NNqsvWINcco9+rUQFjB4O1bsZBHp5uYaeq/V9d736Omn//d0ZQR5uW/91Ylf4lTiO0QpN1StIWZBjBuI5AA==\x01141=Y\x01553=API_KEY\x0125035=2\x0125036=1\x019406=N\x0110=056\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Create OTO order message + msg = client_oe.create_fix_message_with_basic_header("E") + identifier = "1740758400000001000" + working_leg_id = f"w{identifier}" + pending_leg_id = f"p{identifier}" + + msg.append_pair(73, 2) + msg.append_pair(11, working_leg_id) + msg.append_pair(55, INSTRUMENT) + msg.append_pair(54, 1) + msg.append_pair(38, 1) + msg.append_pair(40, 2) + msg.append_pair(44, 730) + msg.append_pair(59, 1) + msg.append_pair(11, pending_leg_id) + msg.append_pair(55, INSTRUMENT) + msg.append_pair(54, 2) + msg.append_pair(38, 1) + msg.append_pair(40, 2) + msg.append_pair(44, 735) + msg.append_pair(59, 1) + msg.append_pair(25010, 1) + msg.append_pair(25011, 3) + msg.append_pair(25012, 0) + msg.append_pair(25013, 1) + msg.append_pair(1385, 2) + msg.append_pair(25014, f"{identifier}") + client_oe.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=252\x0135=E\x0149=BOETRADE\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x0173=2\x0111=w1740758400000001000\x0155=BNBUSDT\x0154=1\x0138=1\x0140=2\x0144=730\x0159=1\x0111=p1740758400000001000\x0155=BNBUSDT\x0154=2\x0138=1\x0140=2\x0144=735\x0159=1\x0125010=1\x0125011=3\x0125012=0\x0125013=1\x011385=2\x0125014=1740758400000001000\x0110=019\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client_oe._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + for _ in range(client_oe.queue_msg_received.qsize()): + msg = client_oe.queue_msg_received.get() + msg_type = None if not msg.get(35) else msg.get(35).decode("utf-8") + if msg_type == "N": + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + list_status_type = ( + None if not msg.get(429) else msg.get(429).decode("utf-8") + ) + list_ord_status = ( + None if not msg.get(431) else msg.get(431).decode("utf-8") + ) + cl_list_id = ( + None if not msg.get(25014) else msg.get(25014).decode("utf-8") + ) + contingency = ( + None if not msg.get(1385) else msg.get(1385).decode("utf-8") + ) + + self.assertEqual("BNBUSDT", symbol) + self.assertEqual( + "EXEC_STARTED", LIST_STATUS.get(list_status_type, list_status_type) + ) + self.assertEqual( + "EXECUTING", LIST_ORD_STATUS.get(list_ord_status, list_ord_status) + ) + self.assertEqual(f"{identifier}", cl_list_id) + self.assertEqual( + "ONE_TRIGGERS_THE_OTHER", + LIST_ORD_TYPE.get(contingency, contingency), + ) + + orders = 0 if not msg.get(73) else int(msg.get(73).decode("utf-8")) + for i in range(orders): + cl_ord_id = ( + None + if not msg.get(11, i + 1) + else msg.get(11, i + 1).decode("utf-8") + ) + symbol = ( + None + if not msg.get(55, i + 1) + else msg.get(55, i + 1).decode("utf-8") + ) + ord_rej_reason = ( + None + if not msg.get(103, i + 1) + else msg.get(103, i + 1).decode("utf-8") + ) + error_code = ( + None + if not msg.get(25016, i + 1) + else msg.get(25016, i + 1).decode("utf-8") + ) + text = ( + None + if not msg.get(58, i + 1) + else msg.get(58, i + 1).decode("utf-8") + ) + + self.assertEqual( + f"w{identifier}" if i == 0 else f"p{identifier}", cl_ord_id + ) + self.assertEqual("BNBUSDT", symbol) + self.assertEqual(None, ord_rej_reason) + self.assertEqual(None, error_code) + self.assertEqual(None, text) + else: + msg_seq_num = ( + None if not msg.get(34) else int(msg.get(34).decode("utf-8")) + ) + if msg_seq_num == 3: + exec_id = None if not msg.get(17) else msg.get(17).decode("utf-8") + cl_ord_id = None if not msg.get(11) else msg.get(11).decode("utf-8") + order_id = None if not msg.get(37) else msg.get(37).decode("utf-8") + order_qty = None if not msg.get(38) else msg.get(38).decode("utf-8") + ord_type = None if not msg.get(40) else msg.get(40).decode("utf-8") + side = None if not msg.get(54) else msg.get(54).decode("utf-8") + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + price = None if not msg.get(44) else msg.get(44).decode("utf-8") + time_in_force = ( + None if not msg.get(59) else msg.get(59).decode("utf-8") + ) + transact_time = ( + None if not msg.get(60) else msg.get(60).decode("utf-8") + ) + order_creation_time = ( + None if not msg.get(25018) else msg.get(25018).decode("utf-8") + ) + list_id = None if not msg.get(66) else msg.get(66).decode("utf-8") + self_trade_prevention_mode = ( + None if not msg.get(25001) else msg.get(25001).decode("utf-8") + ) + exec_type = ( + None if not msg.get(150) else msg.get(150).decode("utf-8") + ) + cum_qty = None if not msg.get(14) else msg.get(14).decode("utf-8") + leaves_qty = ( + None if not msg.get(151) else msg.get(151).decode("utf-8") + ) + cum_quote_qty = ( + None if not msg.get(25017) else msg.get(25017).decode("utf-8") + ) + aggressor_indicator = ( + None if not msg.get(1057) else msg.get(1057).decode("utf-8") + ) + last_qty = None if not msg.get(32) else msg.get(32).decode("utf-8") + ord_status = ( + None if not msg.get(39) else msg.get(39).decode("utf-8") + ) + + self.assertEqual("11493629", exec_id) + self.assertEqual("w1740758400000001000", cl_ord_id) + self.assertEqual("5279178", order_id) + self.assertEqual("1.00000000", order_qty) + self.assertEqual("LIMIT", ORD_TYPES.get(ord_type, ord_type)) + self.assertEqual("BUY", SIDES.get(side, side)) + self.assertEqual("BNBUSDT", symbol) + self.assertEqual("730.00000000", price) + self.assertEqual( + "GOOD_TILL_CANCEL", + TIME_IN_FORCE.get(time_in_force, time_in_force), + ) + self.assertEqual("20250301-01:00:00.000001", transact_time) + self.assertEqual("20250301-01:00:00.000001", order_creation_time) + self.assertEqual("16889", list_id) + self.assertEqual( + "EXPIRE_MAKER", + SELF_TRADE_PREVENTION_MODE.get(self_trade_prevention_mode), + ) + self.assertEqual("NEW", ORD_EXEC_TYPE.get(exec_type)) + self.assertEqual("0.00000000", cum_qty) + self.assertEqual("1.00000000", leaves_qty) + self.assertEqual("0.00000000", cum_quote_qty) + self.assertEqual("Y", aggressor_indicator) + self.assertEqual("0.00000000", last_qty) + self.assertEqual("NEW", ORD_STATUS.get(ord_status)) + elif msg_seq_num == 4: + exec_id = None if not msg.get(17) else msg.get(17).decode("utf-8") + cl_ord_id = None if not msg.get(11) else msg.get(11).decode("utf-8") + order_id = None if not msg.get(37) else msg.get(37).decode("utf-8") + side = None if not msg.get(54) else msg.get(54).decode("utf-8") + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + price = None if not msg.get(44) else msg.get(44).decode("utf-8") + ord_status = ( + None if not msg.get(39) else msg.get(39).decode("utf-8") + ) + + self.assertEqual("11493630", exec_id) + self.assertEqual("p1740758400000001000", cl_ord_id) + self.assertEqual("5279179", order_id) + self.assertEqual("SELL", SIDES.get(side, side)) + self.assertEqual("BNBUSDT", symbol) + self.assertEqual("735.00000000", price) + self.assertEqual("PENDING_NEW", ORD_STATUS.get(ord_status)) + elif msg_seq_num == 5: + order_id = None if not msg.get(37) else msg.get(37).decode("utf-8") + side = None if not msg.get(54) else msg.get(54).decode("utf-8") + exec_type = ( + None if not msg.get(150) else msg.get(150).decode("utf-8") + ) + cum_qty = None if not msg.get(14) else msg.get(14).decode("utf-8") + leaves_qty = ( + None if not msg.get(151) else msg.get(151).decode("utf-8") + ) + cum_quote_qty = ( + None if not msg.get(25017) else msg.get(25017).decode("utf-8") + ) + trade_id = ( + None if not msg.get(1003) else msg.get(1003).decode("utf-8") + ) + last_px = None if not msg.get(31) else msg.get(31).decode("utf-8") + last_qty = None if not msg.get(32) else msg.get(32).decode("utf-8") + + self.assertEqual("5279178", order_id) + self.assertEqual("BUY", SIDES.get(side, side)) + self.assertEqual("TRADE", ORD_EXEC_TYPE.get(exec_type)) + self.assertEqual("1.00000000", cum_qty) + self.assertEqual("0.00000000", leaves_qty) + self.assertEqual("576.12000000", cum_quote_qty) + self.assertEqual("956281", trade_id) + self.assertEqual("576.12000000", last_px) + self.assertEqual("1.00000000", last_qty) + else: + exec_id = None if not msg.get(17) else msg.get(17).decode("utf-8") + cl_ord_id = None if not msg.get(11) else msg.get(11).decode("utf-8") + order_id = None if not msg.get(37) else msg.get(37).decode("utf-8") + side = None if not msg.get(54) else msg.get(54).decode("utf-8") + exec_type = ( + None if not msg.get(150) else msg.get(150).decode("utf-8") + ) + cum_qty = None if not msg.get(14) else msg.get(14).decode("utf-8") + leaves_qty = ( + None if not msg.get(151) else msg.get(151).decode("utf-8") + ) + cum_quote_qty = ( + None if not msg.get(25017) else msg.get(25017).decode("utf-8") + ) + trade_id = ( + None if not msg.get(1003) else msg.get(1003).decode("utf-8") + ) + last_px = None if not msg.get(31) else msg.get(31).decode("utf-8") + last_qty = None if not msg.get(32) else msg.get(32).decode("utf-8") + + self.assertEqual("11493633", exec_id) + self.assertEqual("p1740758400000001000", cl_ord_id) + self.assertEqual("5279179", order_id) + self.assertEqual("SELL", SIDES.get(side, side)) + self.assertEqual("NEW", ORD_EXEC_TYPE.get(exec_type)) + self.assertEqual("0.00000000", cum_qty) + self.assertEqual("1.00000000", leaves_qty) + self.assertEqual("0.00000000", cum_quote_qty) + self.assertEqual(None, trade_id) + self.assertEqual(None, last_px) + self.assertEqual("0.00000000", last_qty) + + # Logout process + client_oe.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_oe._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(3, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BOETRADE\x0156=SPOT\x0134=3\x0152=20250301-01:00:00.000000\x0110=218\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client_oe.retrieve_messages_until( + message_type="5", timeout_seconds=1 + ) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client_oe.disconnect() diff --git a/tests/trade/test_new_order.py b/tests/trade/test_new_order.py new file mode 100644 index 0000000..9b9582f --- /dev/null +++ b/tests/trade/test_new_order.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 + +import logging +import os +import threading +import unittest +from unittest.mock import patch + +from binance_fix_connector.fix_connector import ( + BinanceFixConnector, + create_order_entry_session, + FixMsgTypes, +) +from binance_fix_connector.utils import get_private_key + +logging.basicConfig(level=logging.CRITICAL) + +PRIVATE_KEY = os.path.join(os.path.dirname(__file__), "../unit_test_key.pem") +FIX_OE_URL = "tcp+tls://localhost:1234" +INSTRUMENT = "BNBUSDT" + + +class TestNewOrder(unittest.TestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + self.sentMessage = 0 + self.logOutSent = False + + def recv_side_effect(self, *args, **kwargs): + # Trigger end of message after every message sent + self.sentMessage += 1 + if self.sentMessage % 2 == 0: + return b"" + else: + if not self.logOutSent: + return b"8=FIX.4.4\x019=342\x0135=8\x0149=SPOT\x0156=BOETRADE\x0134=2\x0152=20250204-09:10:09.754155\x0117=10062143\x0111=1738660209676610000\x0137=4709412\x0138=1.00000000\x0140=2\x0154=2\x0155=BNBUSDT\x0144=730.00000000\x0159=1\x0160=20250204-09:10:09.753771\x0125018=20250204-09:10:09.753771\x0125001=3\x01150=0\x0114=0.00000000\x01151=1.00000000\x0125017=0.00000000\x011057=Y\x0132=0.00000000\x0139=0\x01636=Y\x0125023=20250204-09:10:09.753771\x0110=234\x01" + else: + return b"8=FIX.4.4\x019=84\x0135=5\x0134=4\x0149=SPOT\x0152=20250301-01:00:00.002000\x0156=GhQHzrLR\x0158=Logout acknowledgment.\x0110=212\x01" + + @patch("socket.socket") + @patch("socket.create_connection") + @patch("ssl.create_default_context") + @patch("ssl.SSLContext") + @patch.object(BinanceFixConnector, "current_utc_time") + def test_new_order( + self, + current_utc_time_mock, + context_mock, + create_default_context_mock, + create_connection_mock, + socket_mock, + ): + # Mock the socket instance + current_utc_time_mock.return_value = "20250301-01:00:00.000000" + + # Simulate receiving message from the socket + socket_mock.recv.side_effect = self.recv_side_effect + context_mock.wrap_socket.return_value = socket_mock + create_connection_mock.return_value = socket_mock + create_default_context_mock.return_value = context_mock + + # Create FIX client + client_oe = create_order_entry_session( + api_key="API_KEY", + private_key=get_private_key(PRIVATE_KEY), + endpoint=FIX_OE_URL, + ) + + # Create instrument list message + msg = client_oe.create_fix_message_with_basic_header("D") + msg.append_pair(38, 1) + msg.append_pair(40, 2) + msg.append_pair(11, "1740758400000001000") + msg.append_pair(44, 730) + msg.append_pair(54, 2) + msg.append_pair(55, "BNBUSDT") + msg.append_pair(59, 1) + client_oe.send_message(msg) + + # Assert the actual request + self.assertEqual(2, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=119\x0135=D\x0149=BOETRADE\x0156=SPOT\x0134=2\x0152=20250301-01:00:00.000000\x0138=1\x0140=2\x0111=1740758400000001000\x0144=730\x0154=2\x0155=BNBUSDT\x0159=1\x0110=201\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Start a thread to consume messages + receive_thread = threading.Thread( + target=client_oe._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Logout process + client_oe.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_oe._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + for _ in range(client_oe.queue_msg_received.qsize()): + msg = client_oe.queue_msg_received.get() + if msg.message_type.decode("utf-8") == "8": + cl_ord_id = None if not msg.get(11) else msg.get(11).decode("utf-8") + order_qty = None if not msg.get(38) else msg.get(38).decode("utf-8") + ord_type = None if not msg.get(40) else msg.get(40).decode("utf-8") + side = None if not msg.get(54) else msg.get(54).decode("utf-8") + symbol = None if not msg.get(55) else msg.get(55).decode("utf-8") + price = None if not msg.get(44) else msg.get(44).decode("utf-8") + time_in_force = None if not msg.get(59) else msg.get(59).decode("utf-8") + cum_qty = None if not msg.get(14) else msg.get(14).decode("utf-8") + last_qty = None if not msg.get(32) else msg.get(32).decode("utf-8") + ord_status = None if not msg.get(39) else msg.get(39).decode("utf-8") + ord_rej_reason = ( + None if not msg.get(103) else msg.get(103).decode("utf-8") + ) + error_code = ( + None if not msg.get(25016) else msg.get(25016).decode("utf-8") + ) + text = None if not msg.get(58) else msg.get(58).decode("utf-8") + + self.assertEqual("BNBUSDT", symbol) + self.assertEqual("1738660209676610000", cl_ord_id) + self.assertEqual("1.00000000", order_qty) + self.assertEqual("2", ord_type) + self.assertEqual("2", side) + self.assertEqual("730.00000000", price) + self.assertEqual("1", time_in_force) + self.assertEqual("0.00000000", cum_qty) + self.assertEqual("0.00000000", last_qty) + self.assertEqual("0", ord_status) + self.assertEqual(None, ord_rej_reason) + self.assertEqual(None, error_code) + self.assertEqual(None, text) + + # Logout process + client_oe.logout() + self.logOutSent = True + + receive_thread = threading.Thread( + target=client_oe._BinanceFixConnector__receive_messages(), daemon=True + ) + receive_thread.start() + + # Assert the logout request + self.assertEqual(4, socket_mock.sendall.call_count) + self.assertEqual( + b"8=FIX.4.4\x019=58\x0135=5\x0149=BOETRADE\x0156=SPOT\x0134=4\x0152=20250301-01:00:00.000000\x0110=219\x01", + socket_mock.sendall.call_args[0][0], + ) + + # Consume until logout message + messages = client_oe.retrieve_messages_until( + message_type="5", timeout_seconds=1 + ) + + # Logout acknowledgment + log_out_message = messages[-1] + + # Assert logout message + msg_type = ( + None + if not log_out_message.get(35) + else log_out_message.get(35).decode("utf-8") + ) + msg_text = ( + None + if not log_out_message.get(58) + else log_out_message.get(58).decode("utf-8") + ) + self.assertEqual(FixMsgTypes.LOGOUT, msg_type) + self.assertEqual("Logout acknowledgment.", msg_text) + + # Close the connection + client_oe.disconnect() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_test_key.pem b/tests/unit_test_key.pem new file mode 100644 index 0000000..3e20f53 --- /dev/null +++ b/tests/unit_test_key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJjlVz+AXahN5M3GYOlq7UDSG/XbW1w8mVo2mnp6zCLx +-----END PRIVATE KEY-----