feat: add OKX options module with dual API, USDT/USDC convert, and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""期权模块 SQLite 表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inst_id TEXT NOT NULL,
|
||||
underlying TEXT NOT NULL,
|
||||
opt_type TEXT NOT NULL,
|
||||
strike REAL,
|
||||
exp_time TEXT,
|
||||
sheets INTEGER NOT NULL,
|
||||
eth_amount REAL NOT NULL,
|
||||
open_quote REAL,
|
||||
premium_paid REAL,
|
||||
status TEXT DEFAULT 'open',
|
||||
close_quote REAL,
|
||||
premium_received REAL,
|
||||
realized_pnl REAL,
|
||||
profit_alert_sent INTEGER DEFAULT 0,
|
||||
signal_note TEXT,
|
||||
exchange_ord_id TEXT,
|
||||
close_ord_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_convert_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_ccy TEXT,
|
||||
to_ccy TEXT,
|
||||
rfq_sz REAL,
|
||||
received_sz REAL,
|
||||
quote_id TEXT,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_transfer_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ccy TEXT,
|
||||
amount REAL,
|
||||
from_account TEXT,
|
||||
to_account TEXT,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""期权持仓监控:浮盈翻倍微信提醒。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def build_profit_alert_message(
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
premium_paid: float,
|
||||
upl: float,
|
||||
upl_ratio: float | None,
|
||||
bid: float | None,
|
||||
) -> str:
|
||||
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—"
|
||||
bid_txt = f"{bid:.4f}" if bid is not None else "—"
|
||||
return "\n".join(
|
||||
[
|
||||
"【OKX期权·翻倍提醒】",
|
||||
f"账户:{account_label}",
|
||||
f"合约:{inst_id}",
|
||||
f"已付权利金:{premium_paid:.4f} USDC",
|
||||
f"未实现盈亏:{upl:+.4f} USDC({pct})",
|
||||
f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def run_options_profit_alerts(
|
||||
conn: sqlite3.Connection,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
profit_ratio: float,
|
||||
send_wechat: Callable[[str], None],
|
||||
account_label: str,
|
||||
ticker_bid_fn: Callable[[str], float | None],
|
||||
) -> int:
|
||||
"""
|
||||
对比 DB 中 open 记录与交易所持仓;达到阈值发微信。
|
||||
返回发送条数。
|
||||
"""
|
||||
sent = 0
|
||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, premium_paid, profit_alert_sent
|
||||
FROM options_trades
|
||||
WHERE status = 'open'
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if int(row["profit_alert_sent"] or 0):
|
||||
continue
|
||||
inst_id = str(row["inst_id"] or "")
|
||||
prem = _safe_float(row["premium_paid"])
|
||||
if not inst_id or prem is None or prem <= 0:
|
||||
continue
|
||||
pos = pos_by_inst.get(inst_id)
|
||||
if not pos:
|
||||
continue
|
||||
upl = _safe_float(pos.get("upl"))
|
||||
upl_ratio = _safe_float(pos.get("upl_ratio_pct"))
|
||||
if upl_ratio is not None:
|
||||
ratio = upl_ratio / 100.0
|
||||
elif upl is not None:
|
||||
ratio = upl / prem
|
||||
else:
|
||||
continue
|
||||
if ratio < float(profit_ratio):
|
||||
continue
|
||||
bid = ticker_bid_fn(inst_id)
|
||||
msg = build_profit_alert_message(
|
||||
account_label=account_label,
|
||||
inst_id=inst_id,
|
||||
premium_paid=prem,
|
||||
upl=upl or 0.0,
|
||||
upl_ratio=ratio,
|
||||
bid=bid,
|
||||
)
|
||||
try:
|
||||
send_wechat(msg)
|
||||
conn.execute(
|
||||
"UPDATE options_trades SET profit_alert_sent = 1 WHERE id = ?",
|
||||
(int(row["id"]),),
|
||||
)
|
||||
sent += 1
|
||||
except Exception:
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
def options_monitor_loop(
|
||||
*,
|
||||
enabled: bool,
|
||||
poll_seconds: float,
|
||||
get_db: Callable[[], sqlite3.Connection],
|
||||
fetch_positions: Callable[[], list[dict[str, Any]]],
|
||||
ticker_bid_fn: Callable[[str], float | None],
|
||||
send_wechat: Callable[[str], None],
|
||||
account_label: str,
|
||||
profit_ratio: float,
|
||||
stop_event: Any = None,
|
||||
) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
while True:
|
||||
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
|
||||
break
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
positions = fetch_positions()
|
||||
run_options_profit_alerts(
|
||||
conn,
|
||||
positions,
|
||||
profit_ratio=profit_ratio,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
ticker_bid_fn=ticker_bid_fn,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(max(5.0, float(poll_seconds)))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""OKX USDⓈ 期权:张数与权利金计算。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
|
||||
if not meta:
|
||||
return 0.01
|
||||
try:
|
||||
return float(meta.get("ctMult") or 0.01)
|
||||
except (TypeError, ValueError):
|
||||
return 0.01
|
||||
|
||||
|
||||
def min_sz_from_meta(meta: dict[str, Any] | None) -> int:
|
||||
if not meta:
|
||||
return 1
|
||||
try:
|
||||
return max(1, int(float(meta.get("minSz") or 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
|
||||
"""报价为每 1 ETH/BTC;每张权利金 = 报价 × ctMult。"""
|
||||
return float(quote_per_unit) * float(ct_mult)
|
||||
|
||||
|
||||
def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.01) -> float:
|
||||
return float(quote_per_unit) * float(eth_amount)
|
||||
|
||||
|
||||
def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int:
|
||||
if eth_amount <= 0 or ct_mult <= 0:
|
||||
return 0
|
||||
return int(math.floor(eth_amount / ct_mult + 1e-12))
|
||||
|
||||
|
||||
def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float:
|
||||
return round(int(sheets) * float(ct_mult), 8)
|
||||
|
||||
|
||||
def calc_order_size(
|
||||
*,
|
||||
quote_per_unit: float,
|
||||
ct_mult: float,
|
||||
min_sz: int,
|
||||
budget_usdc: float | None = None,
|
||||
budget_buffer: float = 0.95,
|
||||
eth_amount: float | None = None,
|
||||
budget_cap: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回 sheets, eth_amount, total_premium。
|
||||
mode: budget_full 或 eth_amount。
|
||||
"""
|
||||
if quote_per_unit <= 0:
|
||||
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||
|
||||
if eth_amount is not None and eth_amount > 0:
|
||||
sheets = sheets_from_eth_amount(eth_amount, ct_mult)
|
||||
elif budget_usdc is not None and budget_usdc > 0:
|
||||
eff = float(budget_usdc) * float(budget_buffer)
|
||||
per_sheet = premium_per_sheet(quote_per_unit, ct_mult)
|
||||
if per_sheet <= 0:
|
||||
return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||
sheets = int(math.floor(eff / per_sheet))
|
||||
else:
|
||||
return {"ok": False, "msg": "请指定预算或 ETH 数量", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||
|
||||
if sheets < min_sz:
|
||||
per = premium_per_sheet(quote_per_unit, ct_mult)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"预算不足,无法买入 {min_sz} 张(单张约 {per:.4f} USDC)",
|
||||
"sheets": sheets,
|
||||
"eth_amount": eth_amount_from_sheets(sheets, ct_mult),
|
||||
"total_premium": total_premium(quote_per_unit, eth_amount_from_sheets(sheets, ct_mult)),
|
||||
}
|
||||
|
||||
eth = eth_amount_from_sheets(sheets, ct_mult)
|
||||
prem = total_premium(quote_per_unit, eth)
|
||||
if budget_cap is not None and prem > float(budget_cap) + 1e-9:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"权利金 {prem:.4f} 超过单笔上限 {budget_cap} USDC",
|
||||
"sheets": sheets,
|
||||
"eth_amount": eth,
|
||||
"total_premium": prem,
|
||||
}
|
||||
return {"ok": True, "msg": "", "sheets": sheets, "eth_amount": eth, "total_premium": prem}
|
||||
|
||||
|
||||
def is_shallow_itm(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
index_px: float,
|
||||
max_dist_usd: float,
|
||||
) -> bool:
|
||||
o = (opt_type or "").upper()
|
||||
if o == "C":
|
||||
if strike >= index_px:
|
||||
return False
|
||||
return (index_px - strike) <= max_dist_usd
|
||||
if o == "P":
|
||||
if strike <= index_px:
|
||||
return False
|
||||
return (strike - index_px) <= max_dist_usd
|
||||
return False
|
||||
@@ -0,0 +1,466 @@
|
||||
"""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
|
||||
if enabled:
|
||||
register_options_routes(app, cfg)
|
||||
_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]):
|
||||
ex = cfg.get("exchange_options")
|
||||
ok, reason = cfg["options_api_ready"](ex)
|
||||
if not ok:
|
||||
return None, reason
|
||||
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()
|
||||
@@ -0,0 +1,128 @@
|
||||
<div class="card options-page-card" style="grid-column:1/-1" id="options-root"
|
||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}">
|
||||
<h2>期权(USDⓈ 本位 · 仅买方)</h2>
|
||||
<p class="muted options-hint">资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。</p>
|
||||
|
||||
<div class="options-funds-grid">
|
||||
<div class="options-funds-col">
|
||||
<h3>资金账户</h3>
|
||||
<div class="options-fund-row"><span>USDT</span><strong id="opt-funding-usdt">—</strong></div>
|
||||
<div class="options-fund-row"><span>USDC</span><strong id="opt-funding-usdc">—</strong></div>
|
||||
</div>
|
||||
<div class="options-funds-col">
|
||||
<h3>交易账户</h3>
|
||||
<div class="options-fund-row"><span>USDT</span><strong id="opt-trading-usdt">—</strong></div>
|
||||
<div class="options-fund-row"><span>USDC</span><strong id="opt-trading-usdc">—</strong></div>
|
||||
<div class="options-fund-row"><span>USDG</span><strong id="opt-trading-usdg">—</strong></div>
|
||||
</div>
|
||||
<div class="options-funds-col options-funds-meta">
|
||||
<div class="options-fund-row"><span>单笔权利金上限</span><strong id="opt-trade-budget">—</strong></div>
|
||||
<button type="button" class="btn-secondary" id="opt-refresh-balances">刷新余额</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-section card-nested">
|
||||
<h3>币种兑换(资金账户 USDT → USDC)</h3>
|
||||
<div class="form-row options-convert-row">
|
||||
<input type="number" id="opt-convert-amount" min="0" step="0.01" placeholder="USDT 数量">
|
||||
<button type="button" class="btn-secondary" id="opt-convert-quote-btn">询价</button>
|
||||
<button type="button" class="btn-primary" id="opt-convert-exec-btn" disabled>确认兑换</button>
|
||||
</div>
|
||||
<div id="opt-convert-preview" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="options-section card-nested">
|
||||
<h3>账户划转</h3>
|
||||
<div class="form-row options-transfer-row">
|
||||
<select id="opt-transfer-ccy">
|
||||
<option value="USDC" selected>USDC</option>
|
||||
<option value="USDT">USDT</option>
|
||||
</select>
|
||||
<select id="opt-transfer-from">
|
||||
<option value="funding" selected>资金账户</option>
|
||||
<option value="trading">交易账户</option>
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select id="opt-transfer-to">
|
||||
<option value="trading" selected>交易账户</option>
|
||||
<option value="funding">资金账户</option>
|
||||
</select>
|
||||
<input type="number" id="opt-transfer-amount" min="0" step="0.01" placeholder="数量">
|
||||
<button type="button" class="btn-primary" id="opt-transfer-btn">确认划转</button>
|
||||
</div>
|
||||
<div id="opt-transfer-msg" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="options-section">
|
||||
<div class="form-row options-chain-toolbar">
|
||||
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||
<select id="opt-exp-select"><option value="">选择到期日</option></select>
|
||||
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
|
||||
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
|
||||
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
|
||||
</div>
|
||||
<div id="opt-index-line" class="muted"></div>
|
||||
<div class="options-strike-table-wrap">
|
||||
<table class="options-strike-table" id="opt-strike-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>行权价</th>
|
||||
<th>合约</th>
|
||||
<th>卖一</th>
|
||||
<th>买一</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-strike-tbody">
|
||||
<tr><td colspan="5" class="muted">请选择到期日</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-section card-nested" id="opt-order-panel" style="display:none">
|
||||
<h3>下单</h3>
|
||||
<div id="opt-order-inst" class="options-order-inst"></div>
|
||||
<div class="options-order-grid">
|
||||
<div><span class="k">卖一(每1币)</span><span id="opt-order-ask" class="v">—</span></div>
|
||||
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
||||
<div><span class="k">ETH/BTC 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
||||
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="radio" name="opt-size-mode" value="budget_full" checked> 按单笔上限打满</label>
|
||||
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
|
||||
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
|
||||
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
|
||||
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
|
||||
</div>
|
||||
<div id="opt-order-msg" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="options-section">
|
||||
<h3>持仓</h3>
|
||||
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新持仓</button>
|
||||
<div class="options-strike-table-wrap">
|
||||
<table class="options-strike-table" id="opt-positions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合约</th>
|
||||
<th>张数</th>
|
||||
<th>币量</th>
|
||||
<th>开仓均价</th>
|
||||
<th>标记价</th>
|
||||
<th>浮盈</th>
|
||||
<th>收益率</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-positions-tbody">
|
||||
<tr><td colspan="8" class="muted">暂无持仓</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_panel.js?v=1"></script>
|
||||
Reference in New Issue
Block a user