feat: strategy trade snapshots, DCA detail, and hub trend layout

Persist ended trend pullback and roll group snapshots to a unified records page; show replenishment tiers on instance and hub cards with horizontal single-position layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 10:45:27 +08:00
parent 1a6b5f55a1
commit 3fb2023efb
21 changed files with 665 additions and 44 deletions
+4 -4
View File
@@ -6008,12 +6008,12 @@ def render_main_page(page="trade"):
f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向、不自动开仓"
)
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll"):
from strategy_ui import strategy_page_template_vars
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
from strategy_ui import strategy_render_extras
strategy_extra = strategy_page_template_vars(
strategy_extra = strategy_render_extras(
conn,
"strategy",
page,
default_risk_percent=float(RISK_PERCENT),
request_obj=request,
trend_cfg=app.extensions.get("strategy_trend_cfg"),
@@ -259,6 +259,7 @@
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
@@ -594,6 +595,8 @@
</div>
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
{% include 'strategy_trading_page.html' %}
{% elif page == 'strategy_records' %}
{% include 'strategy_records_page.html' %}
{% endif %}
+4 -4
View File
@@ -5964,12 +5964,12 @@ def render_main_page(page="trade"):
f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向、不自动开仓"
)
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll"):
from strategy_ui import strategy_page_template_vars
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
from strategy_ui import strategy_render_extras
strategy_extra = strategy_page_template_vars(
strategy_extra = strategy_render_extras(
conn,
"strategy",
page,
default_risk_percent=float(RISK_PERCENT),
request_obj=request,
trend_cfg=app.extensions.get("strategy_trend_cfg"),
+3
View File
@@ -259,6 +259,7 @@
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
@@ -594,6 +595,8 @@
</div>
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
{% include 'strategy_trading_page.html' %}
{% elif page == 'strategy_records' %}
{% include 'strategy_records_page.html' %}
{% endif %}
+27 -2
View File
@@ -3824,7 +3824,9 @@ def enrich_active_trend_plan_row(row):
d["floating_mark"] = float(m["mark_price"])
else:
d["floating_mark"] = None
return d
from strategy_snapshot_lib import attach_trend_dca_levels
return attach_trend_dca_levels(d)
def opened_at_str_to_ms(opened_at_str):
@@ -4602,6 +4604,25 @@ def _trend_finalize_plan(conn, row, result_label, exit_price, closed_at=None):
if not getattr(cur, "rowcount", 0):
return
conn.commit()
try:
cfg = app.extensions.get("strategy_trend_cfg") or {}
closed = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=?", (plan_id,)
).fetchone()
if closed and cfg:
from strategy_snapshot_lib import save_trend_plan_snapshot
save_trend_plan_snapshot(
cfg,
conn,
closed,
result_label=result_label,
exit_price=float(exit_price),
pnl_amount=float(pnl_amount) if pnl_amount is not None else None,
)
conn.commit()
except Exception:
pass
if _trend_plan_trade_exists(conn, plan_id):
return
session_date = row["session_date"] or get_trading_day()
@@ -5457,7 +5478,11 @@ def render_main_page(page="trade"):
elif pr:
trend_preview_expired = True
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll"):
if page == "strategy_records":
from strategy_ui import strategy_render_extras
strategy_extra = strategy_render_extras(conn, page)
elif page in ("strategy", "strategy_trend", "strategy_roll"):
from strategy_ui import fetch_roll_page_data
strategy_extra = fetch_roll_page_data(
@@ -248,6 +248,7 @@
<div class="top-nav">
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">交易执行</a>
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
@@ -422,6 +423,8 @@
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
{% set can_trade_trend = can_trade %}
{% include 'strategy_trading_page.html' %}
{% elif page == 'strategy_records' %}
{% include 'strategy_records_page.html' %}
{% endif %}
{% if page == 'records' %}
+4 -4
View File
@@ -5608,12 +5608,12 @@ def render_main_page(page="trade"):
f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向、不自动开仓"
)
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll"):
from strategy_ui import strategy_page_template_vars
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
from strategy_ui import strategy_render_extras
strategy_extra = strategy_page_template_vars(
strategy_extra = strategy_render_extras(
conn,
"strategy",
page,
default_risk_percent=float(RISK_PERCENT),
request_obj=request,
trend_cfg=app.extensions.get("strategy_trend_cfg"),
+3
View File
@@ -259,6 +259,7 @@
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
@@ -603,6 +604,8 @@
</div>
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
{% include 'strategy_trading_page.html' %}
{% elif page == 'strategy_records' %}
{% include 'strategy_records_page.html' %}
{% endif %}
+76 -7
View File
@@ -1375,15 +1375,84 @@ body.market-chart-fs-open {
color: #8fc8ff;
}
.exchange-fullscreen .hub-trend-plan-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
align-items: stretch;
.hub-trend-plan-card--horizontal {
display: flex;
flex-wrap: wrap;
gap: 14px 18px;
align-items: flex-start;
background: linear-gradient(145deg, rgba(12, 18, 32, 0.92), rgba(8, 12, 22, 0.88));
border-color: rgba(0, 212, 255, 0.22);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.exchange-fullscreen .hub-trend-plan-card {
height: 100%;
.hub-trend-plan-body {
flex: 1 1 320px;
min-width: 0;
}
.hub-trend-plan-side {
flex: 1 1 220px;
min-width: 200px;
max-width: 100%;
}
.hub-trend-dca-block {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border-soft);
border-radius: 8px;
}
.hub-trend-dca-title {
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
.hub-trend-dca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.hub-trend-dca-table th,
.hub-trend-dca-table td {
padding: 4px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
}
.hub-trend-dca-table th {
color: var(--muted);
font-weight: 600;
}
.hub-trend-dca-table .dca-done {
color: var(--green);
}
.hub-trend-dca-table .dca-pending {
color: var(--muted);
}
.exchange-fullscreen .hub-trend-plan-list {
display: block;
max-width: 100%;
}
.exchange-fullscreen .hub-trend-plan-card--horizontal {
width: 100%;
}
.exchange-fullscreen .hub-trend-plan-metrics-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px 16px;
}
.exchange-fullscreen .hub-trend-plan-grid {
flex: 1 1 280px;
}
/* 顺势加仓 */
+56 -19
View File
@@ -1513,6 +1513,37 @@
return html;
}
function renderTrendDcaTable(t, tickMap) {
const levels = Array.isArray(t.dca_levels) ? t.dca_levels : [];
if (!levels.length) return "";
const sym = t.exchange_symbol || t.symbol || "";
const rows = levels
.map((lv) => {
const price =
lv.price != null && lv.price !== ""
? fmtSymbolPrice(lv.price, sym, tickMap)
: "—";
const amt =
lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—";
const stCls = lv.status === "done" ? "dca-done" : "dca-pending";
const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓");
return `<tr>
<td>${esc(lv.label || lv.leg_key || "—")}</td>
<td>${esc(price)}</td>
<td>${amt}</td>
<td class="${stCls}">${esc(label)}</td>
</tr>`;
})
.join("");
return `<div class="hub-trend-dca-block">
<div class="hub-trend-dca-title">补仓计划明细</div>
<table class="hub-trend-dca-table">
<thead><tr><th>档位</th><th></th><th></th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function renderTrendPlanCard(t, tickMap, pos) {
const sym = t.exchange_symbol || t.symbol || "";
const side = (t.direction || "long").toLowerCase();
@@ -1560,26 +1591,32 @@
<span>计划基数: ${baseTxt}</span>
<span>仓位占比: ${ratioTxt}</span>
</div>`;
return `<div class="hub-trend-plan-card hub-pos-card">
<div class="hub-trend-plan-head">
<span class="hub-trend-plan-title">#${esc(t.id)} ${esc(sym)} ${renderDirectionHtml(t.direction)}</span>
<span class="hub-trend-plan-status">${esc(t.status || "active")}</span>
const dcaHtml = renderTrendDcaTable(t, tickMap);
return `<div class="hub-trend-plan-card hub-pos-card hub-trend-plan-card--horizontal">
<div class="hub-trend-plan-body">
<div class="hub-trend-plan-head">
<span class="hub-trend-plan-title">#${esc(t.id)} ${esc(sym)} ${renderDirectionHtml(t.direction)}</span>
<span class="hub-trend-plan-status">${esc(t.status || "active")}</span>
</div>
<div class="hub-trend-plan-meta">
<span>来源: 趋势回调计划</span>
<span>风险: ${riskTxt}</span>
<span>${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)}</span>
<span>已补仓 <strong>${legsTxt}</strong></span>
</div>
<div class="hub-trend-plan-metrics-row">
<div class="pos-grid hub-trend-plan-grid">
<div class="pos-cell"><span class="pos-label">均价</span><span class="pos-value">${esc(avg)}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${esc(sl)}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value pos-tp-program"> · ${esc(tp)}</span></div>
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${esc(rrTxt)}</span></div>
<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">${esc(mark)}</span></div>
<div class="pos-cell"><span class="pos-label">浮盈亏</span>${pnlInner}</div>
</div>
${footHtml}
</div>
</div>
<div class="hub-trend-plan-meta">
<span>来源: 趋势回调计划</span>
<span>风险: ${riskTxt}</span>
<span>${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)}</span>
<span>已补仓 <strong>${legsTxt}</strong></span>
</div>
<div class="pos-grid hub-trend-plan-grid">
<div class="pos-cell"><span class="pos-label">均价</span><span class="pos-value">${esc(avg)}</span></div>
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${esc(sl)}</span></div>
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value pos-tp-program"> · ${esc(tp)}</span></div>
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${esc(rrTxt)}</span></div>
<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">${esc(mark)}</span></div>
<div class="pos-cell"><span class="pos-label">浮盈亏</span>${pnlInner}</div>
</div>
${footHtml}
${dcaHtml ? `<div class="hub-trend-plan-side">${dcaHtml}</div>` : ""}
</div>`;
}
+2 -2
View File
@@ -14,7 +14,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260604-hub-trend-metrics" />
<link rel="stylesheet" href="/assets/app.css?v=20260604-hub-trend-dca" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -235,6 +235,6 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-hub-trend-plan"></script>
<script src="/assets/app.js?v=20260604-hub-trend-metrics"></script>
<script src="/assets/app.js?v=20260604-hub-trend-dca"></script>
</body>
</html>
+3
View File
@@ -133,11 +133,14 @@ CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
def init_strategy_tables(conn) -> None:
from strategy_snapshot_lib import init_strategy_snapshot_table
conn.execute(ROLL_GROUPS_SQL)
conn.execute(ROLL_LEGS_SQL)
conn.execute(TREND_PLANS_SQL)
conn.execute(TREND_PREVIEWS_SQL)
conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL)
init_strategy_snapshot_table(conn)
for ddl in (
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
+55
View File
@@ -0,0 +1,55 @@
"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。"""
from __future__ import annotations
import json
from typing import Any
from flask import flash, redirect, url_for
from strategy_snapshot_lib import list_strategy_snapshots
def load_strategy_records_page(conn, *, limit: int = 200) -> dict[str, Any]:
snaps = list_strategy_snapshots(conn, limit=limit)
return {"strategy_snapshots": snaps, "strategy_records_limit": limit}
def register_strategy_records(app, cfg: dict[str, Any]) -> None:
login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f):
return login_required(f)
@_lr
@app.route("/strategy/records")
def strategy_records_page():
m = cfg.get("app_module")
fn = getattr(m, "render_main_page", None)
if not callable(fn):
flash("render_main_page 未配置")
return redirect(url_for("strategy_trading_page"))
return fn("strategy_records")
@_lr
@app.route("/strategy/records/<int:snap_id>")
def strategy_records_detail(snap_id: int):
conn = get_db()
row = conn.execute(
"SELECT * FROM strategy_trade_snapshots WHERE id=?",
(int(snap_id),),
).fetchone()
conn.close()
if not row:
flash("未找到该策略快照")
return redirect(url_for("strategy_records_page"))
try:
snap = json.loads(row["snapshot_json"] or "{}")
except Exception:
snap = {}
dca = snap.get("dca_levels") or []
flash(
f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} "
f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)"
)
return redirect(url_for("strategy_records_page"))
+3
View File
@@ -20,6 +20,9 @@ def install_strategy_trading(app: Flask, repo_root: str, app_module: Any = None,
attach_strategy_templates(app, repo_root)
cfg = build_strategy_config(app_module, **build_kw)
register_strategy_trading(app, cfg)
from strategy_records_register import register_strategy_records
register_strategy_records(app, cfg)
app.extensions["strategy_roll_cfg"] = cfg
+6
View File
@@ -76,6 +76,12 @@ def _close_roll_group(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), gid),
)
try:
from strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, group, result_label="结束")
except Exception:
pass
def _reconcile_roll_groups(conn, cfg: dict) -> None:
+241
View File
@@ -0,0 +1,241 @@
"""策略结束快照:趋势回调 / 顺势加仓(四所共用)。"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Callable, Optional
STRATEGY_TREND = "trend_pullback"
STRATEGY_ROLL = "roll"
STRATEGY_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_type TEXT NOT NULL,
source_id INTEGER,
symbol TEXT,
exchange_symbol TEXT,
direction TEXT,
result_label TEXT,
status_at_close TEXT,
opened_at TEXT,
closed_at TEXT,
pnl_amount REAL,
snapshot_json TEXT NOT NULL,
created_at TEXT
)
"""
def init_strategy_snapshot_table(conn) -> None:
conn.execute(STRATEGY_SNAPSHOTS_SQL)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_closed "
"ON strategy_trade_snapshots(closed_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_type "
"ON strategy_trade_snapshots(strategy_type, source_id)"
)
def _row_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _json_dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def build_trend_dca_levels(plan: dict) -> list[dict]:
"""首仓 + 补仓档位列表(供策略页 / 中控)。"""
out: list[dict] = []
p = plan or {}
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
try:
dca_legs = int(p.get("dca_legs") or 0)
except (TypeError, ValueError):
dca_legs = 0
first_done = int(p.get("first_order_done") or 0) != 0
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 = []
out.append(
{
"i": 0,
"leg_key": "first",
"label": "首仓",
"price": None,
"contracts": p.get("first_order_amount"),
"status": "done" if first_done else "pending",
"status_label": "已开仓" if first_done else "待开仓",
}
)
n = max(len(grid), len(leg_amounts), dca_legs)
for idx in range(n):
leg_i = idx + 1
price = grid[idx] if idx < len(grid) else None
contracts = leg_amounts[idx] if idx < len(leg_amounts) else None
done = leg_i <= legs_done
out.append(
{
"i": leg_i,
"leg_key": f"dca_{leg_i}",
"label": f"补仓{leg_i}",
"price": price,
"contracts": contracts,
"status": "done" if done else "pending",
"status_label": "已补仓" if done else "待补仓",
}
)
return out
def attach_trend_dca_levels(plan: dict) -> dict:
d = dict(plan or {})
d["dca_levels"] = build_trend_dca_levels(d)
return d
def save_trend_plan_snapshot(
cfg: dict,
conn,
plan_row: Any,
*,
result_label: str,
exit_price: float | None = None,
pnl_amount: float | None = None,
) -> None:
init_strategy_snapshot_table(conn)
row = _row_dict(plan_row)
plan_id = int(row.get("id") or 0)
if plan_id <= 0:
return
m = cfg.get("app_module")
closed_at = (
m.app_now_str()
if m is not None and hasattr(m, "app_now_str")
else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
)
payload = attach_trend_dca_levels(row)
payload["result_label"] = result_label
payload["exit_price"] = exit_price
payload["pnl_amount"] = pnl_amount
payload["status_at_close"] = row.get("status")
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, exchange_symbol, direction,
result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
STRATEGY_TREND,
plan_id,
row.get("symbol"),
row.get("exchange_symbol"),
row.get("direction"),
result_label,
row.get("status"),
row.get("opened_at"),
closed_at,
pnl_amount,
_json_dumps(payload),
closed_at,
),
)
def save_roll_group_snapshot(
cfg: dict,
conn,
group: dict,
*,
result_label: str = "结束",
pnl_amount: float | None = None,
) -> None:
init_strategy_snapshot_table(conn)
g = dict(group or {})
gid = int(g.get("id") or 0)
if gid <= 0:
return
legs = []
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? ORDER BY leg_index ASC, id ASC",
(gid,),
).fetchall():
ld = _row_dict(leg)
try:
from strategy_roll_monitor_lib import roll_leg_status_label
ld["status_label"] = roll_leg_status_label(ld.get("status"))
except Exception:
ld["status_label"] = ld.get("status") or ""
legs.append(ld)
m = cfg.get("app_module")
closed_at = (
m.app_now_str()
if m is not None and hasattr(m, "app_now_str")
else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
)
payload = {
"group": g,
"legs": legs,
"result_label": result_label,
"pnl_amount": pnl_amount,
}
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, exchange_symbol, direction,
result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
STRATEGY_ROLL,
gid,
g.get("symbol"),
g.get("exchange_symbol"),
g.get("direction"),
result_label,
g.get("status"),
g.get("created_at"),
closed_at,
pnl_amount,
_json_dumps(payload),
closed_at,
),
)
def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]:
init_strategy_snapshot_table(conn)
rows = conn.execute(
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
(max(1, min(int(limit), 500)),),
).fetchall()
out = []
for r in rows:
d = _row_dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except Exception:
d["snapshot"] = {}
st = (d.get("strategy_type") or "").strip()
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
out.append(d)
return out
@@ -0,0 +1,89 @@
<style>
.strategy-records-page{padding:4px 0 16px}
.strategy-records-page h2{margin:0 0 10px;color:#dbe4ff}
.strategy-records-tip{font-size:.78rem;color:#8892b0;line-height:1.55;margin-bottom:14px}
.strategy-records-table{width:100%;border-collapse:collapse;font-size:.82rem}
.strategy-records-table th,.strategy-records-table td{padding:8px 10px;border-bottom:1px solid #2a3150;text-align:left}
.strategy-records-table th{color:#8b95b8;font-weight:600;font-size:.74rem}
.strategy-records-table tr:hover td{background:rgba(42,63,108,.25)}
.strategy-records-table .tag-trend{color:#6ab8ff}
.strategy-records-table .tag-roll{color:#ffb020}
.strategy-records-detail{margin-top:8px;padding:10px 12px;background:#0f1424;border:1px solid #2a3150;border-radius:8px;font-size:.76rem;color:#cfd3ef;line-height:1.5}
.strategy-dca-mini{width:100%;margin-top:6px;border-collapse:collapse;font-size:.72rem}
.strategy-dca-mini th,.strategy-dca-mini td{padding:4px 8px;border-bottom:1px solid #243050}
.strategy-dca-mini .st-done{color:#4cd97f}
.strategy-dca-mini .st-pending{color:#8892b0}
.strategy-snap-pnl.pos{color:#4cd97f}
.strategy-snap-pnl.neg{color:#ff6666}
</style>
<div class="strategy-records-page card full">
<h2>策略交易记录</h2>
<p class="strategy-records-tip">
已结束的趋势回调计划与顺势加仓组会在此留存快照(含补仓档位明细)。保本移交、手动结束、止盈止损均会写入。
</p>
{% if strategy_snapshots %}
<div class="table-wrap">
<table class="strategy-records-table">
<tr>
<th>#</th>
<th>策略</th>
<th>品种</th>
<th>方向</th>
<th>结果</th>
<th>盈亏U</th>
<th>结束时间</th>
<th>补仓明细</th>
</tr>
{% for s in strategy_snapshots %}
{% set snap = s.snapshot or {} %}
{% set dca = snap.dca_levels if snap.dca_levels is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
<tr>
<td>{{ s.id }}</td>
<td><span class="{% if s.strategy_type == 'trend_pullback' %}tag-trend{% else %}tag-roll{% endif %}">{{ s.strategy_label }}</span></td>
<td>{{ s.symbol or s.exchange_symbol or '—' }}</td>
<td><span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span></td>
<td>{{ s.result_label or '—' }}</td>
<td class="{% if pnl is not none %}{% if pnl|float > 0 %}strategy-snap-pnl pos{% elif pnl|float < 0 %}strategy-snap-pnl neg{% endif %}{% endif %}">
{% if pnl is not none %}{{ funds_fmt(pnl) }}{% else %}—{% endif %}
</td>
<td>{{ (s.closed_at or '')[:19] }}</td>
<td>
{% if dca and dca|length %}
<details>
<summary style="cursor:pointer;color:#8fc8ff">{{ dca|length }} 档</summary>
<table class="strategy-dca-mini">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr>
{% for lv in dca %}
<tr>
<td>{{ lv.label or lv.leg_key }}</td>
<td>{% if lv.price is not none %}{{ price_fmt(s.symbol or s.exchange_symbol, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.contracts is not none %}{{ lv.contracts }}{% else %}—{% endif %}</td>
<td class="{% if lv.status == 'done' %}st-done{% else %}st-pending{% endif %}">{{ lv.status_label or '—' }}</td>
</tr>
{% endfor %}
</table>
</details>
{% elif s.strategy_type == 'roll' and snap.legs %}
<details>
<summary style="cursor:pointer;color:#8fc8ff">{{ snap.legs|length }} 腿</summary>
<table class="strategy-dca-mini">
<tr><th>#</th><th>状态</th></tr>
{% for leg in snap.legs %}
<tr>
<td>{{ leg.leg_index or loop.index }}</td>
<td>{{ leg.status_label or leg.status }}</td>
</tr>
{% endfor %}
</table>
</details>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div class="rule-tip" style="color:#8892b0">暂无策略结束快照。结束趋势回调计划或顺势加仓组后会自动出现在此。</div>
{% endif %}
</div>
@@ -19,6 +19,13 @@
.plan-cell .val.pnl-neutral{color:#cfd3ef}
.btn-close-plan{padding:7px 14px;background:#5c1e2a;color:#ffb4b4;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap;display:inline-block}
.btn-close-plan:hover{filter:brightness(1.08)}
.plan-dca-block{margin-top:12px;padding-top:10px;border-top:1px dashed #2a3558}
.plan-dca-title{font-size:.74rem;color:#8b95b8;margin-bottom:8px;letter-spacing:.02em}
.plan-dca-table{width:100%;border-collapse:collapse;font-size:.76rem}
.plan-dca-table th,.plan-dca-table td{padding:6px 8px;border-bottom:1px solid #243050;text-align:left}
.plan-dca-table th{color:#6a7598;font-weight:600}
.plan-dca-table .st-done{color:#4cd97f}
.plan-dca-table .st-pending{color:#9aa3c4}
@media (max-width:720px){
.plan-card-grid{grid-template-columns:1fr}
}
+17 -1
View File
@@ -125,7 +125,7 @@
<span class="accent">{{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }}</span>
| 已补仓 <strong>{{ t.legs_done }}/{{ t.dca_legs }}</strong>
</div>
<div class="plan-card-grid">
<div class="plan-card-grid plan-card-grid--metrics">
<div class="plan-cell">
<span class="lbl">均价</span>
<span class="val">{% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %}</span>
@@ -155,6 +155,22 @@
</span>
</div>
</div>
{% if t.dca_levels %}
<div class="plan-dca-block">
<div class="plan-dca-title">补仓计划明细</div>
<table class="plan-dca-table">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr>
{% for lv in t.dca_levels %}
<tr>
<td>{{ lv.label }}</td>
<td>{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.contracts is not none %}{{ amt_disp(sym, lv.contracts) }}{% else %}—{% endif %}</td>
<td class="{% if lv.status == 'done' %}st-done{% else %}st-pending{% endif %}">{{ lv.status_label }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<div class="plan-card-meta" style="margin-top:8px">
<form action="{{ url_for('trend_pullback_breakeven', pid=t.id) }}" method="post" class="form-row" style="margin:0;align-items:center" onsubmit="return confirm('确认保本?将结束本趋势计划,持仓移交「下单监控」(备注趋势回调计划),并在交易所同时挂保本止损与计划止盈;后续平仓会写入交易记录。');">
<label style="font-size:.78rem;color:#cfd3ef;display:flex;align-items:center;gap:6px">
+33 -1
View File
@@ -434,7 +434,9 @@ def enrich_trend_plan(cfg: dict, row) -> dict:
d["floating_mark"] = None
else:
d["floating_pnl"] = d["floating_mark"] = None
return d
from strategy_snapshot_lib import attach_trend_dca_levels
return attach_trend_dca_levels(d)
def _weighted_avg(old_avg, old_amt, fill_px, add_amt):
@@ -502,6 +504,24 @@ def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) -
if not getattr(cur, "rowcount", 0):
return
conn.commit()
try:
closed = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=?", (plan_id,)
).fetchone()
if closed:
from strategy_snapshot_lib import save_trend_plan_snapshot
save_trend_plan_snapshot(
cfg,
conn,
closed,
result_label=result_label,
exit_price=float(exit_price),
pnl_amount=float(pnl_amount) if pnl_amount is not None else None,
)
conn.commit()
except Exception:
pass
if _trend_plan_trade_exists(conn, plan_id):
return
session_date = row["session_date"] or m.get_trading_day()
@@ -937,6 +957,18 @@ def apply_manual_breakeven(cfg: dict, conn, row, offset_pct=None) -> tuple[bool,
if callable(wl):
lines.insert(1, f"**账户:{wl()}**")
send("\n".join(lines))
try:
handoff = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=?", (plan_id,)
).fetchone()
if handoff:
from strategy_snapshot_lib import save_trend_plan_snapshot
save_trend_plan_snapshot(
cfg, conn, handoff, result_label="保本移交", exit_price=None, pnl_amount=None
)
except Exception:
pass
return True, None
+26
View File
@@ -77,6 +77,32 @@ DEFAULT_TREND_DISABLED_NOTE = (
)
def strategy_render_extras(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 策略相关页变量(含策略交易记录)。"""
if page == "strategy_records":
from strategy_records_register import load_strategy_records_page
return load_strategy_records_page(conn)
return strategy_page_template_vars(
conn,
page,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
trend_disabled_note=trend_disabled_note,
request_obj=request_obj,
trend_cfg=trend_cfg,
)
def strategy_page_template_vars(
conn,
page: str,