diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 7209d8b..87ddd6e 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1320,6 +1320,72 @@ body.market-chart-fs-open { border-color: rgba(0, 255, 157, 0.38); } +.hub-trend-plan-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hub-trend-plan-card { + padding: 12px 14px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid var(--border-soft); + border-radius: 10px; +} + +.hub-trend-plan-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.hub-trend-plan-title { + font-size: 14px; + font-weight: 600; +} + +.hub-trend-plan-status { + font-size: 11px; + color: var(--muted); +} + +.hub-trend-plan-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 12px; + color: var(--muted); + margin-bottom: 10px; +} + +.hub-trend-plan-meta .pos-meta-accent, +.hub-trend-plan-meta strong { + color: var(--accent); +} + +.hub-trend-plan-foot { + margin-top: 10px; + font-size: 11px; + color: var(--muted); +} + +.pos-value.pos-tp-program { + color: #8fc8ff; +} + +.exchange-fullscreen .hub-trend-plan-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; + align-items: stretch; +} + +.exchange-fullscreen .hub-trend-plan-card { + height: 100%; +} + /* 顺势加仓 */ .card-stat-chip.card-stat-roll { color: #ffb020; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 1717890..c972298 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -303,19 +303,34 @@ return side || "—"; } - function monitorOrderSourceLabel(mo) { + function isTrendContext(monitorOrder, trendPlan) { + const mo = monitorOrder || {}; + const tp = trendPlan || {}; + if (tp.id != null && Number(tp.id) > 0) return true; + const tid = Number(mo.trend_plan_id); + if (Number.isFinite(tid) && tid > 0) return true; + const mt = String(mo.monitor_type || "").trim(); + if (mt === "趋势回调") return true; + const kst = String(mo.key_signal_type || "").trim(); + return kst === "趋势回调" || kst === "趋势回调计划"; + } + + function trendAddZoneLabel(direction) { + return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿"; + } + + function monitorOrderSourceLabel(mo, trendPlan) { + if (isTrendContext(mo, trendPlan)) return "趋势回调计划"; const o = mo || {}; - const tid = Number(o.trend_plan_id); - if (Number.isFinite(tid) && tid > 0) return "趋势回调"; const mt = String(o.monitor_type || "").trim(); - if (mt === "趋势回调") return "趋势回调"; - const kst = String(o.key_signal_type || "").trim(); - if (kst === "趋势回调" || kst === "趋势回调计划") return "趋势回调"; return mt || "下单监控"; } - function monitorOrderSourceHtml(mo) { - const src = monitorOrderSourceLabel(mo); + function monitorOrderSourceHtml(mo, trendPlan) { + if (isTrendContext(mo, trendPlan)) { + return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`; + } + const src = monitorOrderSourceLabel(mo, trendPlan); const kst = String((mo && mo.key_signal_type) || "").trim(); let text = src; if (kst && kst !== src && !text.includes(kst)) { @@ -776,7 +791,23 @@ return reward / risk; } - function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored) { + function resolveTrendPlanRr(trendPlan, side, entry, sl, tp) { + const t = trendPlan || {}; + if (t.planned_rr != null && t.planned_rr !== "") { + const n = Number(t.planned_rr); + if (Number.isFinite(n) && n > 0) return n; + } + const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry; + const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl; + const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp; + return calcRrRatio(side, e, s, p); + } + + function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) { + if (tpMonitored && isTrendContext(mo, trendPlan)) { + const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp); + if (rr != null) return rr; + } if (tpMonitored) return null; const snap = mo && mo.rr_ratio; if (snap != null && snap !== "") { @@ -787,6 +818,17 @@ return calcRrRatio(side, entry, initSl || sl, tp); } + function formatTpCellValue(tp, tpMonitored, symbol, tickMap) { + if (tpMonitored) { + if (tp != null && tp !== "") { + return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`; + } + return "程序监控"; + } + if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap); + return "—"; + } + function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { const mo = monitorOrder || {}; const p = pos || {}; @@ -1072,10 +1114,7 @@ ? mo.trigger_price : tp.avg_entry_price; const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null; - const isTrend = - !!(trendPlan && trendPlan.id) || - String(mo.monitor_type || "").trim() === "趋势回调" || - (mo.trend_plan_id != null && Number(mo.trend_plan_id) > 0); + const isTrend = isTrendContext(mo, trendPlan); let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : ""; let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : ""; @@ -1083,10 +1122,14 @@ if (isTrend) { tpMonitored = true; - takeProfit = ""; if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { sl = trendPlan.stop_loss; } + if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") { + takeProfit = trendPlan.take_profit; + } else { + takeProfit = ""; + } } const inferred = inferTpslFromCondOrders(pos.side, cond, entryN); @@ -1401,20 +1444,79 @@ return html; } + function renderTrendPlanCard(t, tickMap) { + const sym = t.exchange_symbol || t.symbol || ""; + const side = (t.direction || "long").toLowerCase(); + const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap); + const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap); + const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap); + const addZone = + t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—"; + const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit); + const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—"; + const mark = + t.floating_mark != null && t.floating_mark !== "" + ? fmtSymbolPrice(t.floating_mark, sym, tickMap) + : "—"; + const legsDone = t.add_count != null ? t.add_count : t.legs_done; + const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs; + const legsTxt = + legsDone != null && legsTotal != null + ? `${esc(legsDone)}/${esc(legsTotal)}` + : legsDone != null + ? esc(legsDone) + : "—"; + let pnlInner = "—"; + if (t.floating_pnl != null && t.floating_pnl !== "") { + let pnlText = `${fmt(t.floating_pnl, 2)}U`; + const margin = Number(t.plan_margin_capital); + if (Number.isFinite(margin) && margin > 0) { + const pct = (Number(t.floating_pnl) / margin) * 100; + pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; + } + pnlInner = `${esc(pnlText)}`; + } + const riskTxt = + t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—"; + const foot = []; + if (t.snapshot_available_usdt != null) { + foot.push(`快照可用 ${fmt(t.snapshot_available_usdt, 2)}U`); + } + if (t.plan_margin_capital != null) { + foot.push(`计划保证金≈${fmt(t.plan_margin_capital, 2)}U`); + } + if (t.leverage != null) foot.push(`杠杆 ${esc(t.leverage)}x`); + const footHtml = foot.length + ? `
${foot.map((x) => esc(x)).join(" | ")}
` + : ""; + return `
+
+ #${esc(t.id)} ${esc(sym)} ${renderDirectionHtml(t.direction)} + ${esc(t.status || "active")} +
+
+ 来源: 趋势回调计划 + 风险: ${riskTxt} + ${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)} + 已补仓 ${legsTxt} +
+
+
均价${esc(avg)}
+
止损${esc(sl)}
+
止盈程序监控 · ${esc(tp)}
+
盈亏比${esc(rrTxt)}
+
标记价${esc(mark)}
+
浮盈亏${pnlInner}
+
+ ${footHtml} +
`; + } + function renderTrendSection(trends, tickMap) { if (!trends || !trends.length) return ""; - return trends - .map((t) => { - const sym = t.exchange_symbol || t.symbol || ""; - const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap); - const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap); - const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap); - return `
-
#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}
-
均价 ${esc(avg)} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)} · 状态 ${esc(t.status || "active")}
-
`; - }) - .join(""); + return `
${trends + .map((t) => renderTrendPlanCard(t, tickMap)) + .join("")}
`; } function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) { @@ -1436,7 +1538,8 @@ const sl = tpsl.sl; const tp = tpsl.tp; const tpMonitored = tpsl.tp_monitored; - const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored); + const isTrend = isTrendContext(mo, trendPlan); + const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan); const beSecured = isBreakevenSecured(side, entry, mo, cond, pos); const upnl = pos.unrealized_pnl; let pnlText = fmt(upnl, 2) + "U"; @@ -1445,22 +1548,42 @@ pnlText += ` (${pct >= 0 ? "" : ""}${pct.toFixed(2)}%)`; } const meta = []; - if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { - meta.push(monitorOrderSourceHtml(mo)); + if (isTrend) { + meta.push(monitorOrderSourceHtml(mo, trendPlan)); + const riskPct = + trendPlan && trendPlan.risk_percent != null && trendPlan.risk_percent !== "" + ? trendPlan.risk_percent + : mo.risk_percent; + if (riskPct != null && riskPct !== "") { + meta.push(`风险: ${esc(riskPct)}%`); + } + if (trendPlan && trendPlan.id) { + const zone = + trendPlan.add_upper_display || + fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) || + "—"; + meta.push( + `${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}` + ); + const addSum = trendAddSummaryHtml(trendPlan, tickMap); + if (addSum) meta.push(addSum.replace(/^ · /, "")); + } + meta.push(`移动保本:关`); + } else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { + meta.push(monitorOrderSourceHtml(mo, trendPlan)); + if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`); + else meta.push("风格: —"); + if (mo.risk_percent != null) { + meta.push(`风险: ${esc(mo.risk_percent)}%`); + } + const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; + meta.push( + `移动保本:${beOn ? "开" : "关"}` + ); } else { meta.push("来源: 交易所持仓"); - } - if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`); - else meta.push("风格: —"); - if (mo.risk_percent != null) { - meta.push(`风险: ${esc(mo.risk_percent)}%`); - } - const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; - meta.push( - `移动保本:${beOn ? "开" : "关"}` - ); - if (trendPlan && trendPlan.id) { - meta.push(`趋势回调${trendAddSummaryHtml(trendPlan, tickMap)}`); + meta.push("风格: —"); + meta.push(`移动保本:关`); } const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); @@ -1480,8 +1603,8 @@
开仓价${fmtEntryPrice(pos, tickMap)}
标记价${fmtMarkPrice(pos, tickMap)}
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
-
止盈${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmtSymbolPrice(tp, symbol, tickMap) : "—"}
-
盈亏比${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}
+
止盈${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}
+
盈亏比${rr != null ? fmt(rr, 2) + ":1" : "—"}
张数${fmt(pos.contracts, 4)}
浮盈亏${pnlText}
diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 209c85c..f179508 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -890,7 +890,10 @@ if (elPosSl) elPosSl.textContent = ctx.stop_loss != null ? fmtPrice(ctx.stop_loss) : "—"; if (elPosTp) { if (ctx.tp_monitored) { - elPosTp.textContent = "程序监控"; + elPosTp.textContent = + ctx.take_profit != null + ? "程序监控 · " + fmtPrice(ctx.take_profit) + : "程序监控"; elPosTp.classList.add("market-pos-tp-monitored"); } else { elPosTp.textContent = ctx.take_profit != null ? fmtPrice(ctx.take_profit) : "—"; @@ -944,8 +947,12 @@ { price: posContext.entry, color: "#5b9cf5", title: "入场" }, { price: posContext.stop_loss, color: "#ff4d6d", title: "止损" }, ]; - if (!posContext.tp_monitored && posContext.take_profit != null) { - specs.push({ price: posContext.take_profit, color: "#00ff9d", title: "止盈" }); + if (posContext.take_profit != null) { + specs.push({ + price: posContext.take_profit, + color: "#00ff9d", + title: posContext.tp_monitored ? "止盈(程序)" : "止盈", + }); } specs.forEach(function (s) { if (s.price == null || !Number.isFinite(Number(s.price))) return; diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index e8eef02..9dba87d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -8,7 +8,7 @@ - + @@ -228,7 +228,7 @@
- - + + diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index 6cdf100..83e6b91 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -179,7 +179,7 @@ curl -s http://127.0.0.1:5100/api/ping | 功能 | 说明 | |------|------| | **2×2 主界面** | 四所信息**完整展示**:余额、持仓表、委托/平仓、折叠委托单、下单监控、关键位、趋势/加仓摘要 | -| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡;独立卡片:**关键位**、**下单监控**、**趋势回调**、**顺势加仓** | +| **全屏放大** | **点击卡片标题栏**(非按钮区)→ 该所**全屏**:每币种一张实盘风格持仓卡(趋势持仓显示**来源: 趋势回调计划**、**风险%**、**程序监控·止盈价**、**盈亏比**,与实例策略页一致);独立卡片:**关键位**、**下单监控**、**趋势回调**(计划卡含均价/止损/止盈/盈亏比/标记价/浮盈亏)、**顺势加仓** | | **委托单折叠** | 仅「委托单」区块默认折叠;展开状态存浏览器本地,**5 秒刷新不重置** | | **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | | **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`、`cancel-symbol` | diff --git a/strategy_trend_register.py b/strategy_trend_register.py index 569280a..08c8f9f 100644 --- a/strategy_trend_register.py +++ b/strategy_trend_register.py @@ -336,8 +336,23 @@ def _trend_add_leg_fields(cfg: dict, d: dict) -> dict: def enrich_trend_plan_for_hub(cfg: dict, raw: dict) -> dict: - """中控 /api/hub/monitor:补仓次数、加仓价(交易所精度)。""" - return _trend_add_leg_fields(cfg, dict(raw or {})) + """中控 /api/hub/monitor:与策略页运行中计划卡片同字段(浮盈亏、标记价、盈亏比等)。""" + d = enrich_trend_plan(cfg, dict(raw or {})) + d["monitor_source"] = "趋势回调计划" + m = _m(cfg) + direction = (d.get("direction") or "long").lower() + try: + avg_e = float(d["avg_entry_price"]) + sl = float(d["stop_loss"]) + tp = float(d["take_profit"]) + rr_fn = getattr(m, "calc_rr_ratio", None) + if callable(rr_fn): + rr = rr_fn(direction, avg_e, sl, tp) + if rr is not None: + d["planned_rr"] = float(rr) + except (TypeError, ValueError, KeyError): + pass + return d def _patch_hub_monitor_enrich(app: Flask, cfg: dict) -> None: