Skip to content

Commit 049db12

Browse files
v3.0.9
Signed-off-by: Dinger <quantdinger@gmail.com>
1 parent 67a123a commit 049db12

5 files changed

Lines changed: 235 additions & 136 deletions

File tree

‎backend_api_python/app/__init__.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,45 @@ def restore_running_strategies():
243243
logger.error(traceback.format_exc())
244244

245245
logger.info(f"Strategy restore completed: {restored_count}/{len(running_strategies)} restored")
246+
_schedule_post_restore_position_sync()
246247

247248
except Exception as e:
248249
logger.error(f"Failed to restore running strategies: {str(e)}")
249250
logger.error(traceback.format_exc())
250251
# Do not raise; avoid breaking app startup.
251252

252253

254+
def _schedule_post_restore_position_sync() -> None:
255+
"""
256+
After restart, run one position-sync pass so strategies with dead API keys /
257+
unreachable IBKR are auto-stopped (DB + executor thread) instead of spamming logs.
258+
"""
259+
import os
260+
import threading
261+
import time
262+
263+
if os.getenv("POSITION_SYNC_ENABLED", "true").lower() != "true":
264+
return
265+
266+
try:
267+
delay = float(os.getenv("POST_RESTORE_SYNC_DELAY_SEC", "12"))
268+
except Exception:
269+
delay = 12.0
270+
if delay < 0:
271+
delay = 0.0
272+
273+
def _run() -> None:
274+
if delay > 0:
275+
time.sleep(delay)
276+
try:
277+
get_pending_order_worker()._sync_positions_best_effort()
278+
logger.info("Post-restore position sync finished (broken live strategies should be stopped)")
279+
except Exception as exc:
280+
logger.warning(f"Post-restore position sync failed: {exc}")
281+
282+
threading.Thread(target=_run, name="PostRestorePositionSync", daemon=True).start()
283+
284+
253285
def create_app(config_name='default'):
254286
"""
255287
Flask application factory.

‎backend_api_python/app/services/pending_order_worker.py‎

Lines changed: 40 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
from app.utils.db import get_db_connection
4040
from app.utils.logger import get_logger
4141
from app.utils.strategy_runtime_logs import append_strategy_log
42+
from app.services.strategy_lifecycle import (
43+
auto_stop_live_strategy,
44+
is_fatal_exchange_error,
45+
should_skip_position_sync,
46+
)
4247

4348
# Lazy import IBKR to avoid ImportError if ib_insync not installed
4449
IBKRClient = None
@@ -194,7 +199,9 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
194199
logger.debug(f"[PositionSync] Found {len(active_rows)} active live strategies in DB.")
195200
for _ar in active_rows:
196201
_sid = int(_ar.get("id") or 0)
197-
if _sid > 0 and _sid not in sid_to_rows:
202+
if _sid <= 0 or should_skip_position_sync(_sid):
203+
continue
204+
if _sid not in sid_to_rows:
198205
if target_strategy_id and target_strategy_id != _sid:
199206
continue
200207
sid_to_rows[_sid] = []
@@ -205,6 +212,8 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
205212
for sid, plist in sid_to_rows.items():
206213
if target_strategy_id and sid != target_strategy_id:
207214
continue
215+
if should_skip_position_sync(int(sid)):
216+
continue
208217
try:
209218
sc = load_strategy_configs(int(sid))
210219
exec_mode = (sc.get("execution_mode") or "").strip().lower()
@@ -272,7 +281,18 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
272281
try:
273282
client = create_client(exchange_config, market_type=market_type)
274283
except Exception as e:
275-
logger.debug(f"[PositionSync] Strategy {sid} skipped: failed to create client (exchange_id={exchange_id}): {e}")
284+
msg = str(e)
285+
if is_fatal_exchange_error(msg):
286+
logger.error(
287+
"[PositionSync] Strategy %s fatal client error; auto-stopping. error=%s",
288+
sid,
289+
msg,
290+
)
291+
auto_stop_live_strategy(int(sid), msg, source="position_sync_client")
292+
else:
293+
logger.debug(
294+
f"[PositionSync] Strategy {sid} skipped: failed to create client (exchange_id={exchange_id}): {e}"
295+
)
276296
continue
277297

278298
# Build an "exchange snapshot" per symbol+side
@@ -287,35 +307,9 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
287307
m = msg.lower()
288308
# Fatal auth/config errors should auto-stop the strategy to avoid endless spam.
289309
# Typical Binance response: HTTP 401 {"code":-2015,"msg":"Invalid API-key, IP, or permissions for action"}
290-
is_fatal_auth = (
291-
"binance http 401" in m
292-
or "invalid api-key" in m
293-
or "invalid api key" in m
294-
or "permissions for action" in m
295-
or '"code":-2015' in m
296-
or "-2015" in m
297-
or "unauthorized" in m
298-
or "forbidden" in m
299-
or "signature" in m
300-
)
301-
if is_fatal_auth:
310+
if is_fatal_exchange_error(msg):
302311
logger.error(f"[PositionSync] Strategy {sid} fatal auth error; auto-stopping. error={msg}")
303-
try:
304-
from app.utils.strategy_runtime_logs import append_strategy_log
305-
append_strategy_log(int(sid), "error", f"Auto-stopped: Binance auth failed: {msg}")
306-
except Exception:
307-
pass
308-
try:
309-
with get_db_connection() as db:
310-
cur = db.cursor()
311-
cur.execute(
312-
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s AND status = 'running'",
313-
(int(sid),),
314-
)
315-
db.commit()
316-
cur.close()
317-
except Exception:
318-
pass
312+
auto_stop_live_strategy(int(sid), msg, source="position_sync_binance")
319313
continue
320314
logger.error(f"[PositionSync] Strategy {sid} get_positions failed: {msg}", exc_info=True)
321315
continue
@@ -350,33 +344,9 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
350344
# Typical OKX response: HTTP 401 {"msg":"Invalid OK-ACCESS-KEY","code":"50111"}
351345
msg = str(e)
352346
m = msg.lower()
353-
is_fatal_auth = (
354-
"okx http 401" in m
355-
or "invalid ok-access-key" in m
356-
or '"code":"50111"' in m
357-
or "50111" in m
358-
or "invalid api" in m
359-
or "unauthorized" in m
360-
or "authentication" in m
361-
)
362-
if is_fatal_auth:
347+
if is_fatal_exchange_error(msg):
363348
logger.error(f"[PositionSync] Strategy {sid} fatal auth error; auto-stopping. error={msg}")
364-
try:
365-
from app.utils.strategy_runtime_logs import append_strategy_log
366-
append_strategy_log(int(sid), "error", f"Auto-stopped: OKX auth failed: {msg}")
367-
except Exception:
368-
pass
369-
try:
370-
with get_db_connection() as db:
371-
cur = db.cursor()
372-
cur.execute(
373-
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s AND status = 'running'",
374-
(int(sid),),
375-
)
376-
db.commit()
377-
cur.close()
378-
except Exception:
379-
pass
349+
auto_stop_live_strategy(int(sid), msg, source="position_sync_okx")
380350
continue
381351
# Non-fatal: keep syncing other strategies, but don't crash the worker loop.
382352
logger.error(f"[PositionSync] Strategy {sid} get_positions failed: {msg}", exc_info=True)
@@ -603,7 +573,16 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
603573
try:
604574
positions = client.get_positions() or []
605575
except Exception as e:
606-
logger.error(f"[PositionSync] Strategy {sid} IBKR get_positions failed: {e}", exc_info=True)
576+
msg = str(e)
577+
if is_fatal_exchange_error(msg):
578+
logger.error(
579+
"[PositionSync] Strategy %s IBKR fatal error; auto-stopping. error=%s",
580+
sid,
581+
msg,
582+
)
583+
auto_stop_live_strategy(int(sid), msg, source="position_sync_ibkr")
584+
else:
585+
logger.error(f"[PositionSync] Strategy {sid} IBKR get_positions failed: {e}", exc_info=True)
607586
continue
608587
if isinstance(positions, list):
609588
for p in positions:
@@ -794,45 +773,10 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None)
794773
if to_insert:
795774
logger.debug(f"position sync: inserted {len(to_insert)} new positions for strategy_id={sid}")
796775
except Exception as e:
797-
# Any exchange auth/config/connectivity fatal errors should stop the strategy to avoid endless spam.
798776
msg = str(e)
799-
m = msg.lower()
800-
is_fatal = any(tok in m for tok in (
801-
# Generic HTTP auth / permission failures
802-
" http 401",
803-
"unauthorized",
804-
"forbidden",
805-
"invalid api",
806-
"invalid api-key",
807-
"invalid api key",
808-
"invalid_key",
809-
"invalid key",
810-
"signature mismatch",
811-
"invalid_signature",
812-
"permission",
813-
# Common connection failures (IBKR/TWS, local gateways)
814-
"connection refused",
815-
"connect call failed",
816-
"errno 111",
817-
))
818-
if is_fatal:
777+
if is_fatal_exchange_error(msg):
819778
logger.error(f"[PositionSync] Strategy {sid} fatal error; auto-stopping. error={msg}", exc_info=True)
820-
try:
821-
from app.utils.strategy_runtime_logs import append_strategy_log
822-
append_strategy_log(int(sid), "error", f"Auto-stopped: position sync fatal error: {msg}")
823-
except Exception:
824-
pass
825-
try:
826-
with get_db_connection() as db:
827-
cur = db.cursor()
828-
cur.execute(
829-
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s AND status = 'running'",
830-
(int(sid),),
831-
)
832-
db.commit()
833-
cur.close()
834-
except Exception:
835-
pass
779+
auto_stop_live_strategy(int(sid), msg, source="position_sync")
836780
else:
837781
logger.error(f"position sync: strategy_id={sid} failed: {e}", exc_info=True)
838782

@@ -2424,34 +2368,8 @@ def _execute_ibkr_order(
24242368
_console_print(f"[worker] IBKR order exception: strategy_id={strategy_id} pending_id={order_id} err={e}")
24252369
_notify_live_best_effort(status="failed", error=str(e))
24262370
append_strategy_log(strategy_id, "error", f"IBKR order exception ({symbol} {signal_type}): {e}")
2427-
# Auto-stop strategy on fatal connectivity issues (e.g. TWS/IBG not reachable).
2428-
try:
2429-
msg = str(e)
2430-
m = msg.lower()
2431-
is_fatal = any(tok in m for tok in (
2432-
"connection refused",
2433-
"connect call failed",
2434-
"errno 111",
2435-
"make sure api port on tws",
2436-
))
2437-
if is_fatal:
2438-
try:
2439-
append_strategy_log(strategy_id, "error", f"Auto-stopped: IBKR connection failed: {msg}")
2440-
except Exception:
2441-
pass
2442-
try:
2443-
with get_db_connection() as db:
2444-
cur = db.cursor()
2445-
cur.execute(
2446-
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s AND status = 'running'",
2447-
(int(strategy_id),),
2448-
)
2449-
db.commit()
2450-
cur.close()
2451-
except Exception:
2452-
pass
2453-
except Exception:
2454-
pass
2371+
if is_fatal_exchange_error(str(e)):
2372+
auto_stop_live_strategy(int(strategy_id), str(e), source="ibkr_order")
24552373

24562374
def _execute_alpaca_order(
24572375
self,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
Unified auto-stop for live strategies after fatal exchange/auth/connectivity errors.
3+
4+
Position sync and order workers call `auto_stop_live_strategy` so DB status, executor
5+
threads, and runtime logs stay consistent (avoids endless retry spam after restart).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import threading
11+
from typing import Set
12+
13+
from app.utils.db import get_db_connection
14+
from app.utils.logger import get_logger
15+
from app.utils.strategy_runtime_logs import append_strategy_log
16+
17+
logger = get_logger(__name__)
18+
19+
_quiet_lock = threading.Lock()
20+
_quiet_sids: Set[int] = set()
21+
22+
23+
def is_fatal_exchange_error(msg: str) -> bool:
24+
"""Return True when the strategy should not keep retrying exchange/private APIs."""
25+
m = (msg or "").lower()
26+
if not m:
27+
return False
28+
if "unsupported market type" in m or "unsupported market" in m:
29+
return True
30+
tokens = (
31+
"binance http 401",
32+
'"code":-2015',
33+
"-2015",
34+
"okx http 401",
35+
'"code":"50111"',
36+
"50111",
37+
"invalid api-key",
38+
"invalid api key",
39+
"invalid ok-access-key",
40+
"invalid ip",
41+
"invalid_ip",
42+
"40018",
43+
"permissions for action",
44+
"unauthorized",
45+
"forbidden",
46+
" http 401",
47+
"authentication",
48+
"signature mismatch",
49+
"invalid_signature",
50+
"permission denied",
51+
"connection refused",
52+
"connect call failed",
53+
"errno 111",
54+
"make sure api port on tws",
55+
"failed to connect to ibkr",
56+
"ibkr connection failed",
57+
"live trading error",
58+
"single-asset collateral mode is temporarily unavailable",
59+
"disabled ibkr",
60+
"已关闭 ibkr",
61+
)
62+
return any(t in m for t in tokens)
63+
64+
65+
def should_skip_position_sync(strategy_id: int) -> bool:
66+
"""In-process guard: skip sync for strategies already auto-stopped this run."""
67+
with _quiet_lock:
68+
return int(strategy_id) in _quiet_sids
69+
70+
71+
def auto_stop_live_strategy(
72+
strategy_id: int,
73+
reason: str,
74+
*,
75+
source: str = "position_sync",
76+
) -> bool:
77+
"""
78+
Mark strategy stopped in DB, stop executor thread if running, append runtime log.
79+
Safe to call multiple times for the same strategy_id.
80+
"""
81+
sid = int(strategy_id)
82+
if sid <= 0:
83+
return False
84+
85+
reason = (reason or "").strip() or "fatal exchange error"
86+
with _quiet_lock:
87+
already = sid in _quiet_sids
88+
_quiet_sids.add(sid)
89+
if already:
90+
return True
91+
92+
log_msg = f"Auto-stopped ({source}): {reason}"
93+
logger.error("[Strategy %s] %s", sid, log_msg)
94+
try:
95+
append_strategy_log(sid, "error", log_msg)
96+
except Exception:
97+
pass
98+
99+
try:
100+
with get_db_connection() as db:
101+
cur = db.cursor()
102+
cur.execute(
103+
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s",
104+
(sid,),
105+
)
106+
db.commit()
107+
cur.close()
108+
except Exception as e:
109+
logger.warning("auto_stop: DB update failed for strategy %s: %s", sid, e)
110+
111+
try:
112+
from app import get_trading_executor
113+
114+
get_trading_executor().stop_strategy(sid)
115+
except Exception as e:
116+
logger.debug("auto_stop: executor stop_strategy(%s): %s", sid, e)
117+
118+
return True

0 commit comments

Comments
 (0)