fix: 交易安全审计修复 — 补偿平仓、中控同步、滚仓/趋势防护
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+132
-91
@@ -131,6 +131,9 @@ from lib.key_monitor.trigger_entry_key_monitor_lib import (
|
||||
TRIGGER_ENTRY_VALIDITY_HOURS,
|
||||
check_trigger_entry_intent_limit,
|
||||
count_pending_trigger_entries,
|
||||
acquire_trigger_entry_exec_lock,
|
||||
is_trigger_entry_in_flight_row,
|
||||
release_trigger_entry_exec_lock,
|
||||
is_breakout_trigger_entry_key_monitor_type,
|
||||
is_trigger_entry_expired,
|
||||
is_trigger_entry_key_monitor_type,
|
||||
@@ -3135,7 +3138,7 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s
|
||||
try:
|
||||
exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule))
|
||||
except Exception:
|
||||
cancel_gate_swap_trigger_orders(exchange_symbol)
|
||||
# 保留已挂止损,仅放弃本次 TP;上层可补偿平仓或重试
|
||||
raise
|
||||
return
|
||||
except Exception as e:
|
||||
@@ -3252,6 +3255,27 @@ def ensure_markets_loaded(force=False):
|
||||
MARKETS_LOADED = True
|
||||
|
||||
|
||||
def _abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, planned_amount):
|
||||
"""TP/SL 挂失败时市价平掉刚开的仓并撤残留条件单。"""
|
||||
from lib.trade.compensating_close_lib import run_compensating_close
|
||||
|
||||
def _close():
|
||||
ensure_markets_loaded()
|
||||
try:
|
||||
cancel_gate_swap_trigger_orders(exchange_symbol)
|
||||
except Exception:
|
||||
pass
|
||||
live = get_live_position_contracts(exchange_symbol, direction)
|
||||
amt = live if live is not None and live > 0 else _gate_contracts_amount_for_tpsl(order, planned_amount)
|
||||
if amt is None or float(amt) <= 0:
|
||||
return
|
||||
side = "sell" if direction == "long" else "buy"
|
||||
params = build_gate_order_params(direction, reduce_only=True)
|
||||
exchange.create_order(exchange_symbol, "market", side, float(amt), None, params)
|
||||
|
||||
run_compensating_close(_close, log_prefix="gate_compensating_close")
|
||||
|
||||
|
||||
def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None):
|
||||
ensure_markets_loaded()
|
||||
exchange.set_leverage(leverage, exchange_symbol)
|
||||
@@ -3265,22 +3289,48 @@ def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss
|
||||
_gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amt, stop_loss, take_profit)
|
||||
order["tpsl_attached"] = True
|
||||
except RuntimeError:
|
||||
_abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount)
|
||||
raise
|
||||
except Exception as e:
|
||||
_abort_market_open_after_tpsl_failure(exchange_symbol, direction, order, amount)
|
||||
raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e
|
||||
return order
|
||||
|
||||
|
||||
def close_exchange_order(order_row):
|
||||
"""
|
||||
市价全平。数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净。
|
||||
"""
|
||||
ensure_markets_loaded()
|
||||
exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"])
|
||||
amount = float(order_row["order_amount"] or 0)
|
||||
if amount <= 0:
|
||||
raise ValueError("平仓失败:缺少有效下单数量")
|
||||
direction = order_row["direction"]
|
||||
db_amt = float(order_row["order_amount"] or 0)
|
||||
side = "sell" if direction == "long" else "buy"
|
||||
params = build_gate_order_params(direction, reduce_only=True)
|
||||
return exchange.create_order(exchange_symbol, "market", side, amount, None, params)
|
||||
last_resp = None
|
||||
for _ in range(3):
|
||||
live = get_live_position_contracts(exchange_symbol, direction)
|
||||
if live is not None and live > 0:
|
||||
raw_amt = live
|
||||
else:
|
||||
raw_amt = db_amt
|
||||
if raw_amt <= 0:
|
||||
if last_resp is not None:
|
||||
return last_resp
|
||||
raise ValueError("平仓失败:缺少有效下单数量")
|
||||
try:
|
||||
amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt))
|
||||
except Exception:
|
||||
amount = float(raw_amt)
|
||||
if amount <= 0:
|
||||
if last_resp is not None:
|
||||
return last_resp
|
||||
raise ValueError("平仓失败:数量经精度舍入后为 0")
|
||||
params = build_gate_order_params(direction, reduce_only=True)
|
||||
last_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params)
|
||||
live_after = get_live_position_contracts(exchange_symbol, direction)
|
||||
if live_after is None or live_after <= 0:
|
||||
return last_resp
|
||||
return last_resp
|
||||
|
||||
|
||||
def _gate_swap_trigger_order_params():
|
||||
@@ -4113,89 +4163,71 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m
|
||||
)
|
||||
|
||||
|
||||
def _finalize_hub_flat_monitor(conn, r, *, result, pnl_amount, closed_at, miss_reason):
|
||||
opened_at = get_opened_at_value(r)
|
||||
closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now()
|
||||
hold_seconds = calc_hold_seconds(opened_at, closed_at_dt)
|
||||
session_date = r["session_date"] or get_trading_day(closed_at_dt)
|
||||
update_session_capital(conn, session_date, pnl_amount)
|
||||
insert_trade_record(
|
||||
conn,
|
||||
symbol=r["symbol"],
|
||||
monitor_type=trade_record_monitor_type(conn, r),
|
||||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||||
key_signal_type=order_row_key_signal_type(r),
|
||||
direction=r["direction"],
|
||||
trigger_price=r["trigger_price"],
|
||||
stop_loss=r["stop_loss"],
|
||||
initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"],
|
||||
take_profit=r["take_profit"],
|
||||
margin_capital=margin_capital_for_trade_record(r),
|
||||
leverage=r["leverage"],
|
||||
pnl_amount=pnl_amount,
|
||||
hold_seconds=hold_seconds,
|
||||
trade_style=r["trade_style"],
|
||||
risk_amount=r["risk_amount"],
|
||||
planned_rr=calc_rr_ratio(
|
||||
r["direction"],
|
||||
r["trigger_price"],
|
||||
r["initial_stop_loss"] or r["stop_loss"],
|
||||
r["take_profit"],
|
||||
),
|
||||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||||
result=result,
|
||||
miss_reason=handoff_trade_miss_reason(miss_reason, r),
|
||||
opened_at=opened_at,
|
||||
closed_at=closed_at,
|
||||
)
|
||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
||||
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
|
||||
|
||||
|
||||
def reconcile_hub_external_close(conn, symbol, direction):
|
||||
"""中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。"""
|
||||
if not exchange_private_api_configured():
|
||||
return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0}
|
||||
from lib.exchange.gate_position_history_lib import unified_symbol_for_match
|
||||
from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl
|
||||
from lib.hub.hub_symbol_lib import symbols_match
|
||||
|
||||
sym_u = unified_symbol_for_match(symbol)
|
||||
dir_l = (direction or "").strip().lower()
|
||||
if dir_l not in ("long", "short"):
|
||||
return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0}
|
||||
synced = 0
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if unified_symbol_for_match(r["symbol"]) != sym_u:
|
||||
continue
|
||||
if (r["direction"] or "").strip().lower() != dir_l:
|
||||
continue
|
||||
oid = int(r["id"])
|
||||
if r["status"] == "error":
|
||||
opened_at_chk = get_opened_at_value(r)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
|
||||
(r["symbol"], opened_at_chk, order_row_monitor_type(r)),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
|
||||
synced += 1
|
||||
continue
|
||||
exchange_symbol = resolve_monitor_exchange_symbol(r)
|
||||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||||
if live_contracts is None:
|
||||
continue
|
||||
if live_contracts > 0:
|
||||
time.sleep(0.6)
|
||||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||||
if live_contracts is None or live_contracts > 0:
|
||||
continue
|
||||
global _RECONCILE_FLAT_STREAK
|
||||
_RECONCILE_FLAT_STREAK.pop(oid, None)
|
||||
cancel_gate_swap_trigger_orders(exchange_symbol)
|
||||
opened_at = get_opened_at_value(r)
|
||||
opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at)
|
||||
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(
|
||||
r, opened_at, opened_at_ms=opened_at_ms, prefer_manual=True
|
||||
)
|
||||
closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now()
|
||||
hold_seconds = calc_hold_seconds(opened_at, closed_at_dt)
|
||||
session_date = r["session_date"] or get_trading_day(closed_at_dt)
|
||||
update_session_capital(conn, session_date, pnl_amount)
|
||||
insert_trade_record(
|
||||
conn,
|
||||
symbol=r["symbol"],
|
||||
monitor_type=trade_record_monitor_type(conn, r),
|
||||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||||
key_signal_type=order_row_key_signal_type(r),
|
||||
direction=r["direction"],
|
||||
trigger_price=r["trigger_price"],
|
||||
stop_loss=r["stop_loss"],
|
||||
initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"],
|
||||
take_profit=r["take_profit"],
|
||||
margin_capital=margin_capital_for_trade_record(r),
|
||||
leverage=r["leverage"],
|
||||
pnl_amount=pnl_amount,
|
||||
hold_seconds=hold_seconds,
|
||||
trade_style=r["trade_style"],
|
||||
risk_amount=r["risk_amount"],
|
||||
planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]),
|
||||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||||
result=result,
|
||||
miss_reason=handoff_trade_miss_reason(miss_reason, r),
|
||||
opened_at=opened_at,
|
||||
closed_at=closed_at,
|
||||
)
|
||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
||||
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
|
||||
synced += 1
|
||||
try:
|
||||
sync_trade_records_from_exchange(conn, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "synced": synced}
|
||||
global _RECONCILE_FLAT_STREAK
|
||||
|
||||
return reconcile_hub_external_close_impl(
|
||||
conn,
|
||||
symbol,
|
||||
direction,
|
||||
exchange_configured=exchange_private_api_configured,
|
||||
not_configured_msg="未配置 GATE_API_KEY / GATE_API_SECRET",
|
||||
symbols_match=symbols_match,
|
||||
get_opened_at_value=get_opened_at_value,
|
||||
resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol,
|
||||
get_live_position_contracts=get_live_position_contracts,
|
||||
cancel_conditional_orders=cancel_gate_swap_trigger_orders,
|
||||
resolve_synced_flat_close=resolve_synced_flat_close,
|
||||
finalize_stopped_monitor=_finalize_hub_flat_monitor,
|
||||
sync_trade_records=sync_trade_records_from_exchange,
|
||||
reconcile_flat_streak=_RECONCILE_FLAT_STREAK,
|
||||
to_ms_with_fallback=_to_ms_with_fallback,
|
||||
prefer_manual_resolve=True,
|
||||
order_row_monitor_type=order_row_monitor_type,
|
||||
)
|
||||
|
||||
|
||||
def reconcile_external_closes(conn, days=None):
|
||||
@@ -5491,7 +5523,7 @@ def _market_open_for_trigger_entry(
|
||||
|
||||
|
||||
def _execute_trigger_entry_cross(conn, row):
|
||||
"""标记价触达计划入场:先删监控行防重复触发,再市价开仓。"""
|
||||
"""标记价触达计划入场:加锁防重复触发,成交成功后再删监控行。"""
|
||||
symbol = row["symbol"]
|
||||
direction = (row["direction"] or "long").lower()
|
||||
ex_sym = normalize_exchange_symbol(symbol)
|
||||
@@ -5502,7 +5534,8 @@ def _execute_trigger_entry_cross(conn, row):
|
||||
tc_en, tc_h, _ = time_close_settings_from_row(row)
|
||||
|
||||
kid = int(row["id"])
|
||||
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
|
||||
if not acquire_trigger_entry_exec_lock(conn, kid):
|
||||
return False, "触价开仓进行中"
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
@@ -5520,6 +5553,8 @@ def _execute_trigger_entry_cross(conn, row):
|
||||
time_close_hours=tc_h,
|
||||
)
|
||||
except Exception as e:
|
||||
release_trigger_entry_exec_lock(conn, kid)
|
||||
conn.commit()
|
||||
fail_msg = friendly_exchange_error(e)
|
||||
send_wechat_msg(
|
||||
f"# ❌ {symbol} 触价开仓异常\n"
|
||||
@@ -5531,6 +5566,8 @@ def _execute_trigger_entry_cross(conn, row):
|
||||
return False, fail_msg
|
||||
|
||||
if ok and det:
|
||||
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
|
||||
conn.commit()
|
||||
rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
|
||||
msg = (
|
||||
f"# ✅ {symbol} 触价开仓成交\n"
|
||||
@@ -5547,6 +5584,8 @@ def _execute_trigger_entry_cross(conn, row):
|
||||
send_wechat_msg(msg)
|
||||
insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
|
||||
return True, None
|
||||
release_trigger_entry_exec_lock(conn, kid)
|
||||
conn.commit()
|
||||
fail_msg = err or "触价触发后开仓失败"
|
||||
send_wechat_msg(
|
||||
f"# ❌ {symbol} 触价开仓失败\n"
|
||||
@@ -5574,6 +5613,8 @@ def check_trigger_entry_key_monitors():
|
||||
sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
|
||||
tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
|
||||
kid = int(r["id"])
|
||||
if is_trigger_entry_in_flight_row(r):
|
||||
continue
|
||||
if entry <= 0 or sl <= 0 or tp <= 0:
|
||||
_finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
|
||||
continue
|
||||
@@ -6117,7 +6158,7 @@ def check_order_monitors():
|
||||
new_sl = round_price_to_exchange(ex_sym, new_sl)
|
||||
tp_ex = float(take_profit or 0)
|
||||
ok_live, _live_reason = ensure_exchange_live_ready()
|
||||
synced_ex = not ok_live
|
||||
synced_ex = False
|
||||
if ok_live and tp_ex > 0:
|
||||
try:
|
||||
replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex)
|
||||
@@ -6526,8 +6567,8 @@ def background_task():
|
||||
from lib.strategy.strategy_trend_register import check_trend_pullback_plans
|
||||
|
||||
check_trend_pullback_plans(cfg)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[monitor_loop] {e}", flush=True)
|
||||
time.sleep(MONITOR_POLL_SECONDS)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user