From 7faaaf53e405f042f319aecad1591237105a9d6f Mon Sep 17 00:00:00 2001 From: leoca Date: Wed, 16 Sep 2026 03:28:19 +0200 Subject: [PATCH 1/2] fix: keep HTX spot frozen balance in the owned base inventory HTX /v1/account/accounts/{id}/balance returns one row per (currency, type): a "trade" row for the sellable part and a "frozen" row for whatever is locked by resting orders. get_spot_base_holding returned on the first row matching the base asset, so the frozen quantity never reached the holding. get_spot_total_base_balance documents that ownership must use the whole account inventory, precisely because open limit orders move quantity from available to locked without changing ownership. With a resting sell order on HTX spot, the total came back short and the drift check saw a position that is not actually gone. Row order is not guaranteed either, so a "frozen"-first payload reported the locked amount as available. Accumulate over every row for the base asset instead: "frozen" adds to the total only, anything else adds to both the total and the available part. --- .../app/services/live_trading/spot_sizing.py | 22 +++++++++--- backend_api_python/tests/test_spot_sizing.py | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/backend_api_python/app/services/live_trading/spot_sizing.py b/backend_api_python/app/services/live_trading/spot_sizing.py index ca2b075c1..39dde23b0 100644 --- a/backend_api_python/app/services/live_trading/spot_sizing.py +++ b/backend_api_python/app/services/live_trading/spot_sizing.py @@ -245,13 +245,27 @@ def get_spot_base_holding( if isinstance(client, HtxClient) and getattr(client, "market_type", "") == "spot": balance = client.get_balance() items = (((balance.get("data") or {}).get("list")) if isinstance(balance, dict) else None) or [] + # HTX splits one currency over several rows: ``trade`` is the + # sellable part, ``frozen`` is locked by resting orders and still + # owned. Order is not guaranteed, so accumulate instead of + # returning on the first row that matches the base asset. + tradable = 0.0 + frozen = 0.0 + avail = 0.0 + matched = False for item in items: if not isinstance(item, dict): continue - if str(item.get("currency") or "").upper() == base_u: - total = _pick_free_from_row(item, "balance") - avail = _pick_free_from_row(item, "available", "balance") - return _spot_holding(total, avail) + if str(item.get("currency") or "").upper() != base_u: + continue + matched = True + if str(item.get("type") or "").strip().lower() == "frozen": + frozen += _pick_free_from_row(item, "balance") + else: + tradable += _pick_free_from_row(item, "balance") + avail += _pick_free_from_row(item, "available", "balance") + if matched: + return _spot_holding(tradable + frozen, avail) except Exception as e: if strict: raise diff --git a/backend_api_python/tests/test_spot_sizing.py b/backend_api_python/tests/test_spot_sizing.py index d78a15df8..a1ded2f36 100644 --- a/backend_api_python/tests/test_spot_sizing.py +++ b/backend_api_python/tests/test_spot_sizing.py @@ -3,6 +3,7 @@ from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.bitget_spot import BitgetSpotClient +from app.services.live_trading.htx import HtxClient from app.services.live_trading.spot_sizing import ( clamp_spot_close_quantity, get_spot_free_base_balance, @@ -72,3 +73,38 @@ def test_clamp_spot_close_no_change_when_within_free(): final, meta = clamp_spot_close_quantity(client, symbol="ETH/USDT", requested_qty=0.5, safety_ratio=1.0) assert final == 0.5 assert "adjusted" not in meta or meta.get("adjusted") is not True + + +def test_htx_spot_ownership_includes_frozen_rows(): + """HTX returns one row per (currency, type); ``frozen`` is still owned.""" + client = MagicMock(spec=HtxClient) + client.market_type = "spot" + client.get_balance.return_value = { + "data": { + "list": [ + {"currency": "btc", "type": "trade", "balance": "0.6", "available": "0.6"}, + {"currency": "btc", "type": "frozen", "balance": "0.4"}, + {"currency": "usdt", "type": "trade", "balance": "1200"}, + ] + } + } + + assert get_spot_total_base_balance(client, symbol="BTC/USDT") == 1.0 + assert get_spot_free_base_balance(client, symbol="BTC/USDT") == 0.6 + + +def test_htx_spot_frozen_row_first_is_not_taken_for_the_whole_holding(): + """The API does not guarantee ``trade`` comes before ``frozen``.""" + client = MagicMock(spec=HtxClient) + client.market_type = "spot" + client.get_balance.return_value = { + "data": { + "list": [ + {"currency": "eth", "type": "frozen", "balance": "2"}, + {"currency": "eth", "type": "trade", "balance": "3", "available": "3"}, + ] + } + } + + assert get_spot_total_base_balance(client, symbol="ETH/USDT") == 5.0 + assert get_spot_free_base_balance(client, symbol="ETH/USDT") == 3.0 From bb9c494d2b167e18e68d445235e1e6b744e069ec Mon Sep 17 00:00:00 2001 From: TIANHE Date: Wed, 16 Sep 2026 14:15:15 +0800 Subject: [PATCH 2/2] fix: restrict HTX spot inventory to trade and frozen balances Only trade balances are sellable; keep frozen balances in owned trading inventory and exclude unrelated balance types. Cover non-trading rows in both orders, close sizing, and fully frozen holdings. --- .../app/services/live_trading/spot_sizing.py | 9 +++-- backend_api_python/tests/test_spot_sizing.py | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/backend_api_python/app/services/live_trading/spot_sizing.py b/backend_api_python/app/services/live_trading/spot_sizing.py index 39dde23b0..861445cd8 100644 --- a/backend_api_python/app/services/live_trading/spot_sizing.py +++ b/backend_api_python/app/services/live_trading/spot_sizing.py @@ -258,12 +258,15 @@ def get_spot_base_holding( continue if str(item.get("currency") or "").upper() != base_u: continue - matched = True - if str(item.get("type") or "").strip().lower() == "frozen": + balance_type = str(item.get("type") or "").strip().lower() + if balance_type == "frozen": + matched = True frozen += _pick_free_from_row(item, "balance") - else: + elif balance_type == "trade": + matched = True tradable += _pick_free_from_row(item, "balance") avail += _pick_free_from_row(item, "available", "balance") + # Other balance types are not part of this spot trading inventory. if matched: return _spot_holding(tradable + frozen, avail) except Exception as e: diff --git a/backend_api_python/tests/test_spot_sizing.py b/backend_api_python/tests/test_spot_sizing.py index a1ded2f36..c7fa244b9 100644 --- a/backend_api_python/tests/test_spot_sizing.py +++ b/backend_api_python/tests/test_spot_sizing.py @@ -1,6 +1,8 @@ from decimal import Decimal from unittest.mock import MagicMock +import pytest + from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.bitget_spot import BitgetSpotClient from app.services.live_trading.htx import HtxClient @@ -108,3 +110,39 @@ def test_htx_spot_frozen_row_first_is_not_taken_for_the_whole_holding(): assert get_spot_total_base_balance(client, symbol="ETH/USDT") == 5.0 assert get_spot_free_base_balance(client, symbol="ETH/USDT") == 3.0 + + +@pytest.mark.parametrize("balance_type", ["lock", "bank", "loan", "interest", "unknown", "", None]) +@pytest.mark.parametrize("extra_first", [False, True]) +def test_htx_spot_non_trading_rows_do_not_increase_sellable_inventory(balance_type, extra_first): + client = MagicMock(spec=HtxClient) + client.market_type = "spot" + rows = [ + {"currency": "btc", "type": "trade", "balance": "0.6"}, + {"currency": "btc", "type": "frozen", "balance": "0.4"}, + ] + extra = {"currency": "btc", "balance": "2", "available": "2"} + if balance_type is not None: + extra["type"] = balance_type + rows.insert(0 if extra_first else len(rows), extra) + client.get_balance.return_value = {"data": {"list": rows}} + + assert get_spot_total_base_balance(client, symbol="BTC/USDT") == 1.0 + assert get_spot_free_base_balance(client, symbol="BTC/USDT") == 0.6 + quantity, meta = clamp_spot_close_quantity( + client, symbol="BTC/USDT", requested_qty=3.0, safety_ratio=1.0, + ) + assert quantity == 0.6 + assert meta["exchange_free"] == 0.6 + + +def test_htx_spot_fully_frozen_inventory_has_zero_available(): + client = MagicMock(spec=HtxClient) + client.market_type = "spot" + client.get_balance.return_value = {"data": {"list": [ + {"currency": "btc", "type": " FROZEN ", "balance": "1", "available": "1"}, + {"currency": "btc", "type": " TRADE ", "balance": "0"}, + ]}} + + assert get_spot_total_base_balance(client, symbol="BTC/USDT") == 1.0 + assert get_spot_free_base_balance(client, symbol="BTC/USDT") == 0.0