Skip to content

Commit 38c0a8e

Browse files
committed
fix: prevent trading lease starvation and unblock order dispatch
1 parent 84ad629 commit 38c0a8e

16 files changed

Lines changed: 749 additions & 111 deletions

‎backend_api_python/app/routes/strategy.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,15 @@ def stop_strategy(strategy_id: int):
228228
strategy_id,
229229
close_positions=close_positions,
230230
)
231-
get_strategy_service().update_strategy_status(strategy_id, "stopped", user_id=int(g.user_id))
231+
status = str(result.get("status") or "")
232+
if status in {"stopping", "stopped"}:
233+
get_strategy_service().update_strategy_status(strategy_id, "stopped", user_id=int(g.user_id))
232234
data = {"id": strategy_id, **result}
233235
if not result.get("success"):
234-
return _error("strategyV2.stopClosePartialFailure", 409, data=data)
236+
message = "strategyV2.stopClosePartialFailure" if close_positions and status == "stopped" else "strategyV2.stopFailed"
237+
return _error(message, 409, data=data)
238+
if status == "stopping":
239+
return _ok(data, "strategyV2.stopQueued"), 202
235240
message = "strategyV2.stoppedAndCloseQueued" if close_positions else "strategyV2.paused"
236241
return _ok(data, message)
237242

‎backend_api_python/app/services/execution_streams/supervisor.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -378,22 +378,26 @@ def _symbols_by_credential() -> Dict[int, Set[str]]:
378378
cur = db.cursor()
379379
cur.execute(
380380
"""
381-
SELECT COALESCE(NULLIF(exchange_config->>'credential_id', ''),
382-
NULLIF(exchange_config->>'credentials_id', ''), '0')::INTEGER AS credential_id,
383-
symbol
381+
SELECT exchange_config::text AS exchange_config, 0 AS credential_id, symbol
384382
FROM qd_strategies_trading
385383
WHERE COALESCE(symbol, '') <> ''
386384
AND LOWER(COALESCE(status, '')) = 'running'
387385
AND LOWER(COALESCE(execution_mode, 'signal')) = 'live'
388386
UNION
389-
SELECT credential_id, symbol
387+
SELECT NULL AS exchange_config, credential_id, symbol
390388
FROM pending_orders
391389
WHERE credential_id > 0 AND COALESCE(symbol, '') <> ''
392390
AND status IN ('pending','processing','sent','syncing')
393391
"""
394392
)
395393
for row in cur.fetchall() or []:
396-
credential_id = int(row.get("credential_id") or 0)
394+
try:
395+
config = json.loads(row.get("exchange_config") or "{}")
396+
if not isinstance(config, dict):
397+
config = {}
398+
credential_id = int(row.get("credential_id") or config.get("credential_id") or config.get("credentials_id") or 0)
399+
except (TypeError, ValueError):
400+
continue
397401
if credential_id > 0:
398402
out.setdefault(credential_id, set()).add(str(row.get("symbol") or ""))
399403
cur.close()

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def __init__(
5050
self.cfg = GridBotConfig.from_trading_config(self.trading_config)
5151
self._create_client = create_client_fn
5252
self._enqueue_market = enqueue_market
53+
self.order_guard = None
5354
self._orders = GridRestingOrderRepository()
5455
self._cells = GridCellRepository()
5556
self._bootstrapped = False
@@ -636,6 +637,8 @@ def _sync_initial_market_leg(
636637
logger.debug("grid initial pre-place probe sid=%s: %s", self.strategy_id, e)
637638
try:
638639
client = self._create_client()
640+
if self.order_guard and not self.order_guard():
641+
return False
639642
execution = execute_grid_market_order(
640643
client,
641644
symbol=self.symbol,
@@ -1474,6 +1477,8 @@ def _place_limit(
14741477
not reduce_only
14751478
and self.cfg.order_mode in ("maker", "limit", "limit_first", "maker_then_market")
14761479
)
1480+
if self.order_guard and not self.order_guard():
1481+
return False
14771482
res = place_grid_limit_order(
14781483
client,
14791484
symbol=self.symbol,
@@ -1816,6 +1821,8 @@ def cancel_exit_orders_on_exchange(self, *, pos_side: str = "") -> None:
18161821

18171822
def cancel_all_orders_on_exchange(self) -> None:
18181823
open_orders = self._orders.list_open(self.strategy_id)
1824+
if not open_orders:
1825+
return
18191826
try:
18201827
client = self._create_client()
18211828
except Exception as e:

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,11 @@ def cancel_grid_order(
651651
) -> None:
652652
mt = str(market_type or "swap").strip().lower()
653653
ex_cfg = exchange_config if isinstance(exchange_config, dict) else {}
654+
if isinstance(client, (GateSpotClient, GateUsdtFuturesClient)):
655+
if not exchange_order_id:
656+
raise LiveTradingError("Gate cancellation requires a confirmed exchange order ID")
657+
client.cancel_order(order_id=str(exchange_order_id))
658+
return
654659
if isinstance(client, OkxClient):
655660
client.cancel_order(
656661
market_type=mt,

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def shutdown_grid_for_strategy(strategy_id: int) -> None:
4444
gr.shutdown()
4545
return
4646
try:
47-
from app.services.exchange_execution import load_strategy_configs, resolve_exchange_config
47+
from app.services.exchange_execution import coalesce_exchange_config_from_payload, load_strategy_configs, resolve_exchange_config
4848
from app.services.live_trading.factory import create_client
4949

5050
sc = load_strategy_configs(sid) or {}
@@ -58,8 +58,8 @@ def shutdown_grid_for_strategy(strategy_id: int) -> None:
5858
if not symbol:
5959
return
6060
user_id = int(sc.get("user_id") or 1)
61-
ex_cfg = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=user_id)
62-
mt = str(tc.get("market_type") or "swap").strip().lower()
61+
ex_cfg = resolve_exchange_config(coalesce_exchange_config_from_payload(sc), user_id=user_id)
62+
mt = str(tc.get("market_type") or sc.get("market_type") or "swap").strip().lower()
6363

6464
def _create_client():
6565
return create_client(ex_cfg, market_type=mt)
@@ -74,7 +74,7 @@ def _create_client():
7474
enqueue_market=lambda *a, **k: False,
7575
)
7676
engine.shutdown()
77-
append_strategy_log(sid, "info", "Grid orders cancelled on strategy stop (no active runner)")
77+
append_strategy_log(sid, "info", "strategyRuntime.gridStopCleanup")
7878
except Exception as e:
7979
logger.warning("shutdown_grid_for_strategy sid=%s: %s", sid, e)
8080
append_strategy_log(sid, "warning", f"Grid stop cancel failed: {e}")
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Independent order dispatch and exchange reconciliation loops."""
2+
from app.utils.logger import get_logger
3+
4+
logger = get_logger(__name__)
5+
6+
7+
class PendingOrderLoops:
8+
def _run_loop(self) -> None:
9+
while not self._stop_event.is_set():
10+
try:
11+
self._tick()
12+
except Exception as e:
13+
logger.warning(f"PendingOrderWorker tick error: {e}")
14+
self._stop_event.wait(self.poll_interval_sec)
15+
16+
def _run_sync_loop(self) -> None:
17+
while not self._stop_event.is_set():
18+
for sync in (self._sync_quick_trade_orders, self._sync_alpaca_sent_orders, self._sync_live_sent_orders, self._maybe_sync_positions):
19+
if self._stop_event.is_set() or (self.lease_guard and not self.lease_guard()):
20+
break
21+
try:
22+
sync()
23+
except Exception:
24+
logger.warning("Order reconciliation failed: %s", sync.__name__, exc_info=True)
25+
self._stop_event.wait(self.poll_interval_sec)
26+
27+
def _tick(self) -> None:
28+
if self.lease_guard and not self.lease_guard():
29+
return
30+
orders = self._fetch_pending_orders(limit=self.batch_size)
31+
if not orders:
32+
return
33+
34+
for o in orders:
35+
if self._stop_event.is_set() or (self.lease_guard and not self.lease_guard()):
36+
break
37+
oid = o.get("id")
38+
if not oid:
39+
continue
40+
41+
# Mark processing (best-effort)
42+
if not self._mark_processing(order_id=int(oid)):
43+
continue
44+
45+
try:
46+
self._dispatch_one(o)
47+
except Exception as e:
48+
self._mark_failed(order_id=int(oid), error=str(e))
49+

‎backend_api_python/app/services/pending_order_worker.py‎

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
116116
from app.services.live_trading.htx import HtxClient
117117
from app.utils.db import get_db_connection
118+
from app.services.pending_order_loops import PendingOrderLoops
118119
from app.utils.logger import get_logger
119120
from app.utils.strategy_runtime_logs import append_strategy_log
120121
from app.services.strategy_lifecycle import (
@@ -135,12 +136,14 @@
135136
ALPACA_FILL_DELTA_EPSILON = 1e-8
136137

137138

138-
class PendingOrderWorker(PendingOrderPositionSyncMixin):
139+
class PendingOrderWorker(PendingOrderLoops, PendingOrderPositionSyncMixin):
139140
def __init__(self, poll_interval_sec: float = 1.0, batch_size: int = 50):
140141
self.poll_interval_sec = float(poll_interval_sec)
141142
self.batch_size = int(batch_size)
142143
self._stop_event = threading.Event()
143144
self._thread: Optional[threading.Thread] = None
145+
self._sync_thread: Optional[threading.Thread] = None
146+
self.lease_guard = None
144147
self._lock = threading.Lock()
145148
self._notifier = SignalNotifier()
146149
self._instrument_rules = get_instrument_rules_provider()
@@ -177,15 +180,19 @@ def request_exchange_catchup(
177180

178181
def start(self) -> bool:
179182
with self._lock:
183+
if self._thread and self._thread.is_alive() and self._sync_thread and self._sync_thread.is_alive():
184+
return True
180185
try:
181186
ensure_position_ledger_schema()
182187
except Exception as e:
183188
logger.warning("ensure_position_ledger_schema failed: %s", e)
184-
if self._thread and self._thread.is_alive():
185-
return True
186189
self._stop_event.clear()
187-
self._thread = threading.Thread(target=self._run_loop, name="PendingOrderWorker", daemon=True)
188-
self._thread.start()
190+
if not self._thread or not self._thread.is_alive():
191+
self._thread = threading.Thread(target=self._run_loop, name="PendingOrderWorker", daemon=True)
192+
self._thread.start()
193+
if not self._sync_thread or not self._sync_thread.is_alive():
194+
self._sync_thread = threading.Thread(target=self._run_sync_loop, name="PendingOrderReconciliation", daemon=True)
195+
self._sync_thread.start()
189196
logger.info("PendingOrderWorker started")
190197
return True
191198

@@ -195,43 +202,10 @@ def stop(self, timeout_sec: float = 5.0) -> None:
195202
th = self._thread
196203
if th and th.is_alive():
197204
th.join(timeout=timeout_sec)
205+
if self._sync_thread and self._sync_thread.is_alive():
206+
self._sync_thread.join(timeout=timeout_sec)
198207
logger.info("PendingOrderWorker stopped")
199208

200-
def _run_loop(self) -> None:
201-
while not self._stop_event.is_set():
202-
try:
203-
self._tick()
204-
except Exception as e:
205-
logger.warning(f"PendingOrderWorker tick error: {e}")
206-
time.sleep(self.poll_interval_sec)
207-
208-
def _tick(self) -> None:
209-
# logger.info(f"[PendingOrderWorker] _tick start. last_sync={self._last_position_sync_ts}")
210-
self._sync_quick_trade_orders()
211-
self._sync_alpaca_sent_orders()
212-
self._sync_live_sent_orders()
213-
orders = self._fetch_pending_orders(limit=self.batch_size)
214-
# logger.info(f"[PendingOrderWorker] orders fetched: {len(orders)}")
215-
if not orders:
216-
self._maybe_sync_positions()
217-
return
218-
219-
for o in orders:
220-
oid = o.get("id")
221-
if not oid:
222-
continue
223-
224-
# Mark processing (best-effort)
225-
if not self._mark_processing(order_id=int(oid)):
226-
continue
227-
228-
try:
229-
self._dispatch_one(o)
230-
except Exception as e:
231-
self._mark_failed(order_id=int(oid), error=str(e))
232-
233-
self._maybe_sync_positions()
234-
235209
def _sync_quick_trade_orders(self, limit: int = 50) -> None:
236210
"""Reconcile non-terminal Quick Trade orders and protect new fills."""
237211
try:

‎backend_api_python/app/services/strategy_command_client.py‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,32 +75,29 @@ def stop_strategy_with_policy(
7575
payload={"close_positions": bool(close_positions)},
7676
)
7777
self._remember(strategy_id, "stop", command.id)
78+
queued = {
79+
"success": True,
80+
"status": "stopping",
81+
"command_id": command.id,
82+
"close_requested": bool(command.payload.get("close_positions")),
83+
"close_orders_queued": 0,
84+
"close_errors": [],
85+
}
7886
timeout = max(0.0, float(os.getenv("STRATEGY_COMMAND_STOP_WAIT_SEC", "5")))
7987
if timeout == 0:
80-
return {
81-
"success": True,
82-
"status": "stopping",
83-
"close_requested": bool(close_positions),
84-
"close_orders_queued": 0,
85-
"close_errors": [],
86-
}
88+
return queued
8789
finished = self._wait(command.id, timeout)
8890
if finished is None or finished.status not in TERMINAL_COMMAND_STATUSES:
8991
logger.warning(
9092
"Strategy stop remains queued: strategy=%s command=%s",
9193
strategy_id,
9294
command.id,
9395
)
94-
return {
95-
"success": False,
96-
"status": "stopping",
97-
"close_requested": bool(close_positions),
98-
"close_orders_queued": 0,
99-
"close_errors": [],
100-
}
96+
return queued
10197
if finished.status == "succeeded":
10298
result = dict(finished.result or {})
10399
result.setdefault("success", True)
100+
result.setdefault("command_id", command.id)
104101
result.setdefault("status", "stopped")
105102
result.setdefault("close_requested", bool(close_positions))
106103
result.setdefault("close_orders_queued", 0)
@@ -109,6 +106,7 @@ def stop_strategy_with_policy(
109106
return {
110107
"success": False,
111108
"status": "running",
109+
"command_id": command.id,
112110
"close_requested": bool(close_positions),
113111
"close_orders_queued": 0,
114112
"close_errors": [finished.error_message] if finished.error_message else [],

‎backend_api_python/app/services/trading_executor.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(self) -> None:
6262
self.order_gateway = StrategyV2OrderGateway()
6363
self._last_start_failure = ""
6464
self._last_exit_reason: dict[int, str] = {}
65+
self.runtime_guard = None
6566

6667
def start_strategy(self, strategy_id: int) -> bool:
6768
strategy_id = int(strategy_id)
@@ -80,6 +81,9 @@ def start_strategy(self, strategy_id: int) -> bool:
8081
self._last_start_failure = str(exc or "strategyV2.livePreflightFailed")
8182
logger.warning("Strategy %s live preflight rejected: %s", strategy_id, exc)
8283
return False
84+
if self.runtime_guard and not self.runtime_guard(strategy_id):
85+
self._last_start_failure = "strategyRuntime.leaseLost"
86+
return False
8387
thread = threading.Thread(
8488
target=self._run_strategy_loop,
8589
args=(strategy_id,),
@@ -1202,6 +1206,8 @@ def _execute_signal(self, **values: Any) -> bool:
12021206
and inflight_check(request)
12031207
):
12041208
return False
1209+
if self.runtime_guard and not self.runtime_guard(strategy_id):
1210+
return False
12051211
pending_id = self.order_gateway.submit(request)
12061212
if pending_id:
12071213
order_details = [f"pending_id={pending_id}"]
@@ -1265,6 +1271,8 @@ def create_grid_client():
12651271
return client
12661272

12671273
def enqueue_market(signal_type: str, quantity: float, price: float, reason: str) -> bool:
1274+
if self.runtime_guard and not self.runtime_guard(strategy_id):
1275+
return False
12681276
return self._execute_signal(
12691277
strategy_id=strategy_id,
12701278
strategy_name=strategy_name,
@@ -1328,6 +1336,7 @@ def evaluate_grid_risk(price: float) -> List[Dict[str, Any]]:
13281336
create_client_fn=create_grid_client,
13291337
risk_exit_fn=evaluate_grid_risk,
13301338
)
1339+
runner.engine.order_guard = lambda: self.runtime_guard is None or self.runtime_guard(strategy_id)
13311340
ok, message = runner.startup(initial_price, bars_df=frame)
13321341
if not ok:
13331342
raise RuntimeError(f"grid.startupFailed:{message}")
@@ -1984,6 +1993,9 @@ def _load_source(strategy: dict[str, Any]) -> tuple[int, str]:
19841993
return source_id, code
19851994

19861995
def _is_strategy_running(self, strategy_id: int, thread: threading.Thread) -> bool:
1996+
if self.runtime_guard and not self.runtime_guard(strategy_id):
1997+
self._last_exit_reason[strategy_id] = "strategyRuntime.leaseLost"
1998+
return False
19871999
with self.lock:
19882000
if self.running_strategies.get(strategy_id) is not thread:
19892001
return False

0 commit comments

Comments
 (0)