总览支持向客户端下发买一全平:确认后转发,无效买一拒绝。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-02 16:03:11 +08:00
parent 73905d236d
commit fb653c150c
4 changed files with 1083 additions and 918 deletions
+24 -1
View File
@@ -25,7 +25,7 @@ if str(ROOT) not in sys.path:
ENV_PATH = ROOT / ".env" ENV_PATH = ROOT / ".env"
load_dotenv(ENV_PATH) load_dotenv(ENV_PATH)
from lib.bridge_client_lib import pull_all_enabled from lib.bridge_client_lib import post_close_full, pull_all_enabled
from lib.db_lib import init_db from lib.db_lib import init_db
from lib.env_lib import apply_env_updates from lib.env_lib import apply_env_updates
from lib.instances_lib import ( from lib.instances_lib import (
@@ -241,5 +241,28 @@ def api_open_url(iid: int):
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]}) return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]})
@app.route("/api/instances/<int:iid>/options/close", methods=["POST"])
@login_required
def api_instance_options_close(iid: int):
"""总览下发全平:中控只转发,客户端本地买一限价执行."""
inst = get_instance(iid)
if not inst:
return jsonify({"ok": False, "msg": "实例不存在"}), 404
if not inst.get("enabled"):
return jsonify({"ok": False, "msg": "实例未启用"}), 400
data = request.get_json(silent=True) or {}
opt_inst = (data.get("inst_id") or "").strip()
if not opt_inst:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
result = post_close_full(inst, inst_id=opt_inst)
status = 200
if not result.get("ok"):
# 客户端业务拒绝(残档等)仍 200 带 ok:false;仅传输层失败用 502
msg = str(result.get("msg") or "")
if msg.startswith("HTTP ") or "超时" in msg or "下发失败" in msg or "缺少 URL" in msg:
status = 502
return jsonify(result), status
if __name__ == "__main__": if __name__ == "__main__":
app.run(host=APP_HOST, port=APP_PORT, debug=True, threaded=True) app.run(host=APP_HOST, port=APP_PORT, debug=True, threaded=True)
+46 -1
View File
@@ -1,4 +1,4 @@
"""拉取各 crypto_okx 客户端只读快照.""" """拉取各 crypto_okx 客户端快照;以及受限写指令(买一全平)."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -18,6 +18,13 @@ def _timeout() -> float:
return 5.0 return 5.0
def _close_timeout() -> float:
try:
return max(8.0, float(os.getenv("HUB_CLOSE_TIMEOUT_SEC") or 45))
except (TypeError, ValueError):
return 45.0
def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]: def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]:
base = (inst.get("base_url") or "").rstrip("/") base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip() token = (inst.get("bridge_token") or "").strip()
@@ -62,6 +69,44 @@ def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]:
return out return out
def post_close_full(inst: dict[str, Any], *, inst_id: str) -> dict[str, Any]:
"""向客户端下发买一全平指令;下单在客户端本地执行."""
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
iid = (inst_id or "").strip()
if not base or not token:
return {"ok": False, "msg": "缺少 URL 或 Token"}
if not iid:
return {"ok": False, "msg": "缺少 inst_id"}
url = f"{base}/api/hub/options/close"
try:
r = requests.post(
url,
headers={
"X-Hub-Token": token,
"Accept": "application/json",
"Content-Type": "application/json",
},
json={"inst_id": iid},
timeout=_close_timeout(),
)
try:
data = r.json()
except Exception:
data = {}
if isinstance(data, dict) and data:
if r.status_code >= 400 and "ok" not in data:
data = dict(data)
data["ok"] = False
data.setdefault("msg", f"HTTP {r.status_code}")
return data
return {"ok": False, "msg": f"HTTP {r.status_code}"}
except requests.Timeout:
return {"ok": False, "msg": "客户端超时(下单可能仍在进行,请刷新确认)"}
except Exception as e:
return {"ok": False, "msg": str(e) or "下发失败"}
def pull_all_enabled() -> list[dict[str, Any]]: def pull_all_enabled() -> list[dict[str, Any]]:
instances = list_instances(enabled_only=True) instances = list_instances(enabled_only=True)
if not instances: if not instances:
+19 -1
View File
@@ -196,6 +196,18 @@ button, .btn {
} }
.btn:hover, button:hover { filter: brightness(1.06); } .btn:hover, button:hover { filter: brightness(1.06); }
.btn-danger { background: #9f1239; color: #fff; } .btn-danger { background: #9f1239; color: #fff; }
.btn-sm {
padding: 3px 8px;
font-size: 0.75rem;
border-radius: 6px;
font-weight: 600;
}
.btn[disabled] {
opacity: 0.45;
cursor: not-allowed;
filter: none;
}
.pos-brief-main { min-width: 0; }
.panel { .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -252,7 +264,13 @@ button, .btn {
color: var(--muted); color: var(--muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.pos-brief li { margin: 2px 0; } .pos-brief li {
margin: 2px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+83 -4
View File
@@ -24,13 +24,41 @@
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function positionSummary(brief) { function positionSummary(brief, instanceId, online) {
const list = brief || []; const list = brief || [];
if (!list.length) return '<span class="muted">暂无持仓</span>'; if (!list.length) return '<span class="muted">暂无持仓</span>';
const items = list.slice(0, 8).map(function (p) { const items = list.slice(0, 8).map(function (p) {
const id = escapeHtml(p.inst_id || ""); const id = p.inst_id || "";
const sheets = escapeHtml(String(p.sheets != null ? p.sheets : "—")); const sheets = escapeHtml(String(p.sheets != null ? p.sheets : "—"));
return "<li>" + id + " · " + sheets + "张 · " + fmtPnlHtml(p.pnl_usdt) + "</li>"; const invalid = !!(p.bid_invalid || (p.bid_px == null && (p.bid_sz == null || p.bid_sz === "")));
const reason = (p.bid_invalid_reason || "买一无效,禁止平仓").toString();
let closeBtn = "";
if (online && id) {
if (invalid) {
closeBtn =
'<button type="button" class="btn btn-sm btn-danger" disabled title="' +
escapeHtml(reason) +
'">平仓</button>';
} else {
closeBtn =
'<button type="button" class="btn btn-sm btn-danger" data-close-inst="' +
encodeURIComponent(String(instanceId)) +
'" data-close-opt="' +
encodeURIComponent(id) +
'" title="买一限价全平">平仓</button>';
}
}
return (
"<li><span class=\"pos-brief-main\">" +
escapeHtml(id) +
" · " +
sheets +
"张 · " +
fmtPnlHtml(p.pnl_usdt) +
"</span>" +
closeBtn +
"</li>"
);
}); });
const more = const more =
list.length > 8 list.length > 8
@@ -120,7 +148,7 @@
fmtPnlHtml(pnl) + fmtPnlHtml(pnl) +
"</td>" + "</td>" +
"<td>" + "<td>" +
positionSummary(brief) + positionSummary(brief, row.id, online) +
"</td>" + "</td>" +
"<td>" + "<td>" +
bidLiquiditySummary(brief) + bidLiquiditySummary(brief) +
@@ -140,6 +168,52 @@
); );
} }
async function requestClose(btn) {
const instanceId = decodeURIComponent(btn.getAttribute("data-close-inst") || "");
const optId = decodeURIComponent(btn.getAttribute("data-close-opt") || "");
if (!instanceId || !optId) return;
if (
!confirm(
"确认向该客户端下发买一限价全平?\n\n合约: " +
optId +
"\n\n无效买一将被拒绝;下单在客户端本地执行。"
)
) {
return;
}
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = "平仓中…";
try {
const rr = await fetch("/api/instances/" + encodeURIComponent(instanceId) + "/options/close", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ inst_id: optId }),
});
let dd = {};
try {
dd = await rr.json();
} catch (e) {
dd = {};
}
if (dd && dd.ok) {
let msg = dd.msg || "已提交买一全平";
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
if (dd.fully_closed) msg += " · 已全平";
alert(msg);
await refresh();
} else {
alert((dd && dd.msg) || "平仓失败");
btn.disabled = false;
btn.textContent = oldText;
}
} catch (e) {
alert((e && e.message) || "请求失败");
btn.disabled = false;
btn.textContent = oldText;
}
}
async function refresh() { async function refresh() {
const status = document.getElementById("overview-status"); const status = document.getElementById("overview-status");
const grid = document.getElementById("overview-grid"); const grid = document.getElementById("overview-grid");
@@ -170,6 +244,11 @@
else alert(dd.msg || "无法打开"); else alert(dd.msg || "无法打开");
}); });
}); });
grid.querySelectorAll("[data-close-opt]").forEach(function (btn) {
btn.addEventListener("click", function () {
requestClose(btn);
});
});
} }
if (status) status.textContent = "已更新 · 共 " + list.length + " 台"; if (status) status.textContent = "已更新 · 共 " + list.length + " 台";
} catch (e) { } catch (e) {