"""OKX 期权模块:Flask 路由注册。""" from __future__ import annotations import os import threading from typing import Any from flask import Flask, jsonify, redirect, request, url_for from jinja2 import ChoiceLoader, FileSystemLoader from lib.options.options_db import init_options_tables from lib.options.options_monitor_lib import options_monitor_loop from lib.options.options_pricing_lib import ( calc_order_size, ct_mult_from_meta, min_sz_from_meta, premium_per_sheet, total_premium, ) def _env_bool(key: str, default: bool = False) -> bool: raw = (os.getenv(key) or "").strip().lower() if not raw: return default return raw in ("1", "true", "yes", "on") def _env_float(key: str, default: float) -> float: try: return float(os.getenv(key, str(default))) except (TypeError, ValueError): return default def attach_options_templates(app: Flask, repo_root: str) -> None: tpl_dir = os.path.join(repo_root, "lib", "options", "templates") if not os.path.isdir(tpl_dir): return existing = app.jinja_loader loaders = [FileSystemLoader(tpl_dir)] if existing is not None: if isinstance(existing, ChoiceLoader): loaders = list(existing.loaders) + loaders else: loaders.insert(0, existing) app.jinja_loader = ChoiceLoader(loaders) def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: enabled = _env_bool("OKX_OPTIONS_ENABLED", False) attach_options_templates(app, repo_root) cfg = _build_cfg(app_module) app.extensions["options_cfg"] = cfg register_options_routes(app, cfg) if enabled: _start_monitor_thread(app, cfg) def _build_cfg(app_module: Any) -> dict[str, Any]: from lib.exchange.okx_options_lib import ( build_option_chain, estimate_usdt_to_usdc, execute_convert, fetch_option_positions, fetch_options_balances, format_position_row, options_api_ready, place_option_limit_order, quote_option_contract, transfer_ccy, ) return { "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), "get_db": app_module.get_db, "login_required": app_module.login_required, "exchange_options": getattr(app_module, "exchange_options", None), "send_wechat": app_module.send_wechat_msg, "render_main_page": app_module.render_main_page, "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "cross").strip(), "allow_market_close": _env_bool("OKX_OPTIONS_ALLOW_MARKET_CLOSE", False), "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), "build_option_chain": build_option_chain, "quote_option_contract": quote_option_contract, "place_option_limit_order": place_option_limit_order, "fetch_option_positions": fetch_option_positions, "fetch_options_balances": fetch_options_balances, "format_position_row": format_position_row, "estimate_usdt_to_usdc": estimate_usdt_to_usdc, "execute_convert": execute_convert, "transfer_ccy": transfer_ccy, "options_api_ready": options_api_ready, } def _require_options_ex(cfg: dict[str, Any]): if not cfg.get("enabled"): return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" ex = cfg.get("exchange_options") ok, reason = cfg["options_api_ready"](ex) if not ok: return None, reason or "期权 API 未配置" return ex, "" def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: lr = cfg["login_required"] @app.route("/api/options/balances") @lr def api_options_balances(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) bal = cfg["fetch_options_balances"](ex) return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]}) @app.route("/api/options/chain") @lr def api_options_chain(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) u = (request.args.get("underlying") or cfg["default_underly"]).upper() chain = cfg["build_option_chain"]( ex, u, max_dte_days=cfg["max_dte_days"], itm_only=True, itm_max_dist_usd=cfg["itm_max_dist"], ) return jsonify({"ok": True, **chain}) @app.route("/api/options/quote") @lr def api_options_quote(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) inst_id = (request.args.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) q = cfg["quote_option_contract"](ex, inst_id) if not q.get("ok"): return jsonify(q) ask = q.get("ask") ct_mult = q.get("ct_mult") or 0.01 min_sz = q.get("min_sz") or 1 mode = (request.args.get("mode") or "budget_full").strip() budget = cfg["trade_budget"] eth_amount = None try: if request.args.get("eth_amount"): eth_amount = float(request.args.get("eth_amount")) except (TypeError, ValueError): pass if ask is None or ask <= 0: return jsonify({**q, "ok": False, "msg": "暂无卖一价"}) sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=float(ct_mult), min_sz=int(min_sz), budget_usdc=budget if mode != "eth_amount" else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount if mode == "eth_amount" else None, budget_cap=cfg["trade_budget"], ) return jsonify( { **q, "quote_per_unit": ask, "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), "sizing": sizing, } ) @app.route("/api/options/open", methods=["POST"]) @lr def api_options_open(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() mode = (data.get("mode") or "budget_full").strip() signal_note = (data.get("signal_note") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) q = cfg["quote_option_contract"](ex, inst_id) if not q.get("ok"): return jsonify(q) ask = q.get("ask") if ask is None or ask <= 0: return jsonify({"ok": False, "msg": "暂无卖一价,无法买入"}) ct_mult = float(q.get("ct_mult") or 0.01) min_sz = int(q.get("min_sz") or 1) eth_amount = None if mode == "eth_amount": try: eth_amount = float(data.get("eth_amount")) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "ETH 数量无效"}) sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=ct_mult, min_sz=min_sz, budget_usdc=cfg["trade_budget"] if mode != "eth_amount" else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount, budget_cap=cfg["trade_budget"], ) if not sizing.get("ok"): return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) sheets = int(sizing["sheets"]) order = cfg["place_option_limit_order"]( ex, inst_id=inst_id, side="buy", sheets=sheets, price=float(ask), td_mode=cfg["td_mode"], ) if not order.get("ok"): return jsonify(order) conn = cfg["get_db"]() try: init_options_tables(conn) meta = q.get("meta") or {} u = str(meta.get("uly") or inst_id).split("-")[0] 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?) """, ( inst_id, u, meta.get("optType"), q.get("strike"), str(q.get("exp_time") or ""), sheets, sizing["eth_amount"], float(ask), sizing["total_premium"], signal_note, (order.get("data") or {}).get("ordId"), ), ) conn.commit() finally: conn.close() return jsonify({"ok": True, "order": order, "sizing": sizing}) @app.route("/api/options/positions") @lr def api_options_positions(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) raw = cfg["fetch_option_positions"](ex) rows = [cfg["format_position_row"](p) for p in raw] return jsonify({"ok": True, "positions": rows}) @app.route("/api/options/close", methods=["POST"]) @lr def api_options_close(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() use_market = bool(data.get("market")) and cfg["allow_market_close"] if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) sheets = data.get("sheets") q = cfg["quote_option_contract"](ex, inst_id) bid = q.get("bid") if not use_market and (bid is None or bid <= 0): return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"}) raw_positions = cfg["fetch_option_positions"](ex) pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) if not pos: return jsonify({"ok": False, "msg": "未找到持仓"}) avail = float(pos.get("availPos") or pos.get("pos") or 0) close_sheets = int(sheets) if sheets else int(abs(avail)) if close_sheets < 1: return jsonify({"ok": False, "msg": "可平张数不足"}) if use_market: try: resp = ex.private_post_trade_order( { "instId": inst_id, "tdMode": cfg["td_mode"], "side": "sell", "ordType": "market", "sz": str(close_sheets), } ) data_rows = (resp or {}).get("data") or [] if not data_rows or str(data_rows[0].get("sCode")) != "0": return jsonify({"ok": False, "msg": data_rows[0].get("sMsg") if data_rows else "市价平仓失败"}) order = {"ok": True, "data": data_rows[0]} except Exception as e: return jsonify({"ok": False, "msg": str(e)}) else: order = cfg["place_option_limit_order"]( ex, inst_id=inst_id, side="sell", sheets=close_sheets, price=float(bid), td_mode=cfg["td_mode"], ) if not order.get("ok"): return jsonify(order) prem_recv = total_premium(float(bid or 0), close_sheets * float(q.get("ct_mult") or 0.01)) conn = cfg["get_db"]() try: init_options_tables(conn) row = conn.execute( "SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1", (inst_id,), ).fetchone() if row: paid = float(row["premium_paid"] or 0) pnl = prem_recv - paid conn.execute( """ UPDATE options_trades SET status = 'closed', close_quote = ?, premium_received = ?, realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP WHERE id = ? """, ( bid, prem_recv, pnl, (order.get("data") or {}).get("ordId"), int(row["id"]), ), ) conn.commit() finally: conn.close() return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets}) @app.route("/api/options/convert/quote", methods=["POST"]) @lr def api_options_convert_quote(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} try: amount = float(data.get("amount")) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "数量无效"}) return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) @app.route("/api/options/convert/execute", methods=["POST"]) @lr def api_options_convert_execute(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} quote_id = (data.get("quote_id") or "").strip() result = cfg["execute_convert"](ex, quote_id) if result.get("ok"): conn = cfg["get_db"]() try: init_options_tables(conn) conn.execute( """ INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') """, ( data.get("rfq_sz"), (result.get("data") or {}).get("baseSz"), quote_id, ), ) conn.commit() finally: conn.close() return jsonify(result) @app.route("/api/options/transfer", methods=["POST"]) @lr def api_options_transfer(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) 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": "数量无效"}) result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct) 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() return jsonify(result) def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: if app.extensions.get("options_monitor_started"): return app.extensions["options_monitor_started"] = True def _bid(inst_id: str) -> float | None: ex = cfg.get("exchange_options") if ex is None: return None try: q = cfg["quote_option_contract"](ex, inst_id) return q.get("bid") except Exception: return None def _positions(): ex = cfg.get("exchange_options") if ex is None: return [] raw = cfg["fetch_option_positions"](ex) return [cfg["format_position_row"](p) for p in raw] t = threading.Thread( target=options_monitor_loop, kwargs={ "enabled": True, "poll_seconds": cfg["poll_seconds"], "get_db": cfg["get_db"], "fetch_positions": _positions, "ticker_bid_fn": _bid, "send_wechat": cfg["send_wechat"], "account_label": cfg["account_label"], "profit_ratio": cfg["profit_ratio"], }, daemon=True, name="options-monitor", ) t.start()