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 {
|
.options-page-wrap .opt-pos-cell--depth {
|
||||||
grid-column: span 2;
|
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 {
|
.options-page-wrap .opt-bid-plain {
|
||||||
color: #dbe6ff;
|
color: #dbe6ff;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -788,6 +788,15 @@
|
|||||||
} else if (mode === "sheets") {
|
} else if (mode === "sheets") {
|
||||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
|
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", {
|
const d = await apiJson("/api/options/open", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -845,6 +854,22 @@
|
|||||||
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
|
(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--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 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>"
|
"</div>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -912,6 +937,28 @@
|
|||||||
closePosition(btn.getAttribute("data-inst"), btn);
|
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) {
|
container.querySelectorAll(".opt-pos-bar").forEach(function (bar) {
|
||||||
bar.addEventListener("click", function () {
|
bar.addEventListener("click", function () {
|
||||||
const item = bar.closest(".opt-pos-accordion-item");
|
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) {
|
async function closePosition(inst, btn) {
|
||||||
const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null;
|
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";
|
let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview";
|
||||||
@@ -1063,7 +1185,25 @@
|
|||||||
const seq = ++positionsRefreshSeq;
|
const seq = ++positionsRefreshSeq;
|
||||||
const d = await apiJson("/api/options/positions");
|
const d = await apiJson("/api/options/positions");
|
||||||
if (seq !== positionsRefreshSeq) return;
|
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() {
|
async function refreshStats() {
|
||||||
|
|||||||
@@ -113,7 +113,10 @@
|
|||||||
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
|
(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--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 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:
|
if raw is None:
|
||||||
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
|
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
|
||||||
positions = build_display_option_positions(cfg, ex, raw)
|
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
|
upl_total = 0.0
|
||||||
has_upl = False
|
has_upl = False
|
||||||
for p in positions:
|
for p in positions:
|
||||||
@@ -45,6 +63,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"positions": positions,
|
"positions": positions,
|
||||||
"position_count": len(positions),
|
"position_count": len(positions),
|
||||||
|
"target_monitors": target_monitors,
|
||||||
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
|
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
|
||||||
"balances": bal,
|
"balances": bal,
|
||||||
"funding_usdc": bal.get("funding_usdc"),
|
"funding_usdc": bal.get("funding_usdc"),
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ def options_monitor_loop(
|
|||||||
account_label: str,
|
account_label: str,
|
||||||
profit_ratio: float,
|
profit_ratio: float,
|
||||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||||
|
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
||||||
stop_event: Any = None,
|
stop_event: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not enabled:
|
if not enabled:
|
||||||
@@ -290,6 +291,16 @@ def options_monitor_loop(
|
|||||||
account_label=account_label,
|
account_label=account_label,
|
||||||
ticker_bid_fn=ticker_bid_fn,
|
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:
|
if sync_trades_fn is not None:
|
||||||
sync_trades_fn(conn)
|
sync_trades_fn(conn)
|
||||||
conn.commit()
|
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()
|
inst_id = (data.get("inst_id") or "").strip()
|
||||||
mode = (data.get("mode") or "budget_full").strip()
|
mode = (data.get("mode") or "budget_full").strip()
|
||||||
signal_note = (data.get("signal_note") or "").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:
|
if not inst_id:
|
||||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||||
q = cfg["quote_option_contract"](ex, 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"):
|
if not order.get("ok"):
|
||||||
return jsonify(order)
|
return jsonify(order)
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
|
trade_id = None
|
||||||
|
target_mon = None
|
||||||
try:
|
try:
|
||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
meta = q.get("meta") or {}
|
meta = q.get("meta") or {}
|
||||||
u = str(meta.get("uly") or inst_id).split("-")[0]
|
u = str(meta.get("uly") or inst_id).split("-")[0]
|
||||||
conn.execute(
|
opt_type = meta.get("optType")
|
||||||
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO options_trades
|
INSERT INTO options_trades
|
||||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
(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,
|
inst_id,
|
||||||
u,
|
u,
|
||||||
meta.get("optType"),
|
opt_type,
|
||||||
q.get("strike"),
|
q.get("strike"),
|
||||||
str(q.get("exp_time") or ""),
|
str(q.get("exp_time") or ""),
|
||||||
sheets,
|
sheets,
|
||||||
@@ -484,6 +496,19 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
(order.get("data") or {}).get("ordId"),
|
(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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -491,7 +516,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
invalidate_option_positions_cache()
|
invalidate_option_positions_cache()
|
||||||
_sync_options_trades(cfg, force=True)
|
_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")
|
@app.route("/api/options/positions")
|
||||||
@lr
|
@lr
|
||||||
@@ -506,6 +539,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
|
from lib.options.options_target_lib import targets_by_inst
|
||||||
|
|
||||||
|
tgt_map = targets_by_inst(conn)
|
||||||
rows = []
|
rows = []
|
||||||
for p in raw:
|
for p in raw:
|
||||||
inst = str(p.get("instId") or "").strip()
|
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,
|
premium_override=premium_override,
|
||||||
)
|
)
|
||||||
_attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
_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)
|
rows.append(row)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": True, "positions": rows})
|
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"])
|
@app.route("/api/options/close", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_close():
|
def api_options_close():
|
||||||
@@ -677,6 +804,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
invalidate_option_positions_cache()
|
invalidate_option_positions_cache()
|
||||||
_sync_options_trades(cfg, force=True)
|
_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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -739,6 +877,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
invalidate_option_positions_cache()
|
invalidate_option_positions_cache()
|
||||||
_sync_options_trades(cfg, force=True)
|
_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})
|
return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets})
|
||||||
|
|
||||||
@app.route("/api/options/convert/quote", methods=["POST"])
|
@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),
|
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(
|
t = threading.Thread(
|
||||||
target=options_monitor_loop,
|
target=options_monitor_loop,
|
||||||
kwargs={
|
kwargs={
|
||||||
@@ -986,6 +1153,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"account_label": cfg["account_label"],
|
"account_label": cfg["account_label"],
|
||||||
"profit_ratio": cfg["profit_ratio"],
|
"profit_ratio": cfg["profit_ratio"],
|
||||||
"sync_trades_fn": _sync,
|
"sync_trades_fn": _sync,
|
||||||
|
"target_close_fn": _target_close,
|
||||||
},
|
},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="options-monitor",
|
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>
|
||||||
<div class="options-estimate-row">
|
<div class="options-estimate-row">
|
||||||
<label class="opt-est-label" for="opt-target-idx">目标位(指数)</label>
|
<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 class="k">预计价值</span>
|
||||||
<span id="opt-est-value" class="v">—</span>
|
<span id="opt-est-value" class="v">—</span>
|
||||||
<span class="k">盈利</span>
|
<span class="k">盈利</span>
|
||||||
<span id="opt-est-profit" class="v">—</span>
|
<span id="opt-est-profit" class="v">—</span>
|
||||||
<span class="k">目标杠杆</span>
|
<span class="k">目标杠杆</span>
|
||||||
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金">—</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>
|
||||||
<div class="form-row options-order-mode-row">
|
<div class="form-row options-order-mode-row">
|
||||||
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
|
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
|
||||||
@@ -115,6 +115,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="options-pos-tab-body">
|
<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 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 id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
|
||||||
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
|
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
|
||||||
<div id="opt-pos-cards"></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;
|
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 {
|
.hub-options-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -3614,8 +3614,18 @@
|
|||||||
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
|
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const pos = Array.isArray(opt.positions) ? opt.positions : [];
|
const pos = Array.isArray(opt.positions) ? opt.positions : [];
|
||||||
|
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
|
||||||
const bal = optionsBalanceFields(opt);
|
const bal = optionsBalanceFields(opt);
|
||||||
html += renderStatRow(bal.funding, bal.trading, bal.upl);
|
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 += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
|
||||||
html += layout === "cards" ? renderOptionsPositionsCards(pos) : renderOptionsPositionsTable(pos);
|
html += layout === "cards" ? renderOptionsPositionsCards(pos) : renderOptionsPositionsTable(pos);
|
||||||
if (row.flask_url_browser || row.flask_url) {
|
if (row.flask_url_browser || row.flask_url) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from unittest import TestCase
|
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
|
from lib.options.options_hub_lib import build_options_hub_snapshot
|
||||||
|
|
||||||
@@ -10,7 +10,15 @@ class OptionsHubLibTests(TestCase):
|
|||||||
self.assertFalse(out["enabled"])
|
self.assertFalse(out["enabled"])
|
||||||
self.assertTrue(out["ok"])
|
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 = {
|
cfg = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"exchange_options": object(),
|
"exchange_options": object(),
|
||||||
@@ -18,19 +26,16 @@ class OptionsHubLibTests(TestCase):
|
|||||||
"fetch_option_positions": lambda ex: [
|
"fetch_option_positions": lambda ex: [
|
||||||
{"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"}
|
{"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},
|
"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,
|
"trade_budget": 10,
|
||||||
"account_label": "OKX期权",
|
"account_label": "OKX期权",
|
||||||
}
|
}
|
||||||
out = build_options_hub_snapshot(cfg)
|
with patch("lib.options.options_target_lib.list_active_targets", return_value=[]):
|
||||||
self.assertTrue(out["ok"])
|
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["position_count"], 1)
|
||||||
self.assertEqual(out["upl_total_usdc"], 1.5)
|
self.assertEqual(out["upl_total_usdc"], 1.5)
|
||||||
self.assertEqual(out["trading_usdc"], 9.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