修复期权币本位高危缺陷:开仓估量、卖回封顶、握库超时与条件单嵌套锁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-07 12:00:08 +08:00
parent ea3e029eb9
commit 77fe0fa679
16 changed files with 724 additions and 201 deletions
+8
View File
@@ -350,10 +350,18 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
machine_has_option_position,
)
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import option_position_limit_block_msg
ex = cfg.get("exchange_options")
if machine_has_option_position(cfg, ex):
return jsonify({"ok": False, "msg": "本机已有期权仓,拒绝条件单", "version": 1})
limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id="",
fetch_positions=cfg.get("fetch_option_positions"),
)
if limit_msg:
return jsonify({"ok": False, "msg": limit_msg, "version": 1})
data = request.get_json(silent=True) or {}
conn = cfg["get_db"]()
try:
+147 -23
View File
@@ -27,6 +27,43 @@ from lib.options.options_spot_bridge_lib import (
)
def _sell_excess_bridge_coin(
cfg: dict[str, Any],
ex: Any,
conn: Any,
*,
bridge_id: int,
underlying: str,
keep_coin: float,
coin_bought: float,
) -> dict[str, Any] | None:
"""开仓后卖掉多买的币:卖量不超过 bridge 买入量,并保留权利金所需 keep_coin."""
try:
keep = max(0.0, float(keep_coin))
except (TypeError, ValueError):
keep = 0.0
try:
bought = max(0.0, float(coin_bought))
except (TypeError, ValueError):
bought = 0.0
excess_cap = max(0.0, bought - keep)
if excess_cap <= 0:
return None
try:
conn.commit()
except Exception:
pass
avail = _spot_bridge.fetch_trading_coin_available(ex, underlying)
if avail is None:
return None
sell_amt = min(float(avail), excess_cap)
if not _spot_bridge.is_sellable_spot_coin(ex, underlying, sell_amt):
return None
return _spot_bridge.spot_market_sell_coin_to_usdt(
ex, underlying=underlying, coin_amount=sell_amt
)
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
@@ -69,19 +106,33 @@ def open_coin_option_buy_full(
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
# 先清粉尘 pending 桥(自管短连接),再开业务连接,避免模拟盘读余额嵌套锁库
# 先清粉尘桥 + 空仓扫尾卖回(短连接),再开业务连接
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], ex)
except Exception:
pass
try:
sweep_spot_after_options_flat(cfg, ex)
except Exception:
pass
conn: Any = None
conn = cfg["get_db"]()
try:
init_options_tables(conn)
try:
conn.commit()
except Exception:
pass
block = _spot_bridge.bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
# 网络检查前再提交,避免握锁拉持仓/行情
try:
conn.commit()
except Exception:
pass
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
@@ -198,14 +249,45 @@ def open_coin_option_buy_full(
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
rb = _spot_bridge.rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
# 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
for _ in range(3):
time.sleep(0.6)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
again = _spot_bridge.fetch_trading_coin_available(ex, underlying)
if again is None:
continue
coin_bought = max(0.0, float(again) - float(coin_before or 0))
if coin_bought > 0:
coin_after = again
break
if coin_bought <= 0:
# 订单回执有数量则用之,否则回滚
try:
hinted = float(buy.get("coin_bought") or 0)
except (TypeError, ValueError):
hinted = 0.0
if hinted > 0:
coin_bought = hinted
else:
rb = _spot_bridge.rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {
"ok": False,
"msg": "买币后可用增量无效",
"rollback": rb,
"budget": budget_info,
}
_spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
try:
conn.commit()
except Exception:
pass
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
@@ -303,6 +385,17 @@ def open_coin_option_buy_full(
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
# 等待成交可能十余秒:先提交并释放业务连接,避免锁死中控/卖币
try:
conn.commit()
except Exception:
pass
try:
conn.close()
except Exception:
pass
conn = None
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
@@ -311,6 +404,9 @@ def open_coin_option_buy_full(
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
conn = cfg["get_db"]()
init_options_tables(conn)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
@@ -328,11 +424,27 @@ def open_coin_option_buy_full(
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
# 部分成交/减半张数后卖掉多余买币(按 bridge coin_bought 封顶)
need_coin = float(premium_paid)
try:
_sell_excess_bridge_coin(
cfg,
ex,
conn,
bridge_id=bridge_id,
underlying=underlying,
keep_coin=need_coin,
coin_bought=float(coin_bought),
)
except Exception:
pass
_spot_bridge.update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
coin_bought=float(need_coin),
message="期权持仓中",
)
@@ -408,10 +520,11 @@ def open_coin_option_buy_full(
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
if conn is not None:
try:
conn.close()
except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
@@ -502,19 +615,30 @@ def sweep_spot_after_options_flat(
return {"ok": False, "msg": "exchange 未就绪"}
live_uly: set[str] = set()
try:
rows = cfg["fetch_option_positions"](exchange) or []
for p in rows:
try:
if abs(float(p.get("pos") or 0)) <= 1e-12:
continue
except (TypeError, ValueError):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
live_uly.add(inst.split("-")[0].upper())
rows = cfg["fetch_option_positions"](exchange)
except Exception as e:
# 持仓查询失败:只清 pending 粉尘桥,绝不卖币
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
return {"ok": False, "msg": f"持仓查询失败: {e}"}
if rows is None:
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
return {"ok": False, "msg": "持仓查询失败: 空响应"}
for p in rows or []:
try:
if abs(float(p.get("pos") or 0)) <= 1e-12:
continue
except (TypeError, ValueError):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
live_uly.add(inst.split("-")[0].upper())
# 先关掉只剩粉尘的 pending 桥,避免拦开仓又看不到重试按钮
try:
+87 -13
View File
@@ -668,25 +668,72 @@ def _try_open_full(
def run_cond_orders_tick(
conn: sqlite3.Connection,
conn_or_get_db: Any,
cfg: dict[str, Any],
*,
send_wechat: Any = None,
account_label: str = "",
) -> None:
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾."""
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is None:
return
orders = list_cond_orders(conn, active_only=True)
if not orders:
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.
推荐传 get_db:开仓前会关闭连接,避免与买币/等待成交嵌套锁库。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
if get_db is None:
conn = conn_or_get_db
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is None:
return
for order in list_cond_orders(conn, active_only=True):
try:
_tick_one(
conn,
cfg,
ex,
order,
send_wechat=send_wechat,
account_label=account_label,
get_db=None,
)
except Exception as e:
try:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
)
conn.commit()
except Exception:
pass
return
ex: Any = None
orders: list[dict[str, Any]] = []
conn = get_db()
try:
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is not None:
orders = list_cond_orders(conn, active_only=True)
try:
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
if ex is None or not orders:
return
for order in orders:
conn = get_db()
try:
_tick_one(
conn,
@@ -695,7 +742,12 @@ def run_cond_orders_tick(
order,
send_wechat=send_wechat,
account_label=account_label,
get_db=get_db,
)
try:
conn.commit()
except Exception:
pass
except Exception as e:
try:
conn.execute(
@@ -705,6 +757,11 @@ def run_cond_orders_tick(
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def _tick_one(
@@ -715,6 +772,7 @@ def _tick_one(
*,
send_wechat: Any,
account_label: str,
get_db: Any = None,
) -> None:
status = str(order.get("status") or "")
underlying = str(order.get("underlying") or "ETH")
@@ -814,11 +872,27 @@ def _tick_one(
)
conn.commit()
return
# 开仓(买币/等成交)前释放本连接,避免嵌套锁库
try:
conn.commit()
except Exception:
pass
hub_id = order["hub_order_id"]
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
result = _try_open_full(cfg, ex, inst_id=inst_id, order=order)
if get_db is not None:
conn = get_db()
ensure_cond_order_table(conn)
assert conn is not None
if not result.get("ok"):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"开仓未成继续等: {result.get('msg') or '失败'}"[:180], _now(), order["hub_order_id"]),
(f"开仓未成继续等: {result.get('msg') or '失败'}"[:180], _now(), hub_id),
)
conn.commit()
return
@@ -837,7 +911,7 @@ def _tick_one(
_now(),
f"已开仓 {inst_id}",
_now(),
order["hub_order_id"],
hub_id,
),
)
conn.commit()
+27 -16
View File
@@ -67,6 +67,8 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
if raw_live is None:
return []
prem_by_inst: dict[str, float | None] = {}
hidden_keys: set[str] = set()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
@@ -76,25 +78,34 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
}
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
open_ms = None
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
try:
if ctime is not None and str(ctime).strip():
open_ms = int(float(ctime))
except (TypeError, ValueError):
open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms))
if inst:
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = prem_by_inst.get(inst)
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
open_ms = None
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
try:
if ctime is not None and str(ctime).strip():
open_ms = int(float(ctime))
except (TypeError, ValueError):
open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms))
hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip()
+4 -1
View File
@@ -23,7 +23,10 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw)
# 中控轮询勿拉买一深度(close_preview),避免超时离线
positions = build_display_option_positions(
cfg, ex, raw, include_close_preview=False
)
target_monitors: list[dict[str, Any]] = []
try:
conn = cfg["get_db"]()
+10 -28
View File
@@ -129,12 +129,16 @@ def run_options_profit_alerts(
premium_ccy=str(bucket.get("premium_ccy") or pos.get("premium_ccy") or ""),
)
try:
# 推送前先提交,避免握锁等 webhook
try:
conn.commit()
except Exception:
pass
send_wechat(msg)
conn.execute(
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
tuple(bucket["ids"]),
)
# 立即提交,避免后续目标/翻倍/同步异常回滚后每轮重推
try:
conn.commit()
except Exception:
@@ -532,19 +536,12 @@ def options_monitor_loop(
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
# 条件单可能嵌套开仓/卖币:单独短连接
# 条件单自管连接(开仓前会释放)
if cond_order_tick_fn is not None:
conn = get_db()
try:
cond_order_tick_fn(conn)
conn.commit()
cond_order_tick_fn(None)
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
conn = get_db()
try:
@@ -565,37 +562,28 @@ def options_monitor_loop(
except Exception:
pass
# 目标/翻倍出场:短连接内扫描;close_fn 在连接关闭后由内部仍可能嵌套—
# 此处每段自管连接,平仓前必 close
# 目标/翻倍出场:传 get_db,平仓时不握业务连接
if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes
conn = get_db()
try:
run_options_target_closes(
conn,
get_db,
positions,
close_fn=target_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
)
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
conn = get_db()
try:
run_options_profit_exits(
conn,
get_db,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
@@ -603,14 +591,8 @@ def options_monitor_loop(
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
if sync_trades_fn is not None:
try:
+35 -16
View File
@@ -212,31 +212,50 @@ def build_display_option_positions(
cfg: dict[str, Any],
ex: Any,
raw_positions: list[dict[str, Any]],
*,
include_close_preview: bool = True,
) -> list[dict[str, Any]]:
"""与实例 /api/options/positions 相同 enrichment + close_preview."""
"""与实例 /api/options/positions 相同 enrichment;可选 close_preview.
DB 读写与交易所拉盘口分离,避免握库期间网络导致 database is locked / 中控超时。
"""
from lib.options.options_db import init_options_tables, sum_open_budget_usdt, sum_open_premium_paid
meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = []
prem_by_inst: dict[str, float | None] = {}
budget_by_inst: dict[str, float | None] = {}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
for p in raw_positions:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
budget_usdt = sum_open_budget_usdt(conn, inst) if inst else None
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
if budget_usdt is not None:
row["budget_usdt"] = round(float(budget_usdt), 4)
row["premium_entry_usdt"] = round(float(budget_usdt), 4)
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
rows.append(row)
if not inst:
continue
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
budget_by_inst[inst] = sum_open_budget_usdt(conn, inst)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
rows: list[dict[str, Any]] = []
for p in raw_positions:
inst = str(p.get("instId") or "").strip()
premium_override = prem_by_inst.get(inst)
budget_usdt = budget_by_inst.get(inst)
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
if budget_usdt is not None:
row["budget_usdt"] = round(float(budget_usdt), 4)
row["premium_entry_usdt"] = round(float(budget_usdt), 4)
if include_close_preview:
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
rows.append(row)
return rows
+64 -39
View File
@@ -300,7 +300,7 @@ def _notify_profit_exit_close(
def run_options_profit_exits(
conn: sqlite3.Connection,
conn_or_get_db: Any,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
@@ -310,56 +310,77 @@ def run_options_profit_exits(
cfg: dict[str, Any] | None = None,
ex: Any = None,
) -> int:
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.
可传 get_db:拉盘口/平仓时不握业务连接。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
conn = conn_or_get_db() if get_db else conn_or_get_db
ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
rules = profit_exit_by_inst(conn)
triggered = 0
closing_ids: list[str] = []
scan_jobs: list[tuple[str, dict[str, Any], float, float]] = []
for inst_id, info in list(rules.items()):
if not inst_id:
continue
pos = pos_by_inst.get(inst_id)
if not pos:
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
continue
state = str(info.get("profit_exit_state") or "active")
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
prem = sum_open_premium_paid(conn, inst_id)
if prem is None or prem <= 0:
continue
# done/idle 不再扫描(查询层已过滤;此处双保险)
if state in ("done", "idle"):
continue
if state == "closing":
# 平仓/卖币会另开连接写库,先提交释放锁(模拟盘尤其敏感)
_commit(conn)
result = close_fn(inst_id)
if result.get("already_flat") or _result_fully_done(result):
_mark_state(conn, inst_id, "done")
try:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
except Exception:
pass
_commit(conn)
else:
_mark_state(conn, inst_id, "closing")
_commit(conn)
closing_ids.append(inst_id)
continue
if not info.get("profit_exit_enabled"):
continue
scan_jobs.append((inst_id, pos, float(prem), float(mult)))
_commit(conn)
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
def _ensure_conn() -> sqlite3.Connection:
nonlocal conn
if conn is not None:
return conn
assert get_db is not None
conn = get_db()
ensure_profit_exit_columns(conn)
return conn
for inst_id in closing_ids:
result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat") or _result_fully_done(result):
_mark_state(c, inst_id, "done")
try:
c.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
except Exception:
pass
_commit(c)
else:
_mark_state(c, inst_id, "closing")
_commit(c)
for inst_id, pos, prem, mult in scan_jobs:
if recycle_fn is not None:
recycle = recycle_fn(pos, prem)
elif cfg is not None and ex is not None:
@@ -371,12 +392,12 @@ def run_options_profit_exits(
if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
continue
_commit(conn)
result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat"):
_mark_state(conn, inst_id, "done")
_mark_state(c, inst_id, "done")
try:
conn.execute(
c.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
@@ -386,18 +407,18 @@ def run_options_profit_exits(
)
except Exception:
pass
_commit(conn)
_commit(c)
continue
if not result.get("ok"):
_mark_state(conn, inst_id, "active")
_commit(conn)
_mark_state(c, inst_id, "active")
_commit(c)
continue
done = _result_fully_done(result)
_mark_state(conn, inst_id, "done" if done else "closing")
_mark_state(c, inst_id, "done" if done else "closing")
if done:
try:
conn.execute(
c.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
@@ -407,8 +428,7 @@ def run_options_profit_exits(
)
except Exception:
pass
# 先落库再推送:同一笔只推一次;closing 重试不再进本分支
_commit(conn)
_commit(c)
triggered += 1
_notify_profit_exit_close(
cfg,
@@ -419,6 +439,11 @@ def run_options_profit_exits(
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
conn=c,
)
if get_db is not None and conn is not None:
try:
conn.close()
except Exception:
pass
return triggered
+3 -2
View File
@@ -2098,11 +2098,12 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass
return out
def _cond_order_tick(conn) -> None:
def _cond_order_tick(_conn=None) -> None:
from lib.options.options_cond_order_lib import run_cond_orders_tick
# 传 get_db:条件单开仓前会释放连接,避免嵌套锁库
run_cond_orders_tick(
conn,
cfg["get_db"],
cfg,
send_wechat=cfg.get("send_wechat"),
account_label=str(cfg.get("account_label") or ""),
+100 -19
View File
@@ -276,12 +276,23 @@ def spot_market_sell_coin_to_usdt(
underlying: str,
coin_amount: float | None = None,
) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.始终卖光交易户可用余额(coin_amount 仅兼容旧调用,不参与定量)."""
"""交易账户:市价卖出标的币换 USDT.
coin_amount 有值时按 min(可用, coin_amount) 卖,避免误卖无关余额;
未传时卖光该币可用(空仓扫尾).
"""
ccy = (underlying or "ETH").upper()
avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
amt = float(avail)
if coin_amount is not None:
try:
lim = float(coin_amount)
except (TypeError, ValueError):
lim = 0.0
if lim > 0:
amt = min(amt, lim)
if amt <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
min_sz = spot_min_sell_sz(ex, ccy)
@@ -334,14 +345,29 @@ def rollback_bought_coin_to_usdt(
reason: str = "",
coin_amount: float | None = None,
) -> dict[str, Any]:
"""买币后开期权失败:卖回 USDT 并关闭桥.卖光交易户可用标的币."""
_ = coin_amount # 兼容旧签名;定量以交易户可用为准
"""买币后开期权失败:按本桥买入量封顶卖回 USDT 并关闭桥."""
ensure_bridge_table(conn)
cap = coin_amount
if cap is None:
try:
row = conn.execute(
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if row is not None:
cap = _safe_float(row["coin_bought"] if hasattr(row, "keys") else row[0])
except Exception:
cap = None
# 卖币(模拟盘)会另开连接写钱包,须先释放本连接事务锁
try:
conn.commit()
except Exception:
pass
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
sell = spot_market_sell_coin_to_usdt(
ex,
underlying=underlying,
coin_amount=float(cap) if cap is not None and float(cap) > 0 else None,
)
if not sell.get("ok"):
update_bridge(
conn,
@@ -376,16 +402,34 @@ def sell_residual_after_option_flat(
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
bid: int | None = None
sell_cap: float | None = None
want_inst = str(inst_id or "").strip()
def _pick_bridge(bridges: list[dict[str, Any]]) -> dict[str, Any] | None:
uly_u = (underlying or "").upper()
matched: list[dict[str, Any]] = []
for b in bridges:
if str(b.get("status")) not in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
continue
if uly_u and str(b.get("underlying") or "").upper() != uly_u:
continue
matched.append(b)
if not matched:
return None
if want_inst:
for b in matched:
if str(b.get("inst_id") or "").strip() == want_inst:
return b
return matched[0]
if get_db is not None:
conn = get_db()
try:
ensure_bridge_table(conn)
bridges = list_open_bridges(conn)
for b in bridges:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
bid = int(b["id"])
break
target = _pick_bridge(list_open_bridges(conn))
if target is not None:
bid = int(target["id"])
sell_cap = _safe_float(target.get("coin_bought"))
try:
conn.commit()
except Exception:
@@ -398,12 +442,10 @@ def sell_residual_after_option_flat(
else:
conn = conn_or_get_db
ensure_bridge_table(conn)
bridges = list_open_bridges(conn)
for b in bridges:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
bid = int(b["id"])
break
target = _pick_bridge(list_open_bridges(conn))
if target is not None:
bid = int(target["id"])
sell_cap = _safe_float(target.get("coin_bought"))
try:
conn.commit()
except Exception:
@@ -414,7 +456,10 @@ def sell_residual_after_option_flat(
avail_chk = fetch_trading_coin_available(ex, underlying)
except Exception:
avail_chk = None
if avail_chk is not None and not is_sellable_spot_coin(ex, underlying, avail_chk):
dust_amt = avail_chk
if dust_amt is None and sell_cap is not None:
dust_amt = sell_cap
if dust_amt is not None and not is_sellable_spot_coin(ex, underlying, dust_amt):
dust_msg = "粉尘低于现货最小可卖,已忽略"
if bid is None:
return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True}
@@ -446,8 +491,13 @@ def sell_residual_after_option_flat(
pass
return _close_dust(conn_or_get_db)
# 有桥时按 coin_bought 封顶,避免误卖无关余额;无桥空仓扫尾仍卖光可用
# 此处无业务连接:模拟盘 convert 可安全另开写钱包
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
sell = spot_market_sell_coin_to_usdt(
ex,
underlying=underlying,
coin_amount=sell_cap if (bid is not None and sell_cap is not None and sell_cap > 0) else None,
)
if bid is None:
if not sell.get("ok"):
msg = str(sell.get("msg") or "")
@@ -527,12 +577,34 @@ def clear_dust_pending_sell_bridges(
get_db = conn_or_get_db if callable(conn_or_get_db) else None
def _pending(c: sqlite3.Connection) -> list[dict[str, Any]]:
# pending_sell 始终可清粉尘;holding 仅在无对应标的期权仓时清(避免误关持仓中桥)
return [
b
for b in list_open_bridges(c)
if str(b.get("status") or "") == BRIDGE_PENDING_SELL
if str(b.get("status") or "") in (BRIDGE_PENDING_SELL, BRIDGE_HOLDING)
]
live_uly: set[str] = set()
positions_ok = False
try:
from lib.exchange.okx_options_lib import fetch_option_positions
raw_pos = fetch_option_positions(ex)
if raw_pos is not None:
positions_ok = True
for p in raw_pos or []:
try:
if abs(float(p.get("pos") or 0)) <= 1e-12:
continue
except (TypeError, ValueError):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if inst:
live_uly.add(inst.split("-")[0].upper())
except Exception:
positions_ok = False
live_uly = set()
if get_db is not None:
conn = get_db()
try:
@@ -556,12 +628,21 @@ def clear_dust_pending_sell_bridges(
to_close: list[dict[str, Any]] = []
for b in pending:
st = str(b.get("status") or "")
uly = str(b.get("underlying") or "ETH").upper() or "ETH"
# holding 仅在「确认无该标的期权仓」时才可按粉尘关闭;持仓查询失败绝不关 holding
if st == BRIDGE_HOLDING and (not positions_ok or uly in live_uly):
continue
try:
avail = fetch_trading_coin_available(ex, uly)
except Exception:
continue
# 余额查不到时:用桥上记录的买入量判断粉尘,避免永久拦开仓
if avail is None:
cb = _safe_float(b.get("coin_bought"))
if cb is None or is_sellable_spot_coin(ex, uly, cb):
continue
to_close.append(b)
continue
if is_sellable_spot_coin(ex, uly, avail):
continue
+65 -39
View File
@@ -360,7 +360,7 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
def run_options_target_closes(
conn: sqlite3.Connection,
conn_or_get_db: Any,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
@@ -371,17 +371,17 @@ def run_options_target_closes(
) -> int:
"""
扫描 active 目标委托;指数到位后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数.
可传 get_db:平仓在无业务连接时执行,避免嵌套锁库。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
conn = conn_or_get_db() if get_db else conn_or_get_db
ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
live_ids = {k for k in pos_by_inst if k}
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn)
# 先处理已挂单等待成交的,绝不再发微信
retry_jobs: list[dict[str, Any]] = []
for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "")
if not inst_id:
@@ -390,32 +390,9 @@ def run_options_target_closes(
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn)
continue
# 平仓/卖币另开连接,先提交释放锁
_commit_monitor(conn)
result = close_fn(inst_id)
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
if result.get("already_flat") or _result_fully_done(result):
mark_monitor(
conn,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成",
)
_commit_monitor(conn)
continue
mark_monitor(
conn,
int(mon["id"]),
status="closing",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
)
_commit_monitor(conn)
retry_jobs.append(dict(mon))
triggered = 0
trigger_jobs: list[tuple[dict[str, Any], float, float]] = []
for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index"))
@@ -433,36 +410,80 @@ def run_options_target_closes(
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue
trigger_jobs.append((dict(mon), float(idx), float(target)))
_commit_monitor(conn)
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
_commit_monitor(conn)
def _ensure_conn() -> sqlite3.Connection:
nonlocal conn
if conn is not None:
return conn
assert get_db is not None
conn = get_db()
ensure_target_tables(conn)
return conn
for mon in retry_jobs:
inst_id = str(mon.get("inst_id") or "")
result = close_fn(inst_id)
c = _ensure_conn()
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
if result.get("already_flat") or _result_fully_done(result):
mark_monitor(
c,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成",
)
else:
mark_monitor(
c,
int(mon["id"]),
status="closing",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
)
_commit_monitor(c)
triggered = 0
for mon, idx, target in trigger_jobs:
inst_id = str(mon.get("inst_id") or "")
result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn)
mark_monitor(c, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(c)
continue
if not result.get("ok"):
mark_monitor(
conn,
c,
int(mon["id"]),
status="active",
trigger_idx=idx,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
)
_commit_monitor(conn)
_commit_monitor(c)
continue
done = _result_fully_done(result)
status = "triggered" if done else "closing"
mark_monitor(
conn,
c,
int(mon["id"]),
status=status,
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
)
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
_commit_monitor(conn)
_commit_monitor(c)
triggered += 1
_notify_target_close(
cfg,
@@ -472,6 +493,11 @@ def run_options_target_closes(
target=target,
idx=idx,
result=result,
conn=conn,
conn=c,
)
if get_db is not None and conn is not None:
try:
conn.close()
except Exception:
pass
return triggered
+2 -2
View File
@@ -843,8 +843,8 @@ class SimBroker:
underlying=uly,
inst_id=inst_id,
)
except Exception:
pass
except Exception as e:
print(f"[sim_expiry_sell] {inst_id}: {e}", flush=True)
settled.append(
{
"inst_id": inst_id,
+10 -3
View File
@@ -468,16 +468,23 @@ def _patch_spot_bridge_lib() -> None:
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
coin = (underlying or "ETH").strip().upper() or "ETH"
_ = coin_amount
avail = fetch_trading_coin_available(ex, coin)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {coin}"}
from lib.options.options_spot_bridge_lib import is_sellable_spot_coin, spot_min_sell_sz
if not is_sellable_spot_coin(ex, coin, avail):
amt = float(avail)
if coin_amount is not None:
try:
lim = float(coin_amount)
except (TypeError, ValueError):
lim = 0.0
if lim > 0:
amt = min(amt, lim)
if not is_sellable_spot_coin(ex, coin, amt):
min_sz = spot_min_sell_sz(ex, coin)
return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"}
sell_sz = max(0.0, float(avail) * 0.999)
sell_sz = max(0.0, float(amt) * 0.999)
if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz):
return {"ok": False, "msg": f"{coin} 可卖数量过小"}
pub = _sim_public_exchange(ex)