单独期权增加全仓复利模式:可选上限开关,仅允许一仓

用期权户全部可用×缓冲开仓,默认不设上限;开启上限后按 env 封顶,全仓时禁止已有持仓再开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-12 09:45:36 +08:00
parent a354811a6e
commit 1522117eeb
8 changed files with 240 additions and 19 deletions
+7
View File
@@ -6875,6 +6875,13 @@ def render_main_page(page="trade", embed_mode=None):
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_compound_full_cap_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_usdc=float(
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
+12
View File
@@ -281,14 +281,26 @@
const sheetsEl = document.getElementById("opt-sheets-amount");
const ethEl = document.getElementById("opt-eth-amount");
const hint = document.getElementById("opt-budget-full-hint");
const compoundHint = document.getElementById("opt-compound-full-hint");
const capEl = document.getElementById("opt-budget-full-cap");
const compoundCapLine = document.getElementById("opt-compound-cap-line");
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
if (hint) hint.style.display = mode === "budget_full" ? "" : "none";
if (compoundHint) compoundHint.style.display = mode === "compound_full" ? "" : "none";
if (capEl && root && root.dataset.tradeBudget) {
const n = Number(root.dataset.tradeBudget);
if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2);
}
if (compoundCapLine && root) {
const on = String(root.dataset.compoundCapEnabled || "") === "1";
const cap = Number(root.dataset.compoundCapUsdc);
if (on && Number.isFinite(cap) && cap > 0) {
compoundCapLine.textContent = "全仓上限已开启:" + cap.toFixed(2) + "U";
} else {
compoundCapLine.textContent = "全仓上限关闭(env可开)";
}
}
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
const radio = chip.querySelector('input[name="opt-size-mode"]');
chip.classList.toggle("is-selected", !!(radio && radio.checked));
+11 -1
View File
@@ -144,7 +144,17 @@ _OPTIONS_SECTION: dict[str, Any] = {
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"默认 false=关闭上限,用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅上限开关开启时生效;例如 300",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
+23
View File
@@ -119,3 +119,26 @@ def option_position_limit_block_msg(
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
)
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
def compound_full_single_position_block_msg(
ex: Any,
*,
fetch_positions=None,
) -> Optional[str]:
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return "无法获取期权持仓,全仓复利模式暂不可开仓"
active = count_live_option_positions(rows)
if active >= 1:
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
return None
+19
View File
@@ -264,6 +264,25 @@ def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> f
return min(float(trading_usdc), float(trade_budget_usdc))
def resolve_compound_full_usdc(
trading_usdc: float,
*,
cap_enabled: bool = False,
cap_usdc: float | None = None,
) -> float:
"""全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶."""
bal = max(0.0, float(trading_usdc or 0))
if not cap_enabled:
return bal
try:
cap = float(cap_usdc) if cap_usdc is not None else 0.0
except (TypeError, ValueError):
cap = 0.0
if cap <= 0:
return bal
return min(bal, cap)
def calc_order_size(
*,
quote_per_unit: float,
+94 -7
View File
@@ -103,6 +103,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"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),
"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),
"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),
@@ -174,6 +176,34 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
return resolve_budget_full_usdc(trading, float(cap)), ""
def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
"""全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer."""
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
from lib.options.options_pricing_lib import resolve_compound_full_usdc
raw = fetch_options_trading_usdc(ex)
if raw is None or float(raw) <= 0:
return None, "交易账户 USDC 可用余额不足"
trading = float(raw)
# 额度热更读 env(与模板启动值无关)
cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False)
cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0)
if cap_on and cap_v <= 0:
return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)"
return (
resolve_compound_full_usdc(
trading,
cap_enabled=cap_on,
cap_usdc=cap_v,
),
"",
)
def _is_budget_mode(mode: str) -> bool:
return mode in ("budget_full", "compound_full")
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
conn = cfg["get_db"]()
try:
@@ -440,6 +470,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex)
elif mode == "compound_full":
budget, budget_err = _compound_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex)
eth_amount = None
try:
@@ -471,6 +509,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
except Exception as e:
@@ -507,6 +546,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
except Exception as e:
@@ -531,9 +571,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
from lib.options.options_position_limit_lib import option_position_limit_block_msg
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if mode == "compound_full":
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": compound_block,
"quote_per_unit": ask,
"premium_per_sheet": None,
"sizing": {
"ok": False,
"msg": compound_block,
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
"available_usdc": available_usdc,
"budget_full_usdc": None,
"compound_full_usdc": budget,
}
)
pos_limit_msg = option_position_limit_block_msg(
ex,
@@ -558,17 +628,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
sizing = calc_order_size(
quote_per_unit=float(ask),
ct_mult=float(ct_mult),
min_sz=int(min_sz),
budget_usdc=budget if mode == "budget_full" else None,
budget_usdc=budget if _is_budget_mode(mode) else None,
budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount if mode == "eth_amount" else None,
sheets=sheet_count if mode == "sheets" else None,
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
budget_cap=budget_cap if mode in ("budget_full", "compound_full", "sheets", "eth_amount") else None,
)
if sizing.get("ok"):
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
@@ -590,7 +661,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ct_mult=float(ct_mult),
min_sz=int(min_sz),
sheets=capped,
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
budget_cap=budget_cap if mode in ("budget_full", "compound_full", "sheets", "eth_amount") else None,
)
if sizing.get("ok"):
sizing["ask_depth_capped"] = True
@@ -612,6 +683,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"sizing": sizing,
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
@@ -672,7 +744,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ref_ask": q.get("ref_ask"),
}
)
from lib.options.options_position_limit_lib import option_position_limit_block_msg
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if mode == "compound_full":
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return jsonify({"ok": False, "msg": compound_block, "can_open": False})
pos_limit_msg = option_position_limit_block_msg(
ex,
@@ -702,15 +784,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget
elif mode == "compound_full":
budget, budget_err = _compound_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget
sizing = calc_order_size(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdc=budget if mode == "budget_full" else None,
budget_usdc=budget if _is_budget_mode(mode) else None,
budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount,
sheets=sheet_count,
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
budget_cap=budget_cap if mode in ("budget_full", "compound_full", "sheets", "eth_amount") else None,
)
if not sizing.get("ok"):
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
+11 -1
View File
@@ -2,6 +2,8 @@
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}"
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code><code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
@@ -23,6 +25,7 @@
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<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>平仓仅买一限价,详见说明文档。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
@@ -131,6 +134,10 @@
<input type="radio" name="opt-size-mode" value="budget_full">
<span>按可用余额打满</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="compound_full">
<span>全仓复利</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span>
@@ -141,6 +148,9 @@
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
</p>
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
</p>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
@@ -324,4 +334,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=56"></script>
<script src="/static/options_panel.js?v=57"></script>
+63 -10
View File
@@ -1,16 +1,69 @@
"""按可用余额打满:min(余额, 单笔预算)."""
"""按可用余额打满 / 全仓复利定仓."""
from __future__ import annotations
from lib.options.options_pricing_lib import resolve_budget_full_usdc
import unittest
from lib.options.options_pricing_lib import (
resolve_budget_full_usdc,
resolve_compound_full_usdc,
)
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
count_live_option_positions,
)
def test_balance_above_budget_uses_budget():
assert resolve_budget_full_usdc(100.0, 10.0) == 10.0
class TestOptionsBudgetModes(unittest.TestCase):
def test_balance_above_budget_uses_budget(self):
self.assertEqual(resolve_budget_full_usdc(100.0, 10.0), 10.0)
def test_balance_below_budget_uses_balance(self):
self.assertEqual(resolve_budget_full_usdc(5.0, 10.0), 5.0)
def test_balance_equals_budget(self):
self.assertEqual(resolve_budget_full_usdc(10.0, 10.0), 10.0)
def test_compound_full_no_cap_uses_all(self):
self.assertEqual(
resolve_compound_full_usdc(200.0, cap_enabled=False, cap_usdc=50.0),
200.0,
)
def test_compound_full_cap_on(self):
self.assertEqual(
resolve_compound_full_usdc(200.0, cap_enabled=True, cap_usdc=50.0),
50.0,
)
self.assertEqual(
resolve_compound_full_usdc(30.0, cap_enabled=True, cap_usdc=50.0),
30.0,
)
def test_compound_full_cap_invalid_falls_back_to_balance(self):
self.assertEqual(
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=0),
80.0,
)
self.assertEqual(
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=None),
80.0,
)
def test_compound_full_blocks_when_position_open(self):
rows = [{"instId": "ETH-USD_UM-260812-1870-P", "pos": "1"}]
msg = compound_full_single_position_block_msg(
object(), fetch_positions=lambda _ex: rows
)
self.assertIsNotNone(msg)
self.assertIn("1 笔", msg or "")
def test_compound_full_allows_when_flat(self):
msg = compound_full_single_position_block_msg(
object(), fetch_positions=lambda _ex: []
)
self.assertIsNone(msg)
self.assertEqual(count_live_option_positions([]), 0)
def test_balance_below_budget_uses_balance():
assert resolve_budget_full_usdc(5.0, 10.0) == 5.0
def test_balance_equals_budget():
assert resolve_budget_full_usdc(10.0, 10.0) == 10.0
if __name__ == "__main__":
unittest.main()