Add period selector and crypto-style trend plan preview table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 22:52:47 +08:00
parent d3b92de703
commit 24190bf679
6 changed files with 253 additions and 25 deletions
+30 -5
View File
@@ -67,7 +67,14 @@ from risk.account_risk_lib import (
from strategy.strategy_db import init_strategy_tables from strategy.strategy_db import init_strategy_tables
from strategy.strategy_roll_lib import preview_roll from strategy.strategy_roll_lib import preview_roll
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached from strategy.strategy_trend_lib import (
compute_trend_plan_futures,
enrich_trend_plan_preview,
normalize_trend_period,
trend_dca_level_reached,
trend_period_label,
trend_strategy_periods,
)
from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND
from symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES, position_symbol_meta from symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES, position_symbol_meta
from trading_context import ( from trading_context import (
@@ -1998,15 +2005,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
roll_groups = conn.execute( roll_groups = conn.execute(
"SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC" "SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC"
).fetchall() ).fetchall()
active_trend_row = dict(active_trend) if active_trend else None
if active_trend_row:
active_trend_row["period_label"] = trend_period_label(active_trend_row.get("period") or "15m")
conn.close() conn.close()
return render_template( return render_template(
"strategy.html", "strategy.html",
capital=capital, capital=capital,
risk_percent=get_risk_percent(get_setting), risk_percent=get_risk_percent(get_setting),
sizing_mode=get_sizing_mode(get_setting), sizing_mode=get_sizing_mode(get_setting),
active_trend=dict(active_trend) if active_trend else None, active_trend=active_trend_row,
monitors=[dict(m) for m in monitors], monitors=[dict(m) for m in monitors],
roll_groups=[dict(g) for g in roll_groups], roll_groups=[dict(g) for g in roll_groups],
trend_periods=trend_strategy_periods(),
) )
@app.route("/strategy/records") @app.route("/strategy/records")
@@ -2471,6 +2482,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
) )
if err: if err:
return jsonify({"ok": False, "error": err}), 400 return jsonify({"ok": False, "error": err}), 400
period = normalize_trend_period(d.get("period"))
sym_name = (d.get("symbol_name") or "").strip()
if not sym_name and codes:
sym_name = codes.get("name") or sym
plan = enrich_trend_plan_preview(
plan, symbol=sym, symbol_name=sym_name, period=period,
)
return jsonify({"ok": True, "plan": plan}) return jsonify({"ok": True, "plan": plan})
@app.route("/api/strategy/trend/execute", methods=["POST"]) @app.route("/api/strategy/trend/execute", methods=["POST"])
@@ -2500,6 +2518,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if perr: if perr:
conn.close() conn.close()
return jsonify({"ok": False, "error": perr}), 400 return jsonify({"ok": False, "error": perr}), 400
period = normalize_trend_period(d.get("period"))
sym_name = (d.get("symbol_name") or "").strip()
if not sym_name and codes:
sym_name = codes.get("name") or sym
plan = enrich_trend_plan_preview(
plan, symbol=sym, symbol_name=sym_name, period=period,
)
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
try: try:
execute_order( execute_order(
@@ -2515,15 +2540,15 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit, status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit,
risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots, risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots,
dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price, dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price,
lots_open, opened_at lots_open, opened_at, period
) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""", ) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""",
( (
sym, codes.get("name", sym) if codes else sym, plan["direction"], sym, sym_name or (codes.get("name", sym) if codes else sym), plan["direction"],
plan["stop_loss"], plan["add_upper"], plan["take_profit"], plan["stop_loss"], plan["add_upper"], plan["take_profit"],
plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"], plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"],
plan["target_lots"], plan["first_lots"], plan["remainder_lots"], plan["target_lots"], plan["first_lots"], plan["remainder_lots"],
plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"], plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"],
price, plan["first_lots"], now, price, plan["first_lots"], now, plan["period"],
), ),
) )
plan_id = cur.lastrowid plan_id = cur.lastrowid
+26
View File
@@ -0,0 +1,26 @@
"""Deploy trend callback period + rich preview."""
import paramiko
import sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
root = Path(__file__).resolve().parents[1]
files = [
"strategy/strategy_trend_lib.py",
"strategy/strategy_db.py",
"install_trading.py",
"templates/strategy.html",
"static/js/strategy.js",
]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("192.168.8.21", username="root", password="woaini88", timeout=15)
sftp = c.open_sftp()
for rel in files:
sftp.put(str(root / rel), f"/opt/qihuo/{rel.replace(chr(92), '/')}")
print("uploaded", rel)
sftp.close()
_, o, _ = c.exec_command("cd /opt/qihuo && pm2 restart qihuo")
print(o.read().decode("utf-8", errors="replace"))
c.close()
+56 -17
View File
@@ -20,29 +20,68 @@
return o; return o;
} }
function showPreview(el, text, ok) { function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function showPreview(el, content, ok, isHtml) {
if (!el) return; if (!el) return;
if (!text) { if (!content) {
el.hidden = true; el.hidden = true;
el.textContent = ''; el.textContent = '';
el.innerHTML = '';
return; return;
} }
el.hidden = false; el.hidden = false;
el.textContent = text;
el.style.color = ok === false ? 'var(--loss)' : ''; el.style.color = ok === false ? 'var(--loss)' : '';
if (isHtml) {
el.innerHTML = content;
} else {
el.innerHTML = '';
el.textContent = content;
}
} }
function formatPlan(plan) { function fmtNum(v) {
if (!plan) return ''; if (v == null || v === '') return '';
var lines = []; return String(v);
if (plan.symbol) lines.push('品种:' + plan.symbol);
if (plan.target_lots != null) lines.push('目标手数:' + plan.target_lots);
if (plan.first_lots != null) lines.push('首仓:' + plan.first_lots + ' 手');
if (plan.grid && plan.grid.length) {
lines.push('补仓档位:' + plan.grid.map(function (g) { return g.price; }).join(' → '));
} }
if (plan.message) lines.push(plan.message);
return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2); function renderTrendPlanHtml(plan) {
if (!plan) return '';
var summary = plan.summary_line || (
(plan.symbol_name || plan.symbol || '') + ' ' +
(plan.direction_label || '') + ' ' + (plan.period_label || '')
);
var detail = plan.detail_line || '';
var rows = plan.preview_rows || [];
var html = '<div class="trend-summary">' + esc(summary) + '</div>';
if (detail) {
html += '<div class="trend-detail">' + esc(detail) + '</div>';
}
if (rows.length) {
html += '<table class="strategy-preview-table"><thead><tr>' +
'<th>档位</th><th>触发/参考价</th><th>手数</th><th>加仓后均价</th>' +
'<th>止盈盈利(元)</th><th>止损(元)</th><th>盈亏比</th>' +
'</tr></thead><tbody>';
rows.forEach(function (row) {
html += '<tr><td>' + esc(row.level) + '</td>' +
'<td>' + fmtNum(row.price) + '</td>' +
'<td>' + fmtNum(row.lots) + '</td>' +
'<td>' + fmtNum(row.avg_after) + '</td>' +
'<td>' + fmtNum(row.profit_at_tp) + '</td>' +
'<td>' + fmtNum(row.loss_at_sl) + '</td>' +
'<td>' + fmtNum(row.rr_ratio) + '</td></tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="trend-detail">目标手数 ' + fmtNum(plan.target_lots) +
' · 首仓 ' + fmtNum(plan.first_lots) + ' 手</div>';
}
return html;
} }
function formatRoll(preview) { function formatRoll(preview) {
@@ -66,12 +105,12 @@
btnPreview.disabled = true; btnPreview.disabled = true;
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) { jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
if (!d.ok) { if (!d.ok) {
showPreview(previewEl, d.error || '预览失败', false); showPreview(previewEl, d.error || '预览失败', false, false);
btnExec.hidden = true; btnExec.hidden = true;
return; return;
} }
trendPayload = formData(trendForm); trendPayload = formData(trendForm);
showPreview(previewEl, formatPlan(d.plan), true); showPreview(previewEl, renderTrendPlanHtml(d.plan), true, true);
btnExec.hidden = false; btnExec.hidden = false;
}).finally(function () { }).finally(function () {
btnPreview.disabled = false; btnPreview.disabled = false;
@@ -102,11 +141,11 @@
btnRollP.disabled = true; btnRollP.disabled = true;
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) { jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
if (!d.ok) { if (!d.ok) {
showPreview(rollPrev, d.error, false); showPreview(rollPrev, d.error, false, false);
btnRollE.hidden = true; btnRollE.hidden = true;
return; return;
} }
showPreview(rollPrev, formatRoll(d.preview), true); showPreview(rollPrev, formatRoll(d.preview), true, false);
btnRollE.hidden = false; btnRollE.hidden = false;
}).finally(function () { }).finally(function () {
btnRollP.disabled = false; btnRollP.disabled = false;
+6 -1
View File
@@ -61,7 +61,8 @@ CREATE TABLE IF NOT EXISTS trend_pullback_plans (
avg_entry_price REAL, avg_entry_price REAL,
lots_open INTEGER DEFAULT 0, lots_open INTEGER DEFAULT 0,
opened_at TEXT, opened_at TEXT,
message TEXT message TEXT,
period TEXT DEFAULT '15m'
) )
""" """
@@ -138,6 +139,10 @@ def init_strategy_tables(conn) -> None:
CTP_SIM_POSITIONS_SQL, CTP_SIM_POSITIONS_SQL,
): ):
conn.execute(sql) conn.execute(sql)
try:
conn.execute("ALTER TABLE trend_pullback_plans ADD COLUMN period TEXT DEFAULT '15m'")
except Exception:
pass
if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone(): if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone():
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)") conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
conn.commit() conn.commit()
+120
View File
@@ -111,3 +111,123 @@ def trend_dca_level_reached(direction: str, mark_price: float, level: float) ->
d = (direction or "long").strip().lower() d = (direction or "long").strip().lower()
pf, lv = float(mark_price), float(level) pf, lv = float(mark_price), float(level)
return pf <= lv if d == "long" else pf >= lv return pf <= lv if d == "long" else pf >= lv
def trend_strategy_periods() -> list[dict[str, str]]:
"""策略页可选 K 线周期。"""
from kline_chart import MARKET_PERIODS
skip = frozenset({"timeshare", "w"})
return [p for p in MARKET_PERIODS if p["key"] not in skip]
def trend_period_label(key: str) -> str:
k = (key or "").strip()
for p in trend_strategy_periods():
if p["key"] == k:
return p["label"]
return k or "15分"
def normalize_trend_period(key: str) -> str:
valid = {p["key"] for p in trend_strategy_periods()}
k = (key or "15m").strip()
return k if k in valid else "15m"
def _avg_after_entries(entries: list[tuple[float, int]]) -> float:
total = sum(q for _, q in entries)
if total <= 0:
return 0.0
return sum(p * q for p, q in entries) / total
def enrich_trend_plan_preview(
plan: dict,
*,
symbol: str,
symbol_name: str = "",
period: str = "15m",
) -> dict[str, Any]:
"""补全预览:周期、风险金额、分档表格(对齐币圈预览样式)。"""
out = dict(plan)
d = (out.get("direction") or "long").strip().lower()
sl = float(out["stop_loss"])
tp = float(out["take_profit"])
mult = float(out.get("mult") or 1)
entry0 = float(out["live_price_ref"])
first_lots = int(out["first_lots"])
leg_amounts = [int(x) for x in (out.get("leg_amounts") or [])]
grid = [float(x) for x in (out.get("grid") or [])]
capital = float(out.get("capital_snapshot") or 0)
risk_pct = float(out.get("risk_percent") or 0)
budget = capital * risk_pct / 100.0
remainder = int(out.get("remainder_lots") or sum(leg_amounts))
out["symbol"] = symbol
out["symbol_name"] = symbol_name or symbol
out["period"] = normalize_trend_period(period)
out["period_label"] = trend_period_label(out["period"])
out["stop_loss_budget"] = round(budget, 2)
out["direction_label"] = "做多" if d == "long" else "做空"
entries: list[tuple[float, int]] = [(entry0, first_lots)]
rows: list[dict[str, Any]] = []
def leg_metrics() -> tuple[float, float, float, Optional[float]]:
total = sum(q for _, q in entries)
avg = _avg_after_entries(entries)
if d == "long":
profit = (tp - avg) * total * mult
loss = (avg - sl) * total * mult
else:
profit = (avg - tp) * total * mult
loss = (sl - avg) * total * mult
rr = profit / loss if loss > 0 else None
return (
round(avg, 4),
round(profit, 2),
round(loss, 2),
round(rr, 2) if rr is not None else None,
)
avg, profit, loss, rr = leg_metrics()
rows.append({
"level": "首仓",
"price": round(entry0, 4),
"lots": first_lots,
"avg_after": avg,
"profit_at_tp": profit,
"loss_at_sl": loss,
"rr_ratio": rr,
})
out["first_rr_ratio"] = rr
for i, lots in enumerate(leg_amounts):
price = grid[i] if i < len(grid) else sl
entries.append((float(price), int(lots)))
avg, profit, loss, rr = leg_metrics()
rows.append({
"level": f"补仓{i + 1}",
"price": round(float(price), 4),
"lots": int(lots),
"avg_after": avg,
"profit_at_tp": profit,
"loss_at_sl": loss,
"rr_ratio": rr,
})
out["preview_rows"] = rows
out["summary_line"] = (
f"{out['symbol_name']} {out['symbol']} {out['direction_label']} {out['period_label']}"
f" | 权益 {capital:.2f}"
f" | 参考价 {entry0}"
f" | 计划保证金 ≈ {out.get('plan_margin')}"
f" | 总手 {out.get('target_lots')}(首仓 {first_lots} + 补仓 {remainder}"
)
out["detail_line"] = (
f"止损价 {sl} | 止损金额 {out['stop_loss_budget']} 元(权益 × 风险 {risk_pct}%"
f" | 补仓边界 {float(out['add_upper'])} | 止盈价 {tp}"
f" | 首仓盈亏比 {out['first_rr_ratio'] if out['first_rr_ratio'] is not None else ''}"
)
return out
+16 -3
View File
@@ -5,7 +5,14 @@
<style> <style>
.strategy-page .split-grid .card{min-height:420px;display:flex;flex-direction:column} .strategy-page .split-grid .card{min-height:420px;display:flex;flex-direction:column}
.strategy-page .split-grid .card-body{flex:1} .strategy-page .split-grid .card-body{flex:1}
.strategy-preview{background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;margin-top:.75rem;white-space:pre-wrap;max-height:200px;overflow:auto} .strategy-preview{background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;margin-top:.75rem;max-height:360px;overflow:auto}
.strategy-preview .trend-summary{margin-bottom:.45rem;color:var(--text-title);font-size:.8rem;line-height:1.55}
.strategy-preview .trend-detail{margin-bottom:.55rem;color:var(--text-muted);font-size:.75rem;line-height:1.5}
.strategy-preview-table{width:100%;border-collapse:collapse;font-size:.72rem}
.strategy-preview-table th,.strategy-preview-table td{padding:.35rem .4rem;border-bottom:1px solid var(--table-border);text-align:right;white-space:nowrap}
.strategy-preview-table th:first-child,.strategy-preview-table td:first-child{text-align:left}
.strategy-preview-table thead th{color:var(--text-muted);font-weight:600;background:var(--list-item-bg)}
.strategy-page .form-line.line-trend-head{grid-template-columns:1.5fr .75fr .85fr}
.strategy-steps{margin:.75rem 0 0;padding-left:1.1rem;font-size:.82rem;color:var(--text-muted);line-height:1.6} .strategy-steps{margin:.75rem 0 0;padding-left:1.1rem;font-size:.82rem;color:var(--text-muted);line-height:1.6}
.strategy-steps a{color:var(--accent)} .strategy-steps a{color:var(--accent)}
.strategy-active-roll{margin-top:.65rem;padding:.55rem .75rem;background:var(--card-inner);border-radius:8px;font-size:.8rem;border:1px solid var(--card-border)} .strategy-active-roll{margin-top:.65rem;padding:.55rem .75rem;background:var(--card-inner);border-radius:8px;font-size:.8rem;border:1px solid var(--card-border)}
@@ -18,7 +25,7 @@
<h2>趋势回调</h2> <h2>趋势回调</h2>
<div class="card-body"> <div class="card-body">
{% if active_trend %} {% if active_trend %}
<p class="hint">运行中 #{{ active_trend.id }} · {{ active_trend.symbol }} · {{ '做多' if active_trend.direction == 'long' else '做空' }}</p> <p class="hint">运行中 #{{ active_trend.id }} · {{ active_trend.symbol_name or active_trend.symbol }} · {{ '做多' if active_trend.direction == 'long' else '做空' }} · {{ active_trend.period_label or '15分' }}</p>
<p class="hint">已开 <strong>{{ active_trend.lots_open or 0 }}</strong> / {{ active_trend.target_lots }} 手 · 止损 {{ active_trend.stop_loss }} · 止盈 {{ active_trend.take_profit }}</p> <p class="hint">已开 <strong>{{ active_trend.lots_open or 0 }}</strong> / {{ active_trend.target_lots }} 手 · 止损 {{ active_trend.stop_loss }} · 止盈 {{ active_trend.take_profit }}</p>
<form id="trend-stop-form" class="form-row" style="margin-top:.75rem"> <form id="trend-stop-form" class="form-row" style="margin-top:.75rem">
<input type="hidden" name="plan_id" value="{{ active_trend.id }}"> <input type="hidden" name="plan_id" value="{{ active_trend.id }}">
@@ -28,14 +35,20 @@
{% else %} {% else %}
<p class="hint" style="margin-bottom:.65rem">设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。</p> <p class="hint" style="margin-bottom:.65rem">设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。</p>
<form id="trend-form" class="form-compact"> <form id="trend-form" class="form-compact">
<div class="form-line line-2"> <div class="form-line line-trend-head">
<div class="symbol-wrap symbol-mains"> <div class="symbol-wrap symbol-mains">
<input type="text" class="symbol-input" placeholder="品种,输入中文或代码" autocomplete="off" required> <input type="text" class="symbol-input" placeholder="品种,输入中文或代码" autocomplete="off" required>
<input type="hidden" name="symbol" class="symbol-ths-code"> <input type="hidden" name="symbol" class="symbol-ths-code">
<input type="hidden" name="symbol_name">
<div class="symbol-dropdown"></div> <div class="symbol-dropdown"></div>
<div class="symbol-selected"></div> <div class="symbol-selected"></div>
</div> </div>
<select name="direction"><option value="long">做多</option><option value="short">做空</option></select> <select name="direction"><option value="long">做多</option><option value="short">做空</option></select>
<select name="period" title="参考 K 线周期">
{% for p in trend_periods %}
<option value="{{ p.key }}"{% if p.key == '15m' %} selected{% endif %}>{{ p.label }}</option>
{% endfor %}
</select>
</div> </div>
<div class="form-line line-3"> <div class="form-line line-3">
<input name="stop_loss" type="number" step="any" placeholder="止损" required> <input name="stop_loss" type="number" step="any" placeholder="止损" required>