Skip to content

Commit d2ae4d3

Browse files
HenryHenry
authored andcommitted
fix: enforce exchange grid quantity rules
1 parent 7225b40 commit d2ae4d3

4 files changed

Lines changed: 204 additions & 2 deletions

File tree

‎backend_api_python/app/services/grid/engine.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,7 @@ def _normalize_grid_base_qty(self, qty: float, price: float) -> float:
11621162
quantity=float(qty),
11631163
market_type=self.cfg.market_type,
11641164
exchange_config=self.exchange_config,
1165+
price=float(price),
11651166
)
11661167
except Exception as e:
11671168
logger.debug("grid normalize qty sid=%s: %s", self.strategy_id, e)

‎backend_api_python/app/services/grid/exchange_orders.py‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def __iter__(self):
4444
yield self.avg_price
4545

4646

47-
def normalize_grid_order_quantity(
47+
def _normalize_grid_order_quantity_for_client(
4848
client: BaseRestClient,
4949
*,
5050
symbol: str,
@@ -174,6 +174,94 @@ def normalize_grid_order_quantity(
174174
return qty
175175

176176

177+
def _grid_exchange_id(client: BaseRestClient, exchange_config: Dict[str, Any]) -> str:
178+
configured = str(
179+
exchange_config.get("exchange_id")
180+
or exchange_config.get("exchange")
181+
or exchange_config.get("exchangeId")
182+
or ""
183+
).strip().lower()
184+
aliases = {
185+
"gateio": "gate",
186+
"gate.io": "gate",
187+
"huobi": "htx",
188+
}
189+
if configured:
190+
return aliases.get(configured, configured)
191+
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
192+
return "binance"
193+
if isinstance(client, (BitgetMixClient, BitgetSpotClient)):
194+
return "bitget"
195+
if isinstance(client, BybitClient):
196+
return "bybit"
197+
if isinstance(client, (GateUsdtFuturesClient, GateSpotClient)):
198+
return "gate"
199+
if isinstance(client, HtxClient):
200+
return "htx"
201+
if isinstance(client, OkxClient):
202+
return "okx"
203+
return ""
204+
205+
206+
def normalize_grid_order_quantity(
207+
client: BaseRestClient,
208+
*,
209+
symbol: str,
210+
quantity: float,
211+
market_type: str,
212+
exchange_config: Optional[Dict[str, Any]] = None,
213+
price: float = 0.0,
214+
) -> float:
215+
"""Normalize a grid order against both client and native exchange rules.
216+
217+
The client-specific pass converts contracts to base units where needed.
218+
The common rules pass then floors amount precision and rejects quantities
219+
below either the exchange minimum amount or minimum order notional.
220+
"""
221+
cfg = exchange_config if isinstance(exchange_config, dict) else {}
222+
normalized = _normalize_grid_order_quantity_for_client(
223+
client,
224+
symbol=symbol,
225+
quantity=quantity,
226+
market_type=market_type,
227+
exchange_config=cfg,
228+
)
229+
if normalized <= 0:
230+
return 0.0
231+
232+
exchange_id = _grid_exchange_id(client, cfg)
233+
if not exchange_id:
234+
return normalized
235+
try:
236+
from app.services.instrument_rules import get_instrument_rules_provider
237+
238+
rules = get_instrument_rules_provider().get_rules(
239+
symbol,
240+
exchange_id=exchange_id,
241+
market_type=market_type,
242+
client=client,
243+
)
244+
normalized = rules.normalize_amount(normalized, enforce_minimum=True)
245+
px = max(0.0, float(price or 0.0))
246+
min_notional = max(0.0, float(rules.min_notional or 0.0))
247+
if normalized <= 0:
248+
return 0.0
249+
if px > 0 and min_notional > 0 and normalized * px < min_notional:
250+
return 0.0
251+
return normalized
252+
except Exception as exc:
253+
# If native rules cannot be verified, skipping the grid order is safer
254+
# than passing an unrounded float to an exchange. The next sync cycle
255+
# retries after the provider cache/endpoint recovers.
256+
logger.warning(
257+
"grid native quantity rules unavailable exchange=%s symbol=%s: %s",
258+
exchange_id,
259+
symbol,
260+
exc,
261+
)
262+
return 0.0
263+
264+
177265
def make_grid_initial_client_order_id(strategy_id: int, leg: str = "") -> str:
178266
"""Stable client oid for grid initial market leg (one per strategy/leg, avoids duplicate opens)."""
179267
suffix = str(leg or "").strip().lower()[:1]

‎backend_api_python/tests/test_grid_exchange_requirements.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ class FakeBitgetClient:
201201
def get_account_pos_mode(self, **kwargs):
202202
return "hedge_mode"
203203

204+
def get_contract(self, **kwargs):
205+
return {
206+
"sizeMultiplier": "0.0001",
207+
"minTradeNum": "0.0001",
208+
"contractSize": "1",
209+
"priceStep": "0.01",
210+
}
211+
204212
placed = []
205213
monkeypatch.setattr(
206214
"app.services.live_trading.position_query.query_exchange_position_size",

‎backend_api_python/tests/test_grid_quantity_normalization.py‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import pytest
2+
3+
from app.services import instrument_rules
14
from app.services.grid.exchange_orders import normalize_grid_order_quantity
2-
from app.services.live_trading.gate import GateUsdtFuturesClient
5+
from app.services.instrument_rules import InstrumentRules
6+
from app.services.live_trading.base import BaseRestClient
7+
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
38
from app.services.live_trading.htx import HtxClient
49

510

@@ -47,3 +52,103 @@ def test_htx_futures_never_rounds_sub_contract_quantity_up():
4752
client.get_contract_info = lambda **_kwargs: {"contract_size": "0.001"}
4853

4954
assert client._base_to_contracts(symbol="BTC/USDT", qty=0.0005) == 0
55+
56+
57+
def test_gate_spot_grid_rejects_float_dust_and_floors_native_precision():
58+
client = GateSpotClient.__new__(GateSpotClient)
59+
client.get_currency_pair = lambda **_kwargs: {
60+
"amount_precision": 6,
61+
"precision": 2,
62+
"min_base_amount": "0.0001",
63+
"min_quote_amount": "1",
64+
}
65+
66+
assert normalize_grid_order_quantity(
67+
client,
68+
symbol="BTC/USDT",
69+
quantity=3.3532799999999997e-07,
70+
market_type="spot",
71+
price=100_000,
72+
) == 0.0
73+
assert normalize_grid_order_quantity(
74+
client,
75+
symbol="BTC/USDT",
76+
quantity=0.0001239,
77+
market_type="spot",
78+
price=100_000,
79+
) == pytest.approx(0.000123)
80+
81+
82+
def test_htx_spot_grid_rejects_float_dust_and_minimum_notional():
83+
client = HtxClient.__new__(HtxClient)
84+
client.get_spot_symbol_info = lambda **_kwargs: {
85+
"amount-precision": 6,
86+
"price-precision": 2,
87+
"min-order-amt": "0.0001",
88+
"min-order-value": "1",
89+
}
90+
91+
assert normalize_grid_order_quantity(
92+
client,
93+
symbol="BTC/USDT",
94+
quantity=3.3532799999999997e-07,
95+
market_type="spot",
96+
price=100_000,
97+
) == 0.0
98+
assert normalize_grid_order_quantity(
99+
client,
100+
symbol="BTC/USDT",
101+
quantity=0.0001,
102+
market_type="spot",
103+
price=5_000,
104+
) == 0.0
105+
106+
107+
@pytest.mark.parametrize("exchange_id", ["binance", "okx", "bitget", "bybit", "gate", "htx"])
108+
@pytest.mark.parametrize("market_type", ["spot", "swap"])
109+
def test_all_grid_exchanges_apply_common_minimum_rules(
110+
monkeypatch,
111+
exchange_id,
112+
market_type,
113+
):
114+
rules = InstrumentRules(
115+
key=f"Crypto:BTC/USDT@{exchange_id}:{market_type}",
116+
exchange_id=exchange_id,
117+
market_type=market_type,
118+
symbol="BTC/USDT",
119+
amount_step=0.001,
120+
min_amount=0.01,
121+
min_notional=5.0,
122+
)
123+
124+
class FakeProvider:
125+
def get_rules(self, *_args, **_kwargs):
126+
return rules
127+
128+
monkeypatch.setattr(instrument_rules, "get_instrument_rules_provider", lambda: FakeProvider())
129+
client = BaseRestClient("https://example.invalid")
130+
131+
assert normalize_grid_order_quantity(
132+
client,
133+
symbol="BTC/USDT",
134+
quantity=0.0099,
135+
market_type=market_type,
136+
exchange_config={"exchange_id": exchange_id},
137+
price=1_000,
138+
) == 0.0
139+
assert normalize_grid_order_quantity(
140+
client,
141+
symbol="BTC/USDT",
142+
quantity=0.0129,
143+
market_type=market_type,
144+
exchange_config={"exchange_id": exchange_id},
145+
price=100,
146+
) == 0.0
147+
assert normalize_grid_order_quantity(
148+
client,
149+
symbol="BTC/USDT",
150+
quantity=0.1239,
151+
market_type=market_type,
152+
exchange_config={"exchange_id": exchange_id},
153+
price=100,
154+
) == pytest.approx(0.123)

0 commit comments

Comments
 (0)