From 77fe0fa6792873fd0297271865328760309d8c23 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 7 Oct 2026 12:00:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=9F=E6=9D=83=E5=B8=81?= =?UTF-8?q?=E6=9C=AC=E4=BD=8D=E9=AB=98=E5=8D=B1=E7=BC=BA=E9=99=B7=EF=BC=9A?= =?UTF-8?q?=E5=BC=80=E4=BB=93=E4=BC=B0=E9=87=8F=E3=80=81=E5=8D=96=E5=9B=9E?= =?UTF-8?q?=E5=B0=81=E9=A1=B6=E3=80=81=E6=8F=A1=E5=BA=93=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=B8=8E=E6=9D=A1=E4=BB=B6=E5=8D=95=E5=B5=8C=E5=A5=97=E9=94=81?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/修复报告-高危-2026-10-07.md | 55 ++++++++ docs/审计报告-高危修复-2026-10-07.md | 68 ++++++++++ lib/hub/hub_bridge_register.py | 8 ++ lib/options/options_coin_open_lib.py | 170 +++++++++++++++++++++---- lib/options/options_cond_order_lib.py | 100 +++++++++++++-- lib/options/options_history_lib.py | 43 ++++--- lib/options/options_hub_lib.py | 5 +- lib/options/options_monitor_lib.py | 38 ++---- lib/options/options_positions_lib.py | 51 +++++--- lib/options/options_profit_exit_lib.py | 103 +++++++++------ lib/options/options_register.py | 5 +- lib/options/options_spot_bridge_lib.py | 119 ++++++++++++++--- lib/options/options_target_lib.py | 104 +++++++++------ lib/sim/broker_lib.py | 4 +- lib/sim/hooks.py | 13 +- tests/test_spot_dust_and_sell_cap.py | 39 ++++++ 16 files changed, 724 insertions(+), 201 deletions(-) create mode 100644 docs/修复报告-高危-2026-10-07.md create mode 100644 docs/审计报告-高危修复-2026-10-07.md create mode 100644 tests/test_spot_dust_and_sell_cap.py diff --git a/docs/修复报告-高危-2026-10-07.md b/docs/修复报告-高危-2026-10-07.md new file mode 100644 index 0000000..95b2cef --- /dev/null +++ b/docs/修复报告-高危-2026-10-07.md @@ -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,非本次高危范围) +- 粉尘仍会留在交易所账户内,仅程序忽略展示与重试 diff --git a/docs/审计报告-高危修复-2026-10-07.md b/docs/审计报告-高危修复-2026-10-07.md new file mode 100644 index 0000000..6801aec --- /dev/null +++ b/docs/审计报告-高危修复-2026-10-07.md @@ -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= 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` 拉取失败时用固定兜底,与交易所实际上调可能短暂不一致 | diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index 5c4e0e7..2f62148 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -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: diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index dea96a4..5883819 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -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: diff --git a/lib/options/options_cond_order_lib.py b/lib/options/options_cond_order_lib.py index 9358839..e64fc43 100644 --- a/lib/options/options_cond_order_lib.py +++ b/lib/options/options_cond_order_lib.py @@ -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() diff --git a/lib/options/options_history_lib.py b/lib/options/options_history_lib.py index eca8798..aac8d6c 100644 --- a/lib/options/options_history_lib.py +++ b/lib/options/options_history_lib.py @@ -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() diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 38df6b8..cd43471 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -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"]() diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index ecda50c..46f37b4 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -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: diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index ddab544..b96b537 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -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 diff --git a/lib/options/options_profit_exit_lib.py b/lib/options/options_profit_exit_lib.py index e006999..90b690c 100644 --- a/lib/options/options_profit_exit_lib.py +++ b/lib/options/options_profit_exit_lib.py @@ -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 diff --git a/lib/options/options_register.py b/lib/options/options_register.py index bc759d2..3537942 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -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 ""), diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index c157fe4..2aa7dcc 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -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 diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index 0f9cb9b..4d1f0e2 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -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 diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index cccf78d..01f1baa 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -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, diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index 1b3deb0..c9e9791 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -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) diff --git a/tests/test_spot_dust_and_sell_cap.py b/tests/test_spot_dust_and_sell_cap.py new file mode 100644 index 0000000..b0b4b19 --- /dev/null +++ b/tests/test_spot_dust_and_sell_cap.py @@ -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()