币本位买币改为按最大可开张数×权利金×可配缓冲,不全额兑换USDT

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-20 14:33:48 +08:00
parent 4fb3be35ef
commit 87910ed71a
9 changed files with 309 additions and 63 deletions
+2
View File
@@ -122,6 +122,8 @@ OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
@@ -57,6 +57,9 @@
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` |
开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。
说明:
+10 -2
View File
@@ -1344,8 +1344,11 @@
? ((d && d.msg) || (sz && sz.msg) || "无法开仓")
: "暂无卖一深度,无法开仓";
} else if (coinMode) {
const buyU = sz && sz.buy_usdt != null ? sz.buy_usdt : null;
openBtn.textContent =
bud != null ? "买币并开仓(预算 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一";
buyU != null
? ("买币并开仓(约 " + Number(buyU).toFixed(2) + " USDT)")
: (bud != null ? "买币并开仓(预算上限 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一");
} else {
openBtn.textContent = "限价买入 @ 卖一";
}
@@ -1577,8 +1580,13 @@
? ((latest && (latest.msg || (latest.sizing && latest.sizing.msg))) || "无法开仓")
: "暂无卖一深度,无法开仓";
} else if (coinMode) {
const buyU = latest && latest.sizing && latest.sizing.buy_usdt != null
? latest.sizing.buy_usdt
: null;
btn.textContent =
bud != null ? "买币并开仓(预算 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一";
buyU != null
? ("买币并开仓(约 " + Number(buyU).toFixed(2) + " USDT)")
: (bud != null ? "买币并开仓(预算上限 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一");
} else {
btn.textContent = "限价买入 @ 卖一";
}
+1
View File
@@ -104,6 +104,7 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER",
+5
View File
@@ -174,6 +174,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
"币本位单笔上限(USDT)",
"仅上限开关开启时生效",
),
(
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
+59 -10
View File
@@ -17,6 +17,7 @@ from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
margin_mode_from_inst_id,
normalize_options_margin_mode,
plan_coin_open_by_budget,
premium_ccy_for_mode,
)
from lib.options.options_spot_bridge_lib import (
@@ -56,8 +57,9 @@ def open_coin_option_buy_full(
target_index: float | None = None,
profit_exit_enabled: bool = False,
profit_exit_mult: float = 1.0,
target_sheets: int | None = None,
) -> 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,
@@ -111,16 +113,56 @@ def open_coin_option_buy_full(
"can_open": False,
}
# 1) 买币(用买入前后可用差作为本轮币量,避免叠加原有现货)
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
idx = None
try:
idx = float(q.get("index_px") or q.get("idxPx") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
try:
from lib.exchange.okx_options_lib import fetch_index_price
idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0)
except Exception:
idx = 0.0
plan = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdt=budget_usdt,
index_px=float(idx),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if not plan.get("ok"):
return {
"ok": False,
"msg": plan.get("msg") or "无法规划买币张数",
"plan": plan,
"budget": budget_info,
"can_open": False,
}
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
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)
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
if not buy.get("ok"):
return {"ok": False, "msg": f"现货买入 {underlying} 失败: {buy.get('msg')}", "budget": budget_info}
return {
"ok": False,
"msg": f"现货买入 {underlying} 失败: {buy.get('msg')}",
"budget": budget_info,
"plan": plan,
}
bridge_id = insert_bridge(
conn,
underlying=underlying,
status=BRIDGE_BOUGHT,
budget_usdt=budget_usdt,
budget_usdt=buy_usdt,
buy_ord_id=str(buy.get("ord_id") or ""),
inst_id=inst_id,
message="已买币,待开期权",
@@ -143,6 +185,7 @@ def open_coin_option_buy_full(
"msg": "买币后读不到可用余额,已尝试卖回 USDT",
"rollback": rb,
"budget": budget_info,
"plan": plan,
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
@@ -155,8 +198,6 @@ def open_coin_option_buy_full(
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,
@@ -172,9 +213,10 @@ def open_coin_option_buy_full(
reason=sizing.get("msg") or "张数不足",
coin_amount=float(coin_bought),
)
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info}
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan}
sheets = int(sizing["sheets"])
# 实盘以买到的币为准,但不超过规划张数
sheets = min(int(sizing["sheets"]), int(plan["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(
@@ -190,6 +232,13 @@ def open_coin_option_buy_full(
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"ask_depth_capped": True,
}
else:
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
}
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
@@ -292,7 +341,7 @@ def open_coin_option_buy_full(
signal_note=signal_note,
exchange_ord_id=ord_id,
bridge_id=bridge_id,
budget_usdt=budget_usdt,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
+150
View File
@@ -134,6 +134,156 @@ def compute_coin_budget_usdt(
}
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
if v <= 0:
return 1.10
if v < 1.0:
return 1.0 + v
return v
def plan_coin_open_by_budget(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdt: float,
index_px: float,
ask_sz: float | None = None,
spot_buy_buffer: float | None = None,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""
先按预算/卖一估最大可开张数,再按「权利金 × 现货缓冲」算应买现货 USDT.
不全额把预算换成币.
"""
import math
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
budget = max(0.0, float(budget_usdt or 0))
idx = float(index_px or 0)
buf = normalize_coin_spot_buy_buffer(spot_buy_buffer)
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0}
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0}
if budget <= 0:
return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0}
per_sheet_coin = ask * mult
# 每张开仓需买的币(含缓冲)及其约合 USDT
per_sheet_buy_coin = per_sheet_coin * buf
per_sheet_usdt = per_sheet_buy_coin * idx
if per_sheet_usdt <= 0:
return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0}
max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12))
if target_sheets is not None:
try:
want = int(target_sheets)
except (TypeError, ValueError):
want = 0
if want < need:
return {
"ok": False,
"msg": f"指定张数无效(需≥{need})",
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
}
sheets = min(want, max_by_budget)
if sheets < want:
return {
"ok": False,
"msg": (
f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g}),"
f"不足指定 {want}"
),
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
"spot_buy_buffer": buf,
}
else:
sheets = max_by_budget
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need)
ask_depth_capped = False
if capped is None:
return {
"ok": False,
"msg": cap_msg or "卖一深度不足",
"sheets": 0,
"buy_usdt": 0.0,
"spot_buy_buffer": buf,
}
if int(capped) < sheets:
sheets = int(capped)
ask_depth_capped = True
if sheets < need:
return {
"ok": False,
"msg": (
f"预算不足,无法买入 {need}"
f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})"
),
"sheets": sheets,
"buy_usdt": 0.0,
"per_sheet_usdt": round(per_sheet_usdt, 8),
"spot_buy_buffer": buf,
"max_by_budget": max_by_budget,
}
premium_coin = sheets * per_sheet_coin
buy_coin = premium_coin * buf
buy_usdt = min(budget, buy_coin * idx)
# 再保险:向下对齐,避免浮点导致略超预算
buy_usdt = min(budget, round(buy_usdt, 8))
out = {
"ok": True,
"msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets}"),
"sheets": sheets,
"eth_amount": round(sheets * mult, 8),
"coin_premium": round(premium_coin, 8),
"total_premium": round(premium_coin, 8),
"per_sheet_coin": per_sheet_coin,
"buy_coin": round(buy_coin, 8),
"buy_usdt": round(buy_usdt, 8),
"budget_usdt": round(budget, 8),
"spot_buy_buffer": buf,
"index_px": idx,
"max_by_budget": max_by_budget,
"ask_depth_capped": ask_depth_capped,
"est_note": (
f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;"
f"不全额兑换预算"
),
}
if target_sheets is not None:
out["est_note"] = (
f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换"
)
out["target_sheets"] = int(target_sheets)
return out
def calc_sheets_from_coin_balance(
*,
quote_per_unit: float,
+40 -51
View File
@@ -553,12 +553,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC
try:
from lib.options.options_margin_mode_lib import (
calc_sheets_from_coin_balance,
is_coin_margin_mode,
margin_mode_from_inst_id,
)
from lib.options.options_coin_open_lib import coin_budget_preview
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth, option_buy_liquidity_ok
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
if is_coin_margin_mode():
ask = q.get("ask")
@@ -637,61 +636,31 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
)
idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx"))
budget_usdt = float(budget_info["budget_usdt"])
est_coin = (budget_usdt / float(idx)) if idx and float(idx) > 0 else None
if est_coin is None or est_coin <= 0:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": "无法用指数估算可买币量",
"options_margin_mode": "coin",
"coin_budget": budget_info,
"sizing": {
"ok": False,
"msg": "缺少指数价,无法预估张数",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
sizing = calc_sheets_from_coin_balance(
target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None
if mode == "eth" and request.args.get("eth"):
# 指定币量:按币量反推张数后再走统一规划
try:
eth_want = float(request.args.get("eth"))
except (TypeError, ValueError):
eth_want = 0.0
if eth_want > 0 and float(ct_mult) > 0:
import math
target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12)))
from lib.options.options_margin_mode_lib import plan_coin_open_by_budget
sizing = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=float(ct_mult),
min_sz=int(min_sz),
coin_available=float(est_coin),
budget_usdt=budget_usdt,
index_px=float(idx or 0),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if sizing.get("ok"):
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
int(sizing.get("sheets") or 0),
ask_sz,
min_sz=int(min_sz),
)
if capped is None:
sizing = {
"ok": False,
"msg": cap_msg,
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
}
elif capped < int(sizing.get("sheets") or 0):
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
ct_mult=float(ct_mult),
min_sz=int(min_sz),
coin_available=float(capped) * float(ask) * float(ct_mult),
)
if sizing.get("ok"):
sizing["ask_depth_capped"] = True
sizing["ask_sz"] = ask_sz
sizing["msg"] = f"已按卖一深度限制为 {capped}"
if sizing.get("ok"):
sizing["total_premium"] = sizing.get("coin_premium")
sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper()
sizing["est_coin"] = round(float(est_coin), 8)
sizing["est_note"] = "张数为预算估币预览;实盘按买币后可用量开满"
sizing["est_coin"] = sizing.get("buy_coin")
q = _attach_close_preview(
cfg,
ex,
@@ -1025,6 +994,25 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
from lib.options.options_coin_open_lib import open_coin_option_buy_full
if is_coin_margin_mode():
want_sheets = None
if mode == "sheets":
try:
want_sheets = int(data.get("sheets") or 0) or None
except (TypeError, ValueError):
want_sheets = None
elif mode == "eth":
try:
eth_want = float(data.get("eth") or 0)
except (TypeError, ValueError):
eth_want = 0.0
if eth_want > 0:
q0 = cfg["quote_option_contract"](ex, inst_id)
ct0 = float((q0 or {}).get("ct_mult") or 0.01)
min0 = int((q0 or {}).get("min_sz") or 1)
if ct0 > 0:
import math
want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12)))
result = open_coin_option_buy_full(
cfg,
ex,
@@ -1033,6 +1021,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
target_index=target_index,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
target_sheets=want_sheets,
)
if result.get("ok"):
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
+39
View File
@@ -82,6 +82,45 @@ class TestOptionsMarginMode(unittest.TestCase):
self.assertTrue(r["ok"])
self.assertEqual(r["sheets"], 97)
def test_spot_buy_buffer_normalize(self):
from lib.options.options_margin_mode_lib import normalize_coin_spot_buy_buffer
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.10), 1.10)
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(0.10), 1.10)
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.25), 1.25)
def test_plan_coin_open_by_budget(self):
from lib.options.options_margin_mode_lib import plan_coin_open_by_budget
# ask 0.01, ct 0.1 → 单张权利金 0.001 ETH;×1.1=0.0011;×指数 2000 → 2.2 USDT/张
r = plan_coin_open_by_budget(
quote_per_unit=0.01,
ct_mult=0.1,
min_sz=1,
budget_usdt=10.0,
index_px=2000.0,
ask_sz=100,
spot_buy_buffer=1.10,
)
self.assertTrue(r["ok"], r.get("msg"))
self.assertEqual(r["sheets"], 4) # floor(10/2.2)=4
self.assertAlmostEqual(r["buy_usdt"], 4 * 0.01 * 0.1 * 1.10 * 2000, places=4)
self.assertLess(r["buy_usdt"], 10.0)
one = plan_coin_open_by_budget(
quote_per_unit=0.01,
ct_mult=0.1,
min_sz=1,
budget_usdt=10.0,
index_px=2000.0,
ask_sz=100,
spot_buy_buffer=1.10,
target_sheets=1,
)
self.assertTrue(one["ok"], one.get("msg"))
self.assertEqual(one["sheets"], 1)
self.assertAlmostEqual(one["buy_usdt"], 0.01 * 0.1 * 1.10 * 2000, places=4)
if __name__ == "__main__":
unittest.main()