Add options index target monitors that auto limit-close on hit.

Position and order forms can arm a target; right-side and hub panels show active monitors; expiry remains the stop with no separate SL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-15 15:27:46 +08:00
parent 8d2da921ee
commit d6823a2903
13 changed files with 1011 additions and 18 deletions
+73
View File
@@ -4091,6 +4091,79 @@ html[data-theme="light"] .options-estimate-row {
.options-page-wrap .opt-pos-cell--depth {
grid-column: span 2;
}
.options-page-wrap .opt-target-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(67, 82, 118, 0.45);
}
.options-page-wrap .opt-target-row-label {
font-size: 0.72rem;
color: #9aa8c7;
min-width: 2.5em;
}
.options-page-wrap .opt-pos-target-input {
width: 110px;
max-width: 36vw;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #3a4660;
background: #0f1420;
color: #e8eefc;
font-size: 0.82rem;
}
.options-page-wrap .opt-target-row .btn-secondary {
padding: 4px 10px;
font-size: 0.75rem;
}
.options-page-wrap .opt-target-row-hint {
font-size: 0.7rem;
}
.opt-target-monitors {
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid rgba(99, 118, 168, 0.45);
border-radius: 10px;
background: rgba(18, 28, 48, 0.75);
}
.opt-target-monitors-head {
font-size: 0.78rem;
font-weight: 600;
color: #c9d6f5;
margin-bottom: 8px;
}
.opt-target-mon-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid rgba(67, 82, 118, 0.35);
}
.opt-target-mon-item:first-child {
border-top: 0;
padding-top: 0;
}
.opt-target-mon-inst {
font-size: 0.72rem;
color: #dbe6ff;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.opt-target-mon-rule {
font-size: 0.78rem;
color: #9ad0ff;
font-variant-numeric: tabular-nums;
}
.opt-target-mon-item .btn-secondary {
margin-left: auto;
padding: 2px 8px;
font-size: 0.7rem;
}
.options-page-wrap .opt-bid-plain {
color: #dbe6ff;
font-variant-numeric: tabular-nums;
+141 -1
View File
@@ -788,6 +788,15 @@
} else if (mode === "sheets") {
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
}
const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim();
if (tgtRaw !== "") {
const tgt = parseFloat(tgtRaw);
if (!Number.isFinite(tgt) || tgt <= 0) {
alert("目标位无效");
return;
}
body.target_index = tgt;
}
const d = await apiJson("/api/options/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -845,6 +854,22 @@
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + fmtClosePreview(closePreview, p.premium_paid) + "</span></div>" +
"</div>" +
renderTargetDelegateRow(p)
);
}
function renderTargetDelegateRow(p) {
const inst = p.inst_id || "";
const tgt = p.target_index != null && p.target_index !== "" ? String(p.target_index) : "";
const armed = tgt !== "";
return (
'<div class="opt-target-row">' +
'<span class="opt-target-row-label">委托</span>' +
'<input type="number" class="opt-pos-target-input" data-inst="' + inst + '" step="0.1" min="0" placeholder="目标指数价" value="' + tgt + '">' +
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' + inst + '">设定</button>' +
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' + inst + '"' + (armed ? "" : " disabled") + ">取消</button>" +
'<span class="muted opt-target-row-hint">' + (armed ? "监控中 · 达价限价平 · 无止损" : "达价限价平 · 无止损 · 到期即止损") + "</span>" +
"</div>"
);
}
@@ -912,6 +937,28 @@
closePosition(btn.getAttribute("data-inst"), btn);
});
});
container.querySelectorAll(".opt-target-set-btn").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.stopPropagation();
setPositionTarget(btn.getAttribute("data-inst"), btn);
});
});
container.querySelectorAll(".opt-target-cancel-btn").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.stopPropagation();
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
});
});
container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
inp.addEventListener("click", function (e) { e.stopPropagation(); });
inp.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
setPositionTarget(inp.getAttribute("data-inst"), null);
}
});
});
container.querySelectorAll(".opt-pos-bar").forEach(function (bar) {
bar.addEventListener("click", function () {
const item = bar.closest(".opt-pos-accordion-item");
@@ -926,6 +973,81 @@
});
}
async function setPositionTarget(inst, btn) {
if (!inst) return;
const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') ||
document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]');
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
const raw = inp ? String(inp.value || "").trim() : "";
const tgt = parseFloat(raw);
if (!Number.isFinite(tgt) || tgt <= 0) {
alert("请输入有效目标指数价");
return;
}
if (btn) btn.disabled = true;
try {
const d = await apiJson("/api/options/target", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inst_id: inst, target_index: tgt }),
});
if (!d.ok) {
alert(d.msg || "设定失败");
return;
}
await refreshAllPositions();
} finally {
if (btn) btn.disabled = false;
}
}
async function cancelPositionTarget(inst, btn) {
if (!inst) return;
if (btn) btn.disabled = true;
try {
const d = await apiJson("/api/options/target/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inst_id: inst }),
});
if (!d.ok) {
alert(d.msg || "取消失败");
return;
}
await refreshAllPositions();
} finally {
if (btn) btn.disabled = false;
}
}
function paintTargetMonitors(list) {
const box = document.getElementById("opt-target-monitors");
const host = document.getElementById("opt-target-monitors-list");
if (!box || !host) return;
const rows = Array.isArray(list) ? list.filter(function (t) { return t && t.inst_id; }) : [];
if (!rows.length) {
box.hidden = true;
host.innerHTML = "";
return;
}
box.hidden = false;
host.innerHTML = rows.map(function (t) {
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
return (
'<div class="opt-target-mon-item">' +
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
'<span class="opt-target-mon-rule">' + side + " " + fmt(t.target_index, 1) + "</span>" +
'<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>' +
"</div>"
);
}).join("");
host.querySelectorAll(".opt-target-mon-cancel").forEach(function (btn) {
btn.addEventListener("click", function () {
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
});
});
}
async function closePosition(inst, btn) {
const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null;
let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview";
@@ -1063,7 +1185,25 @@
const seq = ++positionsRefreshSeq;
const d = await apiJson("/api/options/positions");
if (seq !== positionsRefreshSeq) return;
paintPositions(resolvePositionsList(d));
const list = resolvePositionsList(d);
paintPositions(list);
const fromPos = list
.filter(function (p) { return p && p.target_index != null; })
.map(function (p) {
return {
id: p.target_monitor_id,
inst_id: p.inst_id,
opt_type: p.opt_type,
target_index: p.target_index,
};
});
if (fromPos.length) {
paintTargetMonitors(fromPos);
} else {
const t = await apiJson("/api/options/targets");
if (seq !== positionsRefreshSeq) return;
paintTargetMonitors((t && t.ok && t.targets) ? t.targets : []);
}
}
async function refreshStats() {
+4 -1
View File
@@ -113,7 +113,10 @@
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + fmtClosePreview(closePreview, p.premium_paid, hub) + "</span></div>" +
"</div>"
"</div>" +
(p.target_index != null
? '<div class="opt-target-row opt-target-row--ro"><span class="opt-target-row-label">委托</span><span class="pos-value">目标指数 ' + fmt(p.target_index, 1) + "</span><span class="muted opt-target-row-hint">监控中 · 达价限价平</span></div>"
: "")
);
}
+25
View File
@@ -68,3 +68,28 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_target_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL,
underlying TEXT,
opt_type TEXT,
target_index REAL NOT NULL,
trade_id INTEGER,
sheets INTEGER,
status TEXT DEFAULT 'active',
trigger_idx REAL,
close_ord_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
triggered_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
ON options_target_monitors(status)
"""
)
+19
View File
@@ -30,6 +30,24 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw)
target_monitors: list[dict[str, Any]] = []
try:
conn = cfg["get_db"]()
try:
from lib.options.options_target_lib import list_active_targets, targets_by_inst
target_monitors = list_active_targets(conn)
tgt_map = targets_by_inst(conn)
for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon:
p["target_index"] = mon.get("target_index")
p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon
finally:
conn.close()
except Exception:
target_monitors = []
upl_total = 0.0
has_upl = False
for p in positions:
@@ -45,6 +63,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"enabled": True,
"positions": positions,
"position_count": len(positions),
"target_monitors": target_monitors,
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
"balances": bal,
"funding_usdc": bal.get("funding_usdc"),
+11
View File
@@ -271,6 +271,7 @@ def options_monitor_loop(
account_label: str,
profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
stop_event: Any = None,
) -> None:
if not enabled:
@@ -290,6 +291,16 @@ def options_monitor_loop(
account_label=account_label,
ticker_bid_fn=ticker_bid_fn,
)
if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes
run_options_target_closes(
conn,
positions,
close_fn=target_close_fn,
send_wechat=send_wechat,
account_label=account_label,
)
if sync_trades_fn is not None:
sync_trades_fn(conn)
conn.commit()
+171 -3
View File
@@ -404,6 +404,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
inst_id = (data.get("inst_id") or "").strip()
mode = (data.get("mode") or "budget_full").strip()
signal_note = (data.get("signal_note") or "").strip()
target_index = None
raw_target = data.get("target_index")
if raw_target is not None and str(raw_target).strip() != "":
try:
target_index = float(raw_target)
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"})
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
q = cfg["quote_option_contract"](ex, inst_id)
@@ -459,11 +468,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if not order.get("ok"):
return jsonify(order)
conn = cfg["get_db"]()
trade_id = None
target_mon = None
try:
init_options_tables(conn)
meta = q.get("meta") or {}
u = str(meta.get("uly") or inst_id).split("-")[0]
conn.execute(
opt_type = meta.get("optType")
cur = conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
@@ -473,7 +485,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
(
inst_id,
u,
meta.get("optType"),
opt_type,
q.get("strike"),
str(q.get("exp_time") or ""),
sheets,
@@ -484,6 +496,19 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
(order.get("data") or {}).get("ordId"),
),
)
trade_id = int(cur.lastrowid)
if target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor
target_mon = upsert_target_monitor(
conn,
inst_id=inst_id,
target_index=target_index,
underlying=u,
opt_type=str(opt_type) if opt_type else None,
trade_id=trade_id,
sheets=sheets,
)
conn.commit()
finally:
conn.close()
@@ -491,7 +516,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True)
return jsonify({"ok": True, "order": order, "sizing": sizing})
return jsonify(
{
"ok": True,
"order": order,
"sizing": sizing,
"trade_id": trade_id,
"target_monitor": target_mon,
}
)
@app.route("/api/options/positions")
@lr
@@ -506,6 +539,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
meta_cache: dict[str, dict[str, Any] | None] = {}
conn = cfg["get_db"]()
try:
from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn)
rows = []
for p in raw:
inst = str(p.get("instId") or "").strip()
@@ -529,11 +565,102 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
premium_override=premium_override,
)
_attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
mon = tgt_map.get(inst)
if mon:
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
rows.append(row)
finally:
conn.close()
return jsonify({"ok": True, "positions": rows})
@app.route("/api/options/targets")
@lr
def api_options_targets():
conn = cfg["get_db"]()
try:
from lib.options.options_target_lib import list_active_targets
return jsonify({"ok": True, "targets": list_active_targets(conn)})
finally:
conn.close()
@app.route("/api/options/target", methods=["POST"])
@lr
def api_options_target_set():
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
try:
target_index = float(data.get("target_index"))
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"})
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
pos = _find_position(raw, inst_id)
if not pos:
return jsonify({"ok": False, "msg": "未找到持仓"})
from lib.options.options_target_lib import upsert_target_monitor
fmt = cfg["format_position_row"](pos)
conn = cfg["get_db"]()
try:
trade = conn.execute(
"""
SELECT id, sheets, opt_type, underlying FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id DESC LIMIT 1
""",
(inst_id,),
).fetchone()
trade_id = int(trade["id"]) if trade else None
sheets = int(trade["sheets"]) if trade and trade["sheets"] is not None else int(fmt.get("pos") or 0)
opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type")
underlying = (trade["underlying"] if trade else None) or fmt.get("underlying")
out = upsert_target_monitor(
conn,
inst_id=inst_id,
target_index=target_index,
underlying=str(underlying) if underlying else None,
opt_type=str(opt_type) if opt_type else None,
trade_id=trade_id,
sheets=sheets,
)
conn.commit()
return jsonify(out)
finally:
conn.close()
@app.route("/api/options/target/cancel", methods=["POST"])
@lr
def api_options_target_cancel():
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip() or None
monitor_id = data.get("id")
try:
mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "监控 id 无效"})
if not inst_id and mid is None:
return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"})
from lib.options.options_target_lib import cancel_target_monitor
conn = cfg["get_db"]()
try:
n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid)
conn.commit()
return jsonify({"ok": True, "cancelled": n})
finally:
conn.close()
@app.route("/api/options/close", methods=["POST"])
@lr
def api_options_close():
@@ -677,6 +804,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True)
try:
from lib.options.options_target_lib import cancel_target_monitor
conn2 = cfg["get_db"]()
try:
cancel_target_monitor(conn2, inst_id=inst_id)
conn2.commit()
finally:
conn2.close()
except Exception:
pass
return jsonify(
{
"ok": True,
@@ -739,6 +877,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True)
try:
from lib.options.options_target_lib import cancel_target_monitor
conn2 = cfg["get_db"]()
try:
cancel_target_monitor(conn2, inst_id=inst_id)
conn2.commit()
finally:
conn2.close()
except Exception:
pass
return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets})
@app.route("/api/options/convert/quote", methods=["POST"])
@@ -974,6 +1123,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
)
def _target_close(inst_id: str) -> dict[str, Any]:
from lib.options.options_target_lib import close_option_by_bid_depth
ex = cfg.get("exchange_options")
if ex is None:
return {"ok": False, "msg": "期权 exchange 未就绪"}
result = close_option_by_bid_depth(cfg, ex, inst_id)
if result.get("ok"):
try:
_sync_options_trades(cfg, force=True)
except Exception:
pass
try:
_mark_balances_stale(cfg)
except Exception:
pass
return result
t = threading.Thread(
target=options_monitor_loop,
kwargs={
@@ -986,6 +1153,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
"account_label": cfg["account_label"],
"profit_ratio": cfg["profit_ratio"],
"sync_trades_fn": _sync,
"target_close_fn": _target_close,
},
daemon=True,
name="options-monitor",
+449
View File
@@ -0,0 +1,449 @@
"""期权目标位委托:指数达价后限价平仓(无止损,到期由结算收口)."""
from __future__ import annotations
import sqlite3
import time
from typing import Any, Callable
from lib.options.options_db import init_options_tables
from lib.options.options_pricing_lib import estimate_close_by_bids, total_premium
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 ensure_target_tables(conn: sqlite3.Connection) -> None:
init_options_tables(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_target_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL,
underlying TEXT,
opt_type TEXT,
target_index REAL NOT NULL,
trade_id INTEGER,
sheets INTEGER,
status TEXT DEFAULT 'active',
trigger_idx REAL,
close_ord_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
triggered_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
ON options_target_monitors(status)
"""
)
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
ot = (opt_type or "").strip().upper()
if ot == "P":
return index_px <= target_index
return index_px >= target_index
def upsert_target_monitor(
conn: sqlite3.Connection,
*,
inst_id: str,
target_index: float,
underlying: str | None = None,
opt_type: str | None = None,
trade_id: int | None = None,
sheets: int | None = None,
) -> dict[str, Any]:
ensure_target_tables(conn)
inst_id = (inst_id or "").strip()
if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"}
if target_index is None or float(target_index) <= 0:
return {"ok": False, "msg": "目标位无效"}
target_index = float(target_index)
row = conn.execute(
"""
SELECT id FROM options_target_monitors
WHERE inst_id = ? AND status = 'active'
ORDER BY id DESC LIMIT 1
""",
(inst_id,),
).fetchone()
if row:
conn.execute(
"""
UPDATE options_target_monitors
SET target_index = ?,
underlying = COALESCE(?, underlying),
opt_type = COALESCE(?, opt_type),
trade_id = COALESCE(?, trade_id),
sheets = COALESCE(?, sheets),
message = NULL
WHERE id = ?
""",
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
)
mon_id = int(row["id"])
else:
cur = conn.execute(
"""
INSERT INTO options_target_monitors
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
VALUES (?, ?, ?, ?, ?, ?, 'active')
""",
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
)
mon_id = int(cur.lastrowid)
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
ensure_target_tables(conn)
if monitor_id is not None:
cur = conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消'
WHERE id = ? AND status = 'active'
""",
(int(monitor_id),),
)
return int(cur.rowcount or 0)
if inst_id:
cur = conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消'
WHERE inst_id = ? AND status = 'active'
""",
(inst_id.strip(),),
)
return int(cur.rowcount or 0)
return 0
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_target_tables(conn)
rows = conn.execute(
"""
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'active'
ORDER BY id DESC
"""
).fetchall()
out: list[dict[str, Any]] = []
for r in rows:
out.append(
{
"id": int(r["id"]),
"inst_id": r["inst_id"],
"underlying": r["underlying"],
"opt_type": r["opt_type"],
"target_index": _safe_float(r["target_index"]),
"trade_id": r["trade_id"],
"sheets": r["sheets"],
"status": r["status"],
"message": r["message"],
"created_at": r["created_at"],
}
)
return out
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
return {str(t["inst_id"]): t for t in list_active_targets(conn) if t.get("inst_id")}
def mark_monitor(
conn: sqlite3.Connection,
monitor_id: int,
*,
status: str,
trigger_idx: float | None = None,
close_ord_id: str | None = None,
message: str | None = None,
) -> None:
conn.execute(
"""
UPDATE options_target_monitors
SET status = ?,
trigger_idx = COALESCE(?, trigger_idx),
close_ord_id = COALESCE(?, close_ord_id),
message = COALESCE(?, message),
triggered_at = CASE WHEN ? IN ('triggered', 'expired') THEN CURRENT_TIMESTAMP ELSE triggered_at END
WHERE id = ?
""",
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
)
def cancel_orphans_without_position(
conn: sqlite3.Connection,
*,
live_inst_ids: set[str],
) -> int:
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
ensure_target_tables(conn)
rows = list_active_targets(conn)
n = 0
for t in rows:
inst = str(t.get("inst_id") or "")
if inst and inst not in live_inst_ids:
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
n += 1
return n
def close_option_by_bid_depth(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
"""按买盘拆分限价卖出(最多5档),供目标位自动平仓复用."""
from lib.exchange.okx_options_lib import (
_pos_side_from_position,
invalidate_option_positions_cache,
)
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "报价失败"}
tick_sz = q.get("tick_sz")
ct_mult = float(q.get("ct_mult") or 0.01)
raw_positions = cfg["fetch_option_positions"](ex)
if raw_positions is None:
return {"ok": False, "msg": "获取期权持仓失败"}
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos:
return {"ok": False, "msg": "未找到持仓", "already_flat": True}
def _avail(p: dict[str, Any]) -> int:
avail = _safe_float(p.get("availPos"))
if avail is None or avail <= 0:
avail = abs(_safe_float(p.get("pos")) or 0)
return max(0, int(avail or 0))
avail = _avail(pos)
close_sheets = int(sheets) if sheets else avail
close_sheets = min(close_sheets, avail)
if close_sheets < 1:
return {"ok": False, "msg": "可平张数不足", "already_flat": True}
td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated")
pos_side = _pos_side_from_position(pos) or "net"
remaining = close_sheets
submitted_sheets = 0
filled_or_reduced_sheets = 0
total_received = 0.0
orders: list[dict[str, Any]] = []
stopped_reason = None
ord_ids: list[str] = []
for _ in range(5):
if remaining <= 0:
break
invalidate_option_positions_cache()
raw = cfg["fetch_option_positions"](ex)
if raw is None:
stopped_reason = "refresh_position_failed"
break
cur_pos = next((p for p in raw if str(p.get("instId")) == inst_id), None)
current_avail = _avail(cur_pos) if cur_pos else 0
if current_avail <= 0:
filled_or_reduced_sheets = close_sheets
remaining = 0
break
remaining = min(remaining, current_avail)
book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
preview = estimate_close_by_bids(book.get("bids") or [], remaining, ct_mult=ct_mult)
levels = preview.get("levels") or []
if not levels:
stopped_reason = "no_bid_depth"
break
level = levels[0]
level_sheets = int(level.get("sheets") or 0)
level_px = float(level.get("px") or 0)
if level_sheets <= 0 or level_px <= 0:
stopped_reason = "invalid_bid_depth"
break
before_avail = current_avail
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="sell",
sheets=level_sheets,
price=level_px,
td_mode=td_mode,
tick_sz=tick_sz,
reduce_only=True,
pos_side=pos_side,
)
if not order.get("ok"):
stopped_reason = order.get("msg") or "order_failed"
break
px = float(order.get("px", level_px))
orders.append({"order": order, "px": px, "sheets": level_sheets})
oid = str((order.get("data") or {}).get("ordId") or "")
if oid:
ord_ids.append(oid)
submitted_sheets += level_sheets
total_received += total_premium(px, level_sheets * ct_mult)
time.sleep(0.6)
invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex)
if raw2 is None:
stopped_reason = "refresh_position_failed"
break
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
after_avail = _avail(after_pos) if after_pos else 0
reduced = max(0, before_avail - after_avail)
if reduced <= 0:
stopped_reason = "order_not_filled"
break
filled_or_reduced_sheets += min(reduced, level_sheets)
remaining = max(0, close_sheets - filled_or_reduced_sheets)
if not orders:
return {"ok": False, "msg": "暂无可用买盘深度,无法限价平仓", "stopped_reason": stopped_reason}
avg_bid = (total_received / (submitted_sheets * ct_mult)) if submitted_sheets > 0 and ct_mult > 0 else 0
prem_recv = round(total_received, 4)
fully_submitted = submitted_sheets >= close_sheets and stopped_reason is None
close_ord_id = ",".join(ord_ids) if ord_ids else None
conn = cfg["get_db"]()
try:
ensure_target_tables(conn)
row = conn.execute(
"SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
(inst_id,),
).fetchone()
if row and fully_submitted:
paid = float(row["premium_paid"] or 0)
pnl = prem_recv - paid
conn.execute(
"""
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP,
signal_note = CASE
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '目标位平仓'
ELSE signal_note
END
WHERE id = ?
""",
(avg_bid, prem_recv, pnl, close_ord_id, int(row["id"])),
)
conn.commit()
finally:
conn.close()
invalidate_option_positions_cache()
return {
"ok": True,
"mode": "depth_split",
"orders": orders,
"bid": avg_bid,
"submitted_sheets": submitted_sheets,
"filled_or_reduced_sheets": filled_or_reduced_sheets,
"remaining_sheets": max(0, close_sheets - filled_or_reduced_sheets),
"premium_received": prem_recv,
"stopped_reason": stopped_reason,
"close_ord_id": close_ord_id,
"fully_closed": fully_submitted and remaining == 0,
}
def run_options_target_closes(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权",
) -> int:
"""
扫描 active 目标委托;指数到位后限价平仓.
返回触发条数.
"""
ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
live_ids = {k for k in pos_by_inst if k}
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
triggered = 0
for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index"))
if not inst_id or target is None:
continue
pos = pos_by_inst.get(inst_id)
if not pos:
continue
if index_fn is not None:
idx = index_fn(pos)
else:
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
if idx is None:
continue
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue
result = close_fn(inst_id)
if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
continue
if not result.get("ok"):
mark_monitor(
conn,
int(mon["id"]),
status="active",
trigger_idx=idx,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
)
continue
mark_monitor(
conn,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓",
)
triggered += 1
if send_wechat:
try:
send_wechat(
"\n".join(
[
"【OKX期权·目标位平仓】",
f"账户:{account_label}",
f"合约:{inst_id}",
f"目标指数:{target:g}",
f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or ''}",
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC",
]
)
)
except Exception:
pass
return triggered
+6 -2
View File
@@ -80,14 +80,14 @@
</div>
<div class="options-estimate-row">
<label class="opt-est-label" for="opt-target-idx">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="到期时指数价">
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
<span class="muted opt-est-note">到期测算,仅供参考</span>
<span class="muted opt-est-note">填写后进入右侧监控;达价限价平仓,无止损,到期即止损</span>
</div>
<div class="form-row options-order-mode-row">
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
@@ -115,6 +115,10 @@
</div>
<div class="options-pos-tab-body">
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
<div id="opt-target-monitors" class="opt-target-monitors" hidden>
<div class="opt-target-monitors-head">目标监控</div>
<div id="opt-target-monitors-list"></div>
</div>
<div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
<div id="opt-pos-cards"></div>
+32
View File
@@ -8772,6 +8772,38 @@ html[data-theme="light"] .hub-monitor-options .hub-monitor-block-label {
margin-top: 14px;
}
.hub-opt-target-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0 0 10px;
}
.hub-opt-target-item {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 12px;
color: var(--muted, #9aa8c7);
}
.hub-opt-target-item code {
font-size: 11px;
color: #dbe6ff;
}
.hub-opt-pos-card .opt-target-row--ro {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(67, 82, 118, 0.4);
font-size: 12px;
}
.hub-options-summary {
display: flex;
flex-wrap: wrap;
+10
View File
@@ -3614,8 +3614,18 @@
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
} else {
const pos = Array.isArray(opt.positions) ? opt.positions : [];
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
const bal = optionsBalanceFields(opt);
html += renderStatRow(bal.funding, bal.trading, bal.upl);
if (targets.length) {
html += `<div class="section-title hub-options-title">目标监控 · ${targets.length}</div>`;
html += '<div class="hub-opt-target-list">';
targets.forEach((t) => {
const side = String(t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
html += `<div class="hub-opt-target-item"><code title="${esc(t.inst_id || "")}">${esc(shortOptionsInst(t.inst_id))}</code> <span>${side} ${esc(fmt(t.target_index, 1))}</span></div>`;
});
html += "</div>";
}
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
html += layout === "cards" ? renderOptionsPositionsCards(pos) : renderOptionsPositionsTable(pos);
if (row.flask_url_browser || row.flask_url) {
+16 -11
View File
@@ -1,5 +1,5 @@
from unittest import TestCase
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from lib.options.options_hub_lib import build_options_hub_snapshot
@@ -10,7 +10,15 @@ class OptionsHubLibTests(TestCase):
self.assertFalse(out["enabled"])
self.assertTrue(out["ok"])
def test_build_options_hub_snapshot_positions(self):
@patch("lib.options.options_hub_lib._compute_options_stats", return_value={})
@patch("lib.options.options_positions_lib.build_display_option_positions")
def test_build_options_hub_snapshot_positions(self, mock_positions, _mock_stats):
mock_positions.return_value = [
{"inst_id": "ETH-USD_UM-260703-1800-C", "pos": 2, "upl": 1.5, "mark_px": 0.1}
]
conn = MagicMock()
conn.__enter__ = MagicMock(return_value=conn)
conn.__exit__ = MagicMock(return_value=False)
cfg = {
"enabled": True,
"exchange_options": object(),
@@ -18,19 +26,16 @@ class OptionsHubLibTests(TestCase):
"fetch_option_positions": lambda ex: [
{"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"}
],
"format_position_row": lambda p: {
"inst_id": p.get("instId"),
"pos": 2,
"upl": 1.5,
"mark_px": 0.1,
},
"fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0},
"get_db": MagicMock(),
"get_db": MagicMock(return_value=conn),
"trade_budget": 10,
"account_label": "OKX期权",
}
out = build_options_hub_snapshot(cfg)
self.assertTrue(out["ok"])
with patch("lib.options.options_target_lib.list_active_targets", return_value=[]):
with patch("lib.options.options_target_lib.targets_by_inst", return_value={}):
out = build_options_hub_snapshot(cfg)
self.assertTrue(out["ok"], out.get("msg"))
self.assertEqual(out["position_count"], 1)
self.assertEqual(out["upl_total_usdc"], 1.5)
self.assertEqual(out["trading_usdc"], 9.5)
self.assertEqual(out.get("target_monitors"), [])
+54
View File
@@ -0,0 +1,54 @@
"""期权目标位委托单元测试."""
from __future__ import annotations
import sqlite3
import unittest
from lib.options.options_target_lib import (
ensure_target_tables,
list_active_targets,
run_options_target_closes,
target_hit,
upsert_target_monitor,
)
class OptionsTargetLibTests(unittest.TestCase):
def test_target_hit_call_put(self):
self.assertTrue(target_hit(opt_type="C", index_px=2000, target_index=1950))
self.assertFalse(target_hit(opt_type="C", index_px=1900, target_index=1950))
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
def test_upsert_and_trigger_close(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
ensure_target_tables(conn)
out = upsert_target_monitor(
conn,
inst_id="ETH-USD_UM-260717-1900-C",
target_index=1880,
opt_type="C",
sheets=1,
)
self.assertTrue(out["ok"])
self.assertEqual(len(list_active_targets(conn)), 1)
closed = []
def close_fn(inst_id: str):
closed.append(inst_id)
return {"ok": True, "submitted_sheets": 1, "premium_received": 1.2, "close_ord_id": "oid1"}
n = run_options_target_closes(
conn,
[{"inst_id": "ETH-USD_UM-260717-1900-C", "idx_px": 1885, "opt_type": "C"}],
close_fn=close_fn,
)
self.assertEqual(n, 1)
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
self.assertEqual(len(list_active_targets(conn)), 0)
if __name__ == "__main__":
unittest.main()