diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example
index 52c5b9d..899c942 100644
--- a/crypto_monitor_okx/.env.example
+++ b/crypto_monitor_okx/.env.example
@@ -113,6 +113,7 @@ OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
OKX_OPTIONS_MAX_DTE_DAYS=2
+OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14
OKX_OPTIONS_ITM_MAX_DIST_USD=30
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css
index 9859d93..b9af670 100644
--- a/lib/common/static/instance_theme.css
+++ b/lib/common/static/instance_theme.css
@@ -2401,4 +2401,30 @@ html[data-theme="light"] .settings-export-link {
.opt-row-actions .btn-secondary {
margin-right: 4px;
}
+.opt-moneyness {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.opt-moneyness-itm {
+ color: #7ee787;
+ background: rgba(46, 160, 67, 0.15);
+}
+.opt-moneyness-otm {
+ color: #a8b3cf;
+ background: rgba(136, 146, 176, 0.12);
+}
+.opt-moneyness-atm {
+ color: #ffd166;
+ background: rgba(255, 209, 102, 0.12);
+}
+.options-order-mode-row {
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.options-order-mode-row input[type="number"] {
+ width: 88px;
+}
diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index ea43222..31d76ee 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -29,6 +29,38 @@
}
}
+ function currentSizeMode() {
+ const el = document.querySelector('input[name="opt-size-mode"]:checked');
+ return el ? el.value : "sheets";
+ }
+
+ function updateSizeInputs() {
+ const mode = currentSizeMode();
+ const sheetsEl = document.getElementById("opt-sheets-amount");
+ const ethEl = document.getElementById("opt-eth-amount");
+ if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
+ if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
+ }
+
+ function quoteUrl(instId) {
+ const mode = currentSizeMode();
+ let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode;
+ if (mode === "eth_amount") {
+ const eth = document.getElementById("opt-eth-amount").value;
+ if (eth) url += "ð_amount=" + encodeURIComponent(eth);
+ } else if (mode === "sheets") {
+ const sheets = document.getElementById("opt-sheets-amount").value;
+ if (sheets) url += "&sheets=" + encodeURIComponent(sheets);
+ }
+ return url;
+ }
+
+ function moneynessBadge(c) {
+ const m = (c && c.moneyness) || "";
+ const label = (c && c.moneyness_label) || "—";
+ return '' + label + "";
+ }
+
async function refreshBalances() {
const d = await apiJson("/api/options/balances");
if (!d.ok) return;
@@ -42,7 +74,11 @@
function expLabel(ms) {
try {
- return new Date(Number(ms)).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
+ const dt = new Date(Number(ms));
+ const now = Date.now();
+ const dte = Math.max(0, Math.ceil((Number(ms) - now) / 86400000));
+ const base = dt.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
+ return base + " · " + dte + "D";
} catch (e) {
return String(ms);
}
@@ -58,8 +94,14 @@
o.textContent = expLabel(e.exp_time) + " (" + e.contracts.length + ")";
sel.appendChild(o);
});
+ const idx = state.chain && state.chain.index_px;
+ const dte = state.chain && state.chain.chain_max_dte_days;
+ if (dte != null) {
+ const el = document.getElementById("opt-chain-dte");
+ if (el) el.textContent = String(Math.round(dte));
+ }
document.getElementById("opt-index-line").textContent =
- "指数 " + state.underlying + " ≈ " + fmt(state.chain && state.chain.index_px, 2);
+ "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 实值=价内 · 虚值=价外";
}
function renderStrikes() {
@@ -67,7 +109,7 @@
const expMs = document.getElementById("opt-exp-select").value;
tbody.innerHTML = "";
if (!expMs || !state.chain) {
- tbody.innerHTML = '
| 请选择到期日 |
';
+ tbody.innerHTML = '| 请选择到期日 |
';
return;
}
const exp = (state.chain.expiries || []).find(function (e) {
@@ -78,13 +120,15 @@
return c.opt_type === state.optType;
});
if (!list.length) {
- tbody.innerHTML = '| 无符合的实值合约 |
';
+ tbody.innerHTML = '| 该到期日暂无报价 |
';
return;
}
list.forEach(function (c) {
const tr = document.createElement("tr");
+ if (c.moneyness) tr.classList.add("opt-row-" + c.moneyness);
tr.innerHTML =
"" + c.strike + " | " +
+ "" + moneynessBadge(c) + " | " +
"" + c.inst_id + " | " +
"" + fmt(c.ask, 4) + " | " +
"" + fmt(c.bid, 4) + " | " +
@@ -109,13 +153,7 @@
async function selectContract(instId) {
state.selectedInst = instId;
- const mode = document.querySelector('input[name="opt-size-mode"]:checked').value;
- const ethInput = document.getElementById("opt-eth-amount");
- let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode;
- if (mode === "eth_amount" && ethInput.value) {
- url += "ð_amount=" + encodeURIComponent(ethInput.value);
- }
- const d = await apiJson(url);
+ const d = await apiJson(quoteUrl(instId));
const panel = document.getElementById("opt-order-panel");
panel.style.display = "";
document.getElementById("opt-order-inst").textContent = instId;
@@ -158,7 +196,7 @@
const btn = document.getElementById("opt-open-btn");
btn.disabled = true;
try {
- const mode = document.querySelector('input[name="opt-size-mode"]:checked').value;
+ const mode = currentSizeMode();
const body = {
inst_id: state.selectedInst,
mode: mode,
@@ -166,6 +204,8 @@
};
if (mode === "eth_amount") {
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
+ } else if (mode === "sheets") {
+ body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
}
const d = await apiJson("/api/options/open", {
method: "POST",
@@ -187,7 +227,7 @@
}
async function closePosition(inst, btn) {
- const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=budget_full");
+ const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1");
if (!q.ok) {
alert(q.msg || "获取买一价失败");
return;
@@ -272,8 +312,18 @@
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
r.addEventListener("change", function () {
- document.getElementById("opt-eth-amount").style.display =
- r.value === "eth_amount" && r.checked ? "" : "none";
+ updateSizeInputs();
+ if (state.selectedInst) selectContract(state.selectedInst);
+ });
+ });
+
+ ["opt-sheets-amount", "opt-eth-amount"].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener("change", function () {
+ if (state.selectedInst) selectContract(state.selectedInst);
+ });
+ el.addEventListener("input", function () {
if (state.selectedInst) selectContract(state.selectedInst);
});
});
@@ -331,6 +381,7 @@
refreshBalances();
});
+ updateSizeInputs();
refreshBalances();
loadChain();
refreshPositions();
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index 43d75f3..0b986d0 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -7,7 +7,7 @@ from typing import Any, Callable
import ccxt
-from lib.options.options_pricing_lib import is_shallow_itm
+from lib.options.options_pricing_lib import is_shallow_itm, option_moneyness, option_moneyness_label
def create_options_exchange(
@@ -251,6 +251,7 @@ def build_option_chain(
bid = _safe_float(t.get("bidPx"))
if ask is None and bid is None:
continue
+ mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx)
exp_key = str(exp_ms)
expiries.setdefault(exp_key, []).append(
{
@@ -260,6 +261,8 @@ def build_option_chain(
"exp_time": exp_ms,
"ask": ask,
"bid": bid,
+ "moneyness": mny,
+ "moneyness_label": option_moneyness_label(mny),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
"tick_sz": meta.get("tickSz"),
"min_sz": int(_safe_float(meta.get("minSz")) or 1),
diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py
index 882fdfe..6d1421b 100644
--- a/lib/options/options_pricing_lib.py
+++ b/lib/options/options_pricing_lib.py
@@ -50,16 +50,19 @@ def calc_order_size(
budget_usdc: float | None = None,
budget_buffer: float = 0.95,
eth_amount: float | None = None,
+ sheets: int | None = None,
budget_cap: float | None = None,
) -> dict[str, Any]:
"""
返回 sheets, eth_amount, total_premium。
- mode: budget_full 或 eth_amount。
+ mode: budget_full / eth_amount / sheets。
"""
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:
+ if sheets is not None and int(sheets) > 0:
+ sheets = int(sheets)
+ elif 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)
@@ -68,7 +71,7 @@ def calc_order_size(
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}
+ return {"ok": False, "msg": "请指定预算、币数量或张数", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
if sheets < min_sz:
per = premium_per_sheet(quote_per_unit, ct_mult)
@@ -110,3 +113,22 @@ def is_shallow_itm(
return False
return (strike - index_px) <= max_dist_usd
return False
+
+
+def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
+ """返回 itm / otm / atm。"""
+ o = (opt_type or "").upper()
+ if strike is None or index_px is None or index_px <= 0:
+ return "unknown"
+ atm_band = max(index_px * 0.002, 2.0)
+ if abs(strike - index_px) <= atm_band:
+ return "atm"
+ if o == "C":
+ return "itm" if strike < index_px else "otm"
+ if o == "P":
+ return "itm" if strike > index_px else "otm"
+ return "unknown"
+
+
+def option_moneyness_label(moneyness: str) -> str:
+ return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "")
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index ae703cf..81a969a 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -84,6 +84,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"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),
+ "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.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),
@@ -136,11 +137,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
chain = cfg["build_option_chain"](
ex,
u,
- max_dte_days=cfg["max_dte_days"],
- itm_only=True,
+ max_dte_days=cfg["chain_max_dte_days"],
+ itm_only=False,
itm_max_dist_usd=cfg["itm_max_dist"],
)
- return jsonify({"ok": True, **chain})
+ return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]})
@app.route("/api/options/quote")
@lr
@@ -160,20 +161,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
mode = (request.args.get("mode") or "budget_full").strip()
budget = cfg["trade_budget"]
eth_amount = None
+ sheet_count = None
try:
if request.args.get("eth_amount"):
eth_amount = float(request.args.get("eth_amount"))
except (TypeError, ValueError):
pass
+ try:
+ if request.args.get("sheets"):
+ sheet_count = int(request.args.get("sheets"))
+ 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_usdc=budget if mode == "budget_full" 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=cfg["trade_budget"],
)
return jsonify(
@@ -206,18 +214,25 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
eth_amount = None
+ sheet_count = None
if mode == "eth_amount":
try:
eth_amount = float(data.get("eth_amount"))
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "ETH 数量无效"})
+ elif mode == "sheets":
+ try:
+ sheet_count = int(data.get("sheets"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "张数无效"})
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_usdc=cfg["trade_budget"] if mode == "budget_full" else None,
budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount,
+ sheets=sheet_count,
budget_cap=cfg["trade_budget"],
)
if not sizing.get("ok"):
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index a9ec1b3..fccf3e5 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -5,7 +5,7 @@
{% if not options_enabled %}
期权 API 未启用:请在 crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=true 及主账户 OKX_OPTIONS_API_*,然后 pm2 restart crypto_okx --update-env。
{% endif %}
- 资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。表格中点「买入」直接下单;或点「选择」后在下方确认张数再买入。
+ 资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。期权链展示近 14 日到期合约,标注实值/虚值;下单可指定张数。
@@ -72,6 +72,7 @@
| 行权价 |
+ 类型 |
合约 |
卖一 |
买一 |
@@ -79,7 +80,7 @@
- | 请选择到期日 |
+ | 请选择到期日 |
@@ -94,8 +95,10 @@
ETH/BTC 数量—
预估权利金—
-
-
+
diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py
index 8ee4fa1..4cf1f0b 100644
--- a/tests/test_options_pricing.py
+++ b/tests/test_options_pricing.py
@@ -45,6 +45,28 @@ def test_calc_order_size_budget():
assert r["total_premium"] <= 10
+def test_calc_order_size_sheets():
+ r = calc_order_size(
+ quote_per_unit=15.6,
+ ct_mult=0.01,
+ min_sz=1,
+ sheets=3,
+ budget_cap=10,
+ )
+ assert r["ok"] is True
+ assert r["sheets"] == 3
+ assert abs(r["total_premium"] - 0.468) < 1e-9
+
+
+def test_option_moneyness():
+ from lib.options.options_pricing_lib import option_moneyness, option_moneyness_label
+
+ assert option_moneyness(opt_type="C", strike=1700, index_px=1800) == "itm"
+ assert option_moneyness(opt_type="C", strike=1900, index_px=1800) == "otm"
+ assert option_moneyness_label("itm") == "实值"
+ assert option_moneyness_label("otm") == "虚值"
+
+
def test_calc_order_size_too_small():
r = calc_order_size(
quote_per_unit=2000.0,