diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html
index e870f87..8ea9221 100644
--- a/crypto_monitor_binance/templates/index.html
+++ b/crypto_monitor_binance/templates/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html
index 8a9a610..1b6eaa7 100644
--- a/crypto_monitor_gate/templates/index.html
+++ b/crypto_monitor_gate/templates/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html
index 8a9a610..1b6eaa7 100644
--- a/crypto_monitor_gate_bot/templates/index.html
+++ b/crypto_monitor_gate_bot/templates/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html
index be56ba5..7ab2f9b 100644
--- a/crypto_monitor_okx/templates/index.html
+++ b/crypto_monitor_okx/templates/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js
index 83a49fd..2e19be5 100644
--- a/manual_trading_hub/static/app.js
+++ b/manual_trading_hub/static/app.js
@@ -2944,12 +2944,35 @@
function renderRollSection(rolls, tickMap) {
if (!rolls || !rolls.length) return "";
return rolls
- .map(
- (g) => `
-
组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}
-
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}
-
`
- )
+ .map((g) => {
+ const sym = g.symbol || g.exchange_symbol || "";
+ const avg =
+ g.avg_entry_display || fmtSymbolPrice(g.avg_entry, sym, tickMap) || "—";
+ const tpProfit =
+ g.reward_at_tp_usdt != null && g.reward_at_tp_usdt !== ""
+ ? `${fmt(g.reward_at_tp_usdt, 2)}U`
+ : "—";
+ const legs = Array.isArray(g.recent_legs) ? g.recent_legs : [];
+ const legRows = legs
+ .map((leg) => {
+ const legAvg =
+ leg.avg_entry_display ||
+ fmtSymbolPrice(leg.avg_entry_after, sym, tickMap) ||
+ "—";
+ const legProfit =
+ leg.reward_at_tp_usdt != null && leg.reward_at_tp_usdt !== ""
+ ? `${fmt(leg.reward_at_tp_usdt, 2)}U`
+ : "—";
+ return `腿 #${esc(leg.leg_index)} ${esc(leg.add_mode || "")} · 张 ${esc(leg.amount != null ? leg.amount : "—")} · 均价 ${legAvg} · 止盈 ${legProfit}
`;
+ })
+ .join("");
+ return `
+
组 #${esc(g.id)} · ${esc(g.symbol || "")} ${renderDirectionHtml(g.direction)} · 监控 #${esc(g.order_monitor_id || "—")}
+
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · SL ${fmtSymbolPrice(g.current_stop_loss, sym, tickMap)} · 首仓TP ${fmtSymbolPrice(g.initial_take_profit, sym, tickMap)}
+
当前均价 ${avg} · 止盈盈利 ${tpProfit}
+ ${legRows}
+
`;
+ })
.join("");
}
diff --git a/static/instance_theme.js b/static/instance_theme.js
index 1ea7864..1e937ce 100644
--- a/static/instance_theme.js
+++ b/static/instance_theme.js
@@ -329,19 +329,29 @@
}
}
- /** 中控 iframe 内:拦截顶栏导航,fetch 后 document.write 原地换页,避免 iframe 卸载白屏 */
- function initHubEmbedInFrameNav() {
- if (!isHubLinked()) return;
+ /** 顶栏软导航:fetch + document.write 换页,避免整页卸载白屏(实例 standalone / 中控 iframe 均启用) */
+ function injectNavTransitionGuard(html, theme) {
+ const t = normalize(theme || get());
+ const bg = META[t];
+ const guard = ``;
+ if (html.includes("")) {
+ return html.replace("", `${guard}`);
+ }
+ return guard + html;
+ }
+ function initInstanceTopNavSoft() {
let navToken = 0;
function isSoftNavLink(a) {
if (!a || !a.getAttribute) return false;
+ if (a.hasAttribute("download") || a.target === "_blank") return false;
return !!a.closest(".top-nav, .strategy-subnav");
}
async function navigateInFrame(href, opts) {
const token = ++navToken;
+ const themeNow = get();
try {
const r = await fetch(href, { credentials: "same-origin" });
if (token !== navToken) return;
@@ -349,7 +359,7 @@
location.href = href;
return;
}
- const html = await r.text();
+ const html = injectNavTransitionGuard(await r.text(), themeNow);
if (token !== navToken) return;
let path = href;
try {
@@ -398,13 +408,13 @@
if (isHubLinked()) {
apply(get(), { skipStore: true });
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
- initHubEmbedInFrameNav();
try {
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
} catch (_) {}
} else {
apply(getStandalone());
}
+ initInstanceTopNavSoft();
function observeDynamicLists() {
["journal-list", "review-list"].forEach((id) => {
diff --git a/strategy_roll_ui_lib.py b/strategy_roll_ui_lib.py
new file mode 100644
index 0000000..43039e0
--- /dev/null
+++ b/strategy_roll_ui_lib.py
@@ -0,0 +1,402 @@
+"""顺势加仓 UI:滚仓腿合并均价与止盈盈利展示(实例页 + 中控)。"""
+from __future__ import annotations
+
+from typing import Any, Callable, Optional
+
+from flask import Flask
+
+FILLED_LEG_STATUSES = frozenset({"filled", "done", "complete"})
+
+
+def reward_at_tp_usdt(
+ direction: str,
+ avg_entry: float,
+ take_profit: float,
+ qty: float,
+ *,
+ contract_size: float = 1.0,
+) -> Optional[float]:
+ """与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位盈利。"""
+ try:
+ avg = float(avg_entry)
+ tp = float(take_profit)
+ q = float(qty)
+ cs = float(contract_size or 1.0)
+ except (TypeError, ValueError):
+ return None
+ if avg <= 0 or tp <= 0 or q <= 0:
+ return None
+ direction = (direction or "long").strip().lower()
+ if direction == "short":
+ return (avg - tp) * q * cs
+ return (tp - avg) * q * cs
+
+
+def leg_fill_price(leg: dict) -> Optional[float]:
+ if not isinstance(leg, dict):
+ return None
+ for key in ("fill_price", "limit_price"):
+ try:
+ v = float(leg.get(key) or 0)
+ if v > 0:
+ return v
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def leg_is_filled(leg: dict) -> bool:
+ st = str(leg.get("status") or "").strip().lower()
+ return st in FILLED_LEG_STATUSES
+
+
+def infer_initial_position(
+ qty_live: float,
+ entry_live: float,
+ filled_legs: list[dict],
+ *,
+ monitor: dict | None = None,
+) -> tuple[Optional[float], Optional[float]]:
+ """由当前持仓与各腿成交价反推首仓张数/均价。"""
+ try:
+ qty_live = float(qty_live)
+ entry_live = float(entry_live)
+ except (TypeError, ValueError):
+ qty_live = entry_live = 0.0
+ legs = [
+ lg
+ for lg in filled_legs or []
+ if isinstance(lg, dict) and leg_is_filled(lg) and leg_fill_price(lg) and float(lg.get("amount") or 0) > 0
+ ]
+ add_sum = sum(float(lg.get("amount") or 0) for lg in legs)
+ leg_notional = sum(float(lg.get("amount") or 0) * float(leg_fill_price(lg) or 0) for lg in legs)
+ q0 = qty_live - add_sum
+ if q0 > 1e-12 and entry_live > 0 and qty_live > 0:
+ e0 = (entry_live * qty_live - leg_notional) / q0
+ if e0 > 0:
+ return q0, e0
+ mon = monitor if isinstance(monitor, dict) else {}
+ try:
+ trig = float(mon.get("trigger_price") or 0)
+ except (TypeError, ValueError):
+ trig = 0.0
+ try:
+ mon_amt = float(mon.get("order_amount") or mon.get("amount") or 0)
+ except (TypeError, ValueError):
+ mon_amt = 0.0
+ if trig > 0:
+ q_base = q0 if q0 > 1e-12 else (mon_amt if mon_amt > 0 else max(qty_live - add_sum, 0))
+ if q_base > 0:
+ return q_base, trig
+ return None, None
+
+
+def compute_roll_chain_metrics(
+ group: dict,
+ legs: list[dict],
+ *,
+ qty_live: Optional[float] = None,
+ entry_live: Optional[float] = None,
+ monitor: dict | None = None,
+ contract_size: float = 1.0,
+) -> tuple[dict[Any, dict], dict]:
+ """
+ 返回 (leg_metrics_by_id, group_metrics)。
+ leg_metrics: leg id -> {avg_entry_after, reward_at_tp_usdt}
+ group_metrics: 最后一腿后的 {avg_entry, reward_at_tp_usdt}
+ """
+ per_leg: dict[Any, dict] = {}
+ group_out: dict[str, Any] = {"avg_entry": None, "reward_at_tp_usdt": None}
+ if not isinstance(group, dict):
+ return per_leg, group_out
+ direction = (group.get("direction") or "long").strip().lower()
+ try:
+ tp = float(group.get("initial_take_profit") or 0)
+ except (TypeError, ValueError):
+ tp = 0.0
+ sorted_legs = sorted(
+ [lg for lg in legs or [] if isinstance(lg, dict)],
+ key=lambda x: int(x.get("leg_index") or 0),
+ )
+ filled = [lg for lg in sorted_legs if leg_is_filled(lg)]
+ q0 = e0 = None
+ if qty_live is not None and entry_live is not None:
+ q0, e0 = infer_initial_position(float(qty_live), float(entry_live), filled, monitor=monitor)
+ if q0 is None or e0 is None:
+ return per_leg, group_out
+ qty = float(q0)
+ avg = float(e0)
+ if tp > 0:
+ group_out["avg_entry"] = avg
+ group_out["reward_at_tp_usdt"] = reward_at_tp_usdt(
+ direction, avg, tp, qty, contract_size=contract_size
+ )
+ for leg in sorted_legs:
+ if not leg_is_filled(leg):
+ continue
+ try:
+ amt = float(leg.get("amount") or 0)
+ except (TypeError, ValueError):
+ continue
+ px = leg_fill_price(leg)
+ if not px or amt <= 0:
+ continue
+ prev_qty = qty
+ qty = prev_qty + amt
+ avg = (prev_qty * avg + amt * px) / qty
+ reward = reward_at_tp_usdt(direction, avg, tp, qty, contract_size=contract_size) if tp > 0 else None
+ lid = leg.get("id")
+ if lid is None:
+ lid = f"{group.get('id')}|{leg.get('leg_index')}"
+ per_leg[lid] = {
+ "avg_entry_after": round(avg, 10),
+ "reward_at_tp_usdt": round(reward, 4) if reward is not None else None,
+ }
+ group_out["avg_entry"] = round(avg, 10)
+ group_out["reward_at_tp_usdt"] = round(reward, 4) if reward is not None else None
+ return per_leg, group_out
+
+
+def _row_to_dict(row) -> dict:
+ if row is None:
+ return {}
+ try:
+ return dict(row)
+ except Exception:
+ return {}
+
+
+def _resolve_roll_live(cfg: dict, group: dict, monitor: dict | None) -> tuple[Optional[float], Optional[float], float]:
+ """读取交易所持仓张数、均价、contract_size。"""
+ m = cfg.get("app_module")
+ ex_sym = group.get("exchange_symbol")
+ sym = group.get("symbol") or ""
+ direction = (group.get("direction") or "long").strip().lower()
+ if not ex_sym and m is not None:
+ norm = getattr(m, "normalize_exchange_symbol", None)
+ if callable(norm):
+ try:
+ ex_sym = norm(sym)
+ except Exception:
+ ex_sym = sym
+ cs = 1.0
+ get_cs = cfg.get("get_contract_size")
+ if not callable(get_cs) and m is not None:
+ get_cs = getattr(m, "get_contract_size", None)
+ if callable(get_cs):
+ try:
+ cs = float(get_cs(ex_sym or sym) or 1.0)
+ except Exception:
+ cs = 1.0
+ get_pos = cfg.get("get_position")
+ if not callable(get_pos):
+ return None, None, cs
+ try:
+ pos = get_pos(ex_sym or sym, direction) or {}
+ qty = float(pos.get("contracts") or 0)
+ entry = float(pos.get("entry_price") or 0)
+ if qty > 0 and entry > 0:
+ return qty, entry, cs
+ except Exception:
+ pass
+ metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) if m else None
+ if callable(metrics_fn):
+ try:
+ met = metrics_fn(ex_sym or sym, direction)
+ if isinstance(met, dict):
+ qty = float(met.get("contracts") or met.get("size") or 0)
+ entry = float(met.get("entry_price") or 0)
+ if qty > 0 and entry > 0:
+ return qty, entry, cs
+ except Exception:
+ pass
+ if monitor:
+ try:
+ trig = float(monitor.get("trigger_price") or 0)
+ amt = float(monitor.get("order_amount") or monitor.get("amount") or 0)
+ if trig > 0 and amt > 0:
+ return amt, trig, cs
+ except (TypeError, ValueError):
+ pass
+ return None, None, cs
+
+
+def enrich_roll_page_data(conn, page_data: dict, cfg: dict | None) -> dict:
+ """为 roll_groups / roll_legs 附加 avg_entry、reward_at_tp 展示字段。"""
+ if not isinstance(page_data, dict) or not cfg:
+ return page_data
+ groups = list(page_data.get("roll_groups") or [])
+ legs = list(page_data.get("roll_legs") or [])
+ if not groups:
+ return page_data
+ monitors_by_id: dict[int, dict] = {}
+ try:
+ for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall():
+ od = _row_to_dict(row)
+ mid = od.get("id")
+ if mid is not None:
+ monitors_by_id[int(mid)] = od
+ except Exception:
+ pass
+ legs_by_gid: dict[int, list] = {}
+ for leg in legs:
+ if not isinstance(leg, dict):
+ continue
+ try:
+ gid = int(leg.get("roll_group_id"))
+ except (TypeError, ValueError):
+ continue
+ legs_by_gid.setdefault(gid, []).append(leg)
+ price_fmt = cfg.get("price_fmt")
+ for g in groups:
+ if not isinstance(g, dict) or g.get("id") is None:
+ continue
+ gid = int(g["id"])
+ mon = monitors_by_id.get(int(g.get("order_monitor_id") or 0))
+ qty, entry, cs = _resolve_roll_live(cfg, g, mon)
+ per_leg, group_metrics = compute_roll_chain_metrics(
+ g,
+ legs_by_gid.get(gid, []),
+ qty_live=qty,
+ entry_live=entry,
+ monitor=mon,
+ contract_size=cs,
+ )
+ g["avg_entry"] = group_metrics.get("avg_entry")
+ g["reward_at_tp_usdt"] = group_metrics.get("reward_at_tp_usdt")
+ if callable(price_fmt) and g.get("avg_entry") is not None:
+ try:
+ g["avg_entry_display"] = price_fmt(g.get("symbol"), g["avg_entry"])
+ except Exception:
+ pass
+ for leg in legs_by_gid.get(gid, []):
+ lid = leg.get("id")
+ if lid is None:
+ lid = f"{gid}|{leg.get('leg_index')}"
+ metrics = per_leg.get(lid) or per_leg.get(leg.get("id"))
+ if not metrics:
+ continue
+ leg["avg_entry_after"] = metrics.get("avg_entry_after")
+ leg["reward_at_tp_usdt"] = metrics.get("reward_at_tp_usdt")
+ if callable(price_fmt) and leg.get("avg_entry_after") is not None:
+ try:
+ leg["avg_entry_display"] = price_fmt(g.get("symbol"), leg["avg_entry_after"])
+ except Exception:
+ pass
+ page_data["roll_groups"] = groups
+ page_data["roll_legs"] = legs
+ return page_data
+
+
+def enrich_roll_groups_for_hub(rolls: list[dict], conn, cfg: dict | None) -> list[dict]:
+ """中控 monitor API:每组附带当前均价、止盈盈利与最近滚仓腿。"""
+ if not rolls or not cfg:
+ return rolls
+ out = []
+ gid_list = []
+ for g in rolls:
+ if isinstance(g, dict) and g.get("id") is not None:
+ try:
+ gid_list.append(int(g["id"]))
+ except (TypeError, ValueError):
+ pass
+ legs_by_gid: dict[int, list] = {gid: [] for gid in gid_list}
+ if gid_list:
+ placeholders = ",".join("?" for _ in gid_list)
+ try:
+ rows = conn.execute(
+ f"SELECT * FROM roll_legs WHERE roll_group_id IN ({placeholders}) ORDER BY id DESC",
+ gid_list,
+ ).fetchall()
+ for row in rows:
+ leg = _row_to_dict(row)
+ try:
+ legs_by_gid[int(leg.get("roll_group_id"))].append(leg)
+ except (TypeError, ValueError):
+ pass
+ except Exception:
+ pass
+ monitors_by_id: dict[int, dict] = {}
+ try:
+ for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall():
+ od = _row_to_dict(row)
+ if od.get("id") is not None:
+ monitors_by_id[int(od["id"])] = od
+ except Exception:
+ pass
+ price_fmt = cfg.get("price_fmt")
+ for g in rolls:
+ if not isinstance(g, dict):
+ continue
+ gd = dict(g)
+ try:
+ gid = int(gd.get("id"))
+ except (TypeError, ValueError):
+ out.append(gd)
+ continue
+ mon = monitors_by_id.get(int(gd.get("order_monitor_id") or 0))
+ group_legs = legs_by_gid.get(gid, [])
+ qty, entry, cs = _resolve_roll_live(cfg, gd, mon)
+ per_leg, group_metrics = compute_roll_chain_metrics(
+ gd,
+ group_legs,
+ qty_live=qty,
+ entry_live=entry,
+ monitor=mon,
+ contract_size=cs,
+ )
+ gd.update(group_metrics)
+ if callable(price_fmt) and gd.get("avg_entry") is not None:
+ try:
+ gd["avg_entry_display"] = price_fmt(gd.get("symbol"), gd["avg_entry"])
+ except Exception:
+ pass
+ recent = []
+ for leg in sorted(group_legs, key=lambda x: int(x.get("leg_index") or 0), reverse=True)[:6]:
+ ld = dict(leg)
+ lid = ld.get("id")
+ if lid is None:
+ lid = f"{gid}|{ld.get('leg_index')}"
+ metrics = per_leg.get(lid) or per_leg.get(ld.get("id"))
+ if metrics:
+ ld.update(metrics)
+ if callable(price_fmt) and ld.get("avg_entry_after") is not None:
+ try:
+ ld["avg_entry_display"] = price_fmt(gd.get("symbol"), ld["avg_entry_after"])
+ except Exception:
+ pass
+ recent.append(ld)
+ gd["recent_legs"] = recent
+ out.append(gd)
+ return out
+
+
+def patch_roll_hub_enrich(app: Flask, cfg: dict) -> None:
+ """hub_bridge install 后:/api/hub/monitor 的 rolls 附带均价/止盈盈利。"""
+ ctx = dict(app.config.get("HUB_CTX") or {})
+ prev: Callable | None = ctx.get("enrich_monitor")
+
+ def enrich_monitor(keys=None, orders=None, trends=None, rolls=None):
+ payload: dict[str, Any] = {}
+ if callable(prev):
+ try:
+ prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls)
+ if isinstance(prev_out, dict):
+ payload.update(prev_out)
+ except Exception:
+ pass
+ if rolls:
+ get_db = cfg.get("get_db")
+ if callable(get_db):
+ conn = get_db()
+ try:
+ payload["rolls"] = enrich_roll_groups_for_hub(list(rolls), conn, cfg)
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+ return payload
+
+ ctx["enrich_monitor"] = enrich_monitor
+ app.config["HUB_CTX"] = ctx
diff --git a/strategy_templates/strategy_roll.html b/strategy_templates/strategy_roll.html
index e5bf7ab..281baa2 100644
--- a/strategy_templates/strategy_roll.html
+++ b/strategy_templates/strategy_roll.html
@@ -80,7 +80,7 @@
活跃滚仓组
- | ID | 币种 | 方向 | 腿数 | 首仓TP | 当前SL |
+ | ID | 币种 | 方向 | 腿数 | 首仓TP | 当前SL | 当前均价 | 止盈盈利U |
{% for g in roll_groups %}
| {{ g.id }} |
@@ -89,9 +89,11 @@
{{ g.leg_count }} |
{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %} |
{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %} |
+ {% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %} |
+ {% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %} |
{% else %}
- | 暂无 |
+ | 暂无 |
{% endfor %}
@@ -99,7 +101,7 @@
最近滚仓腿
- | # | 组 | 方式 | 张数 | 新SL | 状态 |
+ | # | 组 | 方式 | 张数 | 新SL | 当前均价 | 止盈盈利U | 状态 |
{% for leg in roll_legs %}
| {{ leg.leg_index }} |
@@ -107,10 +109,12 @@
{{ leg.add_mode }} |
{{ leg.amount }} |
{{ leg.new_stop_loss }} |
+ {% if leg.avg_entry_display %}{{ leg.avg_entry_display }}{% elif leg.avg_entry_after is not none %}{{ leg.avg_entry_after }}{% else %}—{% endif %} |
+ {% if leg.reward_at_tp_usdt is not none %}{{ '%.2f'|format(leg.reward_at_tp_usdt) }}{% else %}—{% endif %} |
{{ leg.status_label or leg.status }} |
{% else %}
- | 暂无 |
+ | 暂无 |
{% endfor %}
diff --git a/strategy_templates/strategy_roll_panel.html b/strategy_templates/strategy_roll_panel.html
index 798b409..b272c0c 100644
--- a/strategy_templates/strategy_roll_panel.html
+++ b/strategy_templates/strategy_roll_panel.html
@@ -39,7 +39,7 @@
活跃滚仓组
- | ID | 币种 | 方向 | 腿数 | 首仓TP | 当前SL |
+ | ID | 币种 | 方向 | 腿数 | 首仓TP | 当前SL | 当前均价 | 止盈盈利U |
{% for g in roll_groups %}
| {{ g.id }} |
@@ -48,9 +48,11 @@
{{ g.leg_count }} |
{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %} |
{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %} |
+ {% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %} |
+ {% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %} |
{% else %}
- | 暂无 |
+ | 暂无 |
{% endfor %}
@@ -58,7 +60,7 @@
最近滚仓腿
- | # | 组 | 方式 | 张数 | 新SL | 状态 |
+ | # | 组 | 方式 | 张数 | 新SL | 当前均价 | 止盈盈利U | 状态 |
{% for leg in roll_legs %}
| {{ leg.leg_index }} |
@@ -66,10 +68,12 @@
{{ leg.add_mode }} |
{{ leg.amount }} |
{{ leg.new_stop_loss }} |
+ {% if leg.avg_entry_display %}{{ leg.avg_entry_display }}{% elif leg.avg_entry_after is not none %}{{ leg.avg_entry_after }}{% else %}—{% endif %} |
+ {% if leg.reward_at_tp_usdt is not none %}{{ '%.2f'|format(leg.reward_at_tp_usdt) }}{% else %}—{% endif %} |
{{ leg.status_label or leg.status }} |
{% else %}
- | 暂无 |
+ | 暂无 |
{% endfor %}
diff --git a/strategy_trend_register.py b/strategy_trend_register.py
index d81fb59..7d18255 100644
--- a/strategy_trend_register.py
+++ b/strategy_trend_register.py
@@ -158,6 +158,11 @@ def install_strategy_trend(app: Flask, repo_root: str, app_module: Any = None, *
app.extensions["strategy_trend_cfg"] = cfg
register_trend_routes(app, cfg)
_patch_hub_monitor_enrich(app, cfg)
+ roll_cfg = app.extensions.get("strategy_roll_cfg")
+ if isinstance(roll_cfg, dict):
+ from strategy_roll_ui_lib import patch_roll_hub_enrich
+
+ patch_roll_hub_enrich(app, roll_cfg)
_patch_hub_trend_views(app)
@app.context_processor
diff --git a/strategy_ui.py b/strategy_ui.py
index 6057454..52a877a 100644
--- a/strategy_ui.py
+++ b/strategy_ui.py
@@ -34,6 +34,7 @@ def fetch_roll_page_data(
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
+ roll_cfg: dict | None = None,
) -> dict[str, Any]:
init_strategy_tables(conn)
monitors = []
@@ -61,13 +62,18 @@ def fetch_roll_page_data(
leg["status_label"] = roll_leg_status_label(leg.get("status"))
roll_legs.append(leg)
roll_legs = roll_legs[:50]
- return {
+ out = {
"roll_monitors": monitors,
"roll_groups": roll_groups,
"roll_legs": roll_legs,
"roll_trend_active": count_active_trend_plans(conn, count_active_trends),
"default_risk_percent": default_risk_percent,
}
+ if roll_cfg:
+ from strategy_roll_ui_lib import enrich_roll_page_data
+
+ enrich_roll_page_data(conn, out, roll_cfg)
+ return out
DEFAULT_TREND_DISABLED_NOTE = (
@@ -116,10 +122,18 @@ def strategy_page_template_vars(
"""render_main_page 在 conn.close() 前合并进 render_template 的变量。"""
if page not in ("strategy", "strategy_trend", "strategy_roll"):
return {}
+ roll_cfg = None
+ try:
+ from flask import current_app
+
+ roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg")
+ except Exception:
+ roll_cfg = None
out = fetch_roll_page_data(
conn,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
+ roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None,
)
if trend_cfg and request_obj is not None:
from strategy_trend_register import load_trend_page_context
diff --git a/tests/test_strategy_roll_ui_lib.py b/tests/test_strategy_roll_ui_lib.py
new file mode 100644
index 0000000..05b00e3
--- /dev/null
+++ b/tests/test_strategy_roll_ui_lib.py
@@ -0,0 +1,44 @@
+"""strategy_roll_ui_lib 单元测试。"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import strategy_roll_ui_lib as roll_ui
+
+
+def test_compute_roll_chain_metrics_short():
+ group = {
+ "id": 1,
+ "direction": "short",
+ "initial_take_profit": 60.0,
+ }
+ legs = [
+ {"id": 10, "leg_index": 1, "amount": 3.0, "fill_price": 65.0, "status": "filled"},
+ {"id": 11, "leg_index": 2, "amount": 5.0, "fill_price": 64.0, "status": "filled"},
+ ]
+ per_leg, group_metrics = roll_ui.compute_roll_chain_metrics(
+ group,
+ legs,
+ qty_live=8.0,
+ entry_live=63.5,
+ monitor={"trigger_price": 66.0, "order_amount": 3.0},
+ )
+ assert per_leg[10]["avg_entry_after"] is not None
+ assert per_leg[11]["avg_entry_after"] is not None
+ assert group_metrics["reward_at_tp_usdt"] is not None
+ assert per_leg[11]["reward_at_tp_usdt"] >= per_leg[10]["reward_at_tp_usdt"]
+
+
+def test_infer_initial_position_from_live():
+ legs = [{"amount": 2.0, "fill_price": 64.0, "status": "filled"}]
+ q0, e0 = roll_ui.infer_initial_position(5.0, 63.0, legs)
+ assert q0 == 3.0
+ assert abs(e0 - 62.3333333333) < 0.001
+
+
+def test_reward_at_tp_long():
+ assert roll_ui.reward_at_tp_usdt("long", 100.0, 110.0, 2.0) == 20.0