总览支持向客户端下发买一全平:确认后转发,无效买一拒绝。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -24,13 +24,41 @@
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user