diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py
index ff3205e..e529c3f 100644
--- a/crypto_monitor_gate_bot/app.py
+++ b/crypto_monitor_gate_bot/app.py
@@ -5607,15 +5607,11 @@ def render_main_page(page="trade"):
).fetchone()
now_ms = int(time.time() * 1000)
if pr and int(pr["expires_at_ms"] or 0) >= now_ms:
+ from strategy_trend_lib import build_trend_preview_level_rows
+
trend_preview = row_to_dict(pr)
preview_expires_ms = int(pr["expires_at_ms"])
- try:
- grid = json.loads(trend_preview.get("grid_prices_json") or "[]")
- legs = json.loads(trend_preview.get("leg_amounts_json") or "[]")
- except Exception:
- grid, legs = [], []
- for i, pair in enumerate(zip(grid, legs), 1):
- trend_preview_levels.append({"i": i, "price": pair[0], "contracts": pair[1]})
+ trend_preview, trend_preview_levels = build_trend_preview_level_rows(trend_preview)
elif pr:
trend_preview_expired = True
strategy_extra = {}
diff --git a/hub_bridge.py b/hub_bridge.py
index 82206c8..fcdc55a 100644
--- a/hub_bridge.py
+++ b/hub_bridge.py
@@ -594,12 +594,26 @@ def _fetch_preview(pid):
now_ms = int(time.time() * 1000)
d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000))
try:
- grid = json.loads(d.get("grid_prices_json") or "[]")
- legs = json.loads(d.get("leg_amounts_json") or "[]")
+ from strategy_trend_lib import build_trend_preview_level_rows
+
+ enriched, level_rows = build_trend_preview_level_rows(d)
+ for key in ("preview_target_rr", "preview_first_take_profit", "preview_unified_stop_loss"):
+ if key in enriched:
+ d[key] = enriched[key]
+ d["preview_level_rows"] = level_rows
d["grid_levels"] = [
- {"i": i + 1, "price": grid[i], "contracts": legs[i] if i < len(legs) else None}
- for i in range(len(grid))
+ {
+ "i": row.get("i"),
+ "label": row.get("label"),
+ "price": row.get("price"),
+ "contracts": row.get("contracts"),
+ "avg_entry": row.get("avg_entry"),
+ "take_profit": row.get("take_profit"),
+ "stop_loss": row.get("stop_loss"),
+ }
+ for row in level_rows
]
except Exception:
d["grid_levels"] = []
+ d["preview_level_rows"] = []
return d
diff --git a/strategy_snapshot_lib.py b/strategy_snapshot_lib.py
index e92b09a..513051e 100644
--- a/strategy_snapshot_lib.py
+++ b/strategy_snapshot_lib.py
@@ -111,8 +111,11 @@ def build_trend_dca_levels(plan: dict) -> list[dict]:
def attach_trend_dca_levels(plan: dict) -> dict:
+ from strategy_trend_lib import enrich_trend_dca_levels_with_tp
+
d = dict(plan or {})
- d["dca_levels"] = build_trend_dca_levels(d)
+ levels = build_trend_dca_levels(d)
+ d["dca_levels"] = enrich_trend_dca_levels_with_tp(d, levels)
return d
diff --git a/strategy_templates/strategy_trend_panel.html b/strategy_templates/strategy_trend_panel.html
index 6a77b87..1e59553 100644
--- a/strategy_templates/strategy_trend_panel.html
+++ b/strategy_templates/strategy_trend_panel.html
@@ -47,13 +47,20 @@
{{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x |
预览可用快照 {{ mf(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} |
计划保证金≈{{ mf(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_disp(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_disp(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_disp(trend_preview.symbol, trend_preview.remainder_total) }})
- 止损 {{ price_fmt(trend_preview.symbol, trend_preview.stop_loss) }} | {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 止盈 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 风险比例 {{ trend_preview.risk_percent }}%
+ 统一止损 {{ price_fmt(trend_preview.symbol, trend_preview.preview_unified_stop_loss or trend_preview.stop_loss) }} | {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 表单止盈 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 目标RR {% if trend_preview.preview_target_rr is not none %}{{ '%.2f'|format(trend_preview.preview_target_rr) }}{% else %}—{% endif %} | 风险比例 {{ trend_preview.risk_percent }}%
- | # | 补仓触发价 | 该档张数 |
+ | 档位 | 触发/参考价 | 张数 | 加仓后均价 | 止盈 | 止损 |
{% for row in trend_preview_levels %}
- | {{ row.i }} | {{ price_fmt(trend_preview.symbol, row.price) }} | {{ amt_disp(trend_preview.symbol, row.contracts) }} |
+
+ | {{ row.label or row.i }} |
+ {{ price_fmt(trend_preview.symbol, row.price) }} |
+ {{ amt_disp(trend_preview.symbol, row.contracts) }} |
+ {% if row.avg_entry is not none %}{{ price_fmt(trend_preview.symbol, row.avg_entry) }}{% else %}—{% endif %} |
+ {% if row.take_profit is not none %}{{ price_fmt(trend_preview.symbol, row.take_profit) }}{% else %}—{% endif %} |
+ {% if row.stop_loss is not none %}{{ price_fmt(trend_preview.symbol, row.stop_loss) }}{% else %}—{% endif %} |
+
{% endfor %}
@@ -159,12 +166,15 @@
补仓计划明细
- | 档位 | 触发价 | 张数 | 状态 |
+ | 档位 | 触发价 | 张数 | 加仓后均价 | 止盈 | 止损 | 状态 |
{% for lv in t.dca_levels %}
| {{ lv.label }} |
{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %} |
{% if lv.contracts is not none %}{{ amt_disp(sym, lv.contracts) }}{% else %}—{% endif %} |
+ {% if lv.avg_entry is not none %}{{ price_fmt(sym, lv.avg_entry) }}{% else %}—{% endif %} |
+ {% if lv.take_profit is not none %}{{ price_fmt(sym, lv.take_profit) }}{% else %}—{% endif %} |
+ {% if lv.stop_loss is not none %}{{ price_fmt(sym, lv.stop_loss) }}{% else %}—{% endif %} |
{{ lv.status_label }} |
{% endfor %}
diff --git a/strategy_trend_lib.py b/strategy_trend_lib.py
index 98b6c21..8305a55 100644
--- a/strategy_trend_lib.py
+++ b/strategy_trend_lib.py
@@ -191,3 +191,201 @@ def compute_trend_plan_core(
"leg_amounts": leg_list,
}
return payload, None
+
+
+def calc_planned_reward_risk_ratio(
+ direction: str, entry_price: float, stop_loss: float, take_profit: float
+) -> Optional[float]:
+ """盈亏比(reward/risk),与四所 calc_rr_ratio 口径一致。"""
+ try:
+ entry = float(entry_price)
+ sl = float(stop_loss)
+ tp = float(take_profit)
+ if entry <= 0 or sl <= 0 or tp <= 0:
+ return None
+ direction = (direction or "long").strip().lower()
+ if direction == "short":
+ risk = sl - entry
+ reward = entry - tp
+ else:
+ risk = entry - sl
+ reward = tp - entry
+ if risk <= 0 or reward <= 0:
+ return None
+ return round(reward / risk, 4)
+ except (TypeError, ValueError):
+ return None
+
+
+def calc_take_profit_for_rr(
+ direction: str, entry_price: float, stop_loss: float, reward_risk_ratio: float
+) -> Optional[float]:
+ """按统一止损与目标 RR 反推止盈价。"""
+ try:
+ entry = float(entry_price)
+ sl = float(stop_loss)
+ rr = float(reward_risk_ratio)
+ if entry <= 0 or sl <= 0 or rr <= 0:
+ return None
+ direction = (direction or "long").strip().lower()
+ if direction == "short":
+ risk = sl - entry
+ if risk <= 0:
+ return None
+ return round(entry - rr * risk, 10)
+ risk = entry - sl
+ if risk <= 0:
+ return None
+ return round(entry + rr * risk, 10)
+ except (TypeError, ValueError):
+ return None
+
+
+def weighted_avg_entry(legs: list[tuple[float, float]]) -> Optional[float]:
+ """按 (成交价, 张数) 加权均价。"""
+ total = 0.0
+ cost = 0.0
+ for price, amount in legs or []:
+ try:
+ p = float(price)
+ a = float(amount)
+ except (TypeError, ValueError):
+ continue
+ if a <= 0:
+ continue
+ total += a
+ cost += p * a
+ if total <= 0:
+ return None
+ return cost / total
+
+
+def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]:
+ """
+ 预览:参考价首仓止盈 + 每档补仓后止盈;止损统一为计划止损(加仓后最大止损)。
+ 返回 (增强后的 preview 字段, 表格行列表,含首仓行)。
+ """
+ p = dict(preview or {})
+ direction = (p.get("direction") or "long").strip().lower()
+ try:
+ ref = float(p.get("live_price_ref"))
+ sl = float(p.get("stop_loss"))
+ user_tp = float(p.get("take_profit"))
+ first_amt = float(p.get("first_order_amount"))
+ except (TypeError, ValueError):
+ return p, []
+
+ rr = calc_planned_reward_risk_ratio(direction, ref, sl, user_tp)
+ if rr is None:
+ return p, []
+
+ first_tp = calc_take_profit_for_rr(direction, ref, sl, rr)
+ p["preview_target_rr"] = rr
+ p["preview_first_take_profit"] = first_tp
+ p["preview_unified_stop_loss"] = sl
+
+ try:
+ grid = json.loads(p.get("grid_prices_json") or "[]")
+ if not isinstance(grid, list):
+ grid = []
+ except Exception:
+ grid = []
+ try:
+ leg_amounts = json.loads(p.get("leg_amounts_json") or "[]")
+ if not isinstance(leg_amounts, list):
+ leg_amounts = []
+ except Exception:
+ leg_amounts = []
+
+ rows: list[dict] = [
+ {
+ "i": 0,
+ "label": "首仓",
+ "price": ref,
+ "contracts": first_amt,
+ "avg_entry": ref,
+ "take_profit": first_tp,
+ "stop_loss": sl,
+ "is_first": True,
+ }
+ ]
+ accumulated: list[tuple[float, float]] = [(ref, first_amt)]
+ for i, pair in enumerate(zip(grid, leg_amounts), 1):
+ try:
+ price = float(pair[0])
+ contracts = float(pair[1])
+ except (TypeError, ValueError):
+ continue
+ accumulated.append((price, contracts))
+ avg = weighted_avg_entry(accumulated)
+ tp_after = calc_take_profit_for_rr(direction, avg, sl, rr) if avg is not None else None
+ rows.append(
+ {
+ "i": i,
+ "label": f"补仓{i}",
+ "price": price,
+ "contracts": contracts,
+ "avg_entry": avg,
+ "take_profit": tp_after,
+ "stop_loss": sl,
+ "is_first": False,
+ }
+ )
+ return p, rows
+
+
+def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]:
+ """运行中计划:为 dca_levels 补充加仓后均价、止盈、统一止损。"""
+ if not levels:
+ return levels
+ p = plan or {}
+ direction = (p.get("direction") or "long").strip().lower()
+ try:
+ sl = float(p.get("stop_loss"))
+ user_tp = float(p.get("take_profit"))
+ first_amt = float(p.get("first_order_amount"))
+ except (TypeError, ValueError):
+ return levels
+
+ ref_raw = p.get("live_price_ref")
+ if ref_raw in (None, ""):
+ ref_raw = p.get("avg_entry_price")
+ try:
+ ref = float(ref_raw)
+ except (TypeError, ValueError):
+ return levels
+
+ rr = calc_planned_reward_risk_ratio(direction, ref, sl, user_tp)
+ if rr is None:
+ return levels
+
+ out: list[dict] = []
+ accumulated: list[tuple[float, float]] = []
+ for lv in levels:
+ row = dict(lv)
+ is_first = row.get("leg_key") == "first" or row.get("label") == "首仓" or row.get("i") == 0
+ if is_first:
+ amt = row.get("contracts")
+ try:
+ amt_f = float(amt if amt is not None else first_amt)
+ except (TypeError, ValueError):
+ amt_f = first_amt
+ accumulated = [(ref, amt_f)]
+ row["avg_entry"] = ref
+ row["take_profit"] = calc_take_profit_for_rr(direction, ref, sl, rr)
+ row["stop_loss"] = sl
+ else:
+ price = row.get("price")
+ contracts = row.get("contracts")
+ if price is not None and contracts is not None:
+ try:
+ accumulated.append((float(price), float(contracts)))
+ avg = weighted_avg_entry(accumulated)
+ if avg is not None:
+ row["avg_entry"] = avg
+ row["take_profit"] = calc_take_profit_for_rr(direction, avg, sl, rr)
+ row["stop_loss"] = sl
+ except (TypeError, ValueError):
+ pass
+ out.append(row)
+ return out
diff --git a/strategy_trend_register.py b/strategy_trend_register.py
index 92d9043..8984f0e 100644
--- a/strategy_trend_register.py
+++ b/strategy_trend_register.py
@@ -1093,15 +1093,11 @@ def load_trend_page_context(conn, request_obj, cfg: dict) -> dict[str, Any]:
).fetchone()
now_ms = int(time.time() * 1000)
if pr and int(pr["expires_at_ms"] or 0) >= now_ms:
+ from strategy_trend_lib import build_trend_preview_level_rows
+
trend_preview = _row(cfg, pr)
preview_expires_ms = int(pr["expires_at_ms"])
- try:
- grid = json.loads(trend_preview.get("grid_prices_json") or "[]")
- legs = json.loads(trend_preview.get("leg_amounts_json") or "[]")
- except Exception:
- grid, legs = [], []
- for i, pair in enumerate(zip(grid, legs), 1):
- trend_preview_levels.append({"i": i, "price": pair[0], "contracts": pair[1]})
+ trend_preview, trend_preview_levels = build_trend_preview_level_rows(trend_preview)
elif pr:
trend_preview_expired = True
return {
diff --git a/tests/test_trend_preview_tp.py b/tests/test_trend_preview_tp.py
new file mode 100644
index 0000000..2d3f65f
--- /dev/null
+++ b/tests/test_trend_preview_tp.py
@@ -0,0 +1,52 @@
+"""趋势回调预览:参考价首仓止盈与补仓后止盈。"""
+from __future__ import annotations
+
+import json
+import sys
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from strategy_trend_lib import ( # noqa: E402
+ build_trend_preview_level_rows,
+ calc_planned_reward_risk_ratio,
+ calc_take_profit_for_rr,
+)
+
+
+class TestTrendPreviewTp(unittest.TestCase):
+ def test_short_ref_price_first_tp_matches_form_tp(self):
+ ref, sl, tp = 72.6, 75.5, 65.0
+ rr = calc_planned_reward_risk_ratio("short", ref, sl, tp)
+ self.assertIsNotNone(rr)
+ first_tp = calc_take_profit_for_rr("short", ref, sl, rr)
+ self.assertAlmostEqual(first_tp, tp, places=2)
+
+ def test_preview_levels_include_first_and_dca_tp(self):
+ preview = {
+ "direction": "short",
+ "live_price_ref": 72.6,
+ "stop_loss": 75.5,
+ "take_profit": 65.0,
+ "first_order_amount": 1113,
+ "grid_prices_json": json.dumps([73.42, 73.83]),
+ "leg_amounts_json": json.dumps([222, 222]),
+ }
+ enriched, rows = build_trend_preview_level_rows(preview)
+ self.assertEqual(enriched["preview_unified_stop_loss"], 75.5)
+ self.assertAlmostEqual(enriched["preview_first_take_profit"], 65.0, places=1)
+ self.assertEqual(len(rows), 3)
+ self.assertEqual(rows[0]["label"], "首仓")
+ self.assertAlmostEqual(rows[0]["take_profit"], 65.0, places=1)
+ self.assertEqual(rows[0]["stop_loss"], 75.5)
+ self.assertIsNotNone(rows[1]["avg_entry"])
+ self.assertIsNotNone(rows[1]["take_profit"])
+ self.assertEqual(rows[1]["stop_loss"], 75.5)
+ # 做空:补仓价上移 → 均价上移 → 同等 RR 下止盈价上移
+ self.assertGreater(rows[2]["take_profit"], rows[1]["take_profit"])
+
+
+if __name__ == "__main__":
+ unittest.main()