diff --git a/app.py b/app.py index 5b6f869..1711e8b 100644 --- a/app.py +++ b/app.py @@ -597,6 +597,43 @@ def api_open_url(iid: int): return jsonify({"ok": True, "url": url, "base_url": inst["base_url"], "tab": tab}) +@app.route("/api/instances//transfer", methods=["POST"]) +@login_required +def api_instance_transfer(iid: int): + """条件单页本页划转:中控发指令,实例执行资金户↔交易户.""" + from lib.bridge_client_lib import post_instance_transfer + + inst = get_instance(iid) + if not inst: + return jsonify({"ok": False, "msg": "不存在"}), 404 + data = request.get_json(silent=True) or {} + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}), 400 + if amount <= 0: + return jsonify({"ok": False, "msg": "数量须大于 0"}), 400 + from_acct = (data.get("from") or "funding").strip() or "funding" + to_acct = (data.get("to") or "trading").strip() or "trading" + ccy = (data.get("ccy") or "USDT").strip().upper() or "USDT" + out = post_instance_transfer( + inst, + amount=amount, + from_acct=from_acct, + to_acct=to_acct, + ccy=ccy, + ) + # 成功后轻量刷新总览/条件单缓存,供下次读到新余额;不整页重载 + if out.get("ok"): + try: + overview_store.request_refresh() + cond_orders_store.request_refresh() + except Exception: + pass + code = 200 if out.get("ok") else 400 + return jsonify(out), code + + @app.route("/api/settings/update/hub/version", methods=["GET"]) @login_required def api_hub_self_version(): diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py index eff0be0..c7b0559 100644 --- a/lib/bridge_client_lib.py +++ b/lib/bridge_client_lib.py @@ -325,6 +325,61 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]: return results +def post_instance_transfer( + inst: dict[str, Any], + *, + amount: float, + from_acct: str = "funding", + to_acct: str = "trading", + ccy: str = "USDT", +) -> dict[str, Any]: + """中控指令:实例账户内划转.""" + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + out: dict[str, Any] = { + "ok": False, + "msg": "", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + if not base or not token: + out["msg"] = "缺少 URL 或 Token" + return out + url = f"{base}/api/hub/options/transfer" + try: + r = requests.post( + url, + headers=_headers(token), + json={ + "amount": float(amount), + "from": from_acct, + "to": to_acct, + "ccy": ccy, + }, + timeout=max(_timeout(), 30.0), + ) + data, err = _response_json(r) + if data is None: + out["msg"] = err or f"HTTP {r.status_code}" + return out + if not isinstance(data, dict): + out["msg"] = f"HTTP {r.status_code}" + return out + out.update(data) + out["instance_id"] = inst.get("id") + out["instance_name"] = inst.get("name") + if r.status_code >= 400 and not out.get("ok"): + out["ok"] = False + out.setdefault("msg", err or f"HTTP {r.status_code}") + return out + except requests.Timeout: + out["msg"] = "划转超时" + return out + except Exception as e: + out["msg"] = str(e) or "划转失败" + return out + + def fetch_one_pnl_curve(inst: dict[str, Any], *, range_key: str = "7d") -> dict[str, Any]: """拉取单机累计已实现盈亏曲线.""" base = (inst.get("base_url") or "").rstrip("/") diff --git a/lib/cond_orders_cache_lib.py b/lib/cond_orders_cache_lib.py index 3228d6b..b2c4b4a 100644 --- a/lib/cond_orders_cache_lib.py +++ b/lib/cond_orders_cache_lib.py @@ -68,6 +68,10 @@ def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: trading_usdt = round(float(trading_usdt), 4) if funding_usdt is not None: funding_usdt = round(float(funding_usdt), 4) + transfer_blocked = bool(snap.get("transfer_blocked")) or has_pos + transfer_block_msg = str(snap.get("transfer_block_msg") or "").strip() + if has_pos and not transfer_block_msg: + transfer_block_msg = "期权持仓中禁止划转" out.append( { "id": row.get("id"), @@ -80,6 +84,8 @@ def _candidates_from_snaps(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: "error": row.get("error") or "", "trading_usdt": trading_usdt, "funding_usdt": funding_usdt, + "transfer_blocked": transfer_blocked, + "transfer_block_msg": transfer_block_msg, } ) return out diff --git a/static/cond_orders.js b/static/cond_orders.js index df7e631..54e8e16 100644 --- a/static/cond_orders.js +++ b/static/cond_orders.js @@ -202,6 +202,8 @@ return m === "sim" || m === "live" ? m : ""; } + var candidatesById = {}; + function fmtTradingU(v) { if (v == null || v === "") return "—"; const n = Number(v); @@ -232,6 +234,97 @@ }); } + function patchCandidateFunds(instanceId, tradingUsdt, fundingUsdt) { + const id = String(instanceId); + const it = candidatesById[id]; + if (it) { + if (tradingUsdt != null) it.trading_usdt = tradingUsdt; + if (fundingUsdt != null) it.funding_usdt = fundingUsdt; + } + const row = document.querySelector( + '.cond-inst-row[data-inst-id="' + id.replace(/"/g, "") + '"]' + ); + if (!row) return; + const tradeEl = row.querySelector(".cond-inst-funds--trading"); + const fundEl = row.querySelector(".cond-inst-funds--funding"); + if (tradeEl) tradeEl.textContent = "交易 " + fmtTradingU(tradingUsdt); + if (fundEl) fundEl.textContent = "资金 " + fmtTradingU(fundingUsdt); + } + + function transferFundingToTrading(instanceId) { + const id = String(instanceId || ""); + const it = candidatesById[id]; + if (!it) return; + if (!it.online) { + window.alert("实例离线,无法划转"); + return; + } + if (it.transfer_blocked) { + window.alert(it.transfer_block_msg || "当前不可划转"); + return; + } + const fund = Number(it.funding_usdt); + const defAmt = + !Number.isNaN(fund) && fund > 0 ? String(Math.round(fund * 10000) / 10000) : ""; + const raw = window.prompt("资金户 → 交易户(USDT)。本页划转,条件单表单不会丢失。", defAmt); + if (raw == null) return; + const amount = Number(String(raw).trim()); + if (!(amount > 0)) { + window.alert("请输入大于 0 的数量"); + return; + } + const btn = document.querySelector( + '.cond-inst-xfer[data-inst-id="' + id.replace(/"/g, "") + '"]' + ); + if (btn) { + btn.disabled = true; + btn.textContent = "划转中…"; + } + fetch("/api/instances/" + encodeURIComponent(id) + "/transfer", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + amount: amount, + from: "funding", + to: "trading", + ccy: "USDT", + }), + }) + .then(function (r) { + return r.json().then(function (d) { + return { http: r.status, d: d }; + }); + }) + .then(function (pack) { + const d = pack.d || {}; + if (!d.ok) { + window.alert(d.msg || "划转失败"); + return; + } + patchCandidateFunds(id, d.trading_usdt, d.funding_usdt); + const st = document.getElementById("cf-status"); + if (st) { + st.textContent = + (it.name || id) + + " 已划入交易 " + + fmtTradingU(amount) + + "(交易 " + + fmtTradingU(d.trading_usdt) + + ")"; + } + }) + .catch(function () { + window.alert("划转请求失败"); + }) + .finally(function () { + if (btn) { + btn.disabled = !!(it.transfer_blocked || !it.online); + btn.textContent = "划入交易"; + } + }); + } + function renderCandidateCard(it, checkedIds) { const disabled = !it.selectable; const tip = disabled @@ -245,13 +338,15 @@ : "可选"; const id = String(it.id); const checked = !disabled && checkedIds.indexOf(id) >= 0; - const fundsTip = - it.funding_usdt != null - ? "资金户 " + fmtTradingU(it.funding_usdt) - : ""; + const xferBlocked = !!(it.transfer_blocked || !it.online); + const xferTitle = xferBlocked + ? it.transfer_block_msg || (!it.online ? "离线" : "不可划转") + : "资金户→交易户,本页完成不丢表单"; return ( '
' + '" + - '' + + '" + + '' + "
" ); } @@ -324,8 +427,10 @@ const sim = []; const live = []; const other = []; + candidatesById = {}; list.forEach(function (it) { const m = instanceMode(it); + candidatesById[String(it.id)] = it; if (m === "sim") sim.push(it); else if (m === "live") live.push(it); else other.push(it); @@ -334,12 +439,18 @@ renderCandidateGroup("sim", sim, checkedIds) + renderCandidateGroup("live", live, checkedIds) + renderCandidateGroup("", other, checkedIds); + box.querySelectorAll(".cond-inst-xfer").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + transferFundingToTrading(btn.getAttribute("data-inst-id") || ""); + }); + }); box.querySelectorAll(".cond-inst-open").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.preventDefault(); ev.stopPropagation(); - const iid = decodeURIComponent(btn.getAttribute("data-inst-id") || ""); - openInstanceTab(iid, "options"); + openInstanceTab(btn.getAttribute("data-inst-id") || "", "options"); }); }); } diff --git a/static/hub.css b/static/hub.css index 48afda5..213434e 100644 --- a/static/hub.css +++ b/static/hub.css @@ -1524,7 +1524,7 @@ input.readonly { .cond-inst-group-label { margin-bottom: 8px; } .cond-inst-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 10px; } .cond-inst-row { @@ -1556,6 +1556,11 @@ input.readonly { white-space: nowrap; flex-shrink: 0; } +.cond-inst-funds--funding { + color: var(--muted); + font-weight: 500; +} +.cond-inst-xfer, .cond-inst-open { flex-shrink: 0; align-self: center; diff --git a/templates/base.html b/templates/base.html index feccb1b..209af37 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,7 +11,7 @@ - + + {% endblock %} diff --git a/tests/test_cond_candidates_funds.py b/tests/test_cond_candidates_funds.py index 325f1e8..e47bb8e 100644 --- a/tests/test_cond_candidates_funds.py +++ b/tests/test_cond_candidates_funds.py @@ -19,6 +19,7 @@ class TestCondCandidatesFunds(unittest.TestCase): "cond_order_locked": False, "instance": {"mode": "live"}, "funds": {"trading_usdt": 123.456, "funding_usdt": 50.0}, + "transfer_blocked": False, }, }, { @@ -31,6 +32,8 @@ class TestCondCandidatesFunds(unittest.TestCase): "cond_order_locked": False, "instance": {"mode": "sim"}, "funds": {}, + "transfer_blocked": True, + "transfer_block_msg": "期权持仓中禁止划转", }, }, ] @@ -39,8 +42,10 @@ class TestCondCandidatesFunds(unittest.TestCase): self.assertTrue(out[0]["selectable"]) self.assertAlmostEqual(out[0]["trading_usdt"], 123.456, places=3) self.assertAlmostEqual(out[0]["funding_usdt"], 50.0, places=3) + self.assertFalse(out[0]["transfer_blocked"]) self.assertFalse(out[1]["selectable"]) self.assertIsNone(out[1]["trading_usdt"]) + self.assertTrue(out[1]["transfer_blocked"]) def test_open_url_tab_appended(self): from lib.sso_lib import build_embed_open_url