diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index 7a4234a..96cea1f 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -400,6 +400,31 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: out["version"] = 1 return jsonify(out) + @app.route("/api/hub/options/transfer", methods=["POST"]) + def api_hub_options_transfer(): + """中控指令:实例内资金户↔交易户划转(默认资金→交易,USDT).""" + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401 + hub_ctx = app.config.get("HUB_CTX") or {} + cfg = hub_ctx.get("options_cfg") + if not isinstance(cfg, dict): + return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503 + data = request.get_json(silent=True) or {} + from lib.options.options_transfer_exec_lib import execute_options_internal_transfer + + out = execute_options_internal_transfer( + cfg, + amount=data.get("amount"), + from_acct=(data.get("from") or "funding"), + to_acct=(data.get("to") or "trading"), + ccy=(data.get("ccy") or "USDT"), + ) + if not isinstance(out, dict): + return jsonify({"ok": False, "msg": "划转返回异常", "version": 1}), 500 + body = dict(out) + body["version"] = 1 + return jsonify(body) + @app.route("/api/hub/options/cond_orders", methods=["GET"]) def api_hub_options_cond_orders_list(): if not hub_bridge_token_ok(request.headers): diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 6930119..c049e2f 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -158,6 +158,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "open_bridges": open_bridges, "cond_order_locked": False, "cond_orders_active": [], + "transfer_blocked": False, + "transfer_block_msg": "", } try: from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders @@ -173,6 +175,19 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: conn_c.close() except Exception: pass + try: + from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg + + conn_tb = cfg["get_db"]() + try: + tmsg = transfer_blocked_by_position_msg(conn_tb, ex) + if tmsg: + out["transfer_blocked"] = True + out["transfer_block_msg"] = tmsg + finally: + conn_tb.close() + except Exception: + pass return out except Exception as e: return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_register.py b/lib/options/options_register.py index f675016..42f5f74 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1261,52 +1261,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) - # 持仓/待卖回禁止划转(规格) - conn_blk = cfg["get_db"]() - try: - from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg - - block = transfer_blocked_by_position_msg(conn_blk, ex) - if block: - return jsonify({"ok": False, "msg": block, "transfer_blocked": True}) - finally: - conn_blk.close() + del ex # 执行层自行取 exchange_options data = request.get_json(silent=True) or {} - ccy = (data.get("ccy") or "USDC").upper() - from_acct = (data.get("from") or "funding").strip() - to_acct = (data.get("to") or "trading").strip() - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - try: - import lib.exchange.okx_options_lib as _opt_lib + from lib.options.options_transfer_exec_lib import execute_options_internal_transfer - xfer = getattr(_opt_lib, "transfer_ccy", None) - except Exception: - xfer = None - if not callable(xfer): - xfer = cfg.get("transfer_ccy") - result = xfer(ex, ccy, amount, from_acct, to_acct) if callable(xfer) else { - "ok": False, - "msg": "划转不可用", - } - if result.get("ok"): - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - """ - INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) - VALUES (?, ?, ?, ?, 'ok', '') - """, - (ccy, amount, from_acct, to_acct), - ) - conn.commit() - finally: - conn.close() - _mark_balances_stale(cfg) - return jsonify(result) + # 前端传 ccy;缺省 USDT(币本位) + ccy = (data.get("ccy") or "USDT").upper() + return jsonify( + execute_options_internal_transfer( + cfg, + amount=data.get("amount"), + from_acct=(data.get("from") or "funding"), + to_acct=(data.get("to") or "trading"), + ccy=ccy, + ) + ) @app.route("/api/options/spot/swap", methods=["POST"]) @lr diff --git a/lib/options/options_transfer_exec_lib.py b/lib/options/options_transfer_exec_lib.py new file mode 100644 index 0000000..41f93ee --- /dev/null +++ b/lib/options/options_transfer_exec_lib.py @@ -0,0 +1,147 @@ +"""期权账户内划转执行(本机设置 / 中控 Bridge 共用).""" +from __future__ import annotations + +from typing import Any + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _pick_stable(bal: dict[str, Any] | None, *, account: str, ccy: str) -> float | None: + if not isinstance(bal, dict): + return None + acct = "trading" if account == "trading" else "funding" + ccy_l = (ccy or "usdt").strip().lower() or "usdt" + avail = _safe_float(bal.get(f"{acct}_{ccy_l}_avail")) + if avail is not None: + return round(float(avail), 8) + total = _safe_float(bal.get(f"{acct}_{ccy_l}")) + if total is not None: + return round(float(total), 8) + return None + + +def execute_options_internal_transfer( + cfg: dict[str, Any], + *, + amount: Any, + from_acct: str = "funding", + to_acct: str = "trading", + ccy: str = "USDT", +) -> dict[str, Any]: + """资金户↔交易户划转;持仓/待卖回拒绝.成功后附带最新 USDT 余额.""" + from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg + from lib.options.options_db import init_options_tables + + ex = cfg.get("exchange_options") + ready_fn = cfg.get("options_api_ready") + if callable(ready_fn): + ok, reason = ready_fn(ex) + if not ok: + return {"ok": False, "msg": reason or "期权 API 未配置"} + if ex is None: + return {"ok": False, "msg": "交易所未就绪"} + + src = (from_acct or "funding").strip().lower() + dst = (to_acct or "trading").strip().lower() + if src not in ("funding", "trading") or dst not in ("funding", "trading"): + return {"ok": False, "msg": "仅支持 funding/trading"} + if src == dst: + return {"ok": False, "msg": "转出与转入账户不能相同"} + ccy_u = (ccy or "USDT").strip().upper() or "USDT" + if ccy_u not in ("USDT", "USDC"): + return {"ok": False, "msg": "仅支持 USDT/USDC"} + amt = _safe_float(amount) + if amt is None or amt <= 0: + return {"ok": False, "msg": "数量无效"} + + get_db = cfg.get("get_db") + if not callable(get_db): + return {"ok": False, "msg": "数据库未就绪"} + conn = get_db() + try: + block = transfer_blocked_by_position_msg(conn, ex) + if block: + return {"ok": False, "msg": block, "transfer_blocked": True} + finally: + conn.close() + + xfer = cfg.get("transfer_ccy") + if not callable(xfer): + try: + import lib.exchange.okx_options_lib as _opt_lib + + xfer = getattr(_opt_lib, "transfer_ccy", None) + except Exception: + xfer = None + if not callable(xfer): + return {"ok": False, "msg": "划转不可用"} + + result = xfer(ex, ccy_u, float(amt), src, dst) + if not isinstance(result, dict): + return {"ok": False, "msg": "划转返回异常"} + out = dict(result) + if not out.get("ok"): + out.setdefault("msg", "划转失败") + return out + + conn2 = get_db() + try: + init_options_tables(conn2) + conn2.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', ?) + """, + (ccy_u, float(amt), src, dst, ""), + ) + conn2.commit() + except Exception: + try: + conn2.rollback() + except Exception: + pass + finally: + conn2.close() + + try: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + invalidate_options_balance_cache() + app_mod = cfg.get("app_module") + if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): + app_mod.invalidate_account_balance_cache() + notify_instance_balance_changed() + except Exception: + pass + + bal = {} + try: + fetch_bal = cfg.get("fetch_options_balances") + if callable(fetch_bal): + bal = fetch_bal(ex) or {} + except Exception: + bal = {} + trading_usdt = _pick_stable(bal, account="trading", ccy="USDT") + funding_usdt = _pick_stable(bal, account="funding", ccy="USDT") + out.update( + { + "ok": True, + "msg": out.get("msg") or "划转成功", + "ccy": ccy_u, + "amount": round(float(amt), 8), + "from": src, + "to": dst, + "trading_usdt": trading_usdt, + "funding_usdt": funding_usdt, + "transfer_blocked": False, + } + ) + return out diff --git a/tests/test_options_transfer_exec.py b/tests/test_options_transfer_exec.py new file mode 100644 index 0000000..ead1581 --- /dev/null +++ b/tests/test_options_transfer_exec.py @@ -0,0 +1,74 @@ +"""账户内划转执行:门禁与余额回写.""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + + +class TestOptionsTransferExec(unittest.TestCase): + def test_block_when_position(self): + from lib.options.options_transfer_exec_lib import execute_options_internal_transfer + + conn = MagicMock() + cfg = { + "exchange_options": object(), + "options_api_ready": lambda ex: (True, ""), + "get_db": lambda: conn, + "transfer_ccy": MagicMock(), + } + with patch( + "lib.options.options_transfer_exec_lib.transfer_blocked_by_position_msg", + create=True, + ): + with patch( + "lib.options.options_account_pnl_lib.transfer_blocked_by_position_msg", + return_value="期权持仓中禁止划转", + ): + out = execute_options_internal_transfer( + cfg, amount=10, from_acct="funding", to_acct="trading" + ) + self.assertFalse(out["ok"]) + self.assertTrue(out.get("transfer_blocked")) + cfg["transfer_ccy"].assert_not_called() + + def test_success_returns_balances(self): + from lib.options.options_transfer_exec_lib import execute_options_internal_transfer + + conn = MagicMock() + xfer = MagicMock(return_value={"ok": True, "msg": "ok"}) + fetch_bal = MagicMock( + return_value={ + "trading_usdt": 30.0, + "funding_usdt": 70.0, + "trading_usdt_avail": 30.0, + "funding_usdt_avail": 70.0, + } + ) + cfg = { + "exchange_options": object(), + "options_api_ready": lambda ex: (True, ""), + "get_db": lambda: conn, + "transfer_ccy": xfer, + "fetch_options_balances": fetch_bal, + } + with patch( + "lib.options.options_account_pnl_lib.transfer_blocked_by_position_msg", + return_value=None, + ), patch( + "lib.options.options_db.init_options_tables", + ), patch( + "lib.exchange.okx_options_lib.invalidate_options_balance_cache", + ), patch( + "lib.instance.instance_live_push_lib.notify_instance_balance_changed", + ): + out = execute_options_internal_transfer( + cfg, amount=10, from_acct="funding", to_acct="trading", ccy="USDT" + ) + self.assertTrue(out["ok"]) + self.assertAlmostEqual(out["trading_usdt"], 30.0) + self.assertAlmostEqual(out["funding_usdt"], 70.0) + xfer.assert_called_once() + + +if __name__ == "__main__": + unittest.main()