修复期权币本位高危缺陷:开仓估量、卖回封顶、握库超时与条件单嵌套锁。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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` 拉取失败时用固定兜底,与交易所实际上调可能短暂不一致 |
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
# 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
|
||||
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}
|
||||
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,6 +520,7 @@ def open_coin_option_buy_full(
|
||||
"fill": fill,
|
||||
}
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
@@ -502,8 +615,21 @@ 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:
|
||||
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
|
||||
@@ -513,8 +639,6 @@ def sweep_spot_after_options_flat(
|
||||
if not inst:
|
||||
continue
|
||||
live_uly.add(inst.split("-")[0].upper())
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"持仓查询失败: {e}"}
|
||||
|
||||
# 先关掉只剩粉尘的 pending 桥,避免拦开仓又看不到重试按钮
|
||||
try:
|
||||
|
||||
@@ -668,13 +668,19 @@ 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:
|
||||
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾."""
|
||||
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.
|
||||
|
||||
推荐传 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
|
||||
@@ -682,11 +688,7 @@ def run_cond_orders_tick(
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return
|
||||
orders = list_cond_orders(conn, active_only=True)
|
||||
if not orders:
|
||||
return
|
||||
|
||||
for order in orders:
|
||||
for order in list_cond_orders(conn, active_only=True):
|
||||
try:
|
||||
_tick_one(
|
||||
conn,
|
||||
@@ -695,6 +697,7 @@ def run_cond_orders_tick(
|
||||
order,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
get_db=None,
|
||||
)
|
||||
except Exception as e:
|
||||
try:
|
||||
@@ -705,6 +708,60 @@ def run_cond_orders_tick(
|
||||
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,
|
||||
cfg,
|
||||
ex,
|
||||
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(
|
||||
"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
|
||||
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()
|
||||
|
||||
@@ -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,7 +78,18 @@ 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
|
||||
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,
|
||||
@@ -92,8 +105,6 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
except (TypeError, ValueError):
|
||||
open_ms = None
|
||||
items.append(format_live_option_history_row(row, open_ms=open_ms))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
hist_raw = fetch_all_option_positions_history(ex, limit=200)
|
||||
for raw in hist_raw:
|
||||
|
||||
@@ -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"]()
|
||||
|
||||
@@ -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,17 +536,10 @@ 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()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
cond_order_tick_fn(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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,12 +591,6 @@ 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
|
||||
|
||||
|
||||
@@ -212,19 +212,39 @@ 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
|
||||
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,
|
||||
@@ -235,8 +255,7 @@ def build_display_option_positions(
|
||||
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)
|
||||
finally:
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
@@ -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,38 +310,62 @@ 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":
|
||||
# 平仓/卖币会另开连接写库,先提交释放锁(模拟盘尤其敏感)
|
||||
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)
|
||||
result = close_fn(inst_id)
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
_mark_state(conn, inst_id, "done")
|
||||
if get_db is not None:
|
||||
try:
|
||||
conn.execute(
|
||||
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
|
||||
@@ -351,15 +375,12 @@ def run_options_profit_exits(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_commit(conn)
|
||||
_commit(c)
|
||||
else:
|
||||
_mark_state(conn, inst_id, "closing")
|
||||
_commit(conn)
|
||||
continue
|
||||
|
||||
if not info.get("profit_exit_enabled"):
|
||||
continue
|
||||
_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
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user