diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index c4d2c23..6180fbb 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -7727,6 +7727,8 @@ try: "add_key": add_key, "preview_trend_pullback": preview_trend_pullback, "execute_trend_pullback": execute_trend_pullback, + "stop_trend_pullback": stop_trend_pullback, + "trend_pullback_breakeven": trend_pullback_breakeven, }, ohlcv_fn=_hub_fetch_ohlcv, ) diff --git a/hub_bridge.py b/hub_bridge.py index 722ff48..8d1533b 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -158,6 +158,29 @@ def _invoke_view(view_name: str, path: str, form=None) -> dict: return {"ok": ok, "messages": msgs} +def _invoke_view_get(view_name: str, path: str) -> dict: + views = _ctx().get("views") or {} + view = views.get(view_name) + if not view: + return {"ok": False, "messages": [f"未配置视图 {view_name}"]} + with current_app.test_request_context(path, method="GET"): + session["logged_in"] = True + try: + view() + except Exception as e: + return {"ok": False, "messages": [str(e)]} + try: + msgs = [str(x) for x in get_flashed_messages()] + except Exception as e: + return {"ok": False, "messages": [f"读取提示信息失败: {e}"]} + ok = True + for m in msgs: + if any(k in m for k in _FAIL_HINTS): + ok = False + break + return {"ok": ok, "messages": msgs} + + def _hub_json(view_name: str, path: str, form=None): try: return jsonify(_invoke_view(view_name, path, form=form)) @@ -445,6 +468,31 @@ def register_hub_routes(app): return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404 return jsonify({"ok": True, "preview": preview}) + @app.route("/api/hub/trend/stop/", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_stop(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + return jsonify(_invoke_view_get("stop_trend_pullback", f"/stop_trend_pullback/{pid}")) + + @app.route("/api/hub/trend/breakeven/", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_breakeven(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + body = request.get_json(silent=True) or {} + raw = (request.form.get("breakeven_offset_pct") or body.get("breakeven_offset_pct") or "").strip() + form = {} + if raw != "": + form["breakeven_offset_pct"] = raw + return jsonify( + _invoke_view( + "trend_pullback_breakeven", + f"/trend_pullback_breakeven/{pid}", + form=form, + ) + ) + @app.route("/hub-sso") def hub_sso_login(): """中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。""" diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index a4a449e..4d80305 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1124,6 +1124,77 @@ class PlaceTpslBody(BaseModel): contracts: float | None = None +class TrendPlanActionBody(BaseModel): + plan_id: int + breakeven_offset_pct: float | None = None + + +def _flask_hub_messages(parsed: dict | None) -> tuple[bool, str]: + if not isinstance(parsed, dict): + return False, "实例返回无效" + msgs = list(parsed.get("messages") or []) + if parsed.get("msg"): + msgs.insert(0, str(parsed["msg"])) + if parsed.get("error"): + msgs.append(str(parsed["error"])) + ok = parsed.get("ok") is not False + if parsed.get("ok") is True: + ok = True + elif parsed.get("ok") is False: + ok = False + else: + for m in msgs: + if any( + k in str(m) + for k in ("失败", "错误", "无法", "缺少", "过期", "未找到", "不允许", "异常") + ): + ok = False + break + text = ";".join(str(x) for x in msgs if x) or ("成功" if ok else "操作失败") + return ok, text + + +@app.post("/api/trend/{exchange_id}/stop") +async def api_trend_plan_stop(exchange_id: str, body: TrendPlanActionBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + if "trend" not in (ex.get("capabilities") or []): + raise HTTPException(status_code=400, detail="该账户未启用趋势计划监控") + pid = int(body.plan_id) + async with httpx.AsyncClient() as client: + parsed = await _fetch_flask_json( + client, ex, f"/api/hub/trend/stop/{pid}", method="POST" + ) + ok, text = _flask_hub_messages(parsed) + _schedule_board_refresh() + return {"ok": ok, "message": text, "payload": parsed} + + +@app.post("/api/trend/{exchange_id}/breakeven") +async def api_trend_plan_breakeven(exchange_id: str, body: TrendPlanActionBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + if "trend" not in (ex.get("capabilities") or []): + raise HTTPException(status_code=400, detail="该账户未启用趋势计划监控") + pid = int(body.plan_id) + data = {} + if body.breakeven_offset_pct is not None: + data["breakeven_offset_pct"] = str(body.breakeven_offset_pct) + async with httpx.AsyncClient() as client: + parsed = await _fetch_flask_json( + client, + ex, + f"/api/hub/trend/breakeven/{pid}", + method="POST", + data=data, + ) + ok, text = _flask_hub_messages(parsed) + _schedule_board_refresh() + return {"ok": ok, "message": text, "payload": parsed} + + @app.post("/api/orders/{exchange_id}/cancel") async def api_cancel_order(exchange_id: str, body: CancelOrderBody): ex = _find_exchange(exchange_id) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 224ed28..095277b 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1676,6 +1676,7 @@ body.market-chart-fs-open { padding: 6px 12px; background: var(--plan-be-btn-bg); color: var(--accent); + border: 1px solid var(--plan-be-input-border); border-radius: 8px; font-size: 0.78rem; text-decoration: none; @@ -1683,6 +1684,15 @@ body.market-chart-fs-open { white-space: nowrap; } +.hub-trend-plan-card button.hub-plan-be-btn { + font-family: inherit; +} + +.hub-trend-plan-card .hub-plan-be-input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .hub-trend-plan-card .hub-plan-be-btn--static { cursor: default; } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 0747c91..ecd22e9 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -1426,6 +1426,22 @@ }); }; }); + box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId); + }; + }); + box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => { + btn.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const card = btn.closest(".hub-trend-plan-card"); + const inp = card ? card.querySelector(".hub-plan-be-input") : null; + hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp); + }; + }); box.querySelectorAll(".btn-close-ex").forEach((btn) => { btn.onclick = () => closeOne(btn.dataset.id); }); @@ -1668,18 +1684,27 @@ ? `≈${fmt(t.plan_margin_capital, 2)}U` : "—"; const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—"; - const bePct = - t.breakeven_offset_pct != null && t.breakeven_offset_pct !== "" - ? esc(t.breakeven_offset_pct) - : "0.3"; + const bePctDefault = + t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== "" + ? t.breakeven_default_offset_pct + : t.breakeven_offset_pct != null && t.breakeven_offset_pct !== "" + ? t.breakeven_offset_pct + : "0.3"; const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : ""; - const canOpen = !!(exchangeRow && (exchangeRow.flask_url_browser || exchangeRow.flask_url)); - const endBtn = canOpen - ? `结束计划` + const planId = esc(t.id); + const caps = (exchangeRow && exchangeRow.capabilities) || []; + const flaskOk = + exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false; + const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId); + const beAppliedFlag = !!t.breakeven_applied; + const endBtn = canHubTrend + ? `` : ""; - const beBtn = canOpen - ? `保本移交下单监控` - : `保本移交下单监控`; + const beBtn = canHubTrend && !beAppliedFlag + ? `` + : beAppliedFlag + ? "" + : `保本移交下单监控`; const beApplied = t.breakeven_applied ? `已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}` @@ -1718,7 +1743,7 @@
${beBtn} ${beApplied} @@ -2446,6 +2471,63 @@
`; } + async function hubTrendPlanStop(exchangeId, planId) { + if (!exchangeId || !planId) { + showToast("缺少交易所或计划 ID", true); + return; + } + if (!confirm("结束计划:市价平仓并撤掉该合约全部挂单,确定?")) return; + try { + const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ plan_id: Number(planId) }), + }); + const j = await r.json(); + showToast(j.message || (j.ok ? "已结束趋势回调计划" : "结束失败"), !j.ok); + if (j.ok) refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + + async function hubTrendPlanBreakeven(exchangeId, planId, inputEl) { + if (!exchangeId || !planId) { + showToast("缺少交易所或计划 ID", true); + return; + } + const raw = inputEl ? String(inputEl.value || "").trim() : ""; + let pct = null; + if (raw !== "") { + pct = Number(raw); + if (!Number.isFinite(pct) || pct < 0) { + showToast("保本偏移% 须为非负数", true); + return; + } + } + if ( + !confirm( + "确认保本?将结束本趋势计划,持仓移交「下单监控」,并在交易所挂保本止损与计划止盈;后续平仓写入交易记录。" + ) + ) { + return; + } + try { + const body = { plan_id: Number(planId) }; + if (pct != null) body.breakeven_offset_pct = pct; + const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/breakeven", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + showToast(j.message || (j.ok ? "保本移交成功" : "保本移交失败"), !j.ok); + if (j.ok) refreshMonitorBoardNow(); + } catch (e) { + showToast(String(e), true); + } + } + async function closeOnePosition(exchangeId, symbol, side) { const label = `${symbol} · ${side}`; if (!confirm(`确认对该账户市价平仓:${label}?`)) return; diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index 1a89cf8..a5567e0 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -146,7 +146,7 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配 | 功能 | 说明 | |------|------| | **2×2 主界面** | 四所信息**完整展示**:余额、持仓表、委托/平仓、折叠委托单、下单监控、关键位、趋势/加仓摘要 | -| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**、**风险%**、**程序监控·止盈价**、**盈亏比**,与实例策略页一致);独立卡片:**关键位**、**下单监控**、**趋势回调**(单计划 **两列**:左=币种基本信息与 3×2 指标,右=**补仓计划明细**,底=保本移交 + 快照可用/计划保证金/杠杆;字段与实例 `/strategy` 一致,结束/保本在实例操作)、**顺势加仓** | +| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**、**风险%**、**程序监控·止盈价**、**盈亏比**,与实例策略页一致);独立卡片:**关键位**、**下单监控**、**趋势回调**(单计划 **两列**:左=币种基本信息与 3×2 指标,右=**补仓计划明细**,底=**保本偏移%** 可编辑 + **保本移交** / **结束计划**(中控直接调实例,与 `/strategy` 一致)、快照可用/计划保证金/杠杆)、**顺势加仓** | | **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** | | **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | | **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`、`cancel-symbol` | diff --git a/strategy_templates/strategy_records_page.html b/strategy_templates/strategy_records_page.html index 4b3a4e4..812afec 100644 --- a/strategy_templates/strategy_records_page.html +++ b/strategy_templates/strategy_records_page.html @@ -1,6 +1,9 @@ {% set mf = money_fmt|default(funds_fmt) %}