"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。""" from __future__ import annotations from lib.paths import strategy_templates_dir import html as html_module import os import re from typing import Any, Optional from flask import Flask, flash, jsonify, redirect, render_template, request, url_for from jinja2 import ChoiceLoader, FileSystemLoader from lib.strategy.strategy_db import init_strategy_tables from lib.strategy.strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll from lib.strategy.strategy_roll_monitor_lib import ( cancel_roll_pending_leg, count_filled_roll_legs, count_pending_roll_legs, sync_roll_after_external_close, ) def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None: """启动时清理历史重复快照(同计划同结果仅保留最新一条)。""" get_db = cfg.get("get_db") if not callable(get_db): return try: from lib.strategy.strategy_snapshot_lib import dedupe_strategy_snapshots conn = get_db() try: removed = dedupe_strategy_snapshots(conn) if removed: conn.commit() print( f"[strategy] deduped {removed} duplicate strategy_trade_snapshots", flush=True, ) finally: conn.close() except Exception as e: print(f"[strategy] snapshot dedupe skipped: {e}", flush=True) def install_strategy_trading(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> None: """在 app.py 末尾调用(login_required 已定义后)。仅注册 POST API;页面由各 app 的 render_main_page 渲染。""" from lib.strategy.strategy_config import build_strategy_config build_kw.pop("render_trend_page", None) attach_strategy_templates(app, repo_root) cfg = build_strategy_config(app_module, **build_kw) register_strategy_trading(app, cfg) from lib.strategy.strategy_records_register import register_strategy_records register_strategy_records(app, cfg) app.extensions["strategy_roll_cfg"] = cfg _dedupe_strategy_snapshots_on_startup(cfg) def attach_strategy_templates(app: Flask, repo_root: str) -> None: strat_dir = strategy_templates_dir(repo_root) if not os.path.isdir(strat_dir): return existing = app.jinja_loader loaders = [FileSystemLoader(strat_dir)] if existing is not None: if isinstance(existing, ChoiceLoader): loaders = list(existing.loaders) + loaders else: loaders.insert(0, existing) app.jinja_loader = ChoiceLoader(loaders) def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None: """cfg 由各市面 app 注入回调(仅 API / DB 差异)。""" login_required = cfg["login_required"] def _lr(f): return login_required(f) @_lr @app.route("/strategy/roll/preview", methods=["POST"]) def strategy_roll_preview(): data = request.get_json(silent=True) or request.form err = _roll_preview_response(cfg, data, json_mode=request.is_json) if request.is_json: return jsonify(err) if err.get("ok"): p = err["preview"] flash( f"预览:约 {p.get('add_amount_display', '-')} 张," f"合并均价 {p.get('avg_entry_after', '-')}," f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U" ) else: flash(err.get("msg") or "预览失败") return redirect(url_for("strategy_trading_page")) @_lr @app.route("/strategy/roll/execute", methods=["POST"]) def strategy_roll_execute(): data = request.form try: ok, msg = _roll_execute(cfg, data) except Exception as e: fe = cfg.get("friendly_error") msg = fe(e) if callable(fe) else str(e) ok = False flash(msg) return redirect(url_for("strategy_trading_page")) @_lr @app.route("/strategy/roll/cancel/", methods=["POST"]) def strategy_roll_cancel_leg(leg_id: int): conn = cfg["get_db"]() try: init_strategy_tables(conn) ok, msg = cancel_roll_pending_leg(cfg, conn, leg_id) finally: conn.close() if request.is_json: return jsonify({"ok": ok, "msg": msg}) flash(msg) return redirect(url_for("strategy_trading_page")) @_lr @app.route("/strategy/roll/docs") def strategy_roll_docs(): path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "顺势加仓滚仓说明.md") if not os.path.isfile(path): flash("滚仓说明文档不存在") return redirect(url_for("strategy_trading_page")) with open(path, encoding="utf-8") as f: raw = f.read() return render_template( "strategy_roll_docs.html", doc_html=_roll_doc_markdown_to_html(raw), exchange_display=cfg.get("exchange_display") or "", ) def _roll_doc_markdown_to_html(text: str) -> str: """轻量 Markdown → HTML(仅供滚仓说明页)。""" lines = text.splitlines() out: list[str] = [] i = 0 in_code = False code_buf: list[str] = [] def flush_code() -> None: nonlocal code_buf if code_buf: out.append( "
"
                + html_module.escape("\n".join(code_buf))
                + "
" ) code_buf = [] def inline_md(s: str) -> str: s = html_module.escape(s) s = re.sub(r"`([^`]+)`", r"\1", s) s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) return s while i < len(lines): line = lines[i] if line.strip().startswith("```"): if in_code: in_code = False flush_code() else: in_code = True i += 1 continue if in_code: code_buf.append(line) i += 1 continue if line.startswith("# "): out.append(f"

{inline_md(line[2:].strip())}

") elif line.startswith("## "): out.append(f"

{inline_md(line[3:].strip())}

") elif line.startswith("### "): out.append(f"

{inline_md(line[4:].strip())}

") elif line.strip() == "---": out.append("
") elif line.startswith("|") and "|" in line[1:]: rows: list[str] = [] while i < len(lines) and lines[i].startswith("|"): rows.append(lines[i]) i += 1 if len(rows) >= 2 and re.match(r"^\|[\s\-:|]+\|$", rows[1].strip()): out.append("") hdr = [c.strip() for c in rows[0].strip("|").split("|")] out.append("" + "".join(f"" for c in hdr) + "") for row in rows[2:]: cells = [c.strip() for c in row.strip("|").split("|")] out.append("" + "".join(f"" for c in cells) + "") out.append("
{inline_md(c)}
{inline_md(c)}
") continue elif re.match(r"^[-*]\s+", line): out.append("") continue elif line.strip(): out.append(f"

{inline_md(line.strip())}

") i += 1 flush_code() return "\n".join(out) def _row_to_dict(row) -> dict: if row is None: return {} try: return dict(row) except Exception: return {} def _count_active_trends(conn, cfg: dict) -> int: fn = cfg.get("count_active_trend_plans") if callable(fn): return int(fn(conn) or 0) try: return int( conn.execute( "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" ).fetchone()[0] ) except Exception: return 0 def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]: try: rp = float(mon.get("risk_percent") or cfg.get("default_risk_percent", 2)) except (TypeError, ValueError): return None, "监控单风险%无效" if rp <= 0: return None, "监控单风险%须大于0" return rp, None def _contract_size(cfg: dict, ex_sym: str) -> float: get_cs = cfg.get("get_contract_size") if callable(get_cs): try: return float(get_cs(ex_sym) or 1.0) except Exception: pass return 1.0 def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]: m = cfg.get("app_module") if m is not None: try: from lib.trade.position_sizing_lib import OPEN_SOURCE_ROLL, assert_open_source_allowed mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL) if not ok_src: return None, src_msg except Exception: pass get_db = cfg["get_db"] symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") if not symbol: return None, "请选择或填写币种" direction = (data.get("direction") or "long").strip().lower() ex_sym = cfg["normalize_exchange_symbol"](symbol) conn = get_db() init_strategy_tables(conn) if _count_active_trends(conn, cfg) > 0: conn.close() return None, "存在运行中的趋势回调计划,请先结束后再滚仓" mon = _get_active_monitor(conn, cfg, symbol, direction) if not mon: conn.close() return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓" rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) if pending > 0: conn.close() return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交" conn_cap = get_db() try: capital = float(cfg["get_trading_capital_usdt"](conn_cap)) finally: conn_cap.close() risk_pct, risk_err = _risk_from_monitor(mon, cfg) if risk_err: conn.close() return None, risk_err pos = cfg["get_position"](ex_sym, direction) qty = float(pos.get("contracts") or 0) if qty <= 0: conn.close() return None, "交易所无该方向持仓,无法滚仓" entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) if entry <= 0: conn.close() return None, "无法获取持仓均价" mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") mark = mark_fn(symbol) if callable(mark_fn) else cfg["get_price"](symbol) ctx = { "conn": conn, "mon": mon, "rg": rg, "legs_done": legs_done, "symbol": symbol, "direction": direction, "ex_sym": ex_sym, "qty": qty, "entry": entry, "mark": float(mark) if mark else None, "capital": capital, "risk_pct": float(risk_pct), "tp0": float(mon.get("take_profit") or rg.get("initial_take_profit") or 0), "contract_size": _contract_size(cfg, ex_sym), } return ctx, None def _parse_roll_form(data: dict, ctx: dict) -> tuple[Optional[dict], Optional[str]]: add_mode = (data.get("add_mode") or MARKET_MODE).strip().lower() raw_sl = data.get("new_stop_loss") or data.get("sl") if raw_sl in (None, ""): return None, "请填写新止损价" try: new_sl = float(raw_sl) except (TypeError, ValueError): return None, "止损价格式错误" if new_sl <= 0: return None, "止损价须大于0" fib_u = fib_l = bp = None try: if data.get("fib_upper") not in (None, ""): fib_u = float(data.get("fib_upper")) if data.get("fib_lower") not in (None, ""): fib_l = float(data.get("fib_lower")) if data.get("breakthrough_price") not in (None, ""): bp = float(data.get("breakthrough_price")) except (TypeError, ValueError): return None, "价格参数格式错误" add_price = ctx.get("mark") if add_mode == MARKET_MODE: if add_price is None or add_price <= 0: return None, "无法获取市价快照" elif add_mode in FIB_MODES: if fib_u is None or fib_l is None: return None, "斐波须填写上沿 H 与下沿 L" elif add_mode == BREAKOUT_MODE: if bp is None: return None, "突破加仓须填写突破价" add_price = ctx.get("mark") else: return None, "加仓方式无效" return { "add_mode": add_mode, "new_stop_loss": new_sl, "fib_upper": fib_u, "fib_lower": fib_l, "breakthrough_price": bp, "add_price": add_price, }, None def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: ctx, err = _roll_context(cfg, data) if err: return {"ok": False, "msg": err} parsed, perr = _parse_roll_form(data, ctx) if perr: ctx["conn"].close() return {"ok": False, "msg": perr} conn = ctx["conn"] try: preview, perr2 = preview_roll( direction=ctx["direction"], symbol=ctx["symbol"], qty_existing=ctx["qty"], entry_existing=ctx["entry"], initial_take_profit=ctx["tp0"], add_mode=parsed["add_mode"], new_stop_loss=parsed["new_stop_loss"], risk_percent=ctx["risk_pct"], capital_base_usdt=ctx["capital"], add_price=parsed["add_price"], fib_upper=parsed["fib_upper"], fib_lower=parsed["fib_lower"], breakthrough_price=parsed["breakthrough_price"], legs_done=ctx["legs_done"], contract_size=ctx["contract_size"], ) finally: conn.close() if perr2: return {"ok": False, "msg": perr2} amt_raw = float(preview["add_amount_raw"]) amt_p = cfg["amount_to_precision"](ctx["ex_sym"], amt_raw) preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw preview["risk_display"] = f"{ctx['risk_pct']:g}%≈{ctx['capital'] * ctx['risk_pct'] / 100:.2f}U" price_fmt = cfg.get("price_fmt") if callable(price_fmt): preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"]) preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"]) preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"]) return {"ok": True, "preview": preview} def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: get_db = cfg["get_db"] conn = None try: ok_live, reason = cfg["ensure_live_ready"]() if not ok_live: return False, reason or "实盘未就绪" prev = _roll_preview_response(cfg, data) if not prev.get("ok"): return False, prev.get("msg") or "预览失败" preview = prev["preview"] symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") direction = preview["direction"] ex_sym = cfg["normalize_exchange_symbol"](symbol) add_mode = preview["add_mode"] new_sl = float(preview["new_stop_loss"]) tp0 = float(preview["initial_take_profit"]) lev_fn = cfg.get("default_leverage") if not callable(lev_fn): lev_fn = lambda _s: 5 leverage = int(data.get("leverage") or 0) or int(lev_fn(symbol)) conn = get_db() init_strategy_tables(conn) mon = _get_active_monitor(conn, cfg, symbol, direction) if not mon: return False, "监控单已不存在" rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) if pending > 0: return False, "已有监控中的滚仓腿,请先删除或等待结束" if add_mode == MARKET_MODE: amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"])) if amount is None or amount <= 0: return False, "加仓张数低于交易所最小精度" order = cfg["market_add"](ex_sym, direction, amount, leverage) fill = float( cfg.get("resolve_fill_price", lambda o, s, p: p)( order, ex_sym, preview["add_price"] ) or preview["add_price"] ) oid = str(order.get("id") or "") if isinstance(order, dict) else "" cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon) conn.execute( """INSERT INTO roll_legs ( roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id, status, created_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( rg["id"], legs_done + 1, preview["add_mode_label"], preview.get("fib_upper"), preview.get("fib_lower"), None, preview.get("breakthrough_price"), fill, amount, new_sl, oid, "filled", cfg["app_now_str"](), ), ) conn.execute( "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", (legs_done + 1, new_sl, cfg["app_now_str"](), rg["id"]), ) conn.execute( "UPDATE order_monitors SET stop_loss=? WHERE id=?", (new_sl, mon["id"]), ) conn.commit() _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓" # 程序监控:斐波 / 突破 limit_px = None if add_mode in FIB_MODES: px_fn = cfg.get("price_to_precision") limit_px = float(preview["add_price"]) if callable(px_fn): limit_px = float(px_fn(ex_sym, limit_px) or limit_px) mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"] conn.execute( """INSERT INTO roll_legs ( roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, breakthrough_price, new_stop_loss, last_mark_price, status, created_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", ( rg["id"], legs_done + 1, preview["add_mode_label"], preview.get("fib_upper"), preview.get("fib_lower"), limit_px, preview.get("breakthrough_price"), new_sl, last_mark, "pending", cfg["app_now_str"](), ), ) conn.commit() _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损" except Exception as e: fe = cfg.get("friendly_error") return False, fe(e) if callable(fe) else str(e) finally: if conn is not None: try: conn.close() except Exception: pass def _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, *, roll_is_new: bool) -> None: if not roll_is_new: return try: from lib.strategy.strategy_wechat_notify import notify_roll_group_started notify_roll_group_started( cfg, group_id=int(rg["id"]), symbol=symbol, direction=direction, order_monitor_id=int(mon["id"]), initial_take_profit=tp0, initial_stop_loss=float(mon.get("stop_loss") or new_sl), ) except Exception: pass def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]: row = conn.execute( "SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1", (symbol, direction), ).fetchone() return _row_to_dict(row) if row else None def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, int, bool]: """返回 (roll_group, filled_legs, pending_legs, is_new_group)。""" row = conn.execute( "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1", (mon["id"],), ).fetchone() if row: d = _row_to_dict(row) gid = int(d["id"]) filled = count_filled_roll_legs(conn, gid) pending = count_pending_roll_legs(conn, gid) return d, filled, pending, False now = mon.get("created_at") or "" cur = conn.execute( """INSERT INTO roll_groups ( order_monitor_id, symbol, exchange_symbol, direction, initial_take_profit, initial_stop_loss, current_stop_loss, risk_percent, leg_count, status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( mon["id"], mon["symbol"], mon.get("exchange_symbol"), mon["direction"], mon.get("take_profit"), mon.get("stop_loss"), mon.get("stop_loss"), mon.get("risk_percent") or 2, 0, "active", now, now, ), ) gid = int(cur.lastrowid) return ( { "id": gid, "leg_count": 0, "initial_take_profit": mon.get("take_profit"), "initial_stop_loss": mon.get("stop_loss"), "symbol": mon.get("symbol"), "direction": mon.get("direction"), }, 0, 0, True, ) def roll_sync_after_external_close(cfg: dict, conn, symbol: str, direction: str) -> dict: """供 hub / del_order 调用的滚仓同步入口。""" return sync_roll_after_external_close( cfg, conn, symbol, direction, reason="手动平仓,滚仓监控已结束" )