diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4f01c1c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,75 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: # Allow manual triggering for testing + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build wheel + + - name: Build package + run: python -m build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/bitfinex-api-py-postonly/ + permissions: + id-token: write # REQUIRED for trusted publishing + + steps: + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + publish-to-testpypi: + name: Publish to TestPyPI (Optional) + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' # Only on manual trigger + environment: + name: testpypi + url: https://test.pypi.org/project/bitfinex-api-py-postonly/ + permissions: + id-token: write + + steps: + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0c1f9b..5633740 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ pip-wheel-metadata/ .idea venv/ + +# Planning documents +plans/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cee4c85 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,223 @@ +# Bitfinex API Python Fork - Safety Features + +## Purpose +This fork adds two safety features to prevent accidental market orders and monitor connection health. + +## Feature 1: Post-Only Order Enforcement +**Status:** ACTIVE +**Protection:** ALL orders automatically become post-only (maker-only) + +### How It Works +- Flag 4096 (POST_ONLY) is automatically added to every order +- Cannot be disabled or bypassed +- Funding offers are not affected + +### Enforcement Points +1. **REST API**: `submit_order()` and `update_order()` in `rest_auth_endpoints.py` +2. **Middleware**: All `order/submit` and `order/update` endpoints in `middleware.py` +3. **WebSocket**: Input validation in `bfx_websocket_inputs.py` and client-level in `bfx_websocket_client.py` + +## Feature 2: Heartbeat Events +**Status:** ACTIVE +**Purpose:** Monitor WebSocket connection health + +### Usage +```python +from bfxapi import Client + +bfx = Client(api_key="...", api_secret="...") + +@bfx.wss.on("heartbeat") +def on_heartbeat(subscription): + if subscription: + print(f"Public channel heartbeat: {subscription['channel']}") + else: + print("Authenticated connection heartbeat") + +bfx.wss.run() +``` + +## Implementation Details + +### Post-Only Enforcement - Technical Implementation + +**Core Function** (`bfxapi/_utils/post_only_enforcement.py`): +```python +def enforce_post_only(flags: Optional[int], order_type: Optional[str] = None) -> int: + """ + Ensure POST_ONLY flag is set, preserving other flags. + Validates that order type is compatible with POST_ONLY. + + Raises ValueError if order type contains 'MARKET' (incompatible with POST_ONLY). + """ + if order_type and 'MARKET' in order_type.upper(): + raise ValueError( + f"Order type '{order_type}' is incompatible with POST_ONLY enforcement. " + "POST_ONLY only works with limit-style orders." + ) + return POST_ONLY | (flags if flags is not None else 0) +``` + +**Hardening Features:** +- Rejects MARKET orders with clear error message +- Case-insensitive order type validation +- Preserves all existing flags using bitwise OR + +**Layer 1 - REST API** (`rest_auth_endpoints.py`): +- Line 106: `flags = enforce_post_only(flags, order_type=type)` in `submit_order()` - validates order type +- Line 146: `flags = enforce_post_only(flags)` in `update_order()` - no type validation needed +- Funding offers explicitly skip enforcement (no flags sent) + +**Layer 2 - Middleware** (`middleware.py`): +- Lines 66-77: Intercepts all `order/submit` and `order/update` endpoints +- Line 73: `enforce_post_only(flags, order_type)` for submit - validates order type +- Line 77: `enforce_post_only(flags)` for update +- Includes maintenance note for endpoint pattern updates +- Catch-all protection layer + +**Layer 3 - WebSocket Input** (`bfx_websocket_inputs.py`): +- Line 32: `enforce_post_only(flags, order_type=type)` in `submit_order()` - validates order type +- Line 71: `enforce_post_only(flags)` in `update_order()` +- Funding offers skip enforcement (line 114) + +**Layer 4 - WebSocket Client** (`bfx_websocket_client.py`): +- Lines 350-355: Final enforcement before sending to WebSocket +- Handles both "on" (new order) and "ou" (update order) events + +### Heartbeat Events - Technical Implementation + +**WebSocket Bucket** (`bfx_websocket_bucket.py`): +- Lines 69-72: Emits heartbeat events for public channels instead of discarding + +**WebSocket Client** (`bfx_websocket_client.py`): +- Lines 267-268: Emits heartbeat events for authenticated channel (channel 0) + +**Event Emitter** (`bfx_event_emitter.py`): +- Line 35: Added "heartbeat" to valid events list + +## Verification for Auditors + +### Quick Verification (< 1 minute) +```bash +# Count enforcement points in production code (excluding imports and definition) +grep -r "enforce_post_only(" bfxapi/ --include="*.py" | grep -v "def enforce" | grep -v test | wc -l +# Expected: 8 occurrences + +# Verify POST_ONLY flag value +grep "POST_ONLY = 4096" bfxapi/constants/order_flags.py +# Expected: Match found + +# Check all enforcement locations +grep -n "enforce_post_only" bfxapi/rest/_interfaces/rest_auth_endpoints.py +# Expected: Lines 106 and 146 + +grep -n "enforce_post_only" bfxapi/rest/_interface/middleware.py +# Expected: Lines 69 and 72 + +grep -n "enforce_post_only" bfxapi/websocket/_client/bfx_websocket_inputs.py +# Expected: Lines 32 and 71 + +grep -n "enforce_post_only" bfxapi/websocket/_client/bfx_websocket_client.py +# Expected: Lines 350 and 353 +``` + +### Production Code Changes + +**Post-Only Enforcement Files:** +``` +bfxapi/_utils/post_only_enforcement.py # NEW: 37 lines (with validation) +bfxapi/constants/order_flags.py # NEW: 6 lines +bfxapi/rest/_interface/middleware.py # +13 lines (with notes) +bfxapi/rest/_interfaces/rest_auth_endpoints.py # +13 lines +bfxapi/websocket/_client/bfx_websocket_inputs.py # +15 lines +bfxapi/websocket/_client/bfx_websocket_client.py # +8 lines +tests/test_post_only_enforcement.py # 240+ lines (13 tests) +``` + +**Heartbeat Events Files:** +``` +bfxapi/websocket/_client/bfx_websocket_bucket.py # +4 lines +bfxapi/websocket/_client/bfx_websocket_client.py # +4 lines +bfxapi/websocket/_event_emitter/bfx_event_emitter.py # +1 line +examples/websocket/heartbeat.py # NEW: 65 lines +README.md # +27 lines +``` + +### Code Delta Summary +- **Post-Only Enforcement**: ~92 lines of production code (with validation) +- **Heartbeat Events**: ~40 lines of production code +- **Core safety logic**: 37 lines (the `enforce_post_only` function with market order validation) +- **Tests**: 240+ lines, 13 comprehensive tests +- **No external dependencies added** + +## PyPI Publishing + +This package uses GitHub Actions with Trusted Publishing (OIDC) for secure, automated PyPI releases. + +### Publishing Process + +1. **Update version in setup.py** +2. **Create Git tag and push:** + ```bash + git tag v3.0.5.post2 + git push origin v3.0.5.post2 + ``` +3. **Create GitHub Release** - triggers automatic PyPI upload via `.github/workflows/publish.yml` + +### Configuration +- **PyPI Project**: bitfinex-api-py-postonly +- **Workflow**: `.github/workflows/publish.yml` +- **No API tokens** - Uses OIDC authentication + +## Important Notes + +⚠️ **This is a SAFETY fork, not for bypassing exchange rules** + +- All orders will be maker-only (won't cross spread) +- MARKET orders are rejected with clear error message +- Monitor heartbeats to ensure connection is alive +- Funding offers are not affected by POST_ONLY enforcement +- The enforcement cannot be disabled without modifying the source code + +## Example: Order Submission + +```python +from bfxapi import Client + +bfx = Client(api_key="...", api_secret="...") + +# This will ALWAYS be post-only (flag 4096 added automatically) +order = bfx.rest.auth.submit_order( + type="EXCHANGE LIMIT", + symbol="tBTCUSD", + amount=0.01, + price=50000 + # No need to specify flags - POST_ONLY is forced +) + +# Even if you try flags=0, POST_ONLY is still added +order = bfx.rest.auth.submit_order( + type="EXCHANGE LIMIT", + symbol="tBTCUSD", + amount=0.01, + price=50000, + flags=0 # Still becomes flags=4096 internally! +) + +# Other flags are preserved +order = bfx.rest.auth.submit_order( + type="EXCHANGE LIMIT", + symbol="tBTCUSD", + amount=0.01, + price=50000, + flags=64 # HIDDEN flag - becomes 4160 (4096 | 64) +) +``` + +## Protection Levels + +1. **Application Level**: `submit_order()` and `update_order()` force POST_ONLY +2. **Middleware Level**: Order submit/update REST calls force POST_ONLY +3. **WebSocket Level**: Order submit/update WS messages force POST_ONLY + +Each level provides redundant protection to ensure no order can bypass the POST_ONLY requirement. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..69efe0e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,35 @@ +# Include important files +include README.md +include LICENSE +include NOTICE + +# Include all Python source files +recursive-include bfxapi *.py + +# EXCLUDE non-production files +exclude CLAUDE.md +exclude .gitignore +exclude .env +exclude *.pyc +exclude setup.cfg +exclude pyproject.toml + +# EXCLUDE directories that should NOT be in package +prune tests +prune examples +prune .github +prune .git +prune .venv +prune venv +prune __pycache__ +prune .pytest_cache +prune *.egg-info +prune build +prune dist +prune .cursor +prune plans + +# Exclude any compiled Python files +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__ \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a625ef5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +bitfinex-api-py-postonly +Copyright 2025 Ferit + +This is a modified fork of bitfinex-api-py +Original Copyright: Bitfinex +Original Repository: https://github.com/bitfinexcom/bitfinex-api-py +Original License: Apache License 2.0 + +SAFETY MODIFICATIONS: +- ALL orders are automatically forced to be POST_ONLY (maker-only) +- WebSocket heartbeat events are exposed for connection monitoring + +CRITICAL: This fork enforces POST_ONLY flag (4096) on ALL orders. +Orders will never cross the spread and will always be maker orders. +This cannot be disabled without modifying the source code. \ No newline at end of file diff --git a/README.md b/README.md index 9a0604a..d6faabc 100644 --- a/README.md +++ b/README.md @@ -1,392 +1,93 @@ -# bitfinex-api-py +# bitfinex-api-py-postonly -[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/bitfinex-api-py)](https://pypi.org/project/bitfinex-api-py/) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -![GitHub Action](https://github.com/bitfinexcom/bitfinex-api-py/actions/workflows/build.yml/badge.svg) +[![Safety Fork](https://img.shields.io/badge/Fork-Safety%20Enhanced-green)](https://github.com/0xferit/bitfinex-api-py) +[![POST_ONLY Enforced](https://img.shields.io/badge/Orders-POST__ONLY%20Enforced-blue)](https://github.com/0xferit/bitfinex-api-py) +[![Python](https://img.shields.io/badge/Python-3.8%2B-blue)](https://www.python.org) -Official implementation of the [Bitfinex APIs (V2)](https://docs.bitfinex.com/docs) for `Python 3.8+`. +**Safety fork of Bitfinex Python API that forces all orders to be POST_ONLY (maker-only), preventing accidental taker/market orders.** -### Features +## What This Changes -* Support for 75+ REST endpoints (a list of available endpoints can be found [here](https://docs.bitfinex.com/reference)) -* New WebSocket client to ensure fast, secure and persistent connections -* Full support for Bitfinex notifications (including custom notifications) -* Native support for type hinting and type checking with [`mypy`](https://github.com/python/mypy) +1. **Forces POST_ONLY flag (4096) on ALL orders** - Cannot be disabled +2. **Rejects MARKET orders** - Throws ValueError with clear message +3. **Adds WebSocket heartbeat events** - Monitor connection health ## Installation -```console -python3 -m pip install bitfinex-api-py +```bash +pip install bitfinex-api-py-postonly ``` -If you intend to use mypy type hints in your project, use: -```console -python3 -m pip install bitfinex-api-py[typing] -``` +## Key Differences ---- +| Feature | Original | This Fork | +|---------|----------|-----------| +| **POST_ONLY default** | ❌ No | ✅ Forced | +| **Market orders** | ✅ Allowed | ❌ Rejected | +| **Can cross spread** | ✅ Yes | ❌ No | +| **Taker fees possible** | ✅ Yes | ❌ No | +| **Heartbeat events** | ❌ No | ✅ Yes | -# Quickstart +## Example +### Original (Manual POST_ONLY) ```python -from bfxapi import Client, REST_HOST - -from bfxapi.types import Notification, Order - -bfx = Client( - rest_host=REST_HOST, - api_key="", - api_secret="" +# Must remember flag 4096 or risk taker execution +order = client.rest.auth.submit_order( + type="EXCHANGE LIMIT", + symbol="tBTCUSD", + amount=0.01, + price=50000, + flags=4096 # ⚠️ Easy to forget! ) - -notification: Notification[Order] = bfx.rest.auth.submit_order( - type="EXCHANGE LIMIT", symbol="tBTCUSD", amount=0.165212, price=30264.0) - -order: Order = notification.data - -if notification.status == "SUCCESS": - print(f"Successful new order for {order.symbol} at {order.price}$.") - -if notification.status == "ERROR": - raise Exception(f"Something went wrong: {notification.text}") -``` - -## Authenticating in your account - -To authenticate in your account, you must provide a valid API-KEY and API-SECRET: -```python -bfx = Client( - [...], - api_key=os.getenv("BFX_API_KEY"), - api_secret=os.getenv("BFX_API_SECRET") -) -``` - -### Warning - -Remember to not share your API-KEYs and API-SECRETs with anyone. \ -Everyone who owns one of your API-KEYs and API-SECRETs will have full access to your account. \ -We suggest saving your credentials in a local `.env` file and accessing them as environment variables. - -_Revoke your API-KEYs and API-SECRETs immediately if you think they might have been stolen._ - -> **NOTE:** A guide on how to create, edit and revoke API-KEYs and API-SECRETs can be found [here](https://support.bitfinex.com/hc/en-us/articles/115003363429-How-to-create-and-revoke-a-Bitfinex-API-Key). - -## Next - -* [WebSocket client documentation](#websocket-client-documentation) - - [Advanced features](#advanced-features) - - [Examples](#examples) -* [How to contribute](#how-to-contribute) - ---- - -# WebSocket client documentation - -1. [Instantiating the client](#instantiating-the-client) - * [Authentication](#authentication) -2. [Running the client](#running-the-client) - * [Closing the connection](#closing-the-connection) -3. [Subscribing to public channels](#subscribing-to-public-channels) - * [Unsubscribing from a public channel](#unsubscribing-from-a-public-channel) - * [Setting a custom `sub_id`](#setting-a-custom-sub_id) -4. [Listening to events](#listening-to-events) - -### Advanced features -* [Using custom notifications](#using-custom-notifications) - -### Examples -* [Creating a new order](#creating-a-new-order) - -## Instantiating the client - -```python -bfx = Client(wss_host=PUB_WSS_HOST) -``` - -`Client::wss` contains an instance of `BfxWebSocketClient` (core implementation of the WebSocket client). \ -The `wss_host` argument is used to indicate the URL to which the WebSocket client should connect. \ -The `bfxapi` package exports 2 constants to quickly set this URL: - -Constant | URL | When to use -:--- | :--- | :--- -WSS_HOST | wss://api.bitfinex.com/ws/2 | Suitable for all situations, supports authentication. -PUB_WSS_HOST | wss://api-pub.bitfinex.com/ws/2 | For public uses only, doesn't support authentication. - -PUB_WSS_HOST is recommended over WSS_HOST for applications that don't require authentication. - -> **NOTE:** The `wss_host` parameter is optional, and the default value is WSS_HOST. - -### Authentication - -To learn how to authenticate in your account, have a look at [Authenticating in your account](#authenticating-in-your-account). - -If authentication is successful, the client will emit the `authenticated` event. \ -All operations that require authentication will fail if run before the emission of this event. \ -The `data` argument contains information about the authentication, such as the `userId`, the `auth_id`, etc... - -```python -@bfx.wss.on("authenticated") -def on_authenticated(data: Dict[str, Any]): - print(f"Successful login for user <{data['userId']}>.") -``` - -`data` can also be useful for checking if an API-KEY has certain permissions: - -```python -@bfx.wss.on("authenticated") -def on_authenticated(data: Dict[str, Any]): - if not data["caps"]["orders"]["read"]: - raise Exception("This application requires read permissions on orders.") - - if not data["caps"]["positions"]["write"]: - raise Exception("This application requires write permissions on positions.") -``` - -## Running the client - -The client can be run using `BfxWebSocketClient::run`: -```python -bfx.wss.run() -``` - -If an event loop is already running, users can start the client with `BfxWebSocketClient::start`: -```python -await bfx.wss.start() -``` - -If the client succeeds in connecting to the server, it will emit the `open` event. \ -This is the right place for all bootstrap activities, such as subscribing to public channels. \ -To learn more about events and public channels, see [Listening to events](#listening-to-events) and [Subscribing to public channels](#subscribing-to-public-channels). - -```python -@bfx.wss.on("open") -async def on_open(): - await bfx.wss.subscribe("ticker", symbol="tBTCUSD") -``` - -### Closing the connection - -Users can close the connection with the WebSocket server using `BfxWebSocketClient::close`: -```python -await bfx.wss.close() -``` - -A custom [close code number](https://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number), along with a verbose reason, can be given as parameters: -```python -await bfx.wss.close(code=1001, reason="Going Away") -``` - -After closing the connection, the client will emit the `disconnected` event: -```python -@bfx.wss.on("disconnected") -def on_disconnected(code: int, reason: str): - if code == 1000 or code == 1001: - print("Closing the connection without errors!") -``` - -## Subscribing to public channels - -Users can subscribe to public channels using `BfxWebSocketClient::subscribe`: -```python -await bfx.wss.subscribe("ticker", symbol="tBTCUSD") -``` - -On each successful subscription, the client will emit the `subscribed` event: -```python -@bfx.wss.on("subscribed") -def on_subscribed(subscription: subscriptions.Subscription): - if subscription["channel"] == "ticker": - print(f"{subscription['symbol']}: {subscription['sub_id']}") # tBTCUSD: f2757df2-7e11-4244-9bb7-a53b7343bef8 -``` - -### Unsubscribing from a public channel - -It is possible to unsubscribe from a public channel at any time. \ -Unsubscribing from a public channel prevents the client from receiving any more data from it. \ -This can be done using `BfxWebSocketClient::unsubscribe`, and passing the `sub_id` of the public channel you want to unsubscribe from: - -```python -await bfx.wss.unsubscribe(sub_id="f2757df2-7e11-4244-9bb7-a53b7343bef8") ``` -### Setting a custom `sub_id` - -The client generates a random `sub_id` for each subscription. \ -These values must be unique, as the client uses them to identify subscriptions. \ -However, it is possible to force this value by passing a custom `sub_id` to `BfxWebSocketClient::subscribe`: - -```python -await bfx.wss.subscribe("candles", key="trade:1m:tBTCUSD", sub_id="507f1f77bcf86cd799439011") -``` - -## Listening to events - -Whenever the WebSocket client receives data, it will emit a specific event. \ -Users can either ignore those events or listen for them by registering callback functions. \ -These callback functions can also be asynchronous; in fact the client fully supports coroutines ([`asyncio`](https://docs.python.org/3/library/asyncio.html)). - -To add a listener for a specific event, users can use the decorator `BfxWebSocketClient::on`: -```python -@bfx.wss.on("candles_update") -def on_candles_update(sub: subscriptions.Candles, candle: Candle): - print(f"Candle update for key <{sub['key']}>: {candle}") -``` - -The same can be done without using decorators: -```python -bfx.wss.on("candles_update", callback=on_candles_update) -``` - -# Advanced features - -## Using custom notifications - -**Using custom notifications requires user authentication.** - -Users can send custom notifications using `BfxWebSocketClient::notify`: -```python -await bfx.wss.notify({ "foo": 1 }) -``` - -Any data can be sent along with a custom notification. - -Custom notifications are broadcast by the server on all user's open connections. \ -So, each custom notification will be sent to every online client of the current user. \ -Whenever a client receives a custom notification, it will emit the `notification` event: -```python -@bfx.wss.on("notification") -def on_notification(notification: Notification[Any]): - print(notification.data) # { "foo": 1 } -``` - -# Examples - -## Creating a new order - +### This Fork (Automatic POST_ONLY) ```python -import os - -from bfxapi import Client, WSS_HOST - -from bfxapi.types import Notification, Order - -bfx = Client( - wss_host=WSS_HOST, - api_key=os.getenv("BFX_API_KEY"), - api_secret=os.getenv("BFX_API_SECRET") +# POST_ONLY always enforced, even with flags=0 +order = client.rest.auth.submit_order( + type="EXCHANGE LIMIT", + symbol="tBTCUSD", + amount=0.01, + price=50000 + # ✅ POST_ONLY added automatically ) -@bfx.wss.on("authenticated") -async def on_authenticated(_): - await bfx.wss.inputs.submit_order( - type="EXCHANGE LIMIT", symbol="tBTCUSD", amount=0.165212, price=30264.0) - -@bfx.wss.on("order_new") -def on_order_new(order: Order): - print(f"Successful new order for {order.symbol} at {order.price}$.") - -@bfx.wss.on("on-req-notification") -def on_notification(notification: Notification[Order]): - if notification.status == "ERROR": - raise Exception(f"Something went wrong: {notification.text}") - -bfx.wss.run() +# Market orders rejected +try: + order = client.rest.auth.submit_order(type="MARKET", ...) +except ValueError: + # "Order type 'MARKET' is incompatible with POST_ONLY enforcement" ``` ---- - -# How to contribute - -All contributions are welcome! :D +## Use Cases -A guide on how to install and set up `bitfinex-api-py`'s source code can be found [here](#installation-and-setup). \ -Before opening any pull requests, please have a look at [Before Opening a PR](#before-opening-a-pr). \ -Contributors must uphold the [Contributor Covenant code of conduct](https://github.com/bitfinexcom/bitfinex-api-py/blob/master/CODE_OF_CONDUCT.md). +**✅ Perfect for:** Market makers, grid trading, DCA bots, arbitrage systems -### Index +**❌ Not for:** Stop-losses, momentum trading, strategies needing immediate execution -1. [Installation and setup](#installation-and-setup) - * [Cloning the repository](#cloning-the-repository) - * [Installing the dependencies](#installing-the-dependencies) - * [Set up the pre-commit hooks (optional)](#set-up-the-pre-commit-hooks-optional) -2. [Before opening a PR](#before-opening-a-pr) - * [Tip](#tip) -3. [License](#license) +## Migration -## Installation and setup - -A brief guide on how to install and set up the project in your Python 3.8+ environment. - -### Cloning the repository - -```console -git clone https://github.com/bitfinexcom/bitfinex-api-py.git -``` - -### Installing the dependencies - -```console -python3 -m pip install -r dev-requirements.txt +```bash +pip uninstall bitfinex-api-py +pip install bitfinex-api-py-postonly ``` -Make sure to install `dev-requirements.txt` (and not `requirements.txt`!). \ -`dev-requirements.txt` will install all dependencies in `requirements.txt` plus any development dependency. \ -dev-requirements includes [mypy](https://github.com/python/mypy), [black](https://github.com/psf/black), [isort](https://github.com/PyCQA/isort), [flake8](https://github.com/PyCQA/flake8), and [pre-commit](https://github.com/pre-commit/pre-commit) (more on these tools in later chapters). +No code changes needed for limit order strategies. -All done, your Python 3.8+ environment should now be able to run `bitfinex-api-py`'s source code. +## Important Notes -### Set up the pre-commit hooks (optional) +- **Funding offers** not affected (no POST_ONLY flag) +- **Other flags** preserved (HIDDEN, REDUCE_ONLY work normally) +- **No bypass** - POST_ONLY enforcement cannot be disabled +- **4-layer enforcement** - REST, WebSocket, middleware, and client levels -**Do not skip this paragraph if you intend to contribute to the project.** +## Links -This repository includes a pre-commit configuration file that defines the following hooks: -1. [isort](https://github.com/PyCQA/isort) -2. [black](https://github.com/psf/black) -3. [flake8](https://github.com/PyCQA/flake8) - -To set up pre-commit use: -```console -python3 -m pre-commit install -``` - -These will ensure that isort, black and flake8 are run on each git commit. - -[Visit this page to learn more about git hooks and pre-commit.](https://pre-commit.com/#introduction) - -#### Manually triggering the pre-commit hooks - -You can also manually trigger the execution of all hooks with: -```console -python3 -m pre-commit run --all-files -``` - -## Before opening a PR - -**We won't accept your PR or we'll request changes if the following requirements aren't met.** - -Wheter you're submitting a bug fix, a new feature or a documentation change, you should first discuss it in an issue. - -You must be able to check off all tasks listed in [PULL_REQUEST_TEMPLATE](https://raw.githubusercontent.com/bitfinexcom/bitfinex-api-py/master/.github/PULL_REQUEST_TEMPLATE.md) before opening a pull request. - -### Tip - -Setting up the project's pre-commit hooks will help automate this process ([more](#set-up-the-pre-commit-hooks-optional)). +- **Fork**: https://github.com/0xferit/bitfinex-api-py +- **Original**: https://github.com/bitfinexcom/bitfinex-api-py +- **PyPI**: https://pypi.org/project/bitfinex-api-py-postonly/ ## License -``` -Copyright 2023 Bitfinex - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` +Apache 2.0 (same as original) \ No newline at end of file diff --git a/bfxapi/_utils/post_only_enforcement.py b/bfxapi/_utils/post_only_enforcement.py new file mode 100644 index 0000000..0ee0055 --- /dev/null +++ b/bfxapi/_utils/post_only_enforcement.py @@ -0,0 +1,36 @@ +from typing import Optional + +from bfxapi.constants.order_flags import POST_ONLY + + +def enforce_post_only(flags: Optional[int], order_type: Optional[str] = None) -> int: + """ + Ensure POST_ONLY flag is set, preserving other flags. + + Validates that order type is compatible with POST_ONLY. + + Args: + flags: Existing flags value (can be None) + order_type: Order type string (optional, for validation) + + Returns: + Flags value with POST_ONLY bit set + + Raises: + ValueError: If order type is incompatible with POST_ONLY + """ + # Validate order type if provided + if order_type: + # Normalize order type for comparison + normalized_type = order_type.upper() + + # Check for MARKET order types (incompatible with POST_ONLY) + if 'MARKET' in normalized_type: + raise ValueError( + f"Order type '{order_type}' is incompatible with POST_ONLY enforcement. " + "POST_ONLY only works with limit-style orders. " + "This fork enforces POST_ONLY on all orders for safety. " + "Please use a LIMIT order type instead." + ) + + return POST_ONLY | (flags if flags is not None else 0) diff --git a/bfxapi/constants/order_flags.py b/bfxapi/constants/order_flags.py new file mode 100644 index 0000000..b0fa506 --- /dev/null +++ b/bfxapi/constants/order_flags.py @@ -0,0 +1,6 @@ +""" +Bitfinex Order Flags +Reference: https://docs.bitfinex.com/docs/flag-values +""" + +POST_ONLY = 4096 # Post-only flag (maker-only, won't cross spread) diff --git a/bfxapi/rest/_interface/middleware.py b/bfxapi/rest/_interface/middleware.py index 92967c1..077970f 100644 --- a/bfxapi/rest/_interface/middleware.py +++ b/bfxapi/rest/_interface/middleware.py @@ -9,6 +9,7 @@ from bfxapi._utils.json_decoder import JSONDecoder from bfxapi._utils.json_encoder import JSONEncoder +from bfxapi._utils.post_only_enforcement import enforce_post_only from bfxapi.exceptions import InvalidCredentialError from bfxapi.rest.exceptions import GenericError, RequestParameterError @@ -61,6 +62,20 @@ def post( body: Optional[Any] = None, params: Optional["_Params"] = None, ) -> Any: + # FORCE POST_ONLY for order endpoints (catch-all protection) + # MAINTENANCE NOTE: If Bitfinex adds new order endpoints with different + # naming patterns (not containing "order/submit" or "order/update"), + # this middleware check must be updated to include them. + # Current patterns as of 2025: auth/w/order/submit, auth/w/order/update + if body and isinstance(body, dict): + if "order/submit" in endpoint: + # Force POST_ONLY flag on all order submissions (validates order type) + body["flags"] = enforce_post_only(body.get("flags"), order_type=body.get("type")) + elif "order/update" in endpoint: + # FORCE POST_ONLY flag on ALL order updates - no exceptions + # Note: Updates don't change order type, so no validation needed + body["flags"] = enforce_post_only(body.get("flags")) + _body = body and json.dumps(body, cls=JSONEncoder) or None headers = {"Accept": "application/json", "Content-Type": "application/json"} diff --git a/bfxapi/rest/_interfaces/rest_auth_endpoints.py b/bfxapi/rest/_interfaces/rest_auth_endpoints.py index ad3b806..a8ebc27 100644 --- a/bfxapi/rest/_interfaces/rest_auth_endpoints.py +++ b/bfxapi/rest/_interfaces/rest_auth_endpoints.py @@ -1,6 +1,7 @@ from decimal import Decimal from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from bfxapi._utils.post_only_enforcement import enforce_post_only from bfxapi.rest._interface import Interface from bfxapi.types import ( BalanceAvailable, @@ -100,6 +101,10 @@ def submit_order( tif: Optional[str] = None, meta: Optional[Dict[str, Any]] = None, ) -> Notification[Order]: + """Submit a new order (ALWAYS post-only).""" + # FORCE POST_ONLY flag - no exceptions (validates order type compatibility) + flags = enforce_post_only(flags, order_type=type) + body = { "type": type, "symbol": symbol, @@ -111,7 +116,7 @@ def submit_order( "price_oco_stop": price_oco_stop, "gid": gid, "cid": cid, - "flags": flags, + "flags": flags, # ALWAYS has POST_ONLY "tif": tif, "meta": meta, } @@ -136,6 +141,10 @@ def update_order( price_trailing: Optional[Union[str, float, Decimal]] = None, tif: Optional[str] = None, ) -> Notification[Order]: + """Update an existing order (ALWAYS post-only).""" + # FORCE POST_ONLY flag on ALL order updates - no exceptions + flags = enforce_post_only(flags) + body = { "id": id, "amount": amount, @@ -143,12 +152,12 @@ def update_order( "cid": cid, "cid_date": cid_date, "gid": gid, - "flags": flags, "lev": lev, "delta": delta, "price_aux_limit": price_aux_limit, "price_trailing": price_trailing, "tif": tif, + "flags": flags, } return _Notification[Order](serializers.Order).parse( @@ -384,13 +393,14 @@ def submit_funding_offer( *, flags: Optional[int] = None, ) -> Notification[FundingOffer]: + """Submit a funding offer. Order flags are not applicable and are ignored.""" + body = { "type": type, "symbol": symbol, "amount": amount, "rate": rate, "period": period, - "flags": flags, } return _Notification[FundingOffer](serializers.FundingOffer).parse( diff --git a/bfxapi/websocket/_client/bfx_websocket_bucket.py b/bfxapi/websocket/_client/bfx_websocket_bucket.py index fa6262f..b613976 100644 --- a/bfxapi/websocket/_client/bfx_websocket_bucket.py +++ b/bfxapi/websocket/_client/bfx_websocket_bucket.py @@ -63,12 +63,13 @@ async def start(self) -> None: self.__on_subscribed(message) if isinstance(message, list): - if ( - (chan_id := cast(int, message[0])) - and (subscription := self.__subscriptions.get(chan_id)) - and (message[1] != Connection._HEARTBEAT) + if (chan_id := cast(int, message[0])) and ( + subscription := self.__subscriptions.get(chan_id) ): - self.__handler.handle(subscription, message[1:]) + if message[1] == Connection._HEARTBEAT: + self.__event_emitter.emit("heartbeat", subscription) + else: + self.__handler.handle(subscription, message[1:]) def __on_subscribed(self, message: Dict[str, Any]) -> None: chan_id = cast(int, message["chan_id"]) diff --git a/bfxapi/websocket/_client/bfx_websocket_client.py b/bfxapi/websocket/_client/bfx_websocket_client.py index ffae0ad..41c3084 100644 --- a/bfxapi/websocket/_client/bfx_websocket_client.py +++ b/bfxapi/websocket/_client/bfx_websocket_client.py @@ -13,6 +13,7 @@ from websockets.exceptions import ConnectionClosedError, InvalidStatusCode from bfxapi._utils.json_encoder import JSONEncoder +from bfxapi._utils.post_only_enforcement import enforce_post_only from bfxapi.exceptions import InvalidCredentialError from bfxapi.websocket._connection import Connection from bfxapi.websocket._event_emitter import BfxEventEmitter @@ -263,12 +264,11 @@ async def __connect(self) -> None: self._authentication = True - if ( - isinstance(message, list) - and message[0] == 0 - and message[1] != Connection._HEARTBEAT - ): - self.__handler.handle(message[1], message[2]) + if isinstance(message, list) and message[0] == 0: + if message[1] == Connection._HEARTBEAT: + self.__event_emitter.emit("heartbeat", None) + else: + self.__handler.handle(message[1], message[2]) async def __new_bucket(self) -> BfxWebSocketBucket: bucket = BfxWebSocketBucket(self._host, self.__event_emitter) @@ -345,6 +345,13 @@ async def notify( @Connection._require_websocket_authentication async def __handle_websocket_input(self, event: str, data: Any) -> None: + # FORCE POST_ONLY for order events + if event == "on" and isinstance(data, dict): # New order + data["flags"] = enforce_post_only(data.get("flags")) + elif event == "ou" and isinstance(data, dict): # Update order + # FORCE POST_ONLY flag on ALL order updates - no exceptions + data["flags"] = enforce_post_only(data.get("flags")) + await self._websocket.send(json.dumps([0, event, None, data], cls=JSONEncoder)) def on(self, event, callback=None): diff --git a/bfxapi/websocket/_client/bfx_websocket_inputs.py b/bfxapi/websocket/_client/bfx_websocket_inputs.py index 48a39d1..6f91828 100644 --- a/bfxapi/websocket/_client/bfx_websocket_inputs.py +++ b/bfxapi/websocket/_client/bfx_websocket_inputs.py @@ -1,6 +1,8 @@ from decimal import Decimal from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union +from bfxapi._utils.post_only_enforcement import enforce_post_only + _Handler = Callable[[str, Any], Awaitable[None]] @@ -25,6 +27,10 @@ async def submit_order( tif: Optional[str] = None, meta: Optional[Dict[str, Any]] = None, ) -> None: + """Submit a new order (ALWAYS post-only).""" + # FORCE POST_ONLY flag - no exceptions (validates order type compatibility) + flags = enforce_post_only(flags, order_type=type) + await self.__handle_websocket_input( "on", { @@ -38,7 +44,7 @@ async def submit_order( "price_oco_stop": price_oco_stop, "gid": gid, "cid": cid, - "flags": flags, + "flags": flags, # ALWAYS has POST_ONLY "tif": tif, "meta": meta, }, @@ -60,23 +66,26 @@ async def update_order( price_trailing: Optional[Union[str, float, Decimal]] = None, tif: Optional[str] = None, ) -> None: - await self.__handle_websocket_input( - "ou", - { - "id": id, - "amount": amount, - "price": price, - "cid": cid, - "cid_date": cid_date, - "gid": gid, - "flags": flags, - "lev": lev, - "delta": delta, - "price_aux_limit": price_aux_limit, - "price_trailing": price_trailing, - "tif": tif, - }, - ) + """Update an existing order (ALWAYS post-only).""" + # FORCE POST_ONLY flag on ALL order updates - no exceptions + flags = enforce_post_only(flags) + + payload: Dict[str, Any] = { + "id": id, + "amount": amount, + "price": price, + "cid": cid, + "cid_date": cid_date, + "gid": gid, + "lev": lev, + "delta": delta, + "price_aux_limit": price_aux_limit, + "price_trailing": price_trailing, + "tif": tif, + "flags": flags, + } + + await self.__handle_websocket_input("ou", payload) async def cancel_order( self, @@ -111,6 +120,8 @@ async def submit_funding_offer( *, flags: Optional[int] = None, ) -> None: + """Submit a funding offer. Order flags are not applicable and are ignored.""" + await self.__handle_websocket_input( "fon", { @@ -119,7 +130,6 @@ async def submit_funding_offer( "amount": amount, "rate": rate, "period": period, - "flags": flags, }, ) diff --git a/bfxapi/websocket/_event_emitter/bfx_event_emitter.py b/bfxapi/websocket/_event_emitter/bfx_event_emitter.py index 21bbfd6..97c1372 100644 --- a/bfxapi/websocket/_event_emitter/bfx_event_emitter.py +++ b/bfxapi/websocket/_event_emitter/bfx_event_emitter.py @@ -32,6 +32,7 @@ _COMMON = [ "disconnected", + "heartbeat", "t_ticker_update", "f_ticker_update", "t_trade_execution", diff --git a/examples/websocket/heartbeat.py b/examples/websocket/heartbeat.py new file mode 100644 index 0000000..1bc48ad --- /dev/null +++ b/examples/websocket/heartbeat.py @@ -0,0 +1,65 @@ +""" +Demonstrates heartbeat event handling for both public and authenticated +WebSocket connections. + +Usage: + python examples/websocket/heartbeat.py + +If BFX_API_KEY and BFX_API_SECRET environment variables are set, the client will +authenticate and display heartbeats on the authenticated connection (channel 0). +Otherwise, only public channel heartbeats are shown. +""" + +import os +from typing import Any, Dict, Optional + +from bfxapi import Client +from bfxapi.websocket.subscriptions import Subscription + +# Initialize client with optional authentication +api_key = os.getenv("BFX_API_KEY") +api_secret = os.getenv("BFX_API_SECRET") + +if api_key and api_secret: + print("Initializing authenticated client...") + bfx = Client(api_key=api_key, api_secret=api_secret) +else: + print("Initializing public client (set BFX_API_KEY and BFX_API_SECRET for auth)...") + bfx = Client() + + +@bfx.wss.on("heartbeat") +def on_heartbeat(subscription: Optional[Subscription]) -> None: + """Handle heartbeat events from both public and authenticated channels.""" + if subscription: + channel = subscription["channel"] + label = subscription.get("symbol", subscription.get("key", "unknown")) + print(f"Heartbeat for {channel}: {label}") + else: + print("Heartbeat on authenticated connection (channel 0)") + + +@bfx.wss.on("authenticated") +async def on_authenticated(event: Dict[str, Any]) -> None: + """Handle authentication confirmation.""" + user_id = event.get("userId", "unknown") + print(f"Successfully authenticated with userId: {user_id}") + + +@bfx.wss.on("open") +async def on_open() -> None: + """Subscribe to public channels when connection opens.""" + print("WebSocket connection opened") + # Subscribe to a public channel to observe subscription heartbeats + await bfx.wss.subscribe("ticker", symbol="tBTCUSD") + print("Subscribed to ticker channel for tBTCUSD") + + +if __name__ == "__main__": + print("Starting WebSocket client... Press Ctrl+C to stop.") + try: + bfx.wss.run() + except KeyboardInterrupt: + print("\nShutting down gracefully...") + except Exception as e: + print(f"Error: {e}") diff --git a/setup.py b/setup.py index cc928e0..eca7a29 100644 --- a/setup.py +++ b/setup.py @@ -1,23 +1,31 @@ -from distutils.core import setup +from setuptools import setup setup( - name="bitfinex-api-py", - version="3.0.5", - description="Official Bitfinex Python API", + name="bitfinex-api-py-postonly", + version="3.0.5.post2", + description="Bitfinex Python API with enforced POST_ONLY orders - prevents accidental market/taker orders", long_description=( - "A Python reference implementation of the Bitfinex API " - "for both REST and websocket interaction." + "A safety-enhanced fork of the official Bitfinex Python API that " + "enforces POST_ONLY (maker-only) flag on ALL orders. " + "Prevents accidental market orders, taker fees, and costly trading mistakes. " + "Orders will never cross the spread. Perfect for market makers, grid bots, and safety-critical trading. " + "Drop-in replacement for bitfinex-api-py with automatic POST_ONLY enforcement." ), long_description_content_type="text/markdown", - url="https://github.com/bitfinexcom/bitfinex-api-py", - author="Bitfinex", - author_email="support@bitfinex.com", + url="https://github.com/0xferit/bitfinex-api-py", + author="0xferit", + author_email="ferit@example.com", license="Apache-2.0", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "Topic :: Software Development :: Build Tools", + "Intended Audience :: Financial and Insurance Industry", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Office/Business :: Financial", "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -25,14 +33,17 @@ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ], - keywords="bitfinex,api,trading", + keywords="bitfinex,api,trading,post-only,postonly,maker-only,maker,safety,no-taker,bitcoin,cryptocurrency,exchange,limit-order,market-maker,grid-trading,algo-trading", project_urls={ - "Bug Reports": "https://github.com/bitfinexcom/bitfinex-api-py/issues", - "Source": "https://github.com/bitfinexcom/bitfinex-api-py", + "Original Source": "https://github.com/bitfinexcom/bitfinex-api-py", + "Fork Source": "https://github.com/0xferit/bitfinex-api-py", + "Documentation": "https://github.com/0xferit/bitfinex-api-py/blob/master/README.md", + "Issues": "https://github.com/0xferit/bitfinex-api-py/issues", }, packages=[ "bfxapi", "bfxapi._utils", + "bfxapi.constants", # CRITICAL: Added for POST_ONLY flag "bfxapi.types", "bfxapi.websocket", "bfxapi.websocket._client", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fd6bb47 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for bitfinex-api-py diff --git a/tests/test_post_only_enforcement.py b/tests/test_post_only_enforcement.py new file mode 100644 index 0000000..2ccb845 --- /dev/null +++ b/tests/test_post_only_enforcement.py @@ -0,0 +1,238 @@ +""" +Tests for POST_ONLY flag enforcement - only real safety tests, no theater. + +These tests verify actual functionality of the enforce_post_only utility function +and flag preservation logic. Mock-based tests that don't verify real behavior +have been removed. +""" + +import unittest + +from bfxapi._utils.post_only_enforcement import enforce_post_only +from bfxapi.constants.order_flags import POST_ONLY + + +class TestPostOnlyUtility(unittest.TestCase): + """Test the enforce_post_only utility function with real operations.""" + + def test_enforce_post_only_with_none(self): + """Test that None is handled correctly.""" + result = enforce_post_only(None) + self.assertEqual(result, POST_ONLY) + + def test_enforce_post_only_with_zero(self): + """Test that 0 returns POST_ONLY.""" + result = enforce_post_only(0) + self.assertEqual(result, POST_ONLY) + + def test_enforce_post_only_with_existing_flags(self): + """Test that existing flags are preserved while adding POST_ONLY.""" + # Test with a different flag (e.g., HIDDEN = 64) + HIDDEN = 64 + result = enforce_post_only(HIDDEN) + self.assertEqual(result, POST_ONLY | HIDDEN) + self.assertEqual(result, 4096 | 64) # Should be 4160 + + def test_enforce_post_only_idempotent(self): + """Test that applying enforce_post_only twice doesn't change the result.""" + result1 = enforce_post_only(0) + result2 = enforce_post_only(result1) + self.assertEqual(result1, result2) + self.assertEqual(result2, POST_ONLY) + + def test_enforce_post_only_with_max_flags(self): + """Test with maximum possible flag values to ensure no overflow.""" + # Test with a very large flag value (all bits except sign bit) + MAX_FLAG = 2**31 - 1 # Maximum positive int32 + result = enforce_post_only(MAX_FLAG) + + # POST_ONLY should still be set + self.assertTrue(result & POST_ONLY) + # All original bits should be preserved + self.assertEqual(result, MAX_FLAG | POST_ONLY) + + # Test with common maximum flag combinations + ALL_COMMON_FLAGS = 64 | 1024 | 2048 | 4096 | 8192 # Multiple flags + result = enforce_post_only(ALL_COMMON_FLAGS) + self.assertTrue(result & POST_ONLY) + self.assertEqual(result, ALL_COMMON_FLAGS) # POST_ONLY already included + + def test_market_order_rejected(self): + """Test that MARKET orders are rejected with clear error message.""" + # Test basic MARKET order + with self.assertRaises(ValueError) as cm: + enforce_post_only(0, order_type="MARKET") + + error_msg = str(cm.exception) + self.assertIn("incompatible with POST_ONLY", error_msg) + self.assertIn("LIMIT order", error_msg) + + # Test various market order formats + market_types = ["MARKET", "EXCHANGE MARKET", "FOK MARKET", "IOC MARKET", "market", "Market"] + for order_type in market_types: + with self.assertRaises(ValueError) as cm: + enforce_post_only(0, order_type=order_type) + self.assertIn("incompatible", str(cm.exception)) + + def test_limit_orders_accepted(self): + """Test that LIMIT orders are accepted.""" + limit_types = ["LIMIT", "EXCHANGE LIMIT", "FOK LIMIT", "IOC LIMIT", "limit", "Limit"] + for order_type in limit_types: + # Should not raise any exception + result = enforce_post_only(0, order_type=order_type) + self.assertEqual(result, POST_ONLY) + + # Also test with existing flags + result = enforce_post_only(64, order_type=order_type) + self.assertEqual(result, POST_ONLY | 64) + + +class TestIntegrationScenarios(unittest.TestCase): + """Test complex integration scenarios with real flag operations.""" + + def test_none_flags_handling_across_layers(self): + """Test that None flags are properly handled without TypeErrors.""" + # This test ensures the fix for the TypeError bug is working + + # Test utility function + result = enforce_post_only(None) + self.assertEqual(result, POST_ONLY) + + # Test with actual flag values + result = enforce_post_only(0) + self.assertEqual(result, POST_ONLY) + + result = enforce_post_only(64) # HIDDEN flag + self.assertEqual(result, POST_ONLY | 64) + + def test_flag_preservation(self): + """Test that other flags are preserved when adding POST_ONLY.""" + HIDDEN = 64 + REDUCE_ONLY = 1024 + + # Test single flag preservation + result = enforce_post_only(HIDDEN) + self.assertTrue(result & POST_ONLY) + self.assertTrue(result & HIDDEN) + + # Test multiple flags preservation + combined = HIDDEN | REDUCE_ONLY + result = enforce_post_only(combined) + self.assertTrue(result & POST_ONLY) + self.assertTrue(result & HIDDEN) + self.assertTrue(result & REDUCE_ONLY) + + def test_post_only_already_set(self): + """Test that POST_ONLY flag is idempotent.""" + # If POST_ONLY is already set, it should remain set + flags_with_post_only = POST_ONLY | 64 + result = enforce_post_only(flags_with_post_only) + self.assertEqual(result, flags_with_post_only) + + +class TestIntegrationPostOnly(unittest.IsolatedAsyncioTestCase): + """Integration tests that verify POST_ONLY is added through the call path.""" + + def test_middleware_integration(self): + """Test that middleware adds POST_ONLY to order endpoints.""" + from unittest.mock import MagicMock, patch + from bfxapi.rest._interface.middleware import Middleware + import json + + # Mock only at the HTTP boundary + with patch('bfxapi.rest._interface.middleware.requests.post') as mock_post: + mock_response = MagicMock() + mock_response.json.return_value = [] + mock_post.return_value = mock_response + + middleware = Middleware("https://api.bitfinex.com", "key", "secret") + + # Submit order without flags + middleware.post("auth/w/order/submit", body={ + "type": "EXCHANGE LIMIT", + "symbol": "tBTCUSD", + "amount": 0.01, + "price": 50000 + }) + + # Verify the actual JSON sent includes POST_ONLY + actual_body = json.loads(mock_post.call_args[1]['data']) + self.assertEqual(actual_body['flags'], 4096) + + # Test update order also adds POST_ONLY + middleware.post("auth/w/order/update", body={ + "id": 12345, + "amount": 0.02 + }) + + actual_body = json.loads(mock_post.call_args[1]['data']) + self.assertEqual(actual_body['flags'], 4096) + + async def test_websocket_update_order_integration(self): + """Test that WebSocket update_order enforces POST_ONLY flag.""" + from unittest.mock import AsyncMock, MagicMock, patch + from bfxapi.websocket._client.bfx_websocket_inputs import BfxWebSocketInputs + + # Mock the WebSocket client + mock_client = MagicMock() + mock_client._BfxWebSocketClient__bucket = MagicMock() + mock_client._BfxWebSocketClient__bucket.put = AsyncMock() + + inputs = BfxWebSocketInputs(mock_client) + + # Patch the private method to be async + with patch.object(inputs, '_BfxWebSocketInputs__handle_websocket_input', new_callable=AsyncMock) as mock_handle: + # Test update_order without flags + await inputs.update_order( + id=123456, + price=51000, + flags=None + ) + + # Verify POST_ONLY was added + mock_handle.assert_called_once() + call_args = mock_handle.call_args + self.assertEqual(call_args[0][0], "ou") # update order command + payload = call_args[0][1] + self.assertEqual(payload["flags"], 4096) # POST_ONLY enforced + + # Reset and test with existing flags + mock_handle.reset_mock() + await inputs.update_order( + id=123456, + price=52000, + flags=64 # HIDDEN + ) + + call_args = mock_handle.call_args + payload = call_args[0][1] + self.assertEqual(payload["flags"], 4096 | 64) # Both flags preserved + + async def test_websocket_submit_order_integration(self): + """Test that WebSocket submit_order adds POST_ONLY through the call path.""" + from bfxapi.websocket._client.bfx_websocket_inputs import BfxWebSocketInputs + + # Create a mock that captures the actual data sent + sent_data = {} + + async def capture_handler(event, data): + sent_data['event'] = event + sent_data['data'] = data + + inputs = BfxWebSocketInputs(capture_handler) + + # Submit order without flags + await inputs.submit_order( + type="EXCHANGE LIMIT", + symbol="tBTCUSD", + amount=0.01, + price=50000 + ) + + # Verify POST_ONLY was added + self.assertEqual(sent_data['event'], 'on') + self.assertEqual(sent_data['data']['flags'], 4096) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file