Files
crypto_monitor/lib/options/options_register.py
T

469 lines
17 KiB
Python

"""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()