Skip to content

Commit 06339a1

Browse files
HenryHenry
authored andcommitted
fix: harden live strategy order recovery
1 parent 972b119 commit 06339a1

9 files changed

Lines changed: 233 additions & 49 deletions

File tree

‎backend_api_python/app/services/live_trading/account_configuration.py‎

Lines changed: 83 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,42 @@ def requires_derivatives_account_configuration(*, market_type: str, reduce_only:
1313
return market in {"swap", "future", "futures", "perp", "perpetual"} and not bool(reduce_only)
1414

1515

16-
def _is_binance_unknown_timeout(exc: BaseException | str) -> bool:
16+
def _is_binance_ambiguous_configuration_error(exc: BaseException | str) -> bool:
1717
text = str(exc or "").lower()
1818
return (
19-
"-1007" in text
19+
"-1000" in text
20+
or "unknown error occurred while processing the request" in text
21+
or "-1006" in text
22+
or "-1007" in text
2023
or "execution status unknown" in text
2124
or ("http 408" in text and "timeout" in text)
2225
)
2326

2427

28+
def resolve_derivatives_margin_mode(
29+
*,
30+
payload: Dict[str, Any],
31+
strategy_config: Dict[str, Any],
32+
exchange_config: Dict[str, Any] | None = None,
33+
) -> str:
34+
"""Resolve the persisted strategy margin mode for order dispatch."""
35+
trading_config = strategy_config.get("trading_config") or {}
36+
if not isinstance(trading_config, dict):
37+
trading_config = {}
38+
venue_config = exchange_config or {}
39+
if not isinstance(venue_config, dict):
40+
venue_config = {}
41+
return normalize_margin_mode(
42+
payload.get("margin_mode")
43+
or payload.get("marginMode")
44+
or trading_config.get("margin_mode")
45+
or trading_config.get("marginMode")
46+
or venue_config.get("margin_mode")
47+
or venue_config.get("marginMode")
48+
or "cross"
49+
)
50+
51+
2552
def _confirm_binance_configuration(
2653
client: Any,
2754
*,
@@ -39,12 +66,12 @@ def _confirm_binance_configuration(
3966
observed_leverage = 0
4067
if require_margin and observed_mode != margin_mode:
4168
raise LiveTradingError(
42-
f"Binance configuration timeout could not be confirmed: "
69+
f"Binance configuration outcome could not be confirmed: "
4370
f"margin_mode expected={margin_mode}, observed={observed_mode}"
4471
)
4572
if require_leverage and observed_leverage != int(leverage):
4673
raise LiveTradingError(
47-
f"Binance configuration timeout could not be confirmed: "
74+
f"Binance configuration outcome could not be confirmed: "
4875
f"leverage expected={int(leverage)}, observed={observed_leverage}"
4976
)
5077
return observed
@@ -92,40 +119,66 @@ def configure_derivatives_account(
92119
}
93120

94121
if isinstance(client, BinanceFuturesClient):
95-
margin_confirmed_after_timeout = False
122+
observed_before: Dict[str, Any] | None = None
123+
try:
124+
observed_before = client.get_symbol_configuration(symbol=symbol) or None
125+
except Exception:
126+
observed_before = None
127+
if observed_before:
128+
details["configuration_before"] = observed_before
129+
130+
observed_mode = ""
131+
if observed_before:
132+
try:
133+
observed_mode = normalize_margin_mode(
134+
str(observed_before.get("margin_mode") or "")
135+
)
136+
except LiveTradingError:
137+
observed_mode = ""
138+
margin_changed = observed_mode != mode
139+
observed_after_margin: Dict[str, Any] | None = None
140+
if margin_changed:
141+
try:
142+
client.set_margin_type(symbol=symbol, margin_mode=mode)
143+
except Exception as exc:
144+
text = str(exc).lower()
145+
if "-4046" not in text and "no need to change margin type" not in text:
146+
if not _is_binance_ambiguous_configuration_error(exc):
147+
raise LiveTradingError(f"Binance margin mode setup failed: {exc}") from exc
148+
observed_after_margin = _confirm_binance_configuration(
149+
client,
150+
symbol=symbol,
151+
margin_mode=mode,
152+
leverage=target_leverage,
153+
require_margin=True,
154+
require_leverage=False,
155+
)
156+
details["readback_after_margin_error"] = observed_after_margin
157+
else:
158+
details["margin_mode_already_configured"] = True
159+
160+
effective_observed = observed_after_margin or observed_before or {}
96161
try:
97-
client.set_margin_type(symbol=symbol, margin_mode=mode)
98-
except Exception as exc:
99-
text = str(exc).lower()
100-
if "-4046" not in text and "no need to change margin type" not in text:
101-
if not _is_binance_unknown_timeout(exc):
102-
raise LiveTradingError(f"Binance margin mode setup failed: {exc}") from exc
162+
effective_leverage = int(float(effective_observed.get("leverage") or 0))
163+
except (TypeError, ValueError):
164+
effective_leverage = 0
165+
if margin_changed or effective_leverage != target_leverage:
166+
try:
167+
client.set_leverage(symbol=symbol, leverage=target_leverage)
168+
except Exception as exc:
169+
if not _is_binance_ambiguous_configuration_error(exc):
170+
raise
103171
observed = _confirm_binance_configuration(
104172
client,
105173
symbol=symbol,
106174
margin_mode=mode,
107175
leverage=target_leverage,
108176
require_margin=True,
109-
require_leverage=False,
177+
require_leverage=True,
110178
)
111-
details["readback_after_margin_timeout"] = observed
112-
margin_confirmed_after_timeout = True
113-
try:
114-
client.set_leverage(symbol=symbol, leverage=target_leverage)
115-
except Exception as exc:
116-
if not _is_binance_unknown_timeout(exc):
117-
raise
118-
observed = _confirm_binance_configuration(
119-
client,
120-
symbol=symbol,
121-
margin_mode=mode,
122-
leverage=target_leverage,
123-
require_margin=True,
124-
require_leverage=True,
125-
)
126-
details["readback_after_leverage_timeout"] = observed
127-
if margin_confirmed_after_timeout:
128-
details["margin_mode_confirmed_after_timeout"] = True
179+
details["readback_after_leverage_error"] = observed
180+
else:
181+
details["leverage_already_configured"] = True
129182
return details
130183

131184
if isinstance(client, OkxClient):

‎backend_api_python/app/services/pending_order_worker.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030
from app.services.live_trading.account_configuration import (
3131
requires_derivatives_account_configuration,
32+
resolve_derivatives_margin_mode,
3233
)
3334
from app.services.live_trading.strategy_position_sync import (
3435
strategy_uses_fill_ledger,
@@ -1937,12 +1938,10 @@ def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], paylo
19371938
try:
19381939
from app.services.live_trading.account_configuration import configure_derivatives_account
19391940

1940-
margin_mode = str(
1941-
payload.get("margin_mode")
1942-
or payload.get("marginMode")
1943-
or cfg.get("margin_mode")
1944-
or cfg.get("marginMode")
1945-
or "cross"
1941+
margin_mode = resolve_derivatives_margin_mode(
1942+
payload=payload,
1943+
strategy_config=cfg,
1944+
exchange_config=exchange_config,
19461945
)
19471946
phases["account_configuration"] = configure_derivatives_account(
19481947
client,

‎backend_api_python/app/services/strategy_v2/live_execution.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class LiveOrderRequest:
2525
market_type: str
2626
execution_mode: str
2727
leverage: float = 1.0
28+
margin_mode: str = "cross"
2829
reason: str = ""
2930
notification_config: dict[str, Any] | None = None
3031
order_type: str = "market"
@@ -286,6 +287,7 @@ def submit(self, request: LiveOrderRequest) -> int | None:
286287
"price": request.limit_price or request.reference_price,
287288
"ref_price": request.reference_price,
288289
"leverage": request.leverage,
290+
"margin_mode": request.margin_mode,
289291
"execution_mode": request.execution_mode,
290292
"notification_config": request.notification_config or {},
291293
"signal_ts": request.signal_timestamp,

‎backend_api_python/app/services/strategy_v2/runtime.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2626,15 +2626,22 @@ def restore_protection_snapshot(self, values: Mapping[str, Any] | None) -> None:
26262626
states[str(symbol)] = state
26272627
self.protection_specs = specs
26282628
self.protection_states = states
2629-
self._protection_exit_pending = {str(item) for item in (raw.get("exitPending") or [])}
2629+
# Older snapshots treated every zero-target strategy order as a
2630+
# protection exit. A rejected ordinary close could therefore leave an
2631+
# orphaned latch that suppressed all future orders for the symbol, even
2632+
# after a runtime restart. A genuine protection exit always has both a
2633+
# persisted spec and state, so discard legacy latches that do not.
2634+
self._protection_exit_pending = {
2635+
str(item)
2636+
for item in (raw.get("exitPending") or [])
2637+
if str(item) in specs and str(item) in states
2638+
}
26302639

26312640
def _capture_protection_intents(self, orders: Iterable[OrderIntent]) -> None:
26322641
for order in orders:
26332642
position_key = _position_key(order.symbol, order.position_side)
26342643
if order.protection is not None:
26352644
self.protection_specs[position_key] = order.protection
2636-
if order.kind in {"target_quantity", "target_value", "target_percent"} and abs(order.value) <= 1e-12:
2637-
self._protection_exit_pending.add(position_key)
26382645

26392646
def _bind_runtime_api(self) -> None:
26402647
ctx = self.context

‎backend_api_python/app/services/trading_executor.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,11 @@ def _execute_signal(self, **values: Any) -> bool:
17721772
market_type=str(values.get("market_type") or "spot"),
17731773
execution_mode=str(values.get("execution_mode") or "signal"),
17741774
leverage=leverage,
1775+
margin_mode=str(
1776+
trading_config.get("margin_mode")
1777+
or trading_config.get("marginMode")
1778+
or "cross"
1779+
),
17751780
reason=str(values.get("signal_reason") or ""),
17761781
notification_config=dict(values.get("notification_config") or {}),
17771782
order_type=str(values.get("order_type") or "market"),

‎backend_api_python/tests/test_derivatives_account_configuration.py‎

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.services.live_trading.account_configuration import (
66
configure_derivatives_account,
77
requires_derivatives_account_configuration,
8+
resolve_derivatives_margin_mode,
89
)
910
from app.services.live_trading.base import LiveTradingError
1011
from app.services.live_trading.bybit import BybitClient
@@ -77,19 +78,49 @@ def test_reduce_only_swap_skips_derivatives_configuration():
7778
assert requires_derivatives_account_configuration(market_type="spot", reduce_only=False) is False
7879

7980

81+
def test_strategy_margin_mode_is_resolved_from_nested_trading_config():
82+
assert resolve_derivatives_margin_mode(
83+
payload={},
84+
strategy_config={"trading_config": {"margin_mode": "isolated"}},
85+
exchange_config={},
86+
) == "isolated"
87+
88+
89+
def test_binance_matching_configuration_skips_mutating_requests():
90+
client = BinanceFuturesClient.__new__(BinanceFuturesClient)
91+
client.get_symbol_configuration = lambda **_kwargs: {
92+
"margin_mode": "cross",
93+
"leverage": 5,
94+
}
95+
client.set_margin_type = lambda **_kwargs: pytest.fail("margin mode must not be changed")
96+
client.set_leverage = lambda **_kwargs: pytest.fail("leverage must not be changed")
97+
98+
result = configure_derivatives_account(
99+
client,
100+
exchange_id="binance",
101+
symbol="BTC/USDT",
102+
leverage=5,
103+
margin_mode="cross",
104+
)
105+
106+
assert result["margin_mode_already_configured"] is True
107+
assert result["leverage_already_configured"] is True
108+
109+
80110
def test_binance_margin_timeout_continues_after_configuration_readback():
81111
client = BinanceFuturesClient.__new__(BinanceFuturesClient)
112+
snapshots = iter((
113+
{"margin_mode": "isolated", "leverage": 5},
114+
{"margin_mode": "cross", "leverage": 5},
115+
))
82116
client.set_margin_type = lambda **_kwargs: (_ for _ in ()).throw(
83117
LiveTradingError(
84118
'Binance HTTP 408: {"code":-1007,"msg":"Timeout waiting for response; execution status unknown."}'
85119
)
86120
)
87121
leverage_calls = []
88122
client.set_leverage = lambda **kwargs: leverage_calls.append(kwargs) or {"leverage": 5}
89-
client.get_symbol_configuration = lambda **_kwargs: {
90-
"margin_mode": "cross",
91-
"leverage": 5,
92-
}
123+
client.get_symbol_configuration = lambda **_kwargs: next(snapshots)
93124

94125
result = configure_derivatives_account(
95126
client,
@@ -99,20 +130,48 @@ def test_binance_margin_timeout_continues_after_configuration_readback():
99130
margin_mode="cross",
100131
)
101132

102-
assert result["margin_mode_confirmed_after_timeout"] is True
133+
assert result["readback_after_margin_error"]["margin_mode"] == "cross"
103134
assert leverage_calls == [{"symbol": "BTC/USDT", "leverage": 5}]
104135

105136

137+
def test_binance_margin_unknown_error_continues_after_configuration_readback():
138+
client = BinanceFuturesClient.__new__(BinanceFuturesClient)
139+
snapshots = iter((
140+
{"margin_mode": "isolated", "leverage": 2},
141+
{"margin_mode": "cross", "leverage": 2},
142+
))
143+
client.get_symbol_configuration = lambda **_kwargs: next(snapshots)
144+
client.set_margin_type = lambda **_kwargs: (_ for _ in ()).throw(
145+
LiveTradingError(
146+
'Binance HTTP 400: {"code":-1000,"msg":"An unknown error occurred while processing the request."}'
147+
)
148+
)
149+
leverage_calls = []
150+
client.set_leverage = lambda **kwargs: leverage_calls.append(kwargs) or {"leverage": 2}
151+
152+
result = configure_derivatives_account(
153+
client,
154+
exchange_id="binance",
155+
symbol="DOGE/USDT",
156+
leverage=2,
157+
margin_mode="cross",
158+
)
159+
160+
assert result["readback_after_margin_error"]["margin_mode"] == "cross"
161+
assert leverage_calls == [{"symbol": "DOGE/USDT", "leverage": 2}]
162+
163+
106164
def test_binance_margin_timeout_fails_when_readback_differs():
107165
client = BinanceFuturesClient.__new__(BinanceFuturesClient)
166+
snapshots = iter((
167+
{"margin_mode": "isolated", "leverage": 5},
168+
{"margin_mode": "isolated", "leverage": 5},
169+
))
108170
client.set_margin_type = lambda **_kwargs: (_ for _ in ()).throw(
109171
LiveTradingError("Binance HTTP 408: code=-1007 execution status unknown")
110172
)
111173
client.set_leverage = lambda **_kwargs: pytest.fail("leverage must not be changed")
112-
client.get_symbol_configuration = lambda **_kwargs: {
113-
"margin_mode": "isolated",
114-
"leverage": 5,
115-
}
174+
client.get_symbol_configuration = lambda **_kwargs: next(snapshots)
116175

117176
with pytest.raises(LiveTradingError, match="could not be confirmed"):
118177
configure_derivatives_account(

‎backend_api_python/tests/test_strategy_v2_order_gateway.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from types import SimpleNamespace
23

34
from app.services.strategy_v2 import live_execution
@@ -49,6 +50,7 @@ def _request(action="open_long", **overrides):
4950
"signal_timestamp": 123,
5051
"market_type": "swap",
5152
"execution_mode": "live",
53+
"margin_mode": "isolated",
5254
}
5355
values.update(overrides)
5456
return LiveOrderRequest(
@@ -136,6 +138,28 @@ def create_intent(self, **_kwargs):
136138
assert StrategyV2OrderGateway().submit(_request()) is None
137139

138140

141+
def test_submit_persists_margin_mode_in_pending_order_payload(monkeypatch):
142+
class _IntentService:
143+
def __init__(self, **_kwargs):
144+
pass
145+
146+
@staticmethod
147+
def build_signal_idempotency_key(**_kwargs):
148+
return "margin-mode-signal"
149+
150+
@staticmethod
151+
def create_intent(**_kwargs):
152+
return SimpleNamespace(id=91, existing=False, status="intent_created")
153+
154+
cursor = _Cursor({"id": 123})
155+
monkeypatch.setattr(live_execution, "OrderIntentService", _IntentService)
156+
monkeypatch.setattr(live_execution, "get_db_connection", lambda: _Db(cursor))
157+
158+
assert StrategyV2OrderGateway().submit(_request(margin_mode="isolated")) == 123
159+
payload = json.loads(cursor.params[10])
160+
assert payload["margin_mode"] == "isolated"
161+
162+
139163
def test_ai_rejection_is_latched_until_the_signal_disappears(monkeypatch):
140164
decisions = []
141165
next_intent_id = iter((91, 92))

0 commit comments

Comments
 (0)