Add period selector and crypto-style trend plan preview table.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+30
-5
@@ -67,7 +67,14 @@ from risk.account_risk_lib import (
|
||||
from strategy.strategy_db import init_strategy_tables
|
||||
from strategy.strategy_roll_lib import preview_roll
|
||||
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 symbols import ths_to_codes, resolve_main_contract, PRODUCTS, PRODUCT_CATEGORIES, position_symbol_meta
|
||||
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(
|
||||
"SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC"
|
||||
).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()
|
||||
return render_template(
|
||||
"strategy.html",
|
||||
capital=capital,
|
||||
risk_percent=get_risk_percent(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],
|
||||
roll_groups=[dict(g) for g in roll_groups],
|
||||
trend_periods=trend_strategy_periods(),
|
||||
)
|
||||
|
||||
@app.route("/strategy/records")
|
||||
@@ -2471,6 +2482,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
||||
)
|
||||
if err:
|
||||
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})
|
||||
|
||||
@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:
|
||||
conn.close()
|
||||
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)
|
||||
try:
|
||||
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,
|
||||
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,
|
||||
lots_open, opened_at
|
||||
lots_open, opened_at, period
|
||||
) 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["risk_percent"], plan["capital_snapshot"], plan["plan_margin"],
|
||||
plan["target_lots"], plan["first_lots"], plan["remainder_lots"],
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
+55
-16
@@ -20,29 +20,68 @@
|
||||
return o;
|
||||
}
|
||||
|
||||
function showPreview(el, text, ok) {
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function showPreview(el, content, ok, isHtml) {
|
||||
if (!el) return;
|
||||
if (!text) {
|
||||
if (!content) {
|
||||
el.hidden = true;
|
||||
el.textContent = '';
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.textContent = text;
|
||||
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 (v == null || v === '') return '—';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function renderTrendPlanHtml(plan) {
|
||||
if (!plan) return '';
|
||||
var lines = [];
|
||||
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(' → '));
|
||||
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 (plan.message) lines.push(plan.message);
|
||||
return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2);
|
||||
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) {
|
||||
@@ -66,12 +105,12 @@
|
||||
btnPreview.disabled = true;
|
||||
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
|
||||
if (!d.ok) {
|
||||
showPreview(previewEl, d.error || '预览失败', false);
|
||||
showPreview(previewEl, d.error || '预览失败', false, false);
|
||||
btnExec.hidden = true;
|
||||
return;
|
||||
}
|
||||
trendPayload = formData(trendForm);
|
||||
showPreview(previewEl, formatPlan(d.plan), true);
|
||||
showPreview(previewEl, renderTrendPlanHtml(d.plan), true, true);
|
||||
btnExec.hidden = false;
|
||||
}).finally(function () {
|
||||
btnPreview.disabled = false;
|
||||
@@ -102,11 +141,11 @@
|
||||
btnRollP.disabled = true;
|
||||
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
|
||||
if (!d.ok) {
|
||||
showPreview(rollPrev, d.error, false);
|
||||
showPreview(rollPrev, d.error, false, false);
|
||||
btnRollE.hidden = true;
|
||||
return;
|
||||
}
|
||||
showPreview(rollPrev, formatRoll(d.preview), true);
|
||||
showPreview(rollPrev, formatRoll(d.preview), true, false);
|
||||
btnRollE.hidden = false;
|
||||
}).finally(function () {
|
||||
btnRollP.disabled = false;
|
||||
|
||||
@@ -61,7 +61,8 @@ CREATE TABLE IF NOT EXISTS trend_pullback_plans (
|
||||
avg_entry_price REAL,
|
||||
lots_open INTEGER DEFAULT 0,
|
||||
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,
|
||||
):
|
||||
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():
|
||||
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
|
||||
conn.commit()
|
||||
|
||||
@@ -111,3 +111,123 @@ def trend_dca_level_reached(direction: str, mark_price: float, level: float) ->
|
||||
d = (direction or "long").strip().lower()
|
||||
pf, lv = float(mark_price), float(level)
|
||||
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
@@ -5,7 +5,14 @@
|
||||
<style>
|
||||
.strategy-page .split-grid .card{min-height:420px;display:flex;flex-direction:column}
|
||||
.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 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)}
|
||||
@@ -18,7 +25,7 @@
|
||||
<h2>趋势回调</h2>
|
||||
<div class="card-body">
|
||||
{% 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>
|
||||
<form id="trend-stop-form" class="form-row" style="margin-top:.75rem">
|
||||
<input type="hidden" name="plan_id" value="{{ active_trend.id }}">
|
||||
@@ -28,14 +35,20 @@
|
||||
{% else %}
|
||||
<p class="hint" style="margin-bottom:.65rem">设置止损/补仓边界/止盈 → 预览 → 确认执行首仓;后续自动分档加仓。</p>
|
||||
<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">
|
||||
<input type="text" class="symbol-input" placeholder="品种,输入中文或代码" autocomplete="off" required>
|
||||
<input type="hidden" name="symbol" class="symbol-ths-code">
|
||||
<input type="hidden" name="symbol_name">
|
||||
<div class="symbol-dropdown"></div>
|
||||
<div class="symbol-selected"></div>
|
||||
</div>
|
||||
<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 class="form-line line-3">
|
||||
<input name="stop_loss" type="number" step="any" placeholder="止损" required>
|
||||
|
||||
Reference in New Issue
Block a user