diff --git a/app.py b/app.py index 0965cd6..3f02bb1 100644 --- a/app.py +++ b/app.py @@ -25,7 +25,14 @@ if str(ROOT) not in sys.path: ENV_PATH = ROOT / ".env" load_dotenv(ENV_PATH) -from lib.bridge_client_lib import post_close_full, pull_all_enabled +from lib.bridge_client_lib import ( + dispatch_cond_order_to_ids, + fetch_cond_orders, + post_close_full, + post_cond_cancel, + post_cond_exit, + pull_all_enabled, +) from lib.db_lib import init_db from lib.env_lib import apply_env_updates from lib.instances_lib import ( @@ -264,5 +271,148 @@ def api_instance_options_close(iid: int): return jsonify(result), status +@app.route("/cond_orders") +@login_required +def cond_orders_page(): + return render_template("cond_orders.html") + + +@app.route("/api/cond_orders/candidates") +@login_required +def api_cond_order_candidates(): + """可选客户端:在线且无持仓、未被条件单锁定.""" + rows = pull_all_enabled() + out = [] + for row in rows: + snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {} + brief = snap.get("positions_brief") or snap.get("positions") or [] + has_pos = isinstance(brief, list) and len(brief) > 0 + locked = bool(snap.get("cond_order_locked")) + selectable = bool(row.get("online")) and not has_pos and not locked + out.append( + { + "id": row.get("id"), + "name": row.get("name"), + "online": bool(row.get("online")), + "has_position": has_pos, + "cond_order_locked": locked, + "selectable": selectable, + "error": row.get("error") or "", + } + ) + return jsonify({"ok": True, "instances": out}) + + +@app.route("/api/cond_orders", methods=["GET"]) +@login_required +def api_cond_orders_list(): + instances = list_instances(enabled_only=True) + all_orders = [] + for inst in instances: + data = fetch_cond_orders(inst) + for o in data.get("orders") or []: + if not isinstance(o, dict): + continue + item = dict(o) + item["instance_id"] = inst.get("id") + item["instance_name"] = inst.get("name") + all_orders.append(item) + return jsonify({"ok": True, "orders": all_orders}) + + +@app.route("/api/cond_orders", methods=["POST"]) +@login_required +def api_cond_orders_create(): + import uuid + + data = request.get_json(silent=True) or {} + ids_raw = data.get("instance_ids") or [] + try: + instance_ids = [int(x) for x in ids_raw] + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "instance_ids 无效"}) + if not instance_ids: + return jsonify({"ok": False, "msg": "请选择客户端"}) + + # 再次按快照过滤有仓/锁定 + rows = {r.get("id"): r for r in pull_all_enabled()} + allowed = [] + rejected = [] + for iid in instance_ids: + row = rows.get(iid) + if not row or not row.get("online"): + rejected.append({"instance_id": iid, "msg": "离线或不可用"}) + continue + snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {} + brief = snap.get("positions_brief") or [] + if brief: + rejected.append({"instance_id": iid, "msg": "已有持仓不可选"}) + continue + if snap.get("cond_order_locked"): + rejected.append({"instance_id": iid, "msg": "已有条件单占用"}) + continue + allowed.append(iid) + if not allowed: + return jsonify({"ok": False, "msg": "没有可下发的客户端", "rejected": rejected}) + + hub_order_id = (data.get("hub_order_id") or "").strip() or str(uuid.uuid4()) + payload = { + "hub_order_id": hub_order_id, + "underlying": (data.get("underlying") or "ETH").strip().upper(), + "opt_type": (data.get("opt_type") or "P").strip().upper(), + "trigger_side": (data.get("trigger_side") or "gte").strip().lower(), + "trigger_px": data.get("trigger_px"), + "deviation_pct": data.get("deviation_pct"), + "min_expiry_hours": data.get("min_expiry_hours"), + "exit": { + "target_index": data.get("target_index"), + "profit_exit_enabled": bool(data.get("profit_exit_enabled")), + "profit_exit_mult": data.get("profit_exit_mult"), + }, + } + results = dispatch_cond_order_to_ids(allowed, payload) + ok_n = sum(1 for r in results if r.get("ok")) + return jsonify( + { + "ok": ok_n > 0, + "msg": f"成功 {ok_n}/{len(results)}", + "hub_order_id": hub_order_id, + "results": results, + "rejected": rejected, + } + ) + + +@app.route("/api/cond_orders//cancel", methods=["POST"]) +@login_required +def api_cond_orders_cancel(hub_order_id: str): + data = request.get_json(silent=True) or {} + iid = data.get("instance_id") + if iid is None: + return jsonify({"ok": False, "msg": "缺少 instance_id"}) + inst = get_instance(int(iid)) + if not inst: + return jsonify({"ok": False, "msg": "实例不存在"}), 404 + return jsonify(post_cond_cancel(inst, hub_order_id)) + + +@app.route("/api/cond_orders//exit", methods=["POST"]) +@login_required +def api_cond_orders_exit(hub_order_id: str): + data = request.get_json(silent=True) or {} + iid = data.get("instance_id") + if iid is None: + return jsonify({"ok": False, "msg": "缺少 instance_id"}) + inst = get_instance(int(iid)) + if not inst: + return jsonify({"ok": False, "msg": "实例不存在"}), 404 + payload = { + "target_index": data.get("target_index"), + "profit_exit_enabled": data.get("profit_exit_enabled"), + "profit_exit_mult": data.get("profit_exit_mult"), + } + return jsonify(post_cond_exit(inst, hub_order_id, payload)) + + if __name__ == "__main__": app.run(host=APP_HOST, port=APP_PORT, debug=True, threaded=True) diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py index 4fa89a2..b60f57a 100644 --- a/lib/bridge_client_lib.py +++ b/lib/bridge_client_lib.py @@ -1,4 +1,4 @@ -"""拉取各 crypto_okx 客户端快照;以及受限写指令(买一全平).""" +"""拉取各 crypto_okx 客户端快照;以及受限写指令(买一全平/条件单).""" from __future__ import annotations import os @@ -8,7 +8,7 @@ from typing import Any import requests -from lib.instances_lib import list_instances +from lib.instances_lib import get_instance, list_instances def _timeout() -> float: @@ -25,6 +25,14 @@ def _close_timeout() -> float: return 45.0 +def _headers(token: str) -> dict[str, str]: + return { + "X-Hub-Token": token, + "Accept": "application/json", + "Content-Type": "application/json", + } + + def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]: base = (inst.get("base_url") or "").rstrip("/") token = (inst.get("bridge_token") or "").strip() @@ -82,11 +90,7 @@ def post_close_full(inst: dict[str, Any], *, inst_id: str) -> dict[str, Any]: try: r = requests.post( url, - headers={ - "X-Hub-Token": token, - "Accept": "application/json", - "Content-Type": "application/json", - }, + headers=_headers(token), json={"inst_id": iid}, timeout=_close_timeout(), ) @@ -107,6 +111,88 @@ def post_close_full(inst: dict[str, Any], *, inst_id: str) -> dict[str, Any]: return {"ok": False, "msg": str(e) or "下发失败"} +def post_cond_order(inst: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + if not base or not token: + return {"ok": False, "msg": "缺少 URL 或 Token", "instance_id": inst.get("id")} + url = f"{base}/api/hub/options/cond_orders" + try: + r = requests.post(url, headers=_headers(token), json=payload, timeout=_close_timeout()) + data = r.json() if r.content else {} + if not isinstance(data, dict): + data = {"ok": False, "msg": f"HTTP {r.status_code}"} + data["instance_id"] = inst.get("id") + data["instance_name"] = inst.get("name") + return data + except requests.Timeout: + return { + "ok": False, + "msg": "客户端超时", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + except Exception as e: + return { + "ok": False, + "msg": str(e) or "下发失败", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + + +def post_cond_cancel(inst: dict[str, Any], hub_order_id: str) -> dict[str, Any]: + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + oid = (hub_order_id or "").strip() + if not base or not token or not oid: + return {"ok": False, "msg": "参数不完整"} + url = f"{base}/api/hub/options/cond_orders/{oid}/cancel" + try: + r = requests.post(url, headers=_headers(token), json={}, timeout=_timeout()) + data = r.json() if r.content else {} + return data if isinstance(data, dict) else {"ok": False, "msg": f"HTTP {r.status_code}"} + except Exception as e: + return {"ok": False, "msg": str(e) or "取消失败"} + + +def post_cond_exit(inst: dict[str, Any], hub_order_id: str, payload: dict[str, Any]) -> dict[str, Any]: + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + oid = (hub_order_id or "").strip() + if not base or not token or not oid: + return {"ok": False, "msg": "参数不完整"} + url = f"{base}/api/hub/options/cond_orders/{oid}/exit" + try: + r = requests.post(url, headers=_headers(token), json=payload or {}, timeout=_timeout()) + data = r.json() if r.content else {} + return data if isinstance(data, dict) else {"ok": False, "msg": f"HTTP {r.status_code}"} + except Exception as e: + return {"ok": False, "msg": str(e) or "更新失败"} + + +def fetch_cond_orders(inst: dict[str, Any]) -> dict[str, Any]: + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + if not base or not token: + return {"ok": False, "msg": "缺少 URL 或 Token", "orders": []} + url = f"{base}/api/hub/options/cond_orders" + try: + r = requests.get( + url, + headers={"X-Hub-Token": token, "Accept": "application/json"}, + timeout=_timeout(), + ) + data = r.json() if r.content else {} + if isinstance(data, dict): + data["instance_id"] = inst.get("id") + data["instance_name"] = inst.get("name") + return data + return {"ok": False, "msg": f"HTTP {r.status_code}", "orders": []} + except Exception as e: + return {"ok": False, "msg": str(e) or "拉取失败", "orders": [], "instance_id": inst.get("id")} + + def pull_all_enabled() -> list[dict[str, Any]]: instances = list_instances(enabled_only=True) if not instances: @@ -121,3 +207,24 @@ def pull_all_enabled() -> list[dict[str, Any]]: for inst in instances: results.append(by_id.get(inst["id"]) or fetch_one_snapshot(inst)) return results + + +def dispatch_cond_order_to_ids(instance_ids: list[int], payload: dict[str, Any]) -> list[dict[str, Any]]: + results = [] + for iid in instance_ids: + inst = get_instance(int(iid)) + if not inst: + results.append({"ok": False, "msg": "实例不存在", "instance_id": iid}) + continue + if not inst.get("enabled"): + results.append( + { + "ok": False, + "msg": "实例未启用", + "instance_id": iid, + "instance_name": inst.get("name"), + } + ) + continue + results.append(post_cond_order(inst, payload)) + return results diff --git a/static/cond_orders.js b/static/cond_orders.js new file mode 100644 index 0000000..595745e --- /dev/null +++ b/static/cond_orders.js @@ -0,0 +1,243 @@ +(function (global) { + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function statusLabel(o) { + const st = o.status || ""; + const reason = o.end_reason || ""; + if (st === "done") { + const map = { + leave_up: "结束·上破", + leave_down: "结束·下破", + cancel: "已取消", + closed: "已完成", + }; + return map[reason] || "已结束"; + } + const map2 = { armed: "待命", triggering: "触发中", open: "持仓中" }; + return map2[st] || st || "—"; + } + + async function loadCandidates() { + const box = document.getElementById("cf-instances"); + if (!box) return; + const r = await fetch("/api/cond_orders/candidates"); + const d = await r.json(); + const list = d.instances || []; + if (!list.length) { + box.innerHTML = '暂无启用实例'; + return; + } + box.innerHTML = list + .map(function (it) { + const disabled = !it.selectable; + const tip = disabled + ? it.has_position + ? "已有持仓" + : it.cond_order_locked + ? "条件单占用" + : it.online + ? "不可选" + : "离线" + : "可选"; + return ( + '" + ); + }) + .join(""); + } + + async function loadOrders() { + const box = document.getElementById("cond-list"); + if (!box) return; + const r = await fetch("/api/cond_orders"); + const d = await r.json(); + const list = d.orders || []; + if (!list.length) { + box.innerHTML = '暂无条件单'; + return; + } + box.innerHTML = + '
' + + "" + + "" + + list + .map(function (o) { + const side = o.trigger_side === "lte" ? "≤" : "≥"; + const exit = + (o.profit_exit_enabled ? "翻倍×" + (o.profit_exit_mult || "") : "") + + (o.target_index != null ? " 目标" + o.target_index : ""); + const canCancel = o.status === "armed" || o.status === "triggering"; + const canExit = o.status === "open"; + let actions = ""; + if (canCancel) { + actions += + ''; + } + if (canExit) { + actions += + ''; + } + return ( + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + '" + + "" + ); + }) + .join("") + + "
客户端状态标的触发偏离到期h出场说明操作
" + + escapeHtml(o.instance_name || "") + + "" + + escapeHtml(statusLabel(o)) + + "" + + escapeHtml((o.underlying || "") + " " + (o.opt_type || "")) + + "" + + escapeHtml(side + " " + (o.trigger_px != null ? o.trigger_px : "")) + + "" + + escapeHtml(o.deviation_pct != null ? o.deviation_pct + "%" : "—") + + "" + + escapeHtml(o.min_expiry_hours != null ? String(o.min_expiry_hours) : "—") + + "" + + escapeHtml(exit || "—") + + "" + + escapeHtml(o.message || "") + + "
' + + actions + + "
"; + + box.querySelectorAll("[data-cancel]").forEach(function (btn) { + btn.addEventListener("click", async function () { + if (!confirm("确认取消该条件单?")) return; + const oid = decodeURIComponent(btn.getAttribute("data-cancel") || ""); + const iid = decodeURIComponent(btn.getAttribute("data-iid") || ""); + const rr = await fetch("/api/cond_orders/" + encodeURIComponent(oid) + "/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instance_id: Number(iid) }), + }); + const dd = await rr.json(); + alert(dd.msg || (dd.ok ? "已取消" : "失败")); + refresh(); + }); + }); + box.querySelectorAll("[data-exit]").forEach(function (btn) { + btn.addEventListener("click", async function () { + const oid = decodeURIComponent(btn.getAttribute("data-exit") || ""); + const iid = decodeURIComponent(btn.getAttribute("data-iid") || ""); + const curT = decodeURIComponent(btn.getAttribute("data-target") || ""); + const curM = decodeURIComponent(btn.getAttribute("data-mult") || "1"); + const pe = btn.getAttribute("data-pe") === "1"; + const t = prompt("目标指数(留空清除)", curT); + if (t === null) return; + const peOn = confirm("是否启用翻倍出场?\n当前:" + (pe ? "开" : "关")); + let mult = curM; + if (peOn) { + const m = prompt("翻倍倍数", curM); + if (m === null) return; + mult = m; + } + const rr = await fetch("/api/cond_orders/" + encodeURIComponent(oid) + "/exit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instance_id: Number(iid), + target_index: t === "" ? "" : t, + profit_exit_enabled: peOn, + profit_exit_mult: mult, + }), + }); + const dd = await rr.json(); + alert(dd.msg || (dd.ok ? "已更新" : "失败")); + refresh(); + }); + }); + } + + async function createOrder() { + const status = document.getElementById("cf-status"); + const ids = Array.prototype.slice + .call(document.querySelectorAll('input[name="cf-inst"]:checked')) + .map(function (el) { + return Number(decodeURIComponent(el.value)); + }); + if (!ids.length) { + alert("请选择至少一台可选客户端"); + return; + } + const body = { + instance_ids: ids, + underlying: document.getElementById("cf-underlying").value, + opt_type: document.getElementById("cf-opt-type").value, + trigger_side: document.getElementById("cf-trigger-side").value, + trigger_px: document.getElementById("cf-trigger-px").value, + deviation_pct: document.getElementById("cf-deviation").value, + min_expiry_hours: document.getElementById("cf-expiry-h").value, + target_index: document.getElementById("cf-target").value, + profit_exit_enabled: document.getElementById("cf-pe").checked, + profit_exit_mult: document.getElementById("cf-pe-mult").value, + }; + if (status) status.textContent = "下发中…"; + const r = await fetch("/api/cond_orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (status) status.textContent = d.msg || ""; + alert(d.msg || (d.ok ? "已下发" : "失败")); + refresh(); + } + + async function refresh() { + await Promise.all([loadCandidates(), loadOrders()]); + } + + function boot() { + const btn = document.getElementById("btn-cond-refresh"); + if (btn) btn.addEventListener("click", refresh); + const create = document.getElementById("btn-cond-create"); + if (create) create.addEventListener("click", createOrder); + refresh(); + setInterval(refresh, 10000); + } + + global.HubCondOrders = { boot: boot, refresh: refresh }; +})(window); diff --git a/static/hub.css b/static/hub.css index 82b9de5..d4f77e9 100644 --- a/static/hub.css +++ b/static/hub.css @@ -202,6 +202,18 @@ button, .btn { border-radius: 6px; font-weight: 600; } +.inst-pick { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; +} +.inst-pick .check { margin: 0; } +.btn-sm { + padding: 3px 8px; + font-size: 0.75rem; + border-radius: 6px; + font-weight: 600; +} .btn[disabled] { opacity: 0.45; cursor: not-allowed; diff --git a/templates/base.html b/templates/base.html index 426c62a..2ef9679 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,29 +1,30 @@ - - - - - - {% block title %}期权中控{% endblock %} - - - - {% if session.get('logged_in') %} -
- -
- {% endif %} -
- {% with messages = get_flashed_messages() %} - {% if messages %}
{{ messages[0] }}
{% endif %} - {% endwith %} - {% block body %}{% endblock %} -
- {% block scripts %}{% endblock %} - - + + + + + + {% block title %}期权中控{% endblock %} + + + + {% if session.get('logged_in') %} +
+ +
+ {% endif %} +
+ {% with messages = get_flashed_messages() %} + {% if messages %}
{{ messages[0] }}
{% endif %} + {% endwith %} + {% block body %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/cond_orders.html b/templates/cond_orders.html new file mode 100644 index 0000000..73835f6 --- /dev/null +++ b/templates/cond_orders.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}条件单 · 期权中控{% endblock %} +{% block body %} +
+

条件开仓单

+ +
+

中控只下发指令;行情监控与买币/开仓/平仓/卖币均在客户端本地执行。有仓或已占用的机器不可选。

+ +
+

新建条件单

+
+ + + + + + + + + +
+
+
选择客户端
+
+
+
+ + +
+
+ +
+

条件单列表

+
加载中…
+
+{% endblock %} +{% block scripts %} + + +{% endblock %}