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

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
+55
View File
@@ -0,0 +1,55 @@
# 修复报告:期权币本位高危缺陷(2026-10-07)
## 1. 目标
按审计清单修复全部 **Critical / High** 项,本地测试 + 不少于两轮审计后,再通知更新实例。
## 2. 修复对照表
| 原问题 | 修复要点 | 主要文件 |
| --- | --- | --- |
| 买币增量≤0 误用整户余额 | 短重试取增量;仅用订单回执 `coin_bought`;否则回滚 | `options_coin_open_lib.py` |
| IOC 部分成交 / 51008 减半残留币 | `_sell_excess_bridge_coin` 按桥买入量封顶卖掉多余币 | `options_coin_open_lib.py` |
| 卖回扫光无关余额 | `spot_market_sell_coin_to_usdt` 尊重 `coin_amount`;桥卖按 `coin_bought` 封顶 | `options_spot_bridge_lib.py`, `sim/hooks.py` |
| 粉尘桥 `None` 拦开仓 | 用桥记录量判断粉尘;开仓前清桥 + 扫尾;holding 仅在确认无仓时清 | `options_spot_bridge_lib.py`, `options_coin_open_lib.py` |
| 握库拉盘口 | 持仓/历史:先读库再 enrich;中控快照关闭 close_preview | `options_positions_lib.py`, `options_history_lib.py`, `options_hub_lib.py` |
| 条件单嵌套开仓锁库 | `run_cond_orders_tick(get_db)`,开仓前关连接 | `options_cond_order_lib.py`, `options_register.py`, `options_monitor_lib.py` |
| 目标/翻倍握库平仓 | 传 `get_db`,收集任务后无连接再 `close_fn` | `options_target_lib.py`, `options_profit_exit_lib.py`, `options_monitor_lib.py` |
| 开仓等成交握库 | 等成交前 `commit+close`,结束后再开短连接写库 | `options_coin_open_lib.py` |
| 模拟到期卖币静默失败 | 打印 `[sim_expiry_sell]` 日志 | `sim/broker_lib.py` |
| 中控条件单不验上限 | 创建前调用 `option_position_limit_block_msg` | `hub_bridge_register.py` |
| 持仓查询失败误卖 | `None`/异常不卖币,只清粉尘 pending | `options_coin_open_lib.py` |
| 查询失败误关 holding | `positions_ok` 为假时不关 holding | `options_spot_bridge_lib.py` |
| 回滚卖币无封顶 | 回滚按桥 `coin_bought` 封顶 | `options_spot_bridge_lib.py` |
## 3. 测试
新增:`tests/test_spot_dust_and_sell_cap.py`
已跑通:
- `tests.test_spot_dust_and_sell_cap`
- `tests.test_options_partial_close_accum`
- `tests.test_options_profit_exit_recycle`
- `tests.test_options_btc_pnl_usdt`
## 4. 审计轮次
| 轮次 | 结果 |
| --- | --- |
| 修复前 | 多条 Critical/High(见审计报告 §3) |
| 第 1 轮 Bugbot | 2 High → 已修 |
| 第 2 轮 Bugbot + 人工 | 无确认残留 High/Critical |
详见:`docs/审计报告-高危修复-2026-10-07.md`
## 5. 部署建议
1. 推送本仓库后,对**所有实例**(含模拟盘「波段-复利」)远程更新并重启
2. 验证:中控平仓后自动卖回;粉尘不再显示/不拦开仓;模拟盘不再频繁「数据库锁定」
3. 若仍见旧 pending 桥:开仓流程会自动清粉尘;或重启后再试一次开仓
## 6. 未改(有意保留)
- 永续监控 `check_order_monitors` 长连接(Medium,非本次高危范围)
- 粉尘仍会留在交易所账户内,仅程序忽略展示与重试
@@ -0,0 +1,68 @@
# 审计报告:期权币本位 / 模拟盘锁库 / 中控超时(2026-10-07)
## 1. 范围
- 币本位开仓、平仓、卖回、粉尘桥
- SQLite `database is locked` 与中控快照超时
- 条件单、目标/翻倍出场、模拟盘到期卖币
- 中控条件单创建与持仓上限
## 2. 审计方法
1. 静态代码审阅(开仓/卖回/监控/中控快照路径)
2. Bugbot 未提交变更审阅(第 1、2 轮)
3. 本地 `unittest`(粉尘阈值、分批平仓累计、翻倍出场、BTC 盈亏折 U 等)
## 3. 第 0 轮(修复前)高危清单(摘要)
| 级别 | 问题 |
| --- | --- |
| Critical | 买币增量≤0 时误用整户余额估张数 |
| High | IOC 部分成交 / 51008 减半后未卖多余币 |
| High | 卖回扫光交易户该币(未按桥封顶) |
| High | 粉尘桥余额 `None` 时不清、拦开仓且无重试按钮 |
| High | 展示持仓/历史握库拉盘口 → 锁库/超时 |
| High | 中控快照每次算 close_preview 加重超时 |
| High | 条件单/目标/翻倍出场握库嵌套开平仓 |
| High | 模拟到期卖币失败静默吞掉 |
| High | 中控建条件单不校验持仓上限 |
| High | 持仓查询失败被当成空仓仍可能卖币(第 1 轮复现相关) |
## 4. 第 1 轮审计(修复后 Bugbot)
仍发现 2 条 High,已当场修复:
| 级别 | 位置 | 问题 | 处置 |
| --- | --- | --- | --- |
| High | `options_spot_bridge_lib.clear_dust_pending_sell_bridges` | 持仓查询失败时 `live_uly` 为空,误关 `holding` 桥 | 持仓未确认成功时禁止关 holding |
| High | `sweep_spot_after_options_flat` | `fetch_option_positions` 返回 `None` 被当成 `[]` 后卖币 | `None` 直接失败返回,只清粉尘 pending |
## 5. 第 2 轮审计
- Bugbot:无通过校验器确认的 High/Critical 残留
- 人工复查:Critical/High 项均有对应修复;`check_order_monitors` 长连接等属 Medium,本次未纳入高危闭环
## 6. 本地测试
```text
PYTHONPATH=<repo> python -m unittest \
tests.test_spot_dust_and_sell_cap \
tests.test_options_partial_close_accum \
tests.test_options_profit_exit_recycle \
tests.test_options_btc_pnl_usdt \
-q
→ OK
```
## 7. 结论
在「高危」范围内,经不少于两轮审计与本地单测,**未发现仍确认存在的 Critical/High 缺陷**。
建议实例更新到含本批修复的提交后,用模拟盘走一遍:开仓 → 中控平仓 → 自动卖回 → 再开仓(确认粉尘桥不再拦截)。
## 8. 残留风险(非高危,供后续)
| 级别 | 说明 |
| --- | --- |
| Medium | `app.py` 永续 `check_order_monitors` 仍单连接贯穿价格/平仓/微信 |
| Medium | 开仓路径部分阶段仍较长,极端并发下 WAL 写锁仍可能短暂排队 |
| Low | 现货 `minSz` 拉取失败时用固定兜底,与交易所实际上调可能短暂不一致 |
+8
View File
@@ -350,10 +350,18 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
machine_has_option_position, machine_has_option_position,
) )
from lib.options.options_db import init_options_tables 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") ex = cfg.get("exchange_options")
if machine_has_option_position(cfg, ex): if machine_has_option_position(cfg, ex):
return jsonify({"ok": False, "msg": "本机已有期权仓,拒绝条件单", "version": 1}) 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 {} data = request.get_json(silent=True) or {}
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: 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]: def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances from lib.exchange.okx_options_lib import fetch_options_balances
@@ -69,19 +106,33 @@ def open_coin_option_buy_full(
parts = inst_id.split("-") parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper() underlying = (parts[0] if parts else "ETH").upper()
# 先清粉尘 pending 桥(自管短连接),再开业务连接,避免模拟盘读余额嵌套锁库 # 先清粉尘桥 + 空仓扫尾卖回(短连接),再开业务连接
try: try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], ex) _spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], ex)
except Exception: except Exception:
pass pass
try:
sweep_spot_after_options_flat(cfg, ex)
except Exception:
pass
conn: Any = None
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_options_tables(conn) init_options_tables(conn)
try:
conn.commit()
except Exception:
pass
block = _spot_bridge.bridge_blocks_new_open_msg(conn) block = _spot_bridge.bridge_blocks_new_open_msg(conn)
if block: if block:
return {"ok": False, "msg": block, "can_open": False} return {"ok": False, "msg": block, "can_open": False}
# 网络检查前再提交,避免握锁拉持仓/行情
try:
conn.commit()
except Exception:
pass
compound_block = compound_full_single_position_block_msg( compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions") 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)) coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0: if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示 # 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
coin_bought = float(coin_after) for _ in range(3):
if coin_bought <= 0: time.sleep(0.6)
rb = _spot_bridge.rollback_bought_coin_to_usdt( try:
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效" from lib.exchange.okx_options_lib import invalidate_options_balance_cache
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info} 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)) _spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
try:
conn.commit()
except Exception:
pass
sizing = calc_sheets_from_coin_balance( sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask), 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")) fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError): except (TypeError, ValueError):
fill_timeout = 12.0 fill_timeout = 12.0
# 等待成交可能十余秒:先提交并释放业务连接,避免锁死中控/卖币
try:
conn.commit()
except Exception:
pass
try:
conn.close()
except Exception:
pass
conn = None
fill = wait_option_order_full_fill( fill = wait_option_order_full_fill(
ex, ex,
inst_id=inst_id, inst_id=inst_id,
@@ -311,6 +404,9 @@ def open_coin_option_buy_full(
timeout_sec=fill_timeout, timeout_sec=fill_timeout,
cancel_on_timeout=True, cancel_on_timeout=True,
) )
conn = cfg["get_db"]()
init_options_tables(conn)
if not fill.get("ok"): if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0) filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0: if filled_n <= 0:
@@ -328,11 +424,27 @@ def open_coin_option_buy_full(
premium_paid = round(float(ask) * eth_amount, 8) premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying) 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( _spot_bridge.update_bridge(
conn, conn,
bridge_id, bridge_id,
status=BRIDGE_HOLDING, status=BRIDGE_HOLDING,
inst_id=inst_id, inst_id=inst_id,
coin_bought=float(need_coin),
message="期权持仓中", message="期权持仓中",
) )
@@ -408,10 +520,11 @@ def open_coin_option_buy_full(
"fill": fill, "fill": fill,
} }
finally: finally:
try: if conn is not None:
conn.close() try:
except Exception: conn.close()
pass except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
@@ -502,19 +615,30 @@ def sweep_spot_after_options_flat(
return {"ok": False, "msg": "exchange 未就绪"} return {"ok": False, "msg": "exchange 未就绪"}
live_uly: set[str] = set() live_uly: set[str] = set()
try: try:
rows = cfg["fetch_option_positions"](exchange) or [] rows = cfg["fetch_option_positions"](exchange)
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())
except Exception as e: 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}"} 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 桥,避免拦开仓又看不到重试按钮 # 先关掉只剩粉尘的 pending 桥,避免拦开仓又看不到重试按钮
try: try:
+87 -13
View File
@@ -668,25 +668,72 @@ def _try_open_full(
def run_cond_orders_tick( def run_cond_orders_tick(
conn: sqlite3.Connection, conn_or_get_db: Any,
cfg: dict[str, Any], cfg: dict[str, Any],
*, *,
send_wechat: Any = None, send_wechat: Any = None,
account_label: str = "", account_label: str = "",
) -> None: ) -> None:
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.""" """监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.
ensure_cond_order_table(conn)
expire_untriggered_cond_orders( 推荐传 get_db:开仓前会关闭连接,避免与买币/等待成交嵌套锁库。
conn, send_wechat=send_wechat, account_label=account_label """
) get_db = conn_or_get_db if callable(conn_or_get_db) else None
ex = cfg.get("exchange_options") if get_db is None:
if ex is None: conn = conn_or_get_db
return ensure_cond_order_table(conn)
orders = list_cond_orders(conn, active_only=True) expire_untriggered_cond_orders(
if not 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 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: for order in orders:
conn = get_db()
try: try:
_tick_one( _tick_one(
conn, conn,
@@ -695,7 +742,12 @@ def run_cond_orders_tick(
order, order,
send_wechat=send_wechat, send_wechat=send_wechat,
account_label=account_label, account_label=account_label,
get_db=get_db,
) )
try:
conn.commit()
except Exception:
pass
except Exception as e: except Exception as e:
try: try:
conn.execute( conn.execute(
@@ -705,6 +757,11 @@ def run_cond_orders_tick(
conn.commit() conn.commit()
except Exception: except Exception:
pass pass
finally:
try:
conn.close()
except Exception:
pass
def _tick_one( def _tick_one(
@@ -715,6 +772,7 @@ def _tick_one(
*, *,
send_wechat: Any, send_wechat: Any,
account_label: str, account_label: str,
get_db: Any = None,
) -> None: ) -> None:
status = str(order.get("status") or "") status = str(order.get("status") or "")
underlying = str(order.get("underlying") or "ETH") underlying = str(order.get("underlying") or "ETH")
@@ -814,11 +872,27 @@ def _tick_one(
) )
conn.commit() conn.commit()
return 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) 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"): if not result.get("ok"):
conn.execute( conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?", "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() conn.commit()
return return
@@ -837,7 +911,7 @@ def _tick_one(
_now(), _now(),
f"已开仓 {inst_id}", f"已开仓 {inst_id}",
_now(), _now(),
order["hub_order_id"], hub_id,
), ),
) )
conn.commit() 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: if raw_live is None:
return [] return []
prem_by_inst: dict[str, float | None] = {}
hidden_keys: set[str] = set()
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_options_tables(conn) 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: for p in raw_live:
inst = str(p.get("instId") or "").strip() inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None if inst:
row = enrich_position_row_display( prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
cfg, try:
ex, conn.commit()
p, except Exception:
meta_cache=meta_cache, pass
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))
finally: finally:
conn.close() 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) hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw: for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip() 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) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} 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]] = [] target_monitors: list[dict[str, Any]] = []
try: try:
conn = cfg["get_db"]() 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 ""), premium_ccy=str(bucket.get("premium_ccy") or pos.get("premium_ccy") or ""),
) )
try: try:
# 推送前先提交,避免握锁等 webhook
try:
conn.commit()
except Exception:
pass
send_wechat(msg) send_wechat(msg)
conn.execute( conn.execute(
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})", f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
tuple(bucket["ids"]), tuple(bucket["ids"]),
) )
# 立即提交,避免后续目标/翻倍/同步异常回滚后每轮重推
try: try:
conn.commit() conn.commit()
except Exception: except Exception:
@@ -532,19 +536,12 @@ def options_monitor_loop(
pe_cfg.setdefault("send_wechat", send_wechat) pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label) pe_cfg.setdefault("account_label", account_label)
# 条件单可能嵌套开仓/卖币:单独短连接 # 条件单自管连接(开仓前会释放)
if cond_order_tick_fn is not None: if cond_order_tick_fn is not None:
conn = get_db()
try: try:
cond_order_tick_fn(conn) cond_order_tick_fn(None)
conn.commit()
except Exception: except Exception:
pass pass
finally:
try:
conn.close()
except Exception:
pass
conn = get_db() conn = get_db()
try: try:
@@ -565,37 +562,28 @@ def options_monitor_loop(
except Exception: except Exception:
pass pass
# 目标/翻倍出场:短连接内扫描;close_fn 在连接关闭后由内部仍可能嵌套— # 目标/翻倍出场:传 get_db,平仓时不握业务连接
# 此处每段自管连接,平仓前必 close
if target_close_fn is not None: if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes from lib.options.options_target_lib import run_options_target_closes
conn = get_db()
try: try:
run_options_target_closes( run_options_target_closes(
conn, get_db,
positions, positions,
close_fn=target_close_fn, close_fn=target_close_fn,
send_wechat=send_wechat, send_wechat=send_wechat,
account_label=account_label, account_label=account_label,
cfg=pe_cfg, cfg=pe_cfg,
) )
conn.commit()
except Exception: except Exception:
pass pass
finally:
try:
conn.close()
except Exception:
pass
if profit_exit_close_fn is not None: if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits from lib.options.options_profit_exit_lib import run_options_profit_exits
conn = get_db()
try: try:
run_options_profit_exits( run_options_profit_exits(
conn, get_db,
positions, positions,
close_fn=profit_exit_close_fn, close_fn=profit_exit_close_fn,
send_wechat=send_wechat, send_wechat=send_wechat,
@@ -603,14 +591,8 @@ def options_monitor_loop(
cfg=pe_cfg, cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"), ex=pe_cfg.get("exchange_options"),
) )
conn.commit()
except Exception: except Exception:
pass pass
finally:
try:
conn.close()
except Exception:
pass
if sync_trades_fn is not None: if sync_trades_fn is not None:
try: try:
+35 -16
View File
@@ -212,31 +212,50 @@ def build_display_option_positions(
cfg: dict[str, Any], cfg: dict[str, Any],
ex: Any, ex: Any,
raw_positions: list[dict[str, Any]], raw_positions: list[dict[str, Any]],
*,
include_close_preview: bool = True,
) -> list[dict[str, Any]]: ) -> 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 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] = {} 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"]() conn = cfg["get_db"]()
try: try:
init_options_tables(conn) init_options_tables(conn)
for p in raw_positions: for p in raw_positions:
inst = str(p.get("instId") or "").strip() inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None if not inst:
budget_usdt = sum_open_budget_usdt(conn, inst) if inst else None continue
row = enrich_position_row_display( prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
cfg, budget_by_inst[inst] = sum_open_budget_usdt(conn, inst)
ex, try:
p, conn.commit()
meta_cache=meta_cache, except Exception:
premium_override=premium_override, pass
)
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)
finally: finally:
conn.close() 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 return rows
+64 -39
View File
@@ -300,7 +300,7 @@ def _notify_profit_exit_close(
def run_options_profit_exits( def run_options_profit_exits(
conn: sqlite3.Connection, conn_or_get_db: Any,
positions: list[dict[str, Any]], positions: list[dict[str, Any]],
*, *,
close_fn: Callable[[str], 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, cfg: dict[str, Any] | None = None,
ex: Any = None, ex: Any = None,
) -> int: ) -> 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) ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
rules = profit_exit_by_inst(conn) rules = profit_exit_by_inst(conn)
triggered = 0 triggered = 0
closing_ids: list[str] = []
scan_jobs: list[tuple[str, dict[str, Any], float, float]] = []
for inst_id, info in list(rules.items()): for inst_id, info in list(rules.items()):
if not inst_id: if not inst_id:
continue continue
pos = pos_by_inst.get(inst_id) pos = pos_by_inst.get(inst_id)
if not pos: if not pos:
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
continue continue
state = str(info.get("profit_exit_state") or "active") state = str(info.get("profit_exit_state") or "active")
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0) mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
prem = sum_open_premium_paid(conn, inst_id) prem = sum_open_premium_paid(conn, inst_id)
if prem is None or prem <= 0: if prem is None or prem <= 0:
continue continue
# done/idle 不再扫描(查询层已过滤;此处双保险)
if state in ("done", "idle"): if state in ("done", "idle"):
continue continue
if state == "closing": if state == "closing":
# 平仓/卖币会另开连接写库,先提交释放锁(模拟盘尤其敏感) closing_ids.append(inst_id)
_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)
continue continue
if not info.get("profit_exit_enabled"): if not info.get("profit_exit_enabled"):
continue 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: if recycle_fn is not None:
recycle = recycle_fn(pos, prem) recycle = recycle_fn(pos, prem)
elif cfg is not None and ex is not None: 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): if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
continue continue
_commit(conn)
result = close_fn(inst_id) result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat"): if result.get("already_flat"):
_mark_state(conn, inst_id, "done") _mark_state(c, inst_id, "done")
try: try:
conn.execute( c.execute(
""" """
UPDATE options_trades UPDATE options_trades
SET profit_exit_enabled = 0 SET profit_exit_enabled = 0
@@ -386,18 +407,18 @@ def run_options_profit_exits(
) )
except Exception: except Exception:
pass pass
_commit(conn) _commit(c)
continue continue
if not result.get("ok"): if not result.get("ok"):
_mark_state(conn, inst_id, "active") _mark_state(c, inst_id, "active")
_commit(conn) _commit(c)
continue continue
done = _result_fully_done(result) 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: if done:
try: try:
conn.execute( c.execute(
""" """
UPDATE options_trades UPDATE options_trades
SET profit_exit_enabled = 0 SET profit_exit_enabled = 0
@@ -407,8 +428,7 @@ def run_options_profit_exits(
) )
except Exception: except Exception:
pass pass
# 先落库再推送:同一笔只推一次;closing 重试不再进本分支 _commit(c)
_commit(conn)
triggered += 1 triggered += 1
_notify_profit_exit_close( _notify_profit_exit_close(
cfg, cfg,
@@ -419,6 +439,11 @@ def run_options_profit_exits(
premium_paid=prem, premium_paid=prem,
recycle=recycle, recycle=recycle,
result=result, 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 return triggered
+3 -2
View File
@@ -2098,11 +2098,12 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass pass
return out 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 from lib.options.options_cond_order_lib import run_cond_orders_tick
# 传 get_db:条件单开仓前会释放连接,避免嵌套锁库
run_cond_orders_tick( run_cond_orders_tick(
conn, cfg["get_db"],
cfg, cfg,
send_wechat=cfg.get("send_wechat"), send_wechat=cfg.get("send_wechat"),
account_label=str(cfg.get("account_label") or ""), 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, underlying: str,
coin_amount: float | None = None, coin_amount: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.始终卖光交易户可用余额(coin_amount 仅兼容旧调用,不参与定量).""" """交易账户:市价卖出标的币换 USDT.
coin_amount 有值时按 min(可用, coin_amount) 卖,避免误卖无关余额;
未传时卖光该币可用(空仓扫尾).
"""
ccy = (underlying or "ETH").upper() ccy = (underlying or "ETH").upper()
avail = fetch_trading_coin_available(ex, ccy) avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0: if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"} return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
amt = float(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 amt <= 0: if amt <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"} return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
min_sz = spot_min_sell_sz(ex, ccy) min_sz = spot_min_sell_sz(ex, ccy)
@@ -334,14 +345,29 @@ def rollback_bought_coin_to_usdt(
reason: str = "", reason: str = "",
coin_amount: float | None = None, coin_amount: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""买币后开期权失败:卖回 USDT 并关闭桥.卖光交易户可用标的币.""" """买币后开期权失败:按本桥买入量封顶卖回 USDT 并关闭桥."""
_ = coin_amount # 兼容旧签名;定量以交易户可用为准 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: try:
conn.commit() conn.commit()
except Exception: except Exception:
pass 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"): if not sell.get("ok"):
update_bridge( update_bridge(
conn, 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 get_db = conn_or_get_db if callable(conn_or_get_db) else None
bid: int | None = 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: if get_db is not None:
conn = get_db() conn = get_db()
try: try:
ensure_bridge_table(conn) ensure_bridge_table(conn)
bridges = list_open_bridges(conn) target = _pick_bridge(list_open_bridges(conn))
for b in bridges: if target is not None:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT): bid = int(target["id"])
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper(): sell_cap = _safe_float(target.get("coin_bought"))
bid = int(b["id"])
break
try: try:
conn.commit() conn.commit()
except Exception: except Exception:
@@ -398,12 +442,10 @@ def sell_residual_after_option_flat(
else: else:
conn = conn_or_get_db conn = conn_or_get_db
ensure_bridge_table(conn) ensure_bridge_table(conn)
bridges = list_open_bridges(conn) target = _pick_bridge(list_open_bridges(conn))
for b in bridges: if target is not None:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT): bid = int(target["id"])
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper(): sell_cap = _safe_float(target.get("coin_bought"))
bid = int(b["id"])
break
try: try:
conn.commit() conn.commit()
except Exception: except Exception:
@@ -414,7 +456,10 @@ def sell_residual_after_option_flat(
avail_chk = fetch_trading_coin_available(ex, underlying) avail_chk = fetch_trading_coin_available(ex, underlying)
except Exception: except Exception:
avail_chk = None 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 = "粉尘低于现货最小可卖,已忽略" dust_msg = "粉尘低于现货最小可卖,已忽略"
if bid is None: if bid is None:
return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True} return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True}
@@ -446,8 +491,13 @@ def sell_residual_after_option_flat(
pass pass
return _close_dust(conn_or_get_db) return _close_dust(conn_or_get_db)
# 有桥时按 coin_bought 封顶,避免误卖无关余额;无桥空仓扫尾仍卖光可用
# 此处无业务连接:模拟盘 convert 可安全另开写钱包 # 此处无业务连接:模拟盘 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 bid is None:
if not sell.get("ok"): if not sell.get("ok"):
msg = str(sell.get("msg") or "") 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 get_db = conn_or_get_db if callable(conn_or_get_db) else None
def _pending(c: sqlite3.Connection) -> list[dict[str, Any]]: def _pending(c: sqlite3.Connection) -> list[dict[str, Any]]:
# pending_sell 始终可清粉尘;holding 仅在无对应标的期权仓时清(避免误关持仓中桥)
return [ return [
b b
for b in list_open_bridges(c) 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: if get_db is not None:
conn = get_db() conn = get_db()
try: try:
@@ -556,12 +628,21 @@ def clear_dust_pending_sell_bridges(
to_close: list[dict[str, Any]] = [] to_close: list[dict[str, Any]] = []
for b in pending: for b in pending:
st = str(b.get("status") or "")
uly = str(b.get("underlying") or "ETH").upper() or "ETH" 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: try:
avail = fetch_trading_coin_available(ex, uly) avail = fetch_trading_coin_available(ex, uly)
except Exception: except Exception:
continue continue
# 余额查不到时:用桥上记录的买入量判断粉尘,避免永久拦开仓
if avail is None: 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 continue
if is_sellable_spot_coin(ex, uly, avail): if is_sellable_spot_coin(ex, uly, avail):
continue continue
+65 -39
View File
@@ -360,7 +360,7 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
def run_options_target_closes( def run_options_target_closes(
conn: sqlite3.Connection, conn_or_get_db: Any,
positions: list[dict[str, Any]], positions: list[dict[str, Any]],
*, *,
close_fn: Callable[[str], dict[str, Any]], close_fn: Callable[[str], dict[str, Any]],
@@ -371,17 +371,17 @@ def run_options_target_closes(
) -> int: ) -> int:
""" """
扫描 active 目标委托;指数到位后限价平仓. 扫描 active 目标委托;指数到位后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. 可传 get_db:平仓在无业务连接时执行,避免嵌套锁库。
未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数.
""" """
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) ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} 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} live_ids = {k for k in pos_by_inst if k}
cancel_orphans_without_position(conn, live_inst_ids=live_ids) cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn) _commit_monitor(conn)
# 先处理已挂单等待成交的,绝不再发微信 retry_jobs: list[dict[str, Any]] = []
for mon in list_closing_targets(conn): for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "") inst_id = str(mon.get("inst_id") or "")
if not inst_id: if not inst_id:
@@ -390,32 +390,9 @@ def run_options_target_closes(
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn) _commit_monitor(conn)
continue continue
# 平仓/卖币另开连接,先提交释放锁 retry_jobs.append(dict(mon))
_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)
triggered = 0 trigger_jobs: list[tuple[dict[str, Any], float, float]] = []
for mon in list_active_targets(conn): for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "") inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index")) 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") 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): if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue 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) 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"): if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") mark_monitor(c, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn) _commit_monitor(c)
continue continue
if not result.get("ok"): if not result.get("ok"):
mark_monitor( mark_monitor(
conn, c,
int(mon["id"]), int(mon["id"]),
status="active", status="active",
trigger_idx=idx, trigger_idx=idx,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
) )
_commit_monitor(conn) _commit_monitor(c)
continue continue
done = _result_fully_done(result) done = _result_fully_done(result)
status = "triggered" if done else "closing" status = "triggered" if done else "closing"
mark_monitor( mark_monitor(
conn, c,
int(mon["id"]), int(mon["id"]),
status=status, status=status,
trigger_idx=idx, trigger_idx=idx,
close_ord_id=result.get("close_ord_id"), close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
) )
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 _commit_monitor(c)
_commit_monitor(conn)
triggered += 1 triggered += 1
_notify_target_close( _notify_target_close(
cfg, cfg,
@@ -472,6 +493,11 @@ def run_options_target_closes(
target=target, target=target,
idx=idx, idx=idx,
result=result, 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 return triggered
+2 -2
View File
@@ -843,8 +843,8 @@ class SimBroker:
underlying=uly, underlying=uly,
inst_id=inst_id, inst_id=inst_id,
) )
except Exception: except Exception as e:
pass print(f"[sim_expiry_sell] {inst_id}: {e}", flush=True)
settled.append( settled.append(
{ {
"inst_id": inst_id, "inst_id": inst_id,
+10 -3
View File
@@ -468,16 +468,23 @@ def _patch_spot_bridge_lib() -> None:
try: try:
if _GET_DB is not None and is_sim_mode(_GET_DB): if _GET_DB is not None and is_sim_mode(_GET_DB):
coin = (underlying or "ETH").strip().upper() or "ETH" coin = (underlying or "ETH").strip().upper() or "ETH"
_ = coin_amount
avail = fetch_trading_coin_available(ex, coin) avail = fetch_trading_coin_available(ex, coin)
if avail is None or float(avail) <= 0: if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {coin}"} return {"ok": False, "msg": f"交易账户无可用 {coin}"}
from lib.options.options_spot_bridge_lib import is_sellable_spot_coin, spot_min_sell_sz 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) min_sz = spot_min_sell_sz(ex, coin)
return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"} 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): if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz):
return {"ok": False, "msg": f"{coin} 可卖数量过小"} return {"ok": False, "msg": f"{coin} 可卖数量过小"}
pub = _sim_public_exchange(ex) pub = _sim_public_exchange(ex)
+39
View File
@@ -0,0 +1,39 @@
"""粉尘阈值 / 卖回封顶 / 买入增量逻辑相关单测."""
from __future__ import annotations
import unittest
from lib.options.options_spot_bridge_lib import (
display_sellable_coin,
is_sellable_spot_coin,
spot_min_sell_sz,
)
class TestSpotDust(unittest.TestCase):
def test_min_sz_fallback(self):
self.assertAlmostEqual(spot_min_sell_sz(None, "ETH"), 0.0001)
self.assertAlmostEqual(spot_min_sell_sz(None, "BTC"), 0.00001)
def test_dust_not_sellable(self):
self.assertFalse(is_sellable_spot_coin(None, "ETH", 0.000004))
self.assertFalse(is_sellable_spot_coin(None, "BTC", 0.000002))
self.assertEqual(display_sellable_coin(None, "ETH", 0.000004), 0.0)
def test_above_min_sellable(self):
self.assertTrue(is_sellable_spot_coin(None, "ETH", 0.0001))
self.assertTrue(is_sellable_spot_coin(None, "BTC", 0.00001))
self.assertGreater(display_sellable_coin(None, "ETH", 0.001), 0)
class TestSellCapLogic(unittest.TestCase):
def test_excess_cap_math(self):
bought, keep = 0.01, 0.004
excess = max(0.0, bought - keep)
self.assertAlmostEqual(excess, 0.006)
sell_amt = min(0.02, excess)
self.assertAlmostEqual(sell_amt, 0.006)
if __name__ == "__main__":
unittest.main()