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:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>"
|
||||
: "")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"), [])
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user