实现OKX单笔期权币本位与USDT桥复利(中控只读,不改Gate)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -113,8 +113,15 @@ OKX_OPTIONS_ENABLED=false
|
||||
# OKX_OPTIONS_API_SECRET=
|
||||
# OKX_OPTIONS_API_PASSPHRASE=
|
||||
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
|
||||
# 单笔期权本位: usdc(默认,权利金USDC) | coin(币本位+USDT买币桥;对冲仍仅USDC)
|
||||
OKX_OPTIONS_MARGIN_MODE=usdc
|
||||
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||
# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走)
|
||||
OKX_OPTIONS_COIN_COMPOUND=true
|
||||
OKX_OPTIONS_COIN_BUDGET_USDT=10
|
||||
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
|
||||
OKX_OPTIONS_COIN_MAX_USDT=50
|
||||
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
|
||||
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
|
||||
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
|
||||
|
||||
> 状态:**方案待实现**(按本文落地;改需求先改本文).
|
||||
> 状态:**已实现首版**(按本文落地;改需求先改本文).
|
||||
> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**.
|
||||
> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为).
|
||||
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md)
|
||||
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-20 · OKX 单笔期权币本位 + USDT 桥 + 复利
|
||||
|
||||
### 修改原因
|
||||
|
||||
币本位期权流动性往往好于 USDC;操作者仍用 USDT 思考本金。需 env 切换本位、自动 USDT↔币桥、交易户 USDT×0.95 复利;对冲仍仅 USDC;中控只读识别本位;不改 Gate。
|
||||
|
||||
### 修改的地方
|
||||
|
||||
| 文件 | 改动摘要 |
|
||||
|------|----------|
|
||||
| `lib/options/options_margin_mode_lib.py` | 本位/合约族/USDT 预算/按币算张数 |
|
||||
| `lib/options/options_spot_bridge_lib.py` | 买币/卖回/桥状态表/回滚 |
|
||||
| `lib/options/options_coin_open_lib.py` | 买满→开满编排;平后卖回 |
|
||||
| `options_register` / `okx_options_lib` / close_exec | 链族切换、开平接入、retry-sell |
|
||||
| `options_hub_lib` + 中控 `app.js` / AI context | 只读字段识别币本位 |
|
||||
| `hedge_plan_register` | 币本位禁止开对冲 |
|
||||
| `env_*` / `.env.example` | 新 env;MARGIN_MODE 需重启;有仓拒切 |
|
||||
| `docs/OKX单笔期权-币本位与USDT桥-开发方案.md` | 方案(已有) |
|
||||
|
||||
### 交付之后的验收
|
||||
|
||||
1. `OKX_OPTIONS_MARGIN_MODE=usdc` 行为与现网一致.
|
||||
2. `=coin` 时链为 `ETH-USD`(非 `_UM`);开仓走买币再开期权;失败回滚卖币.
|
||||
3. 平仓清空后卖回本桥币量;失败可 `POST /api/options/spot-bridge/retry-sell`.
|
||||
4. 预算默认交易户 USDT×0.95;上限开关默认关.
|
||||
5. 中控期权卡显示本位标签;无下单.
|
||||
6. Gate 无改动.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复
|
||||
|
||||
### 修改原因
|
||||
|
||||
@@ -1266,7 +1266,16 @@
|
||||
const openBtn = document.getElementById("opt-open-btn");
|
||||
if (openBtn) {
|
||||
openBtn.disabled = !canOpen || sz.ok === false;
|
||||
openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓";
|
||||
const coinMode = (state.chain && state.chain.margin_mode === "coin") || (state.chain && state.chain.options_margin_mode === "coin");
|
||||
const bud = state.chain && state.chain.coin_budget && state.chain.coin_budget.budget_usdt;
|
||||
if (!canOpen) {
|
||||
openBtn.textContent = "暂无卖一深度,无法开仓";
|
||||
} else if (coinMode) {
|
||||
openBtn.textContent =
|
||||
bud != null ? "买币并开仓(预算 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一";
|
||||
} else {
|
||||
openBtn.textContent = "限价买入 @ 卖一";
|
||||
}
|
||||
}
|
||||
const msgEl = document.getElementById("opt-order-msg");
|
||||
if (!d.ok) {
|
||||
|
||||
Vendored
+4
@@ -100,6 +100,10 @@ HOT_RELOAD_EXACT = frozenset({
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"OKX_OPTIONS_BUDGET_BUFFER",
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"OKX_TRADE_MODE",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_LIVE_ORDER",
|
||||
|
||||
Vendored
+28
-3
@@ -143,16 +143,41 @@ _OPTIONS_SECTION: dict[str, Any] = {
|
||||
"fields": [
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
|
||||
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
||||
(
|
||||
"OKX_OPTIONS_MARGIN_MODE",
|
||||
"单笔期权本位",
|
||||
"usdc=USDⓈ权利金(现状);coin=币本位+USDT买币桥。有持仓/半成品桥时勿切换;改后需重启",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"单笔预算(USDC)",
|
||||
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
|
||||
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
|
||||
),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"币本位按交易户USDT复利",
|
||||
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"币本位固定预算(USDT)",
|
||||
"仅币本位且复利关闭时生效",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"币本位单笔上限开关",
|
||||
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"币本位单笔上限(USDT)",
|
||||
"仅上限开关开启时生效",
|
||||
),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"全仓复利开关",
|
||||
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
|
||||
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
|
||||
),
|
||||
(
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
|
||||
@@ -734,9 +734,19 @@ def build_option_chain(
|
||||
itm_only: bool = True,
|
||||
itm_max_dist_usd: float = 30.0,
|
||||
index_px: float | None = None,
|
||||
margin_mode: str | None = None,
|
||||
inst_family: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
u = (underlying or "ETH").upper()
|
||||
family = f"{u}-USD_UM"
|
||||
if inst_family:
|
||||
family = str(inst_family).strip()
|
||||
else:
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import inst_family_for_underlying
|
||||
|
||||
family = inst_family_for_underlying(u, margin_mode=margin_mode)
|
||||
except Exception:
|
||||
family = f"{u}-USD_UM"
|
||||
uly = f"{u}-USD"
|
||||
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
||||
now_ms = time.time() * 1000
|
||||
@@ -838,6 +848,8 @@ def build_option_chain(
|
||||
"underlying": u,
|
||||
"index_px": idx,
|
||||
"inst_family": family,
|
||||
"margin_mode": "usdc" if "_UM" in family.upper() else "coin",
|
||||
"premium_ccy": "USDC" if "_UM" in family.upper() else u,
|
||||
"expiries": exp_list,
|
||||
"instruments_count": len(instruments),
|
||||
}
|
||||
|
||||
@@ -826,6 +826,20 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
body = request.get_json(silent=True) or {}
|
||||
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
|
||||
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
|
||||
if is_coin_margin_mode() and not dry_run:
|
||||
return jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲",
|
||||
}
|
||||
), 400
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"}
|
||||
), 400
|
||||
with _hedge_start_lock():
|
||||
gates = _gates_dict(cfg, plan_type)
|
||||
if not dry_run and not gates.get("can_start"):
|
||||
|
||||
@@ -106,6 +106,24 @@ def register_instance_settings_routes(
|
||||
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
|
||||
if not clean:
|
||||
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
|
||||
if "OKX_OPTIONS_MARGIN_MODE" in clean:
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
|
||||
|
||||
lines = read_env_lines(env_path)
|
||||
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "usdc")
|
||||
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
|
||||
if old_mode != new_mode:
|
||||
conn_m = get_db()
|
||||
try:
|
||||
block = mode_switch_block_msg(conn_m, None)
|
||||
if block:
|
||||
return jsonify({"ok": False, "msg": block}), 400
|
||||
finally:
|
||||
conn_m.close()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
|
||||
changed = apply_env_updates(env_path, clean)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
|
||||
@@ -351,7 +351,7 @@ def close_option_by_bid1(
|
||||
# 自动平已挂过单:同仓续批只验流动性
|
||||
mark_close_gate_passed(inst_id)
|
||||
|
||||
return {
|
||||
out = {
|
||||
"ok": True,
|
||||
"mode": "bid1",
|
||||
"orders": [{"order": order, "px": px, "sheets": level_sheets}],
|
||||
@@ -369,6 +369,18 @@ def close_option_by_bid1(
|
||||
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
|
||||
),
|
||||
}
|
||||
if fully_closed:
|
||||
try:
|
||||
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
|
||||
|
||||
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out)
|
||||
if spot_sell is not None:
|
||||
out["spot_sell"] = spot_sell
|
||||
if spot_sell.get("bridge_status") == "pending_sell_spot":
|
||||
out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回"
|
||||
except Exception as e:
|
||||
out["spot_sell"] = {"ok": False, "msg": str(e)}
|
||||
return out
|
||||
|
||||
|
||||
# 兼容旧名
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""币本位单笔期权:买满 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,
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
"""先买满 USDT 预算对应的币,再按卖一尽量开满."""
|
||||
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,
|
||||
}
|
||||
|
||||
# 1) 买币(用买入前后可用差作为本轮币量,避免叠加原有现货)
|
||||
coin_before = fetch_trading_coin_available(ex, underlying) or 0.0
|
||||
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=budget_usdt)
|
||||
if not buy.get("ok"):
|
||||
return {"ok": False, "msg": f"现货买入 {underlying} 失败: {buy.get('msg')}", "budget": budget_info}
|
||||
bridge_id = insert_bridge(
|
||||
conn,
|
||||
underlying=underlying,
|
||||
status=BRIDGE_BOUGHT,
|
||||
budget_usdt=budget_usdt,
|
||||
buy_ord_id=str(buy.get("ord_id") or ""),
|
||||
inst_id=inst_id,
|
||||
message="已买币,待开期权",
|
||||
)
|
||||
# 等余额落账
|
||||
time.sleep(0.6)
|
||||
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,
|
||||
}
|
||||
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))
|
||||
|
||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||
min_sz = int(q.get("min_sz") or 1)
|
||||
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}
|
||||
|
||||
sheets = int(sizing["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,
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
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=budget_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
|
||||
@@ -101,11 +101,21 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
|
||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
|
||||
"ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'",
|
||||
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
|
||||
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
|
||||
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
|
||||
):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.options.options_spot_bridge_lib import ensure_bridge_table
|
||||
|
||||
ensure_bridge_table(conn)
|
||||
except Exception:
|
||||
pass
|
||||
init_options_review_tables(conn)
|
||||
|
||||
|
||||
|
||||
@@ -96,6 +96,47 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
has_upl = True
|
||||
upl_total += float(pnl)
|
||||
bal = cfg["fetch_options_balances"](ex)
|
||||
from lib.options.options_margin_mode_lib import (
|
||||
is_coin_margin_mode,
|
||||
normalize_options_margin_mode,
|
||||
premium_ccy_for_mode,
|
||||
)
|
||||
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
for p in positions:
|
||||
mid = str(p.get("inst_id") or "")
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
|
||||
|
||||
row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode
|
||||
p["margin_mode"] = row_mode
|
||||
p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode(
|
||||
row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH")
|
||||
)
|
||||
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
|
||||
|
||||
coin_budget = None
|
||||
bridge_status = None
|
||||
open_bridges = []
|
||||
if is_coin_margin_mode():
|
||||
try:
|
||||
from lib.options.options_coin_open_lib import coin_budget_preview
|
||||
|
||||
coin_budget = coin_budget_preview(cfg, ex)
|
||||
except Exception:
|
||||
coin_budget = None
|
||||
try:
|
||||
conn_b = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_spot_bridge_lib import list_open_bridges
|
||||
|
||||
open_bridges = list_open_bridges(conn_b)
|
||||
if open_bridges:
|
||||
bridge_status = str(open_bridges[0].get("status") or "")
|
||||
finally:
|
||||
conn_b.close()
|
||||
except Exception:
|
||||
open_bridges = []
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
@@ -113,6 +154,11 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"trade_budget": cfg.get("trade_budget"),
|
||||
"account_label": cfg.get("account_label") or "OKX期权",
|
||||
"max_active_positions": options_max_active_positions(),
|
||||
"options_margin_mode": margin_mode,
|
||||
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
|
||||
"coin_budget": coin_budget,
|
||||
"bridge_status": bridge_status,
|
||||
"open_bridges": open_bridges,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "enabled": True, "msg": str(e)}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""OKX 单笔期权本位模式与币本位 USDT 预算."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
MODE_USDC = "usdc"
|
||||
MODE_COIN = "coin"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
v = (os.getenv(name) or "").strip().lower()
|
||||
if not v:
|
||||
return default
|
||||
return v in ("1", "true", "yes", "on", "y")
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name) or default)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def normalize_options_margin_mode(raw: Any = None) -> str:
|
||||
"""返回 usdc | coin."""
|
||||
if raw is None:
|
||||
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
|
||||
v = str(raw or MODE_USDC).strip().lower()
|
||||
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
|
||||
return MODE_COIN
|
||||
return MODE_USDC
|
||||
|
||||
|
||||
def is_coin_margin_mode(raw: Any = None) -> bool:
|
||||
return normalize_options_margin_mode(raw) == MODE_COIN
|
||||
|
||||
|
||||
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
|
||||
u = (underlying or "ETH").strip().upper() or "ETH"
|
||||
mode = normalize_options_margin_mode(margin_mode)
|
||||
if mode == MODE_COIN:
|
||||
return f"{u}-USD"
|
||||
return f"{u}-USD_UM"
|
||||
|
||||
|
||||
def margin_mode_from_inst_id(inst_id: str) -> str:
|
||||
inst = (inst_id or "").strip().upper()
|
||||
if not inst:
|
||||
return normalize_options_margin_mode()
|
||||
if "_UM" in inst:
|
||||
return MODE_USDC
|
||||
# ETH-USD-260701-2500-C / BTC-USD-...
|
||||
if "-USD-" in inst and "_UM" not in inst:
|
||||
return MODE_COIN
|
||||
return normalize_options_margin_mode()
|
||||
|
||||
|
||||
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
|
||||
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
|
||||
return (underlying or "ETH").strip().upper() or "ETH"
|
||||
return "USDC"
|
||||
|
||||
|
||||
def spot_quote_inst_id(underlying: str) -> str:
|
||||
"""现货市价买卖: ETH-USDT / BTC-USDT."""
|
||||
u = (underlying or "ETH").strip().upper() or "ETH"
|
||||
return f"{u}-USDT"
|
||||
|
||||
|
||||
def compute_coin_budget_usdt(
|
||||
trading_usdt: float,
|
||||
*,
|
||||
compound: bool | None = None,
|
||||
buffer: float | None = None,
|
||||
fixed_budget_usdt: float | None = None,
|
||||
max_enabled: bool | None = None,
|
||||
max_usdt: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
币本位单笔 USDT 预算.
|
||||
复利开: trading_usdt × buffer; 复利关: fixed × buffer.
|
||||
上限开: min(..., max_usdt).
|
||||
"""
|
||||
bal = max(0.0, float(trading_usdt or 0))
|
||||
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
|
||||
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
|
||||
if buf <= 0:
|
||||
buf = 0.95
|
||||
fixed = (
|
||||
float(fixed_budget_usdt)
|
||||
if fixed_budget_usdt is not None
|
||||
else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0)
|
||||
)
|
||||
if use_compound:
|
||||
raw = bal * buf
|
||||
source = "compound"
|
||||
else:
|
||||
raw = max(0.0, fixed) * buf
|
||||
source = "fixed"
|
||||
capped = False
|
||||
max_on = (
|
||||
_env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False)
|
||||
if max_enabled is None
|
||||
else bool(max_enabled)
|
||||
)
|
||||
max_n = (
|
||||
float(max_usdt)
|
||||
if max_usdt is not None
|
||||
else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0)
|
||||
)
|
||||
budget = raw
|
||||
if max_on and max_n > 0 and budget > max_n:
|
||||
budget = max_n
|
||||
capped = True
|
||||
return {
|
||||
"ok": budget > 0,
|
||||
"budget_usdt": round(budget, 8),
|
||||
"raw_usdt": round(raw, 8),
|
||||
"trading_usdt": round(bal, 8),
|
||||
"buffer": buf,
|
||||
"compound": use_compound,
|
||||
"source": source,
|
||||
"max_enabled": max_on,
|
||||
"max_usdt": max_n if max_on else None,
|
||||
"capped_by_max": capped,
|
||||
"msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算",
|
||||
}
|
||||
|
||||
|
||||
def calc_sheets_from_coin_balance(
|
||||
*,
|
||||
quote_per_unit: float,
|
||||
ct_mult: float,
|
||||
min_sz: int,
|
||||
coin_available: float,
|
||||
) -> dict[str, Any]:
|
||||
"""用可用标的币尽量开满(权利金以币计)."""
|
||||
import math
|
||||
|
||||
ask = float(quote_per_unit or 0)
|
||||
mult = float(ct_mult or 0.01)
|
||||
need = max(1, int(min_sz or 1))
|
||||
coin = max(0.0, float(coin_available or 0))
|
||||
if ask <= 0 or mult <= 0:
|
||||
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0}
|
||||
per_sheet = ask * mult
|
||||
if per_sheet <= 0:
|
||||
return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0}
|
||||
sheets = int(math.floor((coin / per_sheet) + 1e-12))
|
||||
if sheets < need:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})",
|
||||
"sheets": sheets,
|
||||
"coin_premium": round(sheets * per_sheet, 8),
|
||||
"per_sheet_coin": per_sheet,
|
||||
}
|
||||
prem = sheets * per_sheet
|
||||
return {
|
||||
"ok": True,
|
||||
"msg": "",
|
||||
"sheets": sheets,
|
||||
"coin_premium": round(prem, 8),
|
||||
"per_sheet_coin": per_sheet,
|
||||
"eth_amount": round(sheets * mult, 8),
|
||||
}
|
||||
@@ -106,6 +106,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True),
|
||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
||||
"margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "usdc").strip().lower(),
|
||||
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
||||
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
|
||||
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
|
||||
@@ -421,16 +422,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
**bal,
|
||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
|
||||
"compound_full_enabled": _compound_full_enabled(),
|
||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
||||
}
|
||||
)
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode
|
||||
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
payload = {
|
||||
"ok": True,
|
||||
**bal,
|
||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
|
||||
"compound_full_enabled": _compound_full_enabled(),
|
||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
||||
"options_margin_mode": margin_mode,
|
||||
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
|
||||
}
|
||||
if is_coin_margin_mode():
|
||||
try:
|
||||
from lib.options.options_coin_open_lib import coin_budget_preview
|
||||
|
||||
payload["coin_budget"] = coin_budget_preview(cfg, ex)
|
||||
except Exception as e:
|
||||
payload["coin_budget"] = {"ok": False, "msg": str(e)}
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/options/chain")
|
||||
@lr
|
||||
@@ -442,12 +454,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
||||
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
chain = cfg["build_option_chain"](
|
||||
ex,
|
||||
u,
|
||||
max_dte_days=chain_max_dte,
|
||||
itm_only=False,
|
||||
itm_max_dist_usd=cfg["itm_max_dist"],
|
||||
margin_mode=margin_mode,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
||||
@@ -456,6 +472,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
# 热更新:每次读 env,保存配置后刷新链即可生效
|
||||
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
|
||||
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
|
||||
coin_budget = None
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
from lib.options.options_coin_open_lib import coin_budget_preview
|
||||
|
||||
if is_coin_margin_mode():
|
||||
coin_budget = coin_budget_preview(cfg, ex)
|
||||
except Exception:
|
||||
coin_budget = None
|
||||
if not expiries:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -466,6 +491,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"ask_liq_filter_enabled": ask_liq_filter,
|
||||
"budget_buffer": budget_buffer,
|
||||
"trade_budget": cfg["trade_budget"],
|
||||
"options_margin_mode": chain.get("margin_mode") or margin_mode,
|
||||
"coin_budget": coin_budget,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
@@ -476,6 +503,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"ask_liq_filter_enabled": ask_liq_filter,
|
||||
"budget_buffer": budget_buffer,
|
||||
"trade_budget": cfg["trade_budget"],
|
||||
"options_margin_mode": chain.get("margin_mode") or margin_mode,
|
||||
"coin_budget": coin_budget,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -810,6 +839,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode
|
||||
from lib.options.options_coin_open_lib import open_coin_option_buy_full
|
||||
|
||||
if is_coin_margin_mode():
|
||||
result = open_coin_option_buy_full(
|
||||
cfg,
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
signal_note=signal_note,
|
||||
target_index=target_index,
|
||||
profit_exit_enabled=profit_exit_enabled,
|
||||
profit_exit_mult=profit_exit_mult,
|
||||
)
|
||||
if result.get("ok"):
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
|
||||
invalidate_option_positions_cache()
|
||||
_mark_balances_stale(cfg)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"})
|
||||
q = cfg["quote_option_contract"](ex, inst_id)
|
||||
if not q.get("ok"):
|
||||
return jsonify(q)
|
||||
@@ -1434,9 +1485,46 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn2.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
|
||||
|
||||
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result)
|
||||
if spot_sell is not None:
|
||||
result = dict(result)
|
||||
result["spot_sell"] = spot_sell
|
||||
if spot_sell.get("bridge_status") == "pending_sell_spot":
|
||||
result["msg"] = (
|
||||
str(result.get("msg") or "平仓成功")
|
||||
+ ";但卖回 USDT 失败,请点「重试卖回」"
|
||||
)
|
||||
except Exception as e:
|
||||
result = dict(result)
|
||||
result["spot_sell"] = {"ok": False, "msg": str(e)}
|
||||
_mark_balances_stale(cfg)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/options/spot-bridge/retry-sell", methods=["POST"])
|
||||
@lr
|
||||
def api_options_spot_bridge_retry_sell():
|
||||
"""币本位:重试把残留标的币市价卖回 USDT."""
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
data = request.get_json(silent=True) or {}
|
||||
underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper()
|
||||
inst_id = (data.get("inst_id") or "").strip() or None
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat
|
||||
|
||||
out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
|
||||
if out.get("ok"):
|
||||
_mark_balances_stale(cfg)
|
||||
return jsonify(out)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/convert/quote", methods=["POST"])
|
||||
@lr
|
||||
def api_options_convert_quote():
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""币本位期权:USDT↔标的币现货桥与本地状态."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lib.options.options_margin_mode_lib import spot_quote_inst_id
|
||||
|
||||
|
||||
BRIDGE_BOUGHT = "bought_pending_open"
|
||||
BRIDGE_HOLDING = "holding"
|
||||
BRIDGE_PENDING_SELL = "pending_sell_spot"
|
||||
BRIDGE_CLOSED = "closed"
|
||||
|
||||
|
||||
def ensure_bridge_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_spot_bridge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
underlying TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
budget_usdt REAL,
|
||||
buy_ord_id TEXT,
|
||||
coin_bought REAL,
|
||||
sell_ord_id TEXT,
|
||||
coin_sold REAL,
|
||||
usdt_recovered REAL,
|
||||
inst_id TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_spot_bridge_status
|
||||
ON options_spot_bridge(status)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
ensure_bridge_table(conn)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT id, underlying, status, budget_usdt, buy_ord_id, coin_bought,
|
||||
sell_ord_id, coin_sold, usdt_recovered, inst_id, message,
|
||||
created_at, updated_at, closed_at
|
||||
FROM options_spot_bridge
|
||||
WHERE status IN (?, ?, ?)
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(BRIDGE_BOUGHT, BRIDGE_HOLDING, BRIDGE_PENDING_SELL),
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def has_unfinished_bridge(conn: sqlite3.Connection) -> bool:
|
||||
return bool(list_open_bridges(conn))
|
||||
|
||||
|
||||
def insert_bridge(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
underlying: str,
|
||||
status: str,
|
||||
budget_usdt: float | None = None,
|
||||
buy_ord_id: str | None = None,
|
||||
coin_bought: float | None = None,
|
||||
inst_id: str | None = None,
|
||||
message: str | None = None,
|
||||
) -> int:
|
||||
ensure_bridge_table(conn)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO options_spot_bridge(
|
||||
underlying, status, budget_usdt, buy_ord_id, coin_bought, inst_id, message, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
(underlying or "ETH").upper(),
|
||||
status,
|
||||
budget_usdt,
|
||||
buy_ord_id,
|
||||
coin_bought,
|
||||
inst_id,
|
||||
message,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_bridge(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
*,
|
||||
status: str | None = None,
|
||||
buy_ord_id: str | None = None,
|
||||
coin_bought: float | None = None,
|
||||
sell_ord_id: str | None = None,
|
||||
coin_sold: float | None = None,
|
||||
usdt_recovered: float | None = None,
|
||||
inst_id: str | None = None,
|
||||
message: str | None = None,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
ensure_bridge_table(conn)
|
||||
fields: list[str] = ["updated_at=CURRENT_TIMESTAMP"]
|
||||
vals: list[Any] = []
|
||||
if status is not None:
|
||||
fields.append("status=?")
|
||||
vals.append(status)
|
||||
if buy_ord_id is not None:
|
||||
fields.append("buy_ord_id=?")
|
||||
vals.append(buy_ord_id)
|
||||
if coin_bought is not None:
|
||||
fields.append("coin_bought=?")
|
||||
vals.append(coin_bought)
|
||||
if sell_ord_id is not None:
|
||||
fields.append("sell_ord_id=?")
|
||||
vals.append(sell_ord_id)
|
||||
if coin_sold is not None:
|
||||
fields.append("coin_sold=?")
|
||||
vals.append(coin_sold)
|
||||
if usdt_recovered is not None:
|
||||
fields.append("usdt_recovered=?")
|
||||
vals.append(usdt_recovered)
|
||||
if inst_id is not None:
|
||||
fields.append("inst_id=?")
|
||||
vals.append(inst_id)
|
||||
if message is not None:
|
||||
fields.append("message=?")
|
||||
vals.append(message)
|
||||
if close or status == BRIDGE_CLOSED:
|
||||
fields.append("closed_at=CURRENT_TIMESTAMP")
|
||||
vals.append(int(bridge_id))
|
||||
conn.execute(
|
||||
f"UPDATE options_spot_bridge SET {', '.join(fields)} WHERE id=?",
|
||||
vals,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
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 fetch_trading_coin_available(ex: Any, ccy: str) -> float | None:
|
||||
"""交易账户标的币可用."""
|
||||
from lib.exchange.okx_options_lib import _extract_ccy_free, _safe_float as _sf
|
||||
|
||||
ccy_u = (ccy or "").upper()
|
||||
if not ccy_u:
|
||||
return None
|
||||
try:
|
||||
bal = ex.fetch_balance(params={"type": "trading"})
|
||||
free = _extract_ccy_free(bal, ccy_u)
|
||||
if free is not None:
|
||||
return float(free)
|
||||
# 部分账户结构只有 total
|
||||
from lib.exchange.okx_options_lib import _extract_ccy_balance
|
||||
|
||||
tot = _extract_ccy_balance(bal, ccy_u)
|
||||
return float(tot) if tot is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def spot_market_buy_coin_with_usdt(
|
||||
ex: Any,
|
||||
*,
|
||||
underlying: str,
|
||||
usdt_amount: float,
|
||||
) -> dict[str, Any]:
|
||||
"""交易账户:用 USDT 市价买入标的币."""
|
||||
if usdt_amount <= 0:
|
||||
return {"ok": False, "msg": "USDT 数量须大于 0"}
|
||||
inst_id = spot_quote_inst_id(underlying)
|
||||
try:
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": "buy",
|
||||
"ordType": "market",
|
||||
"sz": str(usdt_amount),
|
||||
"tgtCcy": "quote_ccy",
|
||||
}
|
||||
resp = ex.private_post_trade_order(body)
|
||||
data = (resp or {}).get("data") or []
|
||||
if data and str(data[0].get("sCode")) == "0":
|
||||
return {
|
||||
"ok": True,
|
||||
"inst_id": inst_id,
|
||||
"ord_id": str(data[0].get("ordId") or ""),
|
||||
"data": data[0],
|
||||
"raw": resp,
|
||||
}
|
||||
from lib.exchange.okx_options_lib import _okx_trade_error_message
|
||||
|
||||
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
|
||||
except Exception as e:
|
||||
from lib.exchange.okx_options_lib import _okx_trade_error_message
|
||||
|
||||
return {"ok": False, "msg": _okx_trade_error_message(e)}
|
||||
|
||||
|
||||
def spot_market_sell_coin_to_usdt(
|
||||
ex: Any,
|
||||
*,
|
||||
underlying: str,
|
||||
coin_amount: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""交易账户:市价卖出标的币换 USDT.coin_amount 空则尽量卖光可用."""
|
||||
ccy = (underlying or "ETH").upper()
|
||||
amt = coin_amount
|
||||
if amt is None or float(amt) <= 0:
|
||||
avail = fetch_trading_coin_available(ex, ccy)
|
||||
if avail is None or float(avail) <= 0:
|
||||
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
|
||||
amt = float(avail)
|
||||
if float(amt) <= 0:
|
||||
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
|
||||
# 留一点粉尘避免精度拒单
|
||||
sell_sz = float(amt)
|
||||
if sell_sz > 1e-8:
|
||||
sell_sz = max(0.0, sell_sz * 0.999)
|
||||
inst_id = spot_quote_inst_id(ccy)
|
||||
try:
|
||||
# 现货卖出数量精度:截到 8 位
|
||||
sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".")
|
||||
if not sz or float(sz) <= 0:
|
||||
return {"ok": False, "msg": f"{ccy} 可卖数量过小"}
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": "sell",
|
||||
"ordType": "market",
|
||||
"sz": sz,
|
||||
"tgtCcy": "base_ccy",
|
||||
}
|
||||
resp = ex.private_post_trade_order(body)
|
||||
data = (resp or {}).get("data") or []
|
||||
if data and str(data[0].get("sCode")) == "0":
|
||||
return {
|
||||
"ok": True,
|
||||
"inst_id": inst_id,
|
||||
"ord_id": str(data[0].get("ordId") or ""),
|
||||
"coin_sold": float(sz),
|
||||
"data": data[0],
|
||||
"raw": resp,
|
||||
}
|
||||
from lib.exchange.okx_options_lib import _okx_trade_error_message
|
||||
|
||||
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
|
||||
except Exception as e:
|
||||
from lib.exchange.okx_options_lib import _okx_trade_error_message
|
||||
|
||||
return {"ok": False, "msg": _okx_trade_error_message(e)}
|
||||
|
||||
|
||||
def rollback_bought_coin_to_usdt(
|
||||
conn: sqlite3.Connection,
|
||||
ex: Any,
|
||||
*,
|
||||
bridge_id: int,
|
||||
underlying: str,
|
||||
reason: str = "",
|
||||
coin_amount: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量."""
|
||||
amt = coin_amount
|
||||
if amt is None or float(amt) <= 0:
|
||||
ensure_bridge_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if row:
|
||||
try:
|
||||
amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0)
|
||||
except (TypeError, ValueError, KeyError, IndexError):
|
||||
amt = None
|
||||
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt)
|
||||
if not sell.get("ok"):
|
||||
update_bridge(
|
||||
conn,
|
||||
bridge_id,
|
||||
status=BRIDGE_PENDING_SELL,
|
||||
message=(reason or "") + " | 回滚卖币失败: " + str(sell.get("msg") or ""),
|
||||
)
|
||||
return {"ok": False, "msg": sell.get("msg") or "回滚卖币失败", "bridge_status": BRIDGE_PENDING_SELL}
|
||||
update_bridge(
|
||||
conn,
|
||||
bridge_id,
|
||||
status=BRIDGE_CLOSED,
|
||||
sell_ord_id=str(sell.get("ord_id") or ""),
|
||||
coin_sold=_safe_float(sell.get("coin_sold")),
|
||||
message=reason or "开仓失败已卖回 USDT",
|
||||
close=True,
|
||||
)
|
||||
return {"ok": True, "sell": sell, "bridge_status": BRIDGE_CLOSED}
|
||||
|
||||
|
||||
def sell_residual_after_option_flat(
|
||||
conn: sqlite3.Connection,
|
||||
ex: Any,
|
||||
*,
|
||||
underlying: str,
|
||||
inst_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥."""
|
||||
ensure_bridge_table(conn)
|
||||
bridges = list_open_bridges(conn)
|
||||
target = None
|
||||
for b in bridges:
|
||||
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
|
||||
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
|
||||
target = b
|
||||
break
|
||||
coin_amt = None
|
||||
if target is not None:
|
||||
try:
|
||||
coin_amt = float(target.get("coin_bought") or 0) or None
|
||||
except (TypeError, ValueError):
|
||||
coin_amt = None
|
||||
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=coin_amt)
|
||||
if target is None:
|
||||
if not sell.get("ok"):
|
||||
msg = str(sell.get("msg") or "")
|
||||
if "无可用" in msg or "过小" in msg:
|
||||
return {"ok": True, "msg": "无残留币需卖回", "skipped": True}
|
||||
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL}
|
||||
return {"ok": True, "sell": sell, "bridge_status": None}
|
||||
bid = int(target["id"])
|
||||
if not sell.get("ok"):
|
||||
update_bridge(
|
||||
conn,
|
||||
bid,
|
||||
status=BRIDGE_PENDING_SELL,
|
||||
inst_id=inst_id,
|
||||
message=str(sell.get("msg") or "卖回 USDT 失败"),
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": sell.get("msg") or "卖回 USDT 失败",
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_PENDING_SELL,
|
||||
}
|
||||
update_bridge(
|
||||
conn,
|
||||
bid,
|
||||
status=BRIDGE_CLOSED,
|
||||
sell_ord_id=str(sell.get("ord_id") or ""),
|
||||
coin_sold=_safe_float(sell.get("coin_sold")),
|
||||
inst_id=inst_id,
|
||||
message="期权已平,币已卖回 USDT",
|
||||
close=True,
|
||||
)
|
||||
return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED}
|
||||
|
||||
|
||||
def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
|
||||
bridges = list_open_bridges(conn)
|
||||
if not bridges:
|
||||
return None
|
||||
st = str(bridges[0].get("status") or "")
|
||||
if st == BRIDGE_PENDING_SELL:
|
||||
return "存在待卖回 USDT 的币本位桥残留,请先到期权页重试卖回后再开仓"
|
||||
if st == BRIDGE_BOUGHT:
|
||||
return "存在已买币未完成开仓的桥流程,请等待回滚或联系处理后重试"
|
||||
if st == BRIDGE_HOLDING:
|
||||
return "币本位桥仍在持仓中(一次仅一笔),请先平仓并卖回 USDT"
|
||||
return "存在未完成的币本位资金桥,暂不可开仓"
|
||||
|
||||
|
||||
def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None:
|
||||
"""有单笔期权仓或未完成桥时禁止切换本位."""
|
||||
if has_unfinished_bridge(conn):
|
||||
return "存在未完成的币本位资金桥,禁止切换期权本位模式"
|
||||
if ex is not None:
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import fetch_option_positions
|
||||
|
||||
rows = fetch_option_positions(ex) or []
|
||||
for p in rows:
|
||||
try:
|
||||
pos = float(p.get("pos") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pos = 0.0
|
||||
if abs(pos) > 1e-12:
|
||||
return "存在未平期权持仓,禁止切换期权本位模式"
|
||||
except Exception:
|
||||
pass
|
||||
# 本地 open 交易记录
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM options_trades WHERE status='open'"
|
||||
).fetchone()
|
||||
n = int(row[0] if not isinstance(row, dict) else row.get("COUNT(*)") or list(row.values())[0])
|
||||
if n > 0:
|
||||
return "本地仍有未平期权记录,禁止切换期权本位模式"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -28,6 +28,7 @@
|
||||
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
|
||||
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
|
||||
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
||||
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code>):按交易户 USDT×缓冲买满 ETH/BTC 再开满期权;平仓后自动卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
|
||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
||||
<li>平仓仅买一限价,详见说明文档。</li>
|
||||
</ul>
|
||||
|
||||
@@ -123,9 +123,16 @@ def _format_options_position_detail_line(p: dict) -> str:
|
||||
if sheets is None:
|
||||
sheets = "?"
|
||||
parts = [f"期权 {inst} {label}", f"来源{src}", f"张数{sheets}"]
|
||||
mode_lab = p.get("margin_mode_label") or ("币本位" if p.get("margin_mode") == "coin" else "")
|
||||
if mode_lab:
|
||||
parts.append(f"本位{mode_lab}")
|
||||
paid = _safe_float(p.get("premium_paid"))
|
||||
if paid is not None:
|
||||
parts.append(f"权利金{paid:g}U")
|
||||
ccy = p.get("premium_ccy") or ("ETH" if p.get("margin_mode") == "coin" else "USDC")
|
||||
if str(ccy).upper() == "USDC":
|
||||
parts.append(f"权利金{paid:g}U")
|
||||
else:
|
||||
parts.append(f"权利金{paid:g}{ccy}")
|
||||
net: Optional[float] = None
|
||||
try:
|
||||
from lib.options.options_positions_lib import net_pnl_from_display_row
|
||||
|
||||
@@ -3993,7 +3993,9 @@
|
||||
}
|
||||
const target = findOptionsTargetForInst(targets, p.inst_id);
|
||||
html += `<tr>
|
||||
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}</code></td>
|
||||
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}${
|
||||
p.margin_mode_label || p.margin_mode === "coin" ? ` <span class="hub-opt-mode">${esc(p.margin_mode_label || "币本位")}</span>` : ""
|
||||
}</code></td>
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${esc(p.pos)}</td>
|
||||
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
||||
@@ -4056,7 +4058,14 @@
|
||||
const pos = Array.isArray(opt.positions) ? opt.positions : [];
|
||||
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
|
||||
html += renderOptionsAccountStatRow(opt);
|
||||
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
|
||||
const modeLab = esc(opt.options_margin_mode_label || (opt.options_margin_mode === "coin" ? "币本位" : "USDC"));
|
||||
const bridgeHint =
|
||||
opt.bridge_status === "pending_sell_spot"
|
||||
? " · 待卖回USDT"
|
||||
: opt.bridge_status
|
||||
? ` · 桥:${esc(opt.bridge_status)}`
|
||||
: "";
|
||||
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓 · ${modeLab}${bridgeHint}</div>`;
|
||||
html +=
|
||||
layout === "cards"
|
||||
? renderOptionsPositionsCards(pos)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""币本位模式预算与合约族单测."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestOptionsMarginMode(unittest.TestCase):
|
||||
def test_normalize_mode(self):
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
|
||||
self.assertEqual(normalize_options_margin_mode("usdc"), "usdc")
|
||||
self.assertEqual(normalize_options_margin_mode("coin"), "coin")
|
||||
self.assertEqual(normalize_options_margin_mode("币本位"), "coin")
|
||||
|
||||
def test_inst_family(self):
|
||||
from lib.options.options_margin_mode_lib import inst_family_for_underlying
|
||||
|
||||
self.assertEqual(inst_family_for_underlying("ETH", margin_mode="usdc"), "ETH-USD_UM")
|
||||
self.assertEqual(inst_family_for_underlying("ETH", margin_mode="coin"), "ETH-USD")
|
||||
|
||||
def test_margin_mode_from_inst_id(self):
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
|
||||
|
||||
self.assertEqual(margin_mode_from_inst_id("ETH-USD_UM-260701-2500-C"), "usdc")
|
||||
self.assertEqual(margin_mode_from_inst_id("ETH-USD-260701-2500-C"), "coin")
|
||||
|
||||
def test_coin_budget_compound(self):
|
||||
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
r = compute_coin_budget_usdt(
|
||||
20.0,
|
||||
compound=True,
|
||||
buffer=0.95,
|
||||
max_enabled=False,
|
||||
)
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertAlmostEqual(r["budget_usdt"], 19.0, places=6)
|
||||
|
||||
def test_coin_budget_max_cap(self):
|
||||
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
|
||||
|
||||
r = compute_coin_budget_usdt(
|
||||
100.0,
|
||||
compound=True,
|
||||
buffer=0.95,
|
||||
max_enabled=True,
|
||||
max_usdt=50.0,
|
||||
)
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertAlmostEqual(r["budget_usdt"], 50.0, places=6)
|
||||
self.assertTrue(r["capped_by_max"])
|
||||
|
||||
def test_coin_budget_fixed(self):
|
||||
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
|
||||
|
||||
r = compute_coin_budget_usdt(
|
||||
100.0,
|
||||
compound=False,
|
||||
buffer=0.95,
|
||||
fixed_budget_usdt=10.0,
|
||||
max_enabled=False,
|
||||
)
|
||||
self.assertAlmostEqual(r["budget_usdt"], 9.5, places=6)
|
||||
|
||||
def test_sheets_from_coin(self):
|
||||
from lib.options.options_margin_mode_lib import calc_sheets_from_coin_balance
|
||||
|
||||
# ask 0.01 ETH per 1 ETH, ctMult 0.01 → 每张 0.0001 ETH; 0.01 ETH 可开 100 张
|
||||
r = calc_sheets_from_coin_balance(
|
||||
quote_per_unit=0.01,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
coin_available=0.01,
|
||||
)
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertEqual(r["sheets"], 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user