Files
crypto_monitor/strategy_register.py
T
dekun d467760d5c 顺势加仓 v2:程序监控滚仓、文档页与平仓同步
重写滚仓计仓与四种加仓方式(市价/斐波/突破),程序盯 mark 触价成交;风险读监控单;pending 可删不可改;手动平仓同步结束滚仓。新增 /strategy/roll/docs 说明页与顺势加仓滚仓说明.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:03:23 +08:00

620 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。"""
from __future__ import annotations
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 strategy_db import init_strategy_tables
from strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll
from 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 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 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 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 = os.path.join(repo_root, "strategy_templates")
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/<int:leg_id>", 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(
"<pre><code>"
+ html_module.escape("\n".join(code_buf))
+ "</code></pre>"
)
code_buf = []
def inline_md(s: str) -> str:
s = html_module.escape(s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", 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"<h1>{inline_md(line[2:].strip())}</h1>")
elif line.startswith("## "):
out.append(f"<h2>{inline_md(line[3:].strip())}</h2>")
elif line.startswith("### "):
out.append(f"<h3>{inline_md(line[4:].strip())}</h3>")
elif line.strip() == "---":
out.append("<hr>")
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("<table>")
hdr = [c.strip() for c in rows[0].strip("|").split("|")]
out.append("<tr>" + "".join(f"<th>{inline_md(c)}</th>" for c in hdr) + "</tr>")
for row in rows[2:]:
cells = [c.strip() for c in row.strip("|").split("|")]
out.append("<tr>" + "".join(f"<td>{inline_md(c)}</td>" for c in cells) + "</tr>")
out.append("</table>")
continue
elif re.match(r"^[-*]\s+", line):
out.append("<ul>")
while i < len(lines) and re.match(r"^[-*]\s+", lines[i]):
item = re.sub(r"^[-*]\s+", "", lines[i])
out.append(f"<li>{inline_md(item)}</li>")
i += 1
out.append("</ul>")
continue
elif line.strip():
out.append(f"<p>{inline_md(line.strip())}</p>")
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 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 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="手动平仓,滚仓监控已结束"
)