diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index 48db734..7853160 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -1,479 +1,473 @@ -"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回.""" -from __future__ import annotations - -import os -import time -from typing import Any - -from lib.exchange.okx_options_lib import ( - cap_option_buy_sheets_to_ask_depth, - option_buy_liquidity_ok, - td_mode_for_option_buy, - wait_option_order_full_fill, -) -from lib.options.options_margin_mode_lib import ( - calc_sheets_from_coin_balance, - compute_coin_budget_usdt, - is_coin_margin_mode, - margin_mode_from_inst_id, - normalize_options_margin_mode, - plan_coin_open_by_budget, - premium_ccy_for_mode, -) -from lib.options.options_spot_bridge_lib import ( - BRIDGE_BOUGHT, - BRIDGE_HOLDING, - bridge_blocks_new_open_msg, - fetch_trading_coin_available, - insert_bridge, - rollback_bought_coin_to_usdt, - sell_residual_after_option_flat, - spot_market_buy_coin_with_usdt, - update_bridge, -) - - -def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: - from lib.exchange.okx_options_lib import fetch_options_balances - - bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True) - trading = bal.get("trading_usdt_avail") - if trading is None: - trading = bal.get("trading_usdt") - try: - trading_f = float(trading or 0) - except (TypeError, ValueError): - trading_f = 0.0 - buf = float(cfg.get("budget_buffer") or 0.95) - return compute_coin_budget_usdt(trading_f, buffer=buf) - - -def open_coin_option_buy_full( - cfg: dict[str, Any], - ex: Any, - *, - inst_id: str, - signal_note: str = "", - target_index: float | None = None, - profit_exit_enabled: bool = False, - profit_exit_mult: float = 1.0, - target_sheets: int | None = None, -) -> dict[str, Any]: - """先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算).""" - from lib.options.options_db import init_options_tables - from lib.options.options_position_limit_lib import ( - compound_full_single_position_block_msg, - option_position_limit_block_msg, - ) - - if not is_coin_margin_mode(): - return {"ok": False, "msg": "当前非币本位模式"} - if margin_mode_from_inst_id(inst_id) != "coin": - return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"} - - # 解析标的 - parts = inst_id.split("-") - underlying = (parts[0] if parts else "ETH").upper() - - conn = cfg["get_db"]() - try: - init_options_tables(conn) - block = bridge_blocks_new_open_msg(conn) - if block: - return {"ok": False, "msg": block, "can_open": False} - - compound_block = compound_full_single_position_block_msg( - ex, fetch_positions=cfg.get("fetch_option_positions") - ) - if compound_block: - return {"ok": False, "msg": compound_block, "can_open": False} - pos_limit_msg = option_position_limit_block_msg( - ex, - opening_inst_id=inst_id, - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return {"ok": False, "msg": pos_limit_msg, "can_open": False} - - budget_info = coin_budget_preview(cfg, ex) - if not budget_info.get("ok"): - return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info} - budget_usdt = float(budget_info["budget_usdt"]) - - q = cfg["quote_option_contract"](ex, inst_id) - if not q.get("ok"): - return q - ask = q.get("ask") - ask_sz = q.get("ask_sz") - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - if not can_open: - return { - "ok": False, - "msg": block_msg or "暂无卖一深度,无法买入", - "can_open": False, - } - - ct_mult = float(q.get("ct_mult") or 0.01) - min_sz = int(q.get("min_sz") or 1) - idx = None - try: - idx = float(q.get("index_px") or q.get("idxPx") or 0) - except (TypeError, ValueError): - idx = 0.0 - if idx <= 0: - try: - from lib.exchange.okx_options_lib import fetch_index_price - - idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0) - except Exception: - idx = 0.0 - - plan = plan_coin_open_by_budget( - quote_per_unit=float(ask), - ct_mult=ct_mult, - min_sz=min_sz, - budget_usdt=budget_usdt, - index_px=float(idx), - ask_sz=ask_sz, - target_sheets=target_sheets, - ) - if not plan.get("ok"): - return { - "ok": False, - "msg": plan.get("msg") or "无法规划买币张数", - "plan": plan, - "budget": budget_info, - "can_open": False, - } - buy_usdt = float(plan["buy_usdt"]) - sheets = int(plan["sheets"]) - - # 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算 - coin_before = fetch_trading_coin_available(ex, underlying) or 0.0 - buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt) - if not buy.get("ok"): - return { - "ok": False, - "msg": f"现货买入 {underlying} 失败: {buy.get('msg')}", - "budget": budget_info, - "plan": plan, - } - bridge_id = insert_bridge( - conn, - underlying=underlying, - status=BRIDGE_BOUGHT, - budget_usdt=buy_usdt, - buy_ord_id=str(buy.get("ord_id") or ""), - inst_id=inst_id, - message="已买币,待开期权", - ) - # 等余额落账 - time.sleep(1.5) - try: - from lib.exchange.okx_options_lib import invalidate_options_balance_cache - - invalidate_options_balance_cache() - except Exception: - pass - coin_after = fetch_trading_coin_available(ex, underlying) - if coin_after is None: - rb = rollback_bought_coin_to_usdt( - conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额" - ) - return { - "ok": False, - "msg": "买币后读不到可用余额,已尝试卖回 USDT", - "rollback": rb, - "budget": budget_info, - "plan": plan, - } - 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 = rollback_bought_coin_to_usdt( - conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效" - ) - return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info} - update_bridge(conn, bridge_id, coin_bought=float(coin_bought)) - - sizing = calc_sheets_from_coin_balance( - quote_per_unit=float(ask), - ct_mult=ct_mult, - min_sz=min_sz, - coin_available=float(coin_bought), - ) - if not sizing.get("ok"): - rb = rollback_bought_coin_to_usdt( - conn, - ex, - bridge_id=bridge_id, - underlying=underlying, - reason=sizing.get("msg") or "张数不足", - coin_amount=float(coin_bought), - ) - return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan} - - # 实盘以买到的币为准,但不超过规划张数 - sheets = min(int(sizing["sheets"]), int(plan["sheets"])) - capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) - if capped is None: - rb = rollback_bought_coin_to_usdt( - conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足" - ) - return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb} - if capped < sheets: - sheets = int(capped) - sizing = { - "ok": True, - "sheets": sheets, - "eth_amount": round(sheets * ct_mult, 8), - "coin_premium": round(sheets * float(ask) * ct_mult, 8), - "ask_depth_capped": True, - } - else: - sizing = { - "ok": True, - "sheets": sheets, - "eth_amount": round(sheets * ct_mult, 8), - "coin_premium": round(sheets * float(ask) * ct_mult, 8), - } - - tick_sz = q.get("tick_sz") - order = cfg["place_option_limit_order"]( - ex, - inst_id=inst_id, - side="buy", - sheets=sheets, - price=float(ask), - td_mode=td_mode_for_option_buy(cfg.get("td_mode")), - tick_sz=tick_sz, - ord_type="ioc", - ) - # 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚) - if (not order.get("ok")) and sheets > 1: - msg_l = str(order.get("msg") or "").lower() - if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""): - sheets2 = max(1, sheets // 2) - if sheets2 < sheets: - order2 = cfg["place_option_limit_order"]( - ex, - inst_id=inst_id, - side="buy", - sheets=sheets2, - price=float(ask), - td_mode=td_mode_for_option_buy(cfg.get("td_mode")), - tick_sz=tick_sz, - ord_type="ioc", - ) - if order2.get("ok"): - order = order2 - sheets = sheets2 - sizing = { - "ok": True, - "sheets": sheets, - "eth_amount": round(sheets * ct_mult, 8), - "coin_premium": round(sheets * float(ask) * ct_mult, 8), - "retried_half": True, - } - if not order.get("ok"): - rb = rollback_bought_coin_to_usdt( - conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败" - ) - return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb} - - ord_id = str((order.get("data") or {}).get("ordId") or "").strip() - if not ord_id: - rb = rollback_bought_coin_to_usdt( - conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号" - ) - return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb} - - try: - fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) - except (TypeError, ValueError): - fill_timeout = 12.0 - fill = wait_option_order_full_fill( - ex, - inst_id=inst_id, - ord_id=ord_id, - need_sheets=int(sheets), - timeout_sec=fill_timeout, - cancel_on_timeout=True, - ) - if not fill.get("ok"): - filled_n = int(fill.get("filled_sheets") or 0) - if filled_n <= 0: - rb = rollback_bought_coin_to_usdt( - conn, - ex, - bridge_id=bridge_id, - underlying=underlying, - reason=fill.get("msg") or "未成交", - ) - return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb} - sheets = filled_n - - eth_amount = round(float(sheets) * ct_mult, 8) - premium_paid = round(float(ask) * eth_amount, 8) - premium_ccy = premium_ccy_for_mode("coin", underlying) - - update_bridge( - conn, - bridge_id, - status=BRIDGE_HOLDING, - inst_id=inst_id, - message="期权持仓中", - ) - - trade_id = _insert_coin_trade( - conn, - inst_id=inst_id, - underlying=underlying, - opt_type=str(q.get("opt_type") or ""), - strike=q.get("strike"), - exp_time=q.get("exp_time"), - sheets=int(sheets), - eth_amount=eth_amount, - open_quote=float(ask), - premium_paid=premium_paid, - signal_note=signal_note, - exchange_ord_id=ord_id, - bridge_id=bridge_id, - budget_usdt=buy_usdt, - premium_ccy=premium_ccy, - profit_exit_enabled=profit_exit_enabled, - profit_exit_mult=profit_exit_mult, - ) - - # 目标位 / 翻倍离场 — 复用现有逻辑若存在 - try: - if target_index is not None: - from lib.options.options_target_lib import upsert_target_monitor - - upsert_target_monitor( - conn, - inst_id=inst_id, - underlying=underlying, - opt_type=str(q.get("opt_type") or ""), - target_index=float(target_index), - trade_id=trade_id, - sheets=int(sheets), - ) - except Exception: - pass - try: - from lib.options.options_notify_lib import notify_options_open - - notify_options_open( - cfg, - conn, - trade_id=trade_id, - inst_id=inst_id, - underlying=underlying, - opt_type=str(q.get("opt_type") or ""), - sheets=int(sheets), - premium_paid=premium_paid, - open_quote=float(ask), - target_index=target_index, - signal_note=signal_note, - ) - except Exception: - pass - - return { - "ok": True, - "msg": f"币本位开仓成功 {sheets} 张", - "margin_mode": "coin", - "budget": budget_info, - "sizing": sizing, - "sheets": sheets, - "eth_amount": eth_amount, - "premium_paid": premium_paid, - "premium_ccy": premium_ccy, - "bridge_id": bridge_id, - "trade_id": trade_id, - "order": order, - "fill": fill, - } - finally: - try: - conn.close() - except Exception: - pass - - -def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: - pe = 1 if kwargs.get("profit_exit_enabled") else 0 - pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) - pe_state = "active" if pe else "idle" - cur = conn.execute( - """ - INSERT INTO options_trades( - inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, - open_quote, premium_paid, status, signal_note, exchange_ord_id, - margin_mode, premium_ccy, bridge_id, budget_usdt, - profit_exit_enabled, profit_exit_mult, profit_exit_state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?) - """, - ( - kwargs["inst_id"], - kwargs["underlying"], - kwargs["opt_type"], - kwargs.get("strike"), - str(kwargs.get("exp_time") or ""), - kwargs["sheets"], - kwargs["eth_amount"], - kwargs.get("open_quote"), - kwargs.get("premium_paid"), - kwargs.get("signal_note") or "", - kwargs.get("exchange_ord_id"), - kwargs.get("premium_ccy") or "ETH", - kwargs.get("bridge_id"), - kwargs.get("budget_usdt"), - pe, - pe_mult, - pe_state, - ), - ) - conn.commit() - return int(cur.lastrowid) - - -def maybe_sell_spot_after_close( - cfg: dict[str, Any], - ex: Any, - *, - inst_id: str, - close_result: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.""" - if margin_mode_from_inst_id(inst_id) != "coin": - return None - # 仍有仓则不卖 - try: - rows = cfg["fetch_option_positions"](ex) or [] - for p in rows: - if str(p.get("instId") or p.get("inst_id") or "") != inst_id: - continue - try: - if abs(float(p.get("pos") or 0)) > 1e-12: - return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} - except (TypeError, ValueError): - pass - except Exception: - pass - parts = inst_id.split("-") - underlying = (parts[0] if parts else "ETH").upper() - conn = cfg["get_db"]() - try: - from lib.options.options_db import init_options_tables - - init_options_tables(conn) - return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) - finally: - try: - conn.close() - except Exception: - pass +"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回.""" +from __future__ import annotations + +import os +import time +from typing import Any + +from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, + wait_option_order_full_fill, +) +from lib.options.options_margin_mode_lib import ( + calc_sheets_from_coin_balance, + compute_coin_budget_usdt, + is_coin_margin_mode, + margin_mode_from_inst_id, + normalize_options_margin_mode, + plan_coin_open_by_budget, + premium_ccy_for_mode, +) +from lib.options import options_spot_bridge_lib as _spot_bridge +from lib.options.options_spot_bridge_lib import ( + BRIDGE_BOUGHT, + BRIDGE_HOLDING, +) + + +def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import fetch_options_balances + + bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True) + trading = bal.get("trading_usdt_avail") + if trading is None: + trading = bal.get("trading_usdt") + try: + trading_f = float(trading or 0) + except (TypeError, ValueError): + trading_f = 0.0 + buf = float(cfg.get("budget_buffer") or 0.95) + return compute_coin_budget_usdt(trading_f, buffer=buf) + + +def open_coin_option_buy_full( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + signal_note: str = "", + target_index: float | None = None, + profit_exit_enabled: bool = False, + profit_exit_mult: float = 1.0, + target_sheets: int | None = None, +) -> dict[str, Any]: + """先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算).""" + from lib.options.options_db import init_options_tables + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if not is_coin_margin_mode(): + return {"ok": False, "msg": "当前非币本位模式"} + if margin_mode_from_inst_id(inst_id) != "coin": + return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"} + + # 解析标的 + parts = inst_id.split("-") + underlying = (parts[0] if parts else "ETH").upper() + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + block = _spot_bridge.bridge_blocks_new_open_msg(conn) + if block: + return {"ok": False, "msg": block, "can_open": False} + + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return {"ok": False, "msg": compound_block, "can_open": False} + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return {"ok": False, "msg": pos_limit_msg, "can_open": False} + + budget_info = coin_budget_preview(cfg, ex) + if not budget_info.get("ok"): + return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info} + budget_usdt = float(budget_info["budget_usdt"]) + + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return q + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "can_open": False, + } + + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + idx = None + try: + idx = float(q.get("index_px") or q.get("idxPx") or 0) + except (TypeError, ValueError): + idx = 0.0 + if idx <= 0: + try: + from lib.exchange.okx_options_lib import fetch_index_price + + idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0) + except Exception: + idx = 0.0 + + plan = plan_coin_open_by_budget( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + budget_usdt=budget_usdt, + index_px=float(idx), + ask_sz=ask_sz, + target_sheets=target_sheets, + ) + if not plan.get("ok"): + return { + "ok": False, + "msg": plan.get("msg") or "无法规划买币张数", + "plan": plan, + "budget": budget_info, + "can_open": False, + } + buy_usdt = float(plan["buy_usdt"]) + sheets = int(plan["sheets"]) + + # 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算 + coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0 + buy = _spot_bridge.spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt) + if not buy.get("ok"): + return { + "ok": False, + "msg": f"现货买入 {underlying} 失败: {buy.get('msg')}", + "budget": budget_info, + "plan": plan, + } + bridge_id = _spot_bridge.insert_bridge( + conn, + underlying=underlying, + status=BRIDGE_BOUGHT, + budget_usdt=buy_usdt, + buy_ord_id=str(buy.get("ord_id") or ""), + inst_id=inst_id, + message="已买币,待开期权", + ) + # 等余额落账 + time.sleep(1.5) + try: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + + invalidate_options_balance_cache() + except Exception: + pass + coin_after = _spot_bridge.fetch_trading_coin_available(ex, underlying) + if coin_after is None: + rb = _spot_bridge.rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额" + ) + return { + "ok": False, + "msg": "买币后读不到可用余额,已尝试卖回 USDT", + "rollback": rb, + "budget": budget_info, + "plan": plan, + } + 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} + _spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought)) + + sizing = calc_sheets_from_coin_balance( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + coin_available=float(coin_bought), + ) + if not sizing.get("ok"): + rb = _spot_bridge.rollback_bought_coin_to_usdt( + conn, + ex, + bridge_id=bridge_id, + underlying=underlying, + reason=sizing.get("msg") or "张数不足", + coin_amount=float(coin_bought), + ) + return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan} + + # 实盘以买到的币为准,但不超过规划张数 + sheets = min(int(sizing["sheets"]), int(plan["sheets"])) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + rb = _spot_bridge.rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足" + ) + return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb} + if capped < sheets: + sheets = int(capped) + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + "ask_depth_capped": True, + } + else: + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + } + + tick_sz = q.get("tick_sz") + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg.get("td_mode")), + tick_sz=tick_sz, + ord_type="ioc", + ) + # 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚) + if (not order.get("ok")) and sheets > 1: + msg_l = str(order.get("msg") or "").lower() + if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""): + sheets2 = max(1, sheets // 2) + if sheets2 < sheets: + order2 = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets2, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg.get("td_mode")), + tick_sz=tick_sz, + ord_type="ioc", + ) + if order2.get("ok"): + order = order2 + sheets = sheets2 + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + "retried_half": True, + } + if not order.get("ok"): + rb = _spot_bridge.rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败" + ) + return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb} + + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + rb = _spot_bridge.rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号" + ) + return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb} + + try: + fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + fill_timeout = 12.0 + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=int(sheets), + timeout_sec=fill_timeout, + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + if filled_n <= 0: + rb = _spot_bridge.rollback_bought_coin_to_usdt( + conn, + ex, + bridge_id=bridge_id, + underlying=underlying, + reason=fill.get("msg") or "未成交", + ) + return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb} + sheets = filled_n + + eth_amount = round(float(sheets) * ct_mult, 8) + premium_paid = round(float(ask) * eth_amount, 8) + premium_ccy = premium_ccy_for_mode("coin", underlying) + + _spot_bridge.update_bridge( + conn, + bridge_id, + status=BRIDGE_HOLDING, + inst_id=inst_id, + message="期权持仓中", + ) + + trade_id = _insert_coin_trade( + conn, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + strike=q.get("strike"), + exp_time=q.get("exp_time"), + sheets=int(sheets), + eth_amount=eth_amount, + open_quote=float(ask), + premium_paid=premium_paid, + signal_note=signal_note, + exchange_ord_id=ord_id, + bridge_id=bridge_id, + budget_usdt=buy_usdt, + premium_ccy=premium_ccy, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + ) + + # 目标位 / 翻倍离场 — 复用现有逻辑若存在 + try: + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + upsert_target_monitor( + conn, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + target_index=float(target_index), + trade_id=trade_id, + sheets=int(sheets), + ) + except Exception: + pass + try: + from lib.options.options_notify_lib import notify_options_open + + notify_options_open( + cfg, + conn, + trade_id=trade_id, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + sheets=int(sheets), + premium_paid=premium_paid, + open_quote=float(ask), + target_index=target_index, + signal_note=signal_note, + ) + except Exception: + pass + + return { + "ok": True, + "msg": f"币本位开仓成功 {sheets} 张", + "margin_mode": "coin", + "budget": budget_info, + "sizing": sizing, + "sheets": sheets, + "eth_amount": eth_amount, + "premium_paid": premium_paid, + "premium_ccy": premium_ccy, + "bridge_id": bridge_id, + "trade_id": trade_id, + "order": order, + "fill": fill, + } + finally: + try: + conn.close() + except Exception: + pass + + +def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: + pe = 1 if kwargs.get("profit_exit_enabled") else 0 + pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) + pe_state = "active" if pe else "idle" + cur = conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, signal_note, exchange_ord_id, + margin_mode, premium_ccy, bridge_id, budget_usdt, + profit_exit_enabled, profit_exit_mult, profit_exit_state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?) + """, + ( + kwargs["inst_id"], + kwargs["underlying"], + kwargs["opt_type"], + kwargs.get("strike"), + str(kwargs.get("exp_time") or ""), + kwargs["sheets"], + kwargs["eth_amount"], + kwargs.get("open_quote"), + kwargs.get("premium_paid"), + kwargs.get("signal_note") or "", + kwargs.get("exchange_ord_id"), + kwargs.get("premium_ccy") or "ETH", + kwargs.get("bridge_id"), + kwargs.get("budget_usdt"), + pe, + pe_mult, + pe_state, + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def maybe_sell_spot_after_close( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + close_result: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.""" + if margin_mode_from_inst_id(inst_id) != "coin": + return None + # 仍有仓则不卖 + try: + rows = cfg["fetch_option_positions"](ex) or [] + for p in rows: + if str(p.get("instId") or p.get("inst_id") or "") != inst_id: + continue + try: + if abs(float(p.get("pos") or 0)) > 1e-12: + return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} + except (TypeError, ValueError): + pass + except Exception: + pass + parts = inst_id.split("-") + underlying = (parts[0] if parts else "ETH").upper() + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + return _spot_bridge.sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + finally: + try: + conn.close() + except Exception: + pass diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 27caa53..d0e4523 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -64,6 +64,19 @@ def _contract_size(exchange: Any, symbol: str) -> float: return 1.0 +def _premium_ccy_for_inst(inst_id: str) -> str: + try: + from lib.options.options_margin_mode_lib import ( + margin_mode_from_inst_id, + premium_ccy_for_mode, + ) + + underly = (str(inst_id or "").split("-")[0] or "ETH").upper() + return premium_ccy_for_mode(margin_mode_from_inst_id(inst_id), underly) + except Exception: + return "USDC" + + class SimBroker: def __init__(self, get_db: Callable) -> None: self.get_db = get_db @@ -377,9 +390,10 @@ class SimBroker: qty = n * ct_mult pr = option_fill(action="open", bid=bid, ask=ask, qty=qty, fee_rate=fr) cost = pr.notional + pr.fee + prem_ccy = _premium_ccy_for_inst(inst_id) try: self.wallets.debit_trading( - "USDC", + prem_ccy, cost, kind="option_open", note=f"buy {inst_id} x{n}@{pr.fill_px}", @@ -490,8 +504,9 @@ class SimBroker: conn.close() if credit > 0: + prem_ccy = _premium_ccy_for_inst(inst_id) self.wallets.credit_trading( - "USDC", + prem_ccy, credit, kind="option_close", note=f"sell {inst_id} x{close_n}@{pr.fill_px}", @@ -552,6 +567,67 @@ class SimBroker: ) return result + def convert_usdt_coin( + self, + exchange: Any, + *, + underlying: str, + direction: str, + amount: float, + fee_rate: float | None = None, + account: str = "trading", + ) -> dict[str, Any]: + """模拟 ETH/BTC-USDT 现货市价兑换(交易账户).""" + from lib.options.options_margin_mode_lib import spot_quote_inst_id + from lib.sim.pricing_lib import spot_coin_usdt_fill + + coin = (underlying or "ETH").strip().upper() or "ETH" + if coin not in ("ETH", "BTC"): + return {"ok": False, "msg": f"不支持标的 {coin}"} + # spot_quote_inst_id → ETH-USDT;ccxt 常用 ETH/USDT + inst = spot_quote_inst_id(coin) + symbol = inst.replace("-", "/") if inst else f"{coin}/USDT" + fr = sim_fee_rate(fee_rate) + bid, ask = _ticker_bid_ask(exchange, symbol) + fill = spot_coin_usdt_fill( + direction=direction, + amount=float(amount), + bid=bid, + ask=ask, + fee_rate=fr, + coin=coin, + ) + result = SimWallets(self.get_db).convert( + from_ccy=fill.from_ccy, + to_ccy=fill.to_ccy, + amount=fill.from_amount, + account=account or "trading", + to_amount=fill.to_amount, + rate=fill.fill_px, + fee=fill.fee, + note=f"{symbol} mkt {fill.fill_px:.4f} (bid {bid:.4f}/ask {ask:.4f})", + ) + if not result.get("ok"): + return result + result.update( + { + "direction": fill.direction, + "underlying": coin, + "bid": bid, + "ask": ask, + "base_px": fill.base_px, + "fill_px": fill.fill_px, + "fee_rate": fr, + "symbol": symbol, + "inst_id": inst, + "coin_bought": fill.to_amount if fill.direction == "usdt_to_coin" else None, + "coin_sold": fill.from_amount if fill.direction == "coin_to_usdt" else None, + "usdt_spent": fill.from_amount if fill.direction == "usdt_to_coin" else None, + "usdt_recovered": fill.to_amount if fill.direction == "coin_to_usdt" else None, + } + ) + return result + def _index_px_for_option( self, exchange: Any, @@ -645,6 +721,14 @@ class SimBroker: else: intrinsic_u = 0.0 + prem_ccy = _premium_ccy_for_inst(inst_id) + # 币本位到期兑付用币数量: 实值/指数 × 张数 × 乘数 + if prem_ccy in ("ETH", "BTC") and float(spot) > 0: + settle_recv = round( + max(0.0, (intrinsic_u / float(spot)) * sheets * ct_mult), + 8, + ) + conn = self.get_db() try: conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,)) @@ -681,7 +765,7 @@ class SimBroker: if settle_recv > 1e-12: self.wallets.credit_trading( - "USDC", + prem_ccy, settle_recv, kind="option_expiry", note=f"expiry settle {inst_id} @{spot:g} recv={settle_recv}", diff --git a/lib/sim/db_lib.py b/lib/sim/db_lib.py index 6548d65..9d0b7a4 100644 --- a/lib/sim/db_lib.py +++ b/lib/sim/db_lib.py @@ -14,6 +14,19 @@ def _env_float(key: str, default: float) -> float: return float(default) +def _ensure_sim_wallet_coin_columns(conn: sqlite3.Connection) -> None: + """旧库补齐币本位 ETH/BTC 列.""" + cols = { + str(r[1]) + for r in conn.execute("PRAGMA table_info(sim_wallets)").fetchall() + } + for col in ("funding_eth", "trading_eth", "funding_btc", "trading_btc"): + if col not in cols: + conn.execute( + f"ALTER TABLE sim_wallets ADD COLUMN {col} REAL NOT NULL DEFAULT 0" + ) + + def init_sim_tables(conn: sqlite3.Connection) -> None: conn.execute( """ @@ -23,10 +36,15 @@ def init_sim_tables(conn: sqlite3.Connection) -> None: trading_usdt REAL NOT NULL DEFAULT 0, funding_usdc REAL NOT NULL DEFAULT 0, trading_usdc REAL NOT NULL DEFAULT 0, + funding_eth REAL NOT NULL DEFAULT 0, + trading_eth REAL NOT NULL DEFAULT 0, + funding_btc REAL NOT NULL DEFAULT 0, + trading_btc REAL NOT NULL DEFAULT 0, updated_at TEXT ) """ ) + _ensure_sim_wallet_coin_columns(conn) conn.execute( """ CREATE TABLE IF NOT EXISTS sim_perp_positions ( diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index 3d755e6..66e1cbe 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -192,10 +192,10 @@ def _patch_okx_options_lib(app_module: Any) -> None: if _GET_DB is not None and is_sim_mode(_GET_DB): w = broker().balances_header() return ( - round(float(w["trading_usdc"]), 2), - round(float(w["funding_usdc"]), 2), - round(float(w["funding_usdt"]), 2), - round(float(w["trading_usdt"]), 2), + round(float(w.get("trading_usdc") or 0), 2), + round(float(w.get("funding_usdc") or 0), 2), + round(float(w.get("funding_usdt") or 0), 2), + round(float(w.get("trading_usdt") or 0), 2), ) except Exception: pass @@ -207,20 +207,32 @@ def _patch_okx_options_lib(app_module: Any) -> None: try: if _GET_DB is not None and is_sim_mode(_GET_DB): w = broker().balances_header() - fu = float(w["funding_usdt"]) - fc = float(w["funding_usdc"]) - tu = float(w["trading_usdt"]) - tc = float(w["trading_usdc"]) + fu = float(w.get("funding_usdt") or 0) + fc = float(w.get("funding_usdc") or 0) + tu = float(w.get("trading_usdt") or 0) + tc = float(w.get("trading_usdc") or 0) + fe = float(w.get("funding_eth") or 0) + te = float(w.get("trading_eth") or 0) + fb = float(w.get("funding_btc") or 0) + tb = float(w.get("trading_btc") or 0) return { "scope": "main", "funding_usdt": fu, "funding_usdc": fc, "trading_usdt": tu, "trading_usdc": tc, + "funding_eth": fe, + "trading_eth": te, + "funding_btc": fb, + "trading_btc": tb, "funding_usdt_avail": fu, "funding_usdc_avail": fc, "trading_usdt_avail": tu, "trading_usdc_avail": tc, + "funding_eth_avail": fe, + "trading_eth_avail": te, + "funding_btc_avail": fb, + "trading_btc_avail": tb, } except Exception: pass @@ -355,6 +367,130 @@ def _patch_okx_options_lib(app_module: Any) -> None: opt_lib.fetch_option_positions = fetch_option_positions opt_lib._sim_hooks_applied = True + _patch_spot_bridge_lib() + + +def _patch_spot_bridge_lib() -> None: + """币本位现货桥:模拟盘走本地 USDT↔ETH/BTC,勿打实盘 private_post_trade_order.""" + import lib.options.options_spot_bridge_lib as bridge_lib + + if getattr(bridge_lib, "_sim_hooks_applied", False): + return + + _orig_buy = bridge_lib.spot_market_buy_coin_with_usdt + _orig_sell = bridge_lib.spot_market_sell_coin_to_usdt + _orig_avail = bridge_lib.fetch_trading_coin_available + + def fetch_trading_coin_available(ex, ccy: str): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + ccy_u = (ccy or "").strip().upper() + if ccy_u not in ("ETH", "BTC"): + return None + w = broker().balances_header() + return float(w.get(f"trading_{ccy_u.lower()}") or 0) + except Exception: + pass + return _orig_avail(ex, ccy) + + def spot_market_buy_coin_with_usdt(ex, *, underlying: str, usdt_amount: float): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + pub = ex + if pub is None and _APP_MODULE is not None: + pub = getattr(_APP_MODULE, "exchange", None) or getattr( + _APP_MODULE, "exchange_options", None + ) + if pub is None: + return {"ok": False, "msg": "sim: 无公开行情 exchange"} + result = broker().convert_usdt_coin( + pub, + underlying=underlying, + direction="usdt_to_coin", + amount=float(usdt_amount), + account="trading", + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("detail") or result.get("msg") or "买币失败", + } + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + return { + "ok": True, + "inst_id": result.get("inst_id") or "", + "ord_id": f"sim-spot-buy-{result.get('fill_px') or 0}", + "sim": True, + "coin_bought": result.get("coin_bought"), + "data": {"sCode": "0", "sMsg": "sim filled"}, + **{k: result[k] for k in ("fill_px", "usdt_spent", "underlying") if k in result}, + } + except Exception as e: + return {"ok": False, "msg": str(e)} + return _orig_buy(ex, underlying=underlying, usdt_amount=usdt_amount) + + def spot_market_sell_coin_to_usdt(ex, *, underlying: str, coin_amount: float | None = None): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + coin = (underlying or "ETH").strip().upper() or "ETH" + amt = coin_amount + if amt is None or float(amt) <= 0: + amt = fetch_trading_coin_available(ex, coin) + if amt is None or float(amt) <= 0: + return {"ok": False, "msg": f"交易账户无可用 {coin}"} + sell_sz = float(amt) + if sell_sz > 1e-8: + sell_sz = max(0.0, sell_sz * 0.999) + if sell_sz <= 0: + return {"ok": False, "msg": f"{coin} 可卖数量过小"} + pub = ex + if pub is None and _APP_MODULE is not None: + pub = getattr(_APP_MODULE, "exchange", None) or getattr( + _APP_MODULE, "exchange_options", None + ) + if pub is None: + return {"ok": False, "msg": "sim: 无公开行情 exchange"} + result = broker().convert_usdt_coin( + pub, + underlying=coin, + direction="coin_to_usdt", + amount=float(sell_sz), + account="trading", + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("detail") or result.get("msg") or "卖币失败", + } + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + return { + "ok": True, + "inst_id": result.get("inst_id") or "", + "ord_id": f"sim-spot-sell-{result.get('fill_px') or 0}", + "coin_sold": float(sell_sz), + "sim": True, + "usdt_recovered": result.get("usdt_recovered"), + "data": {"sCode": "0", "sMsg": "sim filled"}, + } + except Exception as e: + return {"ok": False, "msg": str(e)} + return _orig_sell(ex, underlying=underlying, coin_amount=coin_amount) + + bridge_lib.fetch_trading_coin_available = fetch_trading_coin_available + bridge_lib.spot_market_buy_coin_with_usdt = spot_market_buy_coin_with_usdt + bridge_lib.spot_market_sell_coin_to_usdt = spot_market_sell_coin_to_usdt + bridge_lib._sim_hooks_applied = True + def wrap_option_place_fns(get_db: Callable, live_place_limit, live_place_market): """返回按模式分流的 place_option_limit/market.""" diff --git a/lib/sim/pricing_lib.py b/lib/sim/pricing_lib.py index 83d2939..a5e2255 100644 --- a/lib/sim/pricing_lib.py +++ b/lib/sim/pricing_lib.py @@ -145,3 +145,58 @@ def spot_usdc_usdt_fill( fee=fee, ) raise ValueError("direction 须为 usdt_to_usdc 或 usdc_to_usdt") + + +def spot_coin_usdt_fill( + *, + direction: str, + amount: float, + bid: float, + ask: float, + fee_rate: float, + coin: str = "ETH", +) -> SpotConvertResult: + """ + 对齐实盘 ETH-USDT / BTC-USDT 现货市价: + - usdt_to_coin: 用 USDT 买币, 吃卖一 ×(1+f); amount=USDT + - coin_to_usdt: 卖币换 USDT, 吃买一 ×(1-f); amount=币数量 + """ + f = float(fee_rate) + amt = float(amount) + ccy = (coin or "ETH").strip().upper() or "ETH" + d = (direction or "").strip().lower() + if d in ("usdt_to_coin", "usdt_to_eth", "usdt_to_btc"): + base = float(ask) + fill = base * (1.0 + f) + if fill <= 0: + raise ValueError("无效卖一价") + to_amt = amt / fill + fee = amt * f + return SpotConvertResult( + direction="usdt_to_coin", + from_ccy="USDT", + to_ccy=ccy, + from_amount=amt, + to_amount=to_amt, + base_px=base, + fill_px=fill, + fee=fee, + ) + if d in ("coin_to_usdt", "eth_to_usdt", "btc_to_usdt"): + base = float(bid) + fill = base * (1.0 - f) + if fill <= 0: + raise ValueError("无效买一价") + to_amt = amt * fill + fee = to_amt * f + return SpotConvertResult( + direction="coin_to_usdt", + from_ccy=ccy, + to_ccy="USDT", + from_amount=amt, + to_amount=to_amt, + base_px=base, + fill_px=fill, + fee=fee, + ) + raise ValueError("direction 须为 usdt_to_coin 或 coin_to_usdt") diff --git a/lib/sim/wallets_lib.py b/lib/sim/wallets_lib.py index 8c729f6..b5800ac 100644 --- a/lib/sim/wallets_lib.py +++ b/lib/sim/wallets_lib.py @@ -1,4 +1,4 @@ -"""模拟资金钱包: funding/trading × USDT/USDC.""" +"""模拟资金钱包: funding/trading × USDT/USDC/ETH/BTC.""" from __future__ import annotations @@ -11,6 +11,10 @@ WALLET_KEYS = ( "trading_usdt", "funding_usdc", "trading_usdc", + "funding_eth", + "trading_eth", + "funding_btc", + "trading_btc", ) _ACCT_MAP = { @@ -18,6 +22,10 @@ _ACCT_MAP = { ("trading", "usdt"): "trading_usdt", ("funding", "usdc"): "funding_usdc", ("trading", "usdc"): "trading_usdc", + ("funding", "eth"): "funding_eth", + ("trading", "eth"): "trading_eth", + ("funding", "btc"): "funding_btc", + ("trading", "btc"): "trading_btc", } @@ -42,13 +50,26 @@ class SimWallets: def _now(self) -> str: return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + def _row_to_snap(self, row: Any) -> dict[str, float]: + if row is None: + return {k: 0.0 for k in WALLET_KEYS} + keys = set(row.keys()) if hasattr(row, "keys") else set() + out: dict[str, float] = {} + for k in WALLET_KEYS: + if keys and k not in keys: + out[k] = 0.0 + else: + try: + out[k] = float(row[k] or 0) + except (KeyError, IndexError, TypeError, ValueError): + out[k] = 0.0 + return out + def snapshot(self) -> dict[str, float]: conn = self.get_db() try: row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() - if row is None: - return {k: 0.0 for k in WALLET_KEYS} - return {k: float(row[k] or 0) for k in WALLET_KEYS} + return self._row_to_snap(row) finally: conn.close() @@ -56,6 +77,7 @@ class SimWallets: return self.snapshot() def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float: + """稳定币合计(不含 ETH/BTC 折算).""" v = snap or self.view() return round( float(v.get("funding_usdt") or 0) @@ -71,23 +93,30 @@ class SimWallets: conn = self.get_db() try: now = self._now() + full = {k: float(snap.get(k) or 0) for k in WALLET_KEYS} conn.execute( """ UPDATE sim_wallets SET - funding_usdt=?, trading_usdt=?, funding_usdc=?, trading_usdc=?, updated_at=? + funding_usdt=?, trading_usdt=?, funding_usdc=?, trading_usdc=?, + funding_eth=?, trading_eth=?, funding_btc=?, trading_btc=?, + updated_at=? WHERE id=1 """, ( - float(snap["funding_usdt"]), - float(snap["trading_usdt"]), - float(snap["funding_usdc"]), - float(snap["trading_usdc"]), + full["funding_usdt"], + full["trading_usdt"], + full["funding_usdc"], + full["trading_usdc"], + full["funding_eth"], + full["trading_eth"], + full["funding_btc"], + full["trading_btc"], now, ), ) if owns: conn.commit() - return {k: float(snap[k]) for k in WALLET_KEYS} + return full finally: if owns: conn.close() @@ -117,14 +146,14 @@ class SimWallets: raise ValueError("扣款金额须大于 0") key = _ACCT_MAP.get(("trading", (ccy or "").lower())) if not key: - raise ValueError("币种须为 USDT 或 USDC") + raise ValueError("币种须为 USDT/USDC/ETH/BTC") conn = self.get_db() try: row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() - snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + snap = self._row_to_snap(row) bal = float(snap[key]) if amt > bal + 1e-9: - raise InsufficientFunds(f"交易账户 {ccy.upper()} 不足(可用 {bal:.4f})") + raise InsufficientFunds(f"交易账户 {ccy.upper()} 不足(可用 {bal:.8f})") snap[key] = bal - amt self._write(snap, conn=conn) self._ledger( @@ -149,11 +178,11 @@ class SimWallets: return self.snapshot() key = _ACCT_MAP.get(("trading", (ccy or "").lower())) if not key: - raise ValueError("币种须为 USDT 或 USDC") + raise ValueError("币种须为 USDT/USDC/ETH/BTC") conn = self.get_db() try: row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() - snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + snap = self._row_to_snap(row) snap[key] = float(snap[key]) + amt self._write(snap, conn=conn) self._ledger( @@ -191,14 +220,14 @@ class SimWallets: src_key = _ACCT_MAP.get((fa, ccy_l)) dst_key = _ACCT_MAP.get((ta, ccy_l)) if not src_key or not dst_key: - return {"ok": False, "detail": "币种须为 USDT 或 USDC"} + return {"ok": False, "detail": "币种须为 USDT/USDC/ETH/BTC"} conn = self.get_db() try: row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() - snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + snap = self._row_to_snap(row) src_bal = float(snap[src_key]) if amt > src_bal + 1e-9: - return {"ok": False, "detail": f"余额不足(可用 {src_bal:.4f})"} + return {"ok": False, "detail": f"余额不足(可用 {src_bal:.8f})"} snap[src_key] = src_bal - amt snap[dst_key] = float(snap[dst_key]) + amt self._write(snap, conn=conn) @@ -246,7 +275,7 @@ class SimWallets: fee: float | None = None, note: str | None = None, ) -> dict[str, Any]: - """USDT↔USDC 兑换. 默认交易账户; to_amount 未给时按 rate(USDT/USDC) 换算, 再否则 1:1.""" + """USDT↔USDC / USDT↔ETH / USDT↔BTC 兑换. to_amount 未给时按 rate(USDT per coin) 换算.""" amt = float(amount) if amt <= 0: return {"ok": False, "detail": "数量须大于 0"} @@ -255,13 +284,14 @@ class SimWallets: acct = normalize_sim_account(account) or "trading" if acct not in ("funding", "trading"): return {"ok": False, "detail": "account 须为 funding / trading"} - if {fa, ta} != {"usdt", "usdc"}: - return {"ok": False, "detail": "仅支持 USDT↔USDC"} + pair = {fa, ta} + if pair not in ({"usdt", "usdc"}, {"usdt", "eth"}, {"usdt", "btc"}): + return {"ok": False, "detail": "仅支持 USDT↔USDC/ETH/BTC"} if to_amount is not None: got = float(to_amount) elif rate is not None and float(rate) > 0: r = float(rate) - # rate = USDT per 1 USDC + # rate = USDT per 1 coin(USDC/ETH/BTC) got = (amt / r) if fa == "usdt" else (amt * r) else: got = amt @@ -272,10 +302,10 @@ class SimWallets: conn = self.get_db() try: row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() - snap = {k: float(row[k] or 0) for k in WALLET_KEYS} + snap = self._row_to_snap(row) src = float(snap[src_key]) if amt > src + 1e-9: - return {"ok": False, "detail": f"{acct} {fa.upper()} 不足(可用 {src:.4f})"} + return {"ok": False, "detail": f"{acct} {fa.upper()} 不足(可用 {src:.8f})"} snap[src_key] = src - amt snap[dst_key] = float(snap[dst_key]) + got self._write(snap, conn=conn) @@ -341,12 +371,15 @@ class SimWallets: conn.execute("DELETE FROM sim_perp_positions") conn.execute("DELETE FROM sim_option_positions") conn.execute("DELETE FROM sim_option_orders") - now = self._now() snap = { "funding_usdt": amt, "trading_usdt": 0.0, "funding_usdc": 0.0, "trading_usdc": 0.0, + "funding_eth": 0.0, + "trading_eth": 0.0, + "funding_btc": 0.0, + "trading_btc": 0.0, } self._write(snap, conn=conn) self._ledger( @@ -361,4 +394,4 @@ class SimWallets: conn.commit() return {"ok": True, "wallets": snap, "total_usdt_equiv": amt} finally: - conn.close() + conn.close() \ No newline at end of file