diff --git a/install_trading.py b/install_trading.py index 03ef6d9..6cfbcf4 100644 --- a/install_trading.py +++ b/install_trading.py @@ -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 diff --git a/scripts/deploy_trend_preview.py b/scripts/deploy_trend_preview.py new file mode 100644 index 0000000..1d5ccf3 --- /dev/null +++ b/scripts/deploy_trend_preview.py @@ -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() diff --git a/static/js/strategy.js b/static/js/strategy.js index 9495223..bda2ca2 100644 --- a/static/js/strategy.js +++ b/static/js/strategy.js @@ -20,29 +20,68 @@ return o; } - function showPreview(el, text, ok) { + function esc(s) { + return String(s == null ? '' : s) + .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 = '
' + esc(summary) + '
'; + if (detail) { + html += '
' + esc(detail) + '
'; } - if (plan.message) lines.push(plan.message); - return lines.length ? lines.join('\n') : JSON.stringify(plan, null, 2); + if (rows.length) { + html += '' + + '' + + '' + + ''; + rows.forEach(function (row) { + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
档位触发/参考价手数加仓后均价止盈盈利(元)止损(元)盈亏比
' + esc(row.level) + '' + fmtNum(row.price) + '' + fmtNum(row.lots) + '' + fmtNum(row.avg_after) + '' + fmtNum(row.profit_at_tp) + '' + fmtNum(row.loss_at_sl) + '' + fmtNum(row.rr_ratio) + '
'; + } else { + html += '
目标手数 ' + fmtNum(plan.target_lots) + + ' · 首仓 ' + fmtNum(plan.first_lots) + ' 手
'; + } + 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; diff --git a/strategy/strategy_db.py b/strategy/strategy_db.py index 418a5f7..7686e18 100644 --- a/strategy/strategy_db.py +++ b/strategy/strategy_db.py @@ -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() diff --git a/strategy/strategy_trend_lib.py b/strategy/strategy_trend_lib.py index 50aa3ad..e818362 100644 --- a/strategy/strategy_trend_lib.py +++ b/strategy/strategy_trend_lib.py @@ -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 diff --git a/templates/strategy.html b/templates/strategy.html index 3e5f7b0..5f2e93d 100644 --- a/templates/strategy.html +++ b/templates/strategy.html @@ -5,7 +5,14 @@