refactor: 将共用代码迁入 lib/ 模块化目录

统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:23:09 +08:00
parent 4742a0bb9d
commit 5797d49d8a
190 changed files with 27946 additions and 27499 deletions
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+230
View File
@@ -0,0 +1,230 @@
"""各交易所 app 模块 → strategy_register 配置(统一工厂)。"""
from __future__ import annotations
import sys
from typing import Any
def resolve_trading_app_module(app_module: Any = None) -> Any:
"""
须在 login_required 定义之后调用。
PM2 / python app.py 时 __name__ 为 __main__,请传入 sys.modules[__name__]。
"""
if app_module is None:
main = sys.modules.get("__main__")
if main is not None and hasattr(main, "login_required"):
m = main
else:
import inspect
m = None
for fr in inspect.stack():
g = fr.frame.f_globals
if callable(g.get("login_required")) and callable(g.get("get_db")):
m = g
break
if m is None:
raise RuntimeError(
"策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])"
)
else:
m = app_module
if not hasattr(m, "login_required"):
raise RuntimeError(
"策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)"
)
return m
def build_strategy_config(
app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = ""
) -> dict:
m = resolve_trading_app_module(app_module)
def get_trading_capital_usdt(conn):
if hasattr(m, "get_exchange_capitals"):
_, tc = m.get_exchange_capitals(force=True)
if tc is not None:
return float(tc)
if hasattr(m, "get_available_trading_usdt"):
snap = m.get_available_trading_usdt()
if snap is not None:
return float(snap)
day = m.get_trading_day(m.app_now())
row = m.ensure_session(conn, day)
return float(row["current_capital"])
def get_position(ex_sym, direction):
qty = m.get_live_position_contracts(ex_sym, direction)
entry = None
try:
rows = m.exchange.fetch_positions([ex_sym])
for p in rows or []:
matcher = getattr(m, "_row_matches_monitor_direction", None)
if matcher and not matcher(direction, p):
continue
contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p)
if contracts <= 0:
continue
coerce = getattr(m, "_coerce_float", None)
if coerce:
entry = coerce(
p.get("entryPrice"),
p.get("average"),
(p.get("info") or {}).get("entryPrice"),
)
if entry:
break
except Exception:
pass
return {"contracts": float(qty or 0), "entry_price": entry}
def amount_to_precision(ex_sym, amount):
try:
return float(m.exchange.amount_to_precision(ex_sym, float(amount)))
except Exception:
return None
def price_to_precision(ex_sym, price):
try:
return float(m.exchange.price_to_precision(ex_sym, float(price)))
except Exception:
return None
def market_add(ex_sym, direction, amount, leverage):
return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None)
def limit_add(ex_sym, direction, amount, price, leverage):
m.exchange.set_leverage(int(leverage), ex_sym)
side = "buy" if direction == "long" else "sell"
if hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=False)
elif hasattr(m, "build_binance_order_params"):
params = m.build_binance_order_params(direction, reduce_only=False)
elif hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=False)
else:
params = {}
return m.exchange.create_order(
ex_sym, "limit", side, float(amount), float(price), params if params is not None else {}
)
def replace_tpsl(ex_sym, direction, sl, tp, order_row):
row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction}
m.replace_active_monitor_tpsl_on_exchange(row, sl, tp)
def count_trends(conn):
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def friendly_error(err):
fn = getattr(m, "friendly_exchange_error", None) or getattr(
m, "friendly_okx_error", None
)
if not callable(fn):
return str(err)
try:
snap = m.get_available_trading_usdt()
except Exception:
snap = None
try:
return fn(err, available_usdt=snap)
except TypeError:
return fn(err)
def limit_order_status(ex_sym, order_id):
fn = getattr(m, "fib_limit_order_status", None)
if callable(fn):
return fn(ex_sym, order_id)
return "unknown"
def cancel_limit_order(ex_sym, order_id):
fn = getattr(m, "cancel_fib_limit_order", None)
if callable(fn):
try:
return fn(ex_sym, order_id)
except Exception:
pass
if not order_id:
return False
try:
m.exchange.cancel_order(str(order_id), ex_sym)
return True
except Exception:
return False
def get_mark_price(symbol):
fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None)
if not callable(fn):
return None
try:
return fn(symbol)
except Exception:
return None
def wechat_account_label():
fn = getattr(m, "_wechat_account_label", None)
if callable(fn):
try:
return fn()
except Exception:
pass
return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or ""
def wechat_direction_text(direction):
fn = getattr(m, "_wechat_direction_text", None)
if callable(fn):
try:
return fn(direction)
except Exception:
pass
d = (direction or "long").strip().lower()
return "做多" if d == "long" else "做空"
def send_wechat(content):
fn = getattr(m, "send_wechat_msg", None)
if callable(fn):
fn(content)
note = trend_disabled_note or (
"趋势回调(自动补仓)请在 Gate 趋势机器人实例使用:/strategy/trend"
)
return {
"app_module": m,
"exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""),
"trend_enabled": trend_enabled,
"trend_disabled_note": note,
"login_required": m.login_required,
"get_db": m.get_db,
"normalize_symbol_input": m.normalize_symbol_input,
"normalize_exchange_symbol": m.normalize_exchange_symbol,
"get_price": m.get_price,
"get_trading_capital_usdt": get_trading_capital_usdt,
"get_position": get_position,
"amount_to_precision": amount_to_precision,
"price_to_precision": price_to_precision,
"market_add": market_add,
"limit_add": limit_add,
"replace_tpsl": replace_tpsl,
"ensure_live_ready": m.ensure_exchange_live_ready,
"default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)),
"default_leverage": m.infer_leverage,
"friendly_error": friendly_error,
"app_now_str": m.app_now_str,
"resolve_fill_price": m.resolve_order_entry_price,
"price_fmt": m.format_price_for_symbol,
"count_active_trend_plans": count_trends if trend_enabled else count_trends,
"limit_order_status": limit_order_status,
"cancel_limit_order": cancel_limit_order,
"get_mark_price": get_mark_price,
"send_wechat": send_wechat,
"format_price": getattr(m, "format_price_for_symbol", None),
"wechat_account_label": wechat_account_label,
"wechat_direction_text": wechat_direction_text,
}
+164
View File
@@ -0,0 +1,164 @@
"""策略交易相关表结构(各所 crypto.db 共用 schema)。"""
ROLL_GROUPS_SQL = """
CREATE TABLE IF NOT EXISTS roll_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_monitor_id INTEGER,
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL,
initial_take_profit REAL,
initial_stop_loss REAL,
current_stop_loss REAL,
risk_percent REAL DEFAULT 2,
leg_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
)
"""
ROLL_LEGS_SQL = """
CREATE TABLE IF NOT EXISTS roll_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roll_group_id INTEGER NOT NULL,
leg_index INTEGER NOT NULL,
add_mode TEXT NOT NULL,
fib_upper REAL,
fib_lower REAL,
limit_price REAL,
fill_price REAL,
amount REAL,
new_stop_loss REAL,
exchange_order_id TEXT,
status TEXT DEFAULT 'filled',
created_at TEXT,
FOREIGN KEY (roll_group_id) REFERENCES roll_groups(id)
)
"""
TREND_PLANS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL DEFAULT 'long',
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
snapshot_available_usdt REAL,
snapshot_at TEXT,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER DEFAULT 5,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
last_mark_price REAL,
avg_entry_price REAL,
order_amount_open REAL,
opened_at TEXT,
opened_at_ms INTEGER,
session_date TEXT,
message TEXT,
initial_stop_loss REAL,
breakeven_applied INTEGER DEFAULT 0,
breakeven_applied_at TEXT
)
"""
TREND_PREVIEWS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_previews (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
created_at TEXT
)
"""
TREND_PREVIEW_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
preview_id TEXT NOT NULL UNIQUE,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
preview_created_at TEXT,
outcome TEXT DEFAULT 'open',
executed_plan_id INTEGER
)
"""
def init_strategy_tables(conn) -> None:
from lib.strategy.strategy_snapshot_lib import init_strategy_snapshot_table
conn.execute(ROLL_GROUPS_SQL)
conn.execute(ROLL_LEGS_SQL)
conn.execute(TREND_PLANS_SQL)
conn.execute(TREND_PREVIEWS_SQL)
conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL)
init_strategy_snapshot_table(conn)
for ddl in (
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER",
"ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT",
"ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT",
"ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
+48
View File
@@ -0,0 +1,48 @@
"""交易所策略适配器接口(各所 app 注入 ccxt 实现)。"""
from __future__ import annotations
from typing import Any, Optional, Protocol
class StrategyExchangeAdapter(Protocol):
exchange_key: str
def normalize_symbol(self, raw: str) -> str: ...
def normalize_exchange_symbol(self, symbol: str) -> str: ...
def get_mark_price(self, symbol: str) -> Optional[float]: ...
def get_position(self, exchange_symbol: str, direction: str) -> dict[str, Any]:
"""返回 {contracts, entry_price, leverage?}。"""
...
def amount_to_precision(self, exchange_symbol: str, amount: float) -> Optional[float]: ...
def price_to_precision(self, exchange_symbol: str, price: float) -> Optional[float]: ...
def market_add(
self, exchange_symbol: str, direction: str, amount: float, leverage: int
) -> dict[str, Any]: ...
def limit_add(
self,
exchange_symbol: str,
direction: str,
amount: float,
price: float,
leverage: int,
) -> dict[str, Any]: ...
def cancel_order(self, exchange_symbol: str, order_id: str) -> None: ...
def replace_position_tpsl(
self,
exchange_symbol: str,
direction: str,
stop_loss: float,
take_profit: float,
order_monitor_row: Any = None,
) -> None: ...
def ensure_live_ready(self) -> tuple[bool, str]: ...
@@ -0,0 +1,4 @@
"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+9
View File
@@ -0,0 +1,9 @@
"""
Gate.io USDT 永续 — 策略交易交易所侧能力。
实现方式:各 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
ccxt 下单、精度、换 TP/SL;本文件为文档与类型锚点,避免在四个 app 重复实现滚仓公式。
"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+4
View File
@@ -0,0 +1,4 @@
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+72
View File
@@ -0,0 +1,72 @@
"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。"""
from __future__ import annotations
import json
from typing import Any
from flask import flash, redirect, url_for
from lib.strategy.strategy_snapshot_lib import (
STRATEGY_SNAPSHOTS_MAX_ROWS,
dedupe_strategy_snapshots,
list_strategy_snapshots_split,
)
def load_strategy_records_page(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> dict[str, Any]:
try:
if dedupe_strategy_snapshots(conn):
conn.commit()
except Exception:
pass
trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit)
return {
"strategy_trend_records": trend,
"strategy_roll_records": roll,
"strategy_record_symbols": symbols,
"strategy_records_limit": limit,
"strategy_snapshots": trend + roll,
}
def register_strategy_records(app, cfg: dict[str, Any]) -> None:
login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f):
return login_required(f)
@_lr
@app.route("/strategy/records")
def strategy_records_page():
m = cfg.get("app_module")
fn = getattr(m, "render_main_page", None)
if not callable(fn):
flash("render_main_page 未配置")
return redirect(url_for("strategy_trading_page"))
return fn("strategy_records")
@_lr
@app.route("/strategy/records/<int:snap_id>")
def strategy_records_detail(snap_id: int):
conn = get_db()
row = conn.execute(
"SELECT * FROM strategy_trade_snapshots WHERE id=?",
(int(snap_id),),
).fetchone()
conn.close()
if not row:
flash("未找到该策略快照")
return redirect(url_for("strategy_records_page"))
try:
snap = json.loads(row["snapshot_json"] or "{}")
except Exception:
snap = {}
dca = snap.get("dca_levels") or []
flash(
f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} "
f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)"
)
return redirect(url_for("strategy_records_page"))
+621
View File
@@ -0,0 +1,621 @@
"""策略交易: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/<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 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="手动平仓,滚仓监控已结束"
)
+385
View File
@@ -0,0 +1,385 @@
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交。"""
from __future__ import annotations
from typing import Any, Optional, Tuple
from lib.key_monitor.fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
MARKET_MODE = "market"
FIB_MODES = frozenset({"fib_618", "fib_786"})
BREAKOUT_MODE = "breakout"
MODE_LABELS = {
MARKET_MODE: "市价加仓",
"fib_618": "斐波0.618",
"fib_786": "斐波0.786",
BREAKOUT_MODE: "突破加仓",
}
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in ("fib_618", "618", "0.618"):
return 0.618
if m in ("fib_786", "786", "0.786"):
return 0.786
return None
def mode_label(mode: str) -> str:
m = (mode or MARKET_MODE).strip().lower()
return MODE_LABELS.get(m, m)
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
"""H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。"""
ratio = fib_ratio_from_mode(mode)
if ratio is None:
return None, "斐波档位无效"
h, l = float(upper), float(lower)
if h <= l:
return None, "上沿须大于下沿"
direction = (direction or "long").strip().lower()
if direction == "short":
plan = calc_fib_plan("short", h, l, ratio)
else:
plan = calc_fib_plan("long", h, l, ratio)
if not plan:
return None, "无法计算斐波限价"
entry, _sl, _tp = plan
return float(entry), None
def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def avg_entry_after_add(
qty_existing: float,
entry_existing: float,
add_qty: float,
add_price: float,
) -> float:
q1 = float(qty_existing)
e1 = float(entry_existing)
q2 = float(add_qty)
e2 = float(add_price)
total = q1 + q2
if total <= 0:
return 0.0
return (q1 * e1 + q2 * e2) / total
def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float:
return float(capital_base_usdt) * (float(risk_percent) / 100.0)
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""
合并持仓打到 new_stop 时总亏损 ≈ risk_budget(方案 C)。
long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2)
"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)"
return q2, None
def loss_at_stop_usdt(
direction: str,
avg: float,
qty: float,
stop: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(stop) - float(avg)) * float(qty) * cs
return (float(avg) - float(stop)) * float(qty) * cs
def reward_at_tp_usdt(
direction: str,
avg: float,
take_profit: float,
qty: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(avg) - float(take_profit)) * float(qty) * cs
return (float(take_profit) - float(avg)) * float(qty) * cs
def roll_fib_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
limit_price: float,
) -> bool:
"""斐波:多=向下穿越限价;空=向上穿越限价。"""
try:
m = float(mark)
lv = float(limit_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m <= lv
return pm > lv and m <= lv
if pm is None:
return m >= lv
return pm < lv and m >= lv
def roll_breakout_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
breakthrough_price: float,
) -> bool:
"""突破:多=向上穿越突破价;空=向下穿越突破价。"""
try:
m = float(mark)
bp = float(breakthrough_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > bp
return pm <= bp and m > bp
if pm is None:
return m < bp
return pm >= bp and m < bp
def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool:
"""斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L)。"""
return fib_invalidate_by_mark(direction, mark, upper, lower)
def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool:
"""突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S)。"""
try:
m = float(mark)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
return m <= sl
return m >= sl
def validate_roll_geometry(
direction: str,
add_mode: str,
*,
new_stop_loss: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
entry_existing: float = 0.0,
initial_take_profit: float = 0.0,
mark_price: Optional[float] = None,
) -> Optional[str]:
direction = (direction or "long").strip().lower()
mode = (add_mode or MARKET_MODE).strip().lower()
try:
sl = float(new_stop_loss)
tp = float(initial_take_profit)
e1 = float(entry_existing or 0)
except (TypeError, ValueError):
return "止损/止盈格式错误"
if sl <= 0 or tp <= 0:
return "止损与首仓止盈须大于0"
if direction == "long":
if e1 > 0 and tp <= e1:
return "做多:首仓止盈须高于当前持仓均价"
else:
if e1 > 0 and tp >= e1:
return "做空:首仓止盈须低于当前持仓均价"
if mode == MARKET_MODE:
if add_price is None or float(add_price) <= 0:
return "市价加仓需要有效参考价"
entry_add = float(add_price)
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return "斐波须填写上沿 H 与下沿 L"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return err
if entry_add is None or entry_add <= 0:
return "无法计算斐波限价"
elif mode == BREAKOUT_MODE:
if breakthrough_price is None:
return "突破加仓须填写突破价"
try:
bp = float(breakthrough_price)
except (TypeError, ValueError):
return "突破价格式错误"
if bp <= 0:
return "突破价须大于0"
entry_add = bp
if direction == "long":
if sl >= bp:
return "做多:止损须低于突破价"
if mark_price is not None and float(mark_price) >= bp:
return "做多:当前价须低于突破价(等待向上突破)"
else:
if sl <= bp:
return "做空:止损须高于突破价"
if mark_price is not None and float(mark_price) <= bp:
return "做空:当前价须高于突破价(等待向下跌破)"
else:
return "加仓方式无效"
if mode != BREAKOUT_MODE:
entry_add = float(entry_add) # type: ignore[arg-type]
if direction == "long":
if sl >= entry_add:
return "做多:新止损须低于加仓价"
else:
if sl <= entry_add:
return "做空:新止损须高于加仓价"
return None
def preview_roll(
*,
direction: str,
symbol: str,
qty_existing: float,
entry_existing: float,
initial_take_profit: float,
add_mode: str,
new_stop_loss: Optional[float] = None,
risk_percent: float,
capital_base_usdt: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
legs_done: int = 0,
contract_size: float = 1.0,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or MARKET_MODE).strip().lower()
if new_stop_loss is None:
return None, "请填写新止损价"
try:
sl = float(new_stop_loss)
except (TypeError, ValueError):
return None, "止损价格式错误"
if sl <= 0:
return None, "止损须大于0"
geom_err = validate_roll_geometry(
direction,
mode,
new_stop_loss=sl,
add_price=add_price,
fib_upper=fib_upper,
fib_lower=fib_lower,
breakthrough_price=breakthrough_price,
entry_existing=entry_existing,
initial_take_profit=initial_take_profit,
mark_price=add_price if mode == BREAKOUT_MODE else add_price,
)
if geom_err:
return None, geom_err
if mode == MARKET_MODE:
entry_add = float(add_price) # validated
elif mode in FIB_MODES:
entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
entry_add = float(entry_add or 0)
else:
entry_add = float(breakthrough_price or 0)
risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = float(q2_raw)
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
cs = float(contract_size or 1.0)
loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs)
reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs)
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label(mode),
"add_price": round(entry_add, 10),
"new_stop_loss": round(sl, 10),
"breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None,
"initial_take_profit": float(initial_take_profit),
"risk_percent": float(risk_percent),
"risk_budget_usdt": round(risk_budget, 4),
"add_amount_raw": q2,
"qty_existing": float(qty_existing),
"entry_existing": float(entry_existing),
"qty_after": new_qty,
"avg_entry_after": round(new_avg, 10),
"loss_at_sl_usdt": round(loss_sl, 4),
"reward_at_tp_usdt": round(reward_tp, 4),
"legs_done": int(legs_done),
"leg_index_next": int(legs_done) + 1,
"fib_upper": fib_upper,
"fib_lower": fib_lower,
"contract_size": cs,
}, None
+520
View File
@@ -0,0 +1,520 @@
"""滚仓程序监控:斐波/突破触价市价成交、失效、外部平仓同步(各所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from lib.strategy.strategy_roll_lib import (
BREAKOUT_MODE,
FIB_MODES,
MARKET_MODE,
mode_label,
roll_breakout_invalidate,
roll_breakout_trigger_crossed,
roll_fib_invalidate,
roll_fib_trigger_crossed,
calc_risk_budget_usdt,
max_roll_legs,
preview_roll,
solve_add_amount_for_total_risk,
)
from lib.strategy.strategy_db import init_strategy_tables
ROLL_LEG_STATUS_LABELS = {
"pending": "监控中",
"filled": "已成交",
"cancelled": "已删除",
"invalidated": "已失效",
}
def roll_leg_status_label(status: Optional[str]) -> str:
s = (status or "").strip().lower()
return ROLL_LEG_STATUS_LABELS.get(s, status or "")
def check_roll_monitors(cfg: dict[str, Any]) -> None:
get_db = cfg["get_db"]
conn = get_db()
try:
init_strategy_tables(conn)
_reconcile_roll_groups(conn, cfg)
_check_pending_roll_legs(conn, cfg)
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def sync_roll_after_external_close(
cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平"
) -> dict[str, Any]:
"""中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史)。"""
norm = cfg.get("normalize_symbol_input")
sym = norm(symbol) if callable(norm) else (symbol or "").strip()
if not sym:
return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0}
direction = (direction or "long").strip().lower()
init_strategy_tables(conn)
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
WHERE g.status='active' AND g.symbol=? AND g.direction=?""",
(sym, direction),
).fetchall()
closed = cancelled = 0
for row in rows:
g = _row_dict(row)
cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), int(g["id"])),
)
if getattr(cur, "rowcount", 0):
closed += 1
try:
from lib.strategy.strategy_wechat_notify import notify_roll_group_ended
notify_roll_group_ended(
cfg,
group_id=int(g["id"]),
symbol=sym,
direction=direction,
reason=reason,
leg_count=int(g.get("leg_count") or 0),
)
except Exception:
pass
try:
from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, g, result_label="结束")
except Exception:
pass
return {
"ok": True,
"symbol": sym,
"direction": direction,
"closed_groups": closed,
"cancelled_legs": cancelled,
}
def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]:
"""用户删除 pending 滚仓腿(不可修改,仅删除)。"""
init_strategy_tables(conn)
row = conn.execute(
"SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l "
"INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?",
(int(leg_id),),
).fetchone()
if not row:
return False, "滚仓腿不存在"
leg = _row_dict(row)
if (leg.get("status") or "").strip().lower() != "pending":
return False, "仅监控中的腿可删除"
_cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg)
conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'",
(int(leg_id),),
)
conn.commit()
return True, "已删除滚仓监控"
def count_filled_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def count_pending_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def _row_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _now(cfg: dict) -> str:
fn = cfg.get("app_now_str")
return fn() if callable(fn) else ""
def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int:
gid = int(group["id"])
n = 0
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(gid,),
).fetchall():
ld = _row_dict(leg)
_cancel_roll_leg_order(cfg, group, ld)
conn.execute(
"UPDATE roll_legs SET status=? WHERE id=? AND status='pending'",
(status, ld["id"]),
)
n += 1
return n
def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None:
gid = int(group["id"])
_cancel_pending_legs_for_group(conn, cfg, group, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), gid),
)
if getattr(cur, "rowcount", 0):
try:
from lib.strategy.strategy_wechat_notify import notify_roll_group_ended
notify_roll_group_ended(
cfg,
group_id=gid,
symbol=group.get("symbol") or "",
direction=group.get("direction") or "long",
reason=reason,
leg_count=int(group.get("leg_count") or 0),
)
except Exception:
pass
try:
from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, group, result_label="结束")
except Exception:
pass
def _reconcile_roll_groups(conn, cfg: dict) -> None:
rows = conn.execute(
"""SELECT g.*, m.status AS monitor_status
FROM roll_groups g
LEFT JOIN order_monitors m ON m.id = g.order_monitor_id
WHERE g.status='active'"""
).fetchall()
for row in rows:
g = _row_dict(row)
symbol = g.get("symbol") or ""
direction = (g.get("direction") or "long").strip().lower()
ex_sym = g.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
mon_ok = (row["monitor_status"] or "").strip().lower() == "active"
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if not mon_ok or qty <= 0:
_close_roll_group(conn, cfg, g)
def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None:
oid = (leg.get("exchange_order_id") or "").strip()
if not oid:
return
symbol = group.get("symbol") or ""
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
cancel = cfg.get("cancel_limit_order")
if callable(cancel):
try:
cancel(ex_sym, oid)
except Exception:
pass
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 _resolve_add_mode(leg: dict) -> str:
raw = (leg.get("add_mode") or "").strip().lower()
if raw in (MARKET_MODE, "market", "市价", "市价加仓"):
return MARKET_MODE
if "786" in raw or raw == "fib_786":
return "fib_786"
if "618" in raw or raw == "fib_618":
return "fib_618"
if raw in (BREAKOUT_MODE, "突破", "突破加仓"):
return BREAKOUT_MODE
if raw.startswith("fib"):
return raw.replace(".", "_").replace("0.", "0")
return raw or MARKET_MODE
def _check_pending_roll_legs(conn, cfg: dict) -> None:
rows = conn.execute(
"""SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit,
g.order_monitor_id, g.risk_percent, g.leg_count
FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active'
WHERE l.status='pending'"""
).fetchall()
for row in rows:
leg = _row_dict(row)
group = {
"id": leg["roll_group_id"],
"symbol": leg["symbol"],
"exchange_symbol": leg["exchange_symbol"],
"direction": leg["direction"],
"initial_take_profit": leg["initial_take_profit"],
"order_monitor_id": leg["order_monitor_id"],
"risk_percent": leg.get("risk_percent"),
"leg_count": leg.get("leg_count"),
}
_process_pending_roll_leg(conn, cfg, group, leg)
def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None:
symbol = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else None
if mark is None:
return
mark_f = float(mark)
prev_mark = leg.get("last_mark_price")
try:
prev_f = float(prev_mark) if prev_mark not in (None, "") else None
except (TypeError, ValueError):
prev_f = None
mode = _resolve_add_mode(leg)
sl = float(leg.get("new_stop_loss") or 0)
fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower")
bp = leg.get("breakthrough_price")
if mode in FIB_MODES and fib_u is not None and fib_l is not None:
if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破")
return
elif mode == BREAKOUT_MODE and sl > 0:
if roll_breakout_invalidate(direction, mark_f, sl):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破")
return
triggered = False
if mode in FIB_MODES:
lp = leg.get("limit_price")
if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)):
triggered = True
elif mode == BREAKOUT_MODE and bp is not None:
if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)):
triggered = True
conn.execute(
"UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'",
(mark_f, int(leg["id"])),
)
if triggered:
_execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f)
return
def _execute_pending_roll_leg(
conn,
cfg: dict,
group: dict,
leg: dict,
ex_sym: str,
direction: str,
mark: float,
) -> None:
leg_id = int(leg["id"])
gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"])
mon_id = group.get("order_monitor_id")
mon = None
if mon_id:
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone()
mon = _row_dict(row) if row else None
if not mon or (mon.get("status") or "").strip().lower() != "active":
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效")
return
pos = cfg["get_position"](ex_sym, direction) or {}
qty = float(pos.get("contracts") or 0)
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if qty <= 0 or entry <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓")
return
filled = count_filled_roll_legs(conn, gid)
if filled >= max_roll_legs(direction):
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满")
return
try:
risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2)
except (TypeError, ValueError):
risk_pct = 2.0
conn_cap = cfg["get_db"]()
try:
capital = float(cfg["get_trading_capital_usdt"](conn_cap))
finally:
conn_cap.close()
cs = _contract_size(cfg, ex_sym)
sl = float(leg.get("new_stop_loss") or 0)
tp0 = float(group.get("initial_take_profit") or mon.get("take_profit") or 0)
mode = _resolve_add_mode(leg)
q2_raw, err = solve_add_amount_for_total_risk(
direction, qty, entry, mark, sl, calc_risk_budget_usdt(capital, risk_pct), cs
)
if err or q2_raw is None or float(q2_raw) <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason=err or "无法计算加仓张数")
return
amount = cfg["amount_to_precision"](ex_sym, float(q2_raw))
if amount is None or float(amount) <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="加仓张数低于交易所最小精度")
return
lev_fn = cfg.get("default_leverage")
if not callable(lev_fn):
lev_fn = lambda _s: 5
leverage = int(lev_fn(group.get("symbol") or ""))
try:
order = cfg["market_add"](ex_sym, direction, float(amount), leverage)
fill = float(
cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, mark) or mark
)
except Exception as e:
fe = cfg.get("friendly_error")
msg = fe(e) if callable(fe) else str(e)
_notify_roll_fail(cfg, group, leg, mark, msg)
return
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon)
conn.execute(
"""UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?,
new_stop_loss=? WHERE id=? AND status='pending'""",
(fill, float(amount), oid, sl, leg_id),
)
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(filled + 1, sl, _now(cfg), gid),
)
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'",
(sl, mon["id"]),
)
notify = cfg.get("send_wechat")
if callable(notify):
sym = group.get("symbol") or ""
mode_lbl = leg.get("add_mode") or mode_label(mode)
fmt = cfg.get("format_price")
px_txt = fmt(sym, fill) if callable(fmt) else str(fill)
sl_txt = fmt(sym, sl) if callable(fmt) else str(sl)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
notify(
f"# ✅ {sym} 滚仓触价成交\n"
f"**账户:{acct}**\n"
f"- 方式:{mode_lbl}{dir_txt}\n"
f"- 成交价:{px_txt}|张数:{amount}\n"
f"- 新止损:{sl_txt}(止盈仍为首仓)\n"
)
def _invalidate_roll_leg(
conn,
cfg: dict,
group: dict,
leg: dict,
mark: float,
*,
reason: str = "",
) -> None:
leg_id = int(leg["id"])
cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone()
if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"):
return
_cancel_roll_leg_order(cfg, group, leg)
conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'",
(leg_id,),
)
_send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason)
def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
mode = leg.get("add_mode") or "滚仓"
acct = _wechat_account(cfg)
notify(
f"# ❌ {sym} 滚仓触价成交失败\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}\n"
f"- 原因:{reason}\n"
)
def _send_roll_invalidate_wechat(
cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = ""
) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
mode = leg.get("add_mode") or "滚仓监控"
fmt = cfg.get("format_price")
mark_txt = fmt(sym, mark) if callable(fmt) else str(mark)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
detail = reason or "条件不满足"
notify(
f"# ⚠️ {sym} 滚仓监控失效\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n"
f"- 标记价 {mark_txt}{detail}\n"
f"- 本条监控已结案,可重新提交\n"
)
def _wechat_account(cfg: dict) -> str:
fn = cfg.get("wechat_account_label")
if callable(fn):
try:
return str(fn())
except Exception:
pass
return str(cfg.get("exchange_display") or "")
def _wechat_dir(cfg: dict, direction: str) -> str:
fn = cfg.get("wechat_direction_text")
if callable(fn):
try:
return str(fn(direction))
except Exception:
pass
return "做多" if (direction or "long").strip().lower() == "long" else "做空"
+402
View File
@@ -0,0 +1,402 @@
"""顺势加仓 UI:滚仓腿合并均价与止盈盈利展示(实例页 + 中控)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from flask import Flask
FILLED_LEG_STATUSES = frozenset({"filled", "done", "complete"})
def reward_at_tp_usdt(
direction: str,
avg_entry: float,
take_profit: float,
qty: float,
*,
contract_size: float = 1.0,
) -> Optional[float]:
"""与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位盈利。"""
try:
avg = float(avg_entry)
tp = float(take_profit)
q = float(qty)
cs = float(contract_size or 1.0)
except (TypeError, ValueError):
return None
if avg <= 0 or tp <= 0 or q <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
return (avg - tp) * q * cs
return (tp - avg) * q * cs
def leg_fill_price(leg: dict) -> Optional[float]:
if not isinstance(leg, dict):
return None
for key in ("fill_price", "limit_price"):
try:
v = float(leg.get(key) or 0)
if v > 0:
return v
except (TypeError, ValueError):
continue
return None
def leg_is_filled(leg: dict) -> bool:
st = str(leg.get("status") or "").strip().lower()
return st in FILLED_LEG_STATUSES
def infer_initial_position(
qty_live: float,
entry_live: float,
filled_legs: list[dict],
*,
monitor: dict | None = None,
) -> tuple[Optional[float], Optional[float]]:
"""由当前持仓与各腿成交价反推首仓张数/均价。"""
try:
qty_live = float(qty_live)
entry_live = float(entry_live)
except (TypeError, ValueError):
qty_live = entry_live = 0.0
legs = [
lg
for lg in filled_legs or []
if isinstance(lg, dict) and leg_is_filled(lg) and leg_fill_price(lg) and float(lg.get("amount") or 0) > 0
]
add_sum = sum(float(lg.get("amount") or 0) for lg in legs)
leg_notional = sum(float(lg.get("amount") or 0) * float(leg_fill_price(lg) or 0) for lg in legs)
q0 = qty_live - add_sum
if q0 > 1e-12 and entry_live > 0 and qty_live > 0:
e0 = (entry_live * qty_live - leg_notional) / q0
if e0 > 0:
return q0, e0
mon = monitor if isinstance(monitor, dict) else {}
try:
trig = float(mon.get("trigger_price") or 0)
except (TypeError, ValueError):
trig = 0.0
try:
mon_amt = float(mon.get("order_amount") or mon.get("amount") or 0)
except (TypeError, ValueError):
mon_amt = 0.0
if trig > 0:
q_base = q0 if q0 > 1e-12 else (mon_amt if mon_amt > 0 else max(qty_live - add_sum, 0))
if q_base > 0:
return q_base, trig
return None, None
def compute_roll_chain_metrics(
group: dict,
legs: list[dict],
*,
qty_live: Optional[float] = None,
entry_live: Optional[float] = None,
monitor: dict | None = None,
contract_size: float = 1.0,
) -> tuple[dict[Any, dict], dict]:
"""
返回 (leg_metrics_by_id, group_metrics)。
leg_metrics: leg id -> {avg_entry_after, reward_at_tp_usdt}
group_metrics: 最后一腿后的 {avg_entry, reward_at_tp_usdt}
"""
per_leg: dict[Any, dict] = {}
group_out: dict[str, Any] = {"avg_entry": None, "reward_at_tp_usdt": None}
if not isinstance(group, dict):
return per_leg, group_out
direction = (group.get("direction") or "long").strip().lower()
try:
tp = float(group.get("initial_take_profit") or 0)
except (TypeError, ValueError):
tp = 0.0
sorted_legs = sorted(
[lg for lg in legs or [] if isinstance(lg, dict)],
key=lambda x: int(x.get("leg_index") or 0),
)
filled = [lg for lg in sorted_legs if leg_is_filled(lg)]
q0 = e0 = None
if qty_live is not None and entry_live is not None:
q0, e0 = infer_initial_position(float(qty_live), float(entry_live), filled, monitor=monitor)
if q0 is None or e0 is None:
return per_leg, group_out
qty = float(q0)
avg = float(e0)
if tp > 0:
group_out["avg_entry"] = avg
group_out["reward_at_tp_usdt"] = reward_at_tp_usdt(
direction, avg, tp, qty, contract_size=contract_size
)
for leg in sorted_legs:
if not leg_is_filled(leg):
continue
try:
amt = float(leg.get("amount") or 0)
except (TypeError, ValueError):
continue
px = leg_fill_price(leg)
if not px or amt <= 0:
continue
prev_qty = qty
qty = prev_qty + amt
avg = (prev_qty * avg + amt * px) / qty
reward = reward_at_tp_usdt(direction, avg, tp, qty, contract_size=contract_size) if tp > 0 else None
lid = leg.get("id")
if lid is None:
lid = f"{group.get('id')}|{leg.get('leg_index')}"
per_leg[lid] = {
"avg_entry_after": round(avg, 10),
"reward_at_tp_usdt": round(reward, 4) if reward is not None else None,
}
group_out["avg_entry"] = round(avg, 10)
group_out["reward_at_tp_usdt"] = round(reward, 4) if reward is not None else None
return per_leg, group_out
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _resolve_roll_live(cfg: dict, group: dict, monitor: dict | None) -> tuple[Optional[float], Optional[float], float]:
"""读取交易所持仓张数、均价、contract_size。"""
m = cfg.get("app_module")
ex_sym = group.get("exchange_symbol")
sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
if not ex_sym and m is not None:
norm = getattr(m, "normalize_exchange_symbol", None)
if callable(norm):
try:
ex_sym = norm(sym)
except Exception:
ex_sym = sym
cs = 1.0
get_cs = cfg.get("get_contract_size")
if not callable(get_cs) and m is not None:
get_cs = getattr(m, "get_contract_size", None)
if callable(get_cs):
try:
cs = float(get_cs(ex_sym or sym) or 1.0)
except Exception:
cs = 1.0
get_pos = cfg.get("get_position")
if not callable(get_pos):
return None, None, cs
try:
pos = get_pos(ex_sym or sym, direction) or {}
qty = float(pos.get("contracts") or 0)
entry = float(pos.get("entry_price") or 0)
if qty > 0 and entry > 0:
return qty, entry, cs
except Exception:
pass
metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) if m else None
if callable(metrics_fn):
try:
met = metrics_fn(ex_sym or sym, direction)
if isinstance(met, dict):
qty = float(met.get("contracts") or met.get("size") or 0)
entry = float(met.get("entry_price") or 0)
if qty > 0 and entry > 0:
return qty, entry, cs
except Exception:
pass
if monitor:
try:
trig = float(monitor.get("trigger_price") or 0)
amt = float(monitor.get("order_amount") or monitor.get("amount") or 0)
if trig > 0 and amt > 0:
return amt, trig, cs
except (TypeError, ValueError):
pass
return None, None, cs
def enrich_roll_page_data(conn, page_data: dict, cfg: dict | None) -> dict:
"""为 roll_groups / roll_legs 附加 avg_entry、reward_at_tp 展示字段。"""
if not isinstance(page_data, dict) or not cfg:
return page_data
groups = list(page_data.get("roll_groups") or [])
legs = list(page_data.get("roll_legs") or [])
if not groups:
return page_data
monitors_by_id: dict[int, dict] = {}
try:
for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall():
od = _row_to_dict(row)
mid = od.get("id")
if mid is not None:
monitors_by_id[int(mid)] = od
except Exception:
pass
legs_by_gid: dict[int, list] = {}
for leg in legs:
if not isinstance(leg, dict):
continue
try:
gid = int(leg.get("roll_group_id"))
except (TypeError, ValueError):
continue
legs_by_gid.setdefault(gid, []).append(leg)
price_fmt = cfg.get("price_fmt")
for g in groups:
if not isinstance(g, dict) or g.get("id") is None:
continue
gid = int(g["id"])
mon = monitors_by_id.get(int(g.get("order_monitor_id") or 0))
qty, entry, cs = _resolve_roll_live(cfg, g, mon)
per_leg, group_metrics = compute_roll_chain_metrics(
g,
legs_by_gid.get(gid, []),
qty_live=qty,
entry_live=entry,
monitor=mon,
contract_size=cs,
)
g["avg_entry"] = group_metrics.get("avg_entry")
g["reward_at_tp_usdt"] = group_metrics.get("reward_at_tp_usdt")
if callable(price_fmt) and g.get("avg_entry") is not None:
try:
g["avg_entry_display"] = price_fmt(g.get("symbol"), g["avg_entry"])
except Exception:
pass
for leg in legs_by_gid.get(gid, []):
lid = leg.get("id")
if lid is None:
lid = f"{gid}|{leg.get('leg_index')}"
metrics = per_leg.get(lid) or per_leg.get(leg.get("id"))
if not metrics:
continue
leg["avg_entry_after"] = metrics.get("avg_entry_after")
leg["reward_at_tp_usdt"] = metrics.get("reward_at_tp_usdt")
if callable(price_fmt) and leg.get("avg_entry_after") is not None:
try:
leg["avg_entry_display"] = price_fmt(g.get("symbol"), leg["avg_entry_after"])
except Exception:
pass
page_data["roll_groups"] = groups
page_data["roll_legs"] = legs
return page_data
def enrich_roll_groups_for_hub(rolls: list[dict], conn, cfg: dict | None) -> list[dict]:
"""中控 monitor API:每组附带当前均价、止盈盈利与最近滚仓腿。"""
if not rolls or not cfg:
return rolls
out = []
gid_list = []
for g in rolls:
if isinstance(g, dict) and g.get("id") is not None:
try:
gid_list.append(int(g["id"]))
except (TypeError, ValueError):
pass
legs_by_gid: dict[int, list] = {gid: [] for gid in gid_list}
if gid_list:
placeholders = ",".join("?" for _ in gid_list)
try:
rows = conn.execute(
f"SELECT * FROM roll_legs WHERE roll_group_id IN ({placeholders}) ORDER BY id DESC",
gid_list,
).fetchall()
for row in rows:
leg = _row_to_dict(row)
try:
legs_by_gid[int(leg.get("roll_group_id"))].append(leg)
except (TypeError, ValueError):
pass
except Exception:
pass
monitors_by_id: dict[int, dict] = {}
try:
for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall():
od = _row_to_dict(row)
if od.get("id") is not None:
monitors_by_id[int(od["id"])] = od
except Exception:
pass
price_fmt = cfg.get("price_fmt")
for g in rolls:
if not isinstance(g, dict):
continue
gd = dict(g)
try:
gid = int(gd.get("id"))
except (TypeError, ValueError):
out.append(gd)
continue
mon = monitors_by_id.get(int(gd.get("order_monitor_id") or 0))
group_legs = legs_by_gid.get(gid, [])
qty, entry, cs = _resolve_roll_live(cfg, gd, mon)
per_leg, group_metrics = compute_roll_chain_metrics(
gd,
group_legs,
qty_live=qty,
entry_live=entry,
monitor=mon,
contract_size=cs,
)
gd.update(group_metrics)
if callable(price_fmt) and gd.get("avg_entry") is not None:
try:
gd["avg_entry_display"] = price_fmt(gd.get("symbol"), gd["avg_entry"])
except Exception:
pass
recent = []
for leg in sorted(group_legs, key=lambda x: int(x.get("leg_index") or 0), reverse=True)[:6]:
ld = dict(leg)
lid = ld.get("id")
if lid is None:
lid = f"{gid}|{ld.get('leg_index')}"
metrics = per_leg.get(lid) or per_leg.get(ld.get("id"))
if metrics:
ld.update(metrics)
if callable(price_fmt) and ld.get("avg_entry_after") is not None:
try:
ld["avg_entry_display"] = price_fmt(gd.get("symbol"), ld["avg_entry_after"])
except Exception:
pass
recent.append(ld)
gd["recent_legs"] = recent
out.append(gd)
return out
def patch_roll_hub_enrich(app: Flask, cfg: dict) -> None:
"""hub_bridge install 后:/api/hub/monitor 的 rolls 附带均价/止盈盈利。"""
ctx = dict(app.config.get("HUB_CTX") or {})
prev: Callable | None = ctx.get("enrich_monitor")
def enrich_monitor(keys=None, orders=None, trends=None, rolls=None):
payload: dict[str, Any] = {}
if callable(prev):
try:
prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls)
if isinstance(prev_out, dict):
payload.update(prev_out)
except Exception:
pass
if rolls:
get_db = cfg.get("get_db")
if callable(get_db):
conn = get_db()
try:
payload["rolls"] = enrich_roll_groups_for_hub(list(rolls), conn, cfg)
finally:
try:
conn.close()
except Exception:
pass
return payload
ctx["enrich_monitor"] = enrich_monitor
app.config["HUB_CTX"] = ctx
+529
View File
@@ -0,0 +1,529 @@
"""策略结束快照:趋势回调 / 顺势加仓(四所共用)。"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Callable, Optional
STRATEGY_TREND = "trend_pullback"
STRATEGY_ROLL = "roll"
STRATEGY_SNAPSHOTS_MAX_ROWS = 100
# 同一趋势计划只允许一条「结束类」快照(中控全平 + 监控止损 + 实例结束计划)
FINAL_TREND_CLOSE_RANK = {
"手动平仓": 3,
"止盈": 2,
"止损": 1,
}
FINAL_TREND_CLOSE_LABELS = tuple(FINAL_TREND_CLOSE_RANK.keys())
STRATEGY_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_type TEXT NOT NULL,
source_id INTEGER,
symbol TEXT,
exchange_symbol TEXT,
direction TEXT,
result_label TEXT,
status_at_close TEXT,
opened_at TEXT,
closed_at TEXT,
pnl_amount REAL,
snapshot_json TEXT NOT NULL,
created_at TEXT
)
"""
def init_strategy_snapshot_table(conn) -> None:
conn.execute(STRATEGY_SNAPSHOTS_SQL)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_closed "
"ON strategy_trade_snapshots(closed_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_type "
"ON strategy_trade_snapshots(strategy_type, source_id)"
)
def _row_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def _json_dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def build_trend_dca_levels(plan: dict) -> list[dict]:
"""首仓 + 补仓档位列表(供策略页 / 中控)。"""
out: list[dict] = []
p = plan or {}
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
try:
dca_legs = int(p.get("dca_legs") or 0)
except (TypeError, ValueError):
dca_legs = 0
first_done = int(p.get("first_order_done") or 0) != 0
try:
grid = json.loads(p.get("grid_prices_json") or "[]")
if not isinstance(grid, list):
grid = []
except Exception:
grid = []
try:
leg_amounts = json.loads(p.get("leg_amounts_json") or "[]")
if not isinstance(leg_amounts, list):
leg_amounts = []
except Exception:
leg_amounts = []
out.append(
{
"i": 0,
"leg_key": "first",
"label": "首仓",
"price": None,
"contracts": p.get("first_order_amount"),
"status": "done" if first_done else "pending",
"status_label": "已开仓" if first_done else "待开仓",
}
)
n = max(len(grid), len(leg_amounts), dca_legs)
for idx in range(n):
leg_i = idx + 1
price = grid[idx] if idx < len(grid) else None
contracts = leg_amounts[idx] if idx < len(leg_amounts) else None
done = leg_i <= legs_done
out.append(
{
"i": leg_i,
"leg_key": f"dca_{leg_i}",
"label": f"补仓{leg_i}",
"price": price,
"contracts": contracts,
"status": "done" if done else "pending",
"status_label": "已补仓" if done else "待补仓",
}
)
return out
def attach_trend_dca_levels(plan: dict) -> dict:
from lib.strategy.strategy_trend_lib import enrich_trend_dca_levels_with_tp
d = dict(plan or {})
levels = build_trend_dca_levels(d)
d["dca_levels"] = enrich_trend_dca_levels_with_tp(d, levels)
return d
def _snapshot_key_exists(
conn, strategy_type: str, source_id: int, result_label: str
) -> bool:
if source_id <= 0:
return False
label = (result_label or "").strip()
row = conn.execute(
"""SELECT 1 FROM strategy_trade_snapshots
WHERE strategy_type=? AND source_id=? AND result_label=?
LIMIT 1""",
(strategy_type, int(source_id), label),
).fetchone()
return row is not None
def _final_trend_close_rank(result_label: str) -> int:
return int(FINAL_TREND_CLOSE_RANK.get((result_label or "").strip(), 0))
def _purge_weaker_trend_final_snapshots(
conn, plan_id: int, result_label: str
) -> None:
"""写入更高优先级结束快照时,删除同计划较弱的结束记录。"""
rank = _final_trend_close_rank(result_label)
if rank <= 0 or plan_id <= 0:
return
for label, lr in FINAL_TREND_CLOSE_RANK.items():
if lr < rank:
conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE strategy_type=? AND source_id=? AND result_label=?""",
(STRATEGY_TREND, int(plan_id), label),
)
def dedupe_strategy_snapshots(conn) -> int:
"""删除重复快照:同结果去重 + 同计划仅保留最高优先级结束类记录。"""
init_strategy_snapshot_table(conn)
removed = 0
cur = conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE id IN (
SELECT s1.id FROM strategy_trade_snapshots s1
INNER JOIN strategy_trade_snapshots s2
ON s1.strategy_type = s2.strategy_type
AND s1.source_id = s2.source_id
AND s1.result_label = s2.result_label
AND s1.id < s2.id
)"""
)
removed += int(getattr(cur, "rowcount", 0) or 0)
rows = conn.execute(
f"""SELECT id, source_id, result_label FROM strategy_trade_snapshots
WHERE strategy_type=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""",
(STRATEGY_TREND, *FINAL_TREND_CLOSE_LABELS),
).fetchall()
by_plan: dict[int, list] = {}
for row in rows:
d = _row_dict(row)
try:
pid = int(d.get("source_id") or 0)
except (TypeError, ValueError):
pid = 0
if pid <= 0:
continue
by_plan.setdefault(pid, []).append(d)
drop_ids: list[int] = []
for snaps in by_plan.values():
if len(snaps) <= 1:
continue
best = max(
snaps,
key=lambda s: (
_final_trend_close_rank(str(s.get("result_label") or "")),
int(s.get("id") or 0),
),
)
keep_id = int(best.get("id") or 0)
for s in snaps:
sid = int(s.get("id") or 0)
if sid and sid != keep_id:
drop_ids.append(sid)
if drop_ids:
placeholders = ",".join("?" * len(drop_ids))
cur2 = conn.execute(
f"DELETE FROM strategy_trade_snapshots WHERE id IN ({placeholders})",
drop_ids,
)
removed += int(getattr(cur2, "rowcount", 0) or 0)
return removed
def save_trend_plan_snapshot(
cfg: dict,
conn,
plan_row: Any,
*,
result_label: str,
exit_price: float | None = None,
pnl_amount: float | None = None,
closed_at: str | None = None,
) -> None:
init_strategy_snapshot_table(conn)
row = _row_dict(plan_row)
plan_id = int(row.get("id") or 0)
if plan_id <= 0:
return
label = (result_label or "").strip()
close_rank = _final_trend_close_rank(label)
if close_rank > 0:
existing = conn.execute(
f"""SELECT result_label FROM strategy_trade_snapshots
WHERE strategy_type=? AND source_id=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""",
(STRATEGY_TREND, plan_id, *FINAL_TREND_CLOSE_LABELS),
).fetchall()
for ex in existing:
ex_label = str(_row_dict(ex).get("result_label") or "")
if _final_trend_close_rank(ex_label) >= close_rank:
return
_purge_weaker_trend_final_snapshots(conn, plan_id, label)
elif _snapshot_key_exists(conn, STRATEGY_TREND, plan_id, label):
return
m = cfg.get("app_module")
close_ts = (closed_at or "").strip() or (
m.app_now_str()
if m is not None and hasattr(m, "app_now_str")
else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
)
payload = attach_trend_dca_levels(row)
payload["result_label"] = result_label
payload["exit_price"] = exit_price
payload["pnl_amount"] = pnl_amount
payload["status_at_close"] = row.get("status")
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, exchange_symbol, direction,
result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
STRATEGY_TREND,
plan_id,
row.get("symbol"),
row.get("exchange_symbol"),
row.get("direction"),
result_label,
row.get("status"),
row.get("opened_at"),
close_ts,
pnl_amount,
_json_dumps(payload),
close_ts,
),
)
prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS)
def save_roll_group_snapshot(
cfg: dict,
conn,
group: dict,
*,
result_label: str = "结束",
pnl_amount: float | None = None,
) -> None:
init_strategy_snapshot_table(conn)
g = dict(group or {})
gid = int(g.get("id") or 0)
if gid <= 0:
return
label = (result_label or "结束").strip()
if _snapshot_key_exists(conn, STRATEGY_ROLL, gid, label):
return
legs = []
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? ORDER BY leg_index ASC, id ASC",
(gid,),
).fetchall():
ld = _row_dict(leg)
try:
from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label
ld["status_label"] = roll_leg_status_label(ld.get("status"))
except Exception:
ld["status_label"] = ld.get("status") or ""
legs.append(ld)
m = cfg.get("app_module")
closed_at = (
m.app_now_str()
if m is not None and hasattr(m, "app_now_str")
else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
)
payload = {
"group": g,
"legs": legs,
"result_label": result_label,
"pnl_amount": pnl_amount,
}
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, exchange_symbol, direction,
result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
STRATEGY_ROLL,
gid,
g.get("symbol"),
g.get("exchange_symbol"),
g.get("direction"),
result_label,
g.get("status"),
g.get("created_at"),
closed_at,
pnl_amount,
_json_dumps(payload),
closed_at,
),
)
prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS)
def prune_strategy_snapshots(conn, *, keep: int = STRATEGY_SNAPSHOTS_MAX_ROWS) -> None:
"""仅保留最近 keep 条策略快照(按 closed_at / id 倒序)。"""
dedupe_strategy_snapshots(conn)
k = max(1, min(int(keep), 500))
conn.execute(
"""DELETE FROM strategy_trade_snapshots
WHERE id NOT IN (
SELECT id FROM strategy_trade_snapshots
ORDER BY COALESCE(closed_at, created_at, '') DESC, id DESC
LIMIT ?
)""",
(k,),
)
def _snapshot_pnl(row: dict, snap: dict) -> float | None:
for key in ("pnl_amount",):
v = row.get(key)
if v is not None and v != "":
try:
return float(v)
except (TypeError, ValueError):
pass
v = snap.get("pnl_amount")
if v is not None and v != "":
try:
return float(v)
except (TypeError, ValueError):
pass
return None
def _trend_dca_stats(snap: dict) -> dict:
levels = snap.get("dca_levels") or build_trend_dca_levels(snap)
dca_only = [
lv
for lv in levels
if (lv.get("leg_key") or "") != "first" and (lv.get("label") or "") != "首仓"
]
done = sum(1 for lv in dca_only if lv.get("status") == "done")
total = len(dca_only)
pending = total - done
if total <= 0:
tag = "na"
elif done <= 0:
tag = "no_dca"
elif done >= total:
tag = "dca_done"
else:
tag = "dca_partial"
return {
"dca_done": done,
"dca_total": total,
"dca_pending": pending,
"dca_tag": tag,
}
def _roll_leg_stats(snap: dict) -> dict:
legs = snap.get("legs") or []
if not isinstance(legs, list):
legs = []
filled = sum(1 for lg in legs if (lg.get("status") or "").lower() == "filled")
total = len(legs)
pending = total - filled
if total <= 0:
tag = "na"
elif filled <= 0:
tag = "no_dca"
elif filled >= total:
tag = "dca_done"
else:
tag = "dca_partial"
return {
"dca_done": filled,
"dca_total": total,
"dca_pending": pending,
"dca_tag": tag,
}
def enrich_strategy_snapshot_row(row: dict) -> dict:
d = dict(row or {})
snap = d.get("snapshot") or {}
st = (d.get("strategy_type") or "").strip()
pnl = _snapshot_pnl(d, snap)
if pnl is not None:
if pnl > 1e-9:
d["filter_pnl"] = "profit"
elif pnl < -1e-9:
d["filter_pnl"] = "loss"
else:
d["filter_pnl"] = "flat"
else:
d["filter_pnl"] = "unknown"
snap_sym = ""
if isinstance(snap, dict):
snap_sym = (snap.get("symbol") or snap.get("exchange_symbol") or "").strip()
sym = (d.get("symbol") or d.get("exchange_symbol") or snap_sym or "").strip()
if sym:
d["symbol"] = d.get("symbol") or sym
d["exchange_symbol"] = d.get("exchange_symbol") or sym
d["filter_symbol"] = sym.upper().split("/")[0].split(":")[0] if sym else ""
closed = (d.get("closed_at") or d.get("created_at") or "").strip()
d["sort_ts"] = closed
if st == STRATEGY_TREND:
stats = _trend_dca_stats(snap)
d.update(stats)
legs_txt = (
f"{stats['dca_done']}/{stats['dca_total']}"
if stats["dca_total"] > 0
else "0/0"
)
d["summary_dca"] = legs_txt
else:
stats = _roll_leg_stats(snap)
d.update(stats)
d["summary_dca"] = (
f"{stats['dca_done']}/{stats['dca_total']}"
if stats["dca_total"] > 0
else ""
)
return d
def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]:
init_strategy_snapshot_table(conn)
rows = conn.execute(
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
(max(1, min(int(limit), 500)),),
).fetchall()
out = []
seen: dict[tuple[str, int, str], int] = {}
for r in rows:
d = _row_dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except Exception:
d["snapshot"] = {}
st = (d.get("strategy_type") or "").strip()
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
enriched = enrich_strategy_snapshot_row(d)
try:
source_id = int(enriched.get("source_id") or 0)
except (TypeError, ValueError):
source_id = 0
result_label = (enriched.get("result_label") or "").strip()
close_rank = _final_trend_close_rank(result_label)
if st == STRATEGY_TREND and source_id > 0 and close_rank > 0:
plan_key = (st, source_id)
snap_id = int(enriched.get("id") or 0)
prev = seen.get(plan_key)
if prev is not None:
prev_id, prev_rank = prev
if prev_rank > close_rank or (prev_rank == close_rank and prev_id >= snap_id):
continue
out = [x for x in out if int(x.get("id") or 0) != prev_id]
seen[plan_key] = (snap_id, close_rank)
out.append(enriched)
continue
key = (st, source_id, result_label)
snap_id = int(enriched.get("id") or 0)
prev = seen.get(key)
if prev is not None and prev[0] >= snap_id:
continue
if prev is not None:
out = [x for x in out if int(x.get("id") or 0) != prev[0]]
seen[key] = (snap_id, 0)
out.append(enriched)
return out
def list_strategy_snapshots_split(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> tuple[list[dict], list[dict], list[str]]:
"""趋势 / 顺势分组,及筛选用币种列表。"""
all_rows = list_strategy_snapshots(conn, limit=limit)
trend = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_TREND]
roll = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_ROLL]
symbols = sorted({r.get("filter_symbol") or "" for r in all_rows if r.get("filter_symbol")})
return trend, roll, symbols
+159
View File
@@ -0,0 +1,159 @@
"""策略交易写入 trade_records 时的类型与复盘开仓类型标注。"""
from __future__ import annotations
from typing import Optional
MONITOR_TYPE_TREND_PULLBACK = "趋势回调"
MONITOR_TYPE_ROLL = "顺势加仓"
ENTRY_REASON_TREND_PULLBACK = "趋势回调"
ENTRY_REASON_ROLL = "顺势加仓"
STRATEGY_ENTRY_REASON_OPTIONS = (
ENTRY_REASON_TREND_PULLBACK,
ENTRY_REASON_ROLL,
)
# 趋势回调保本移交下单监控:order_monitors.key_signal_type / 平仓备注
TREND_HANDOFF_KEY_SIGNAL = ENTRY_REASON_TREND_PULLBACK
TREND_HANDOFF_TRADE_NOTE = "趋势回调计划"
def handoff_trade_miss_reason(miss_reason, row) -> Optional[str]:
"""趋势保本移交的监控单平仓:交易记录备注带来源。"""
if trend_plan_id_from_monitor_row(row) is None:
return miss_reason
base = (miss_reason or "").strip()
if TREND_HANDOFF_TRADE_NOTE in base:
return base or TREND_HANDOFF_TRADE_NOTE
if base:
return f"{TREND_HANDOFF_TRADE_NOTE}{base}"
return TREND_HANDOFF_TRADE_NOTE
def trend_plan_id_from_monitor_row(row) -> Optional[int]:
if row is None:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "trend_plan_id" not in keys or row["trend_plan_id"] in (None, ""):
return None
try:
tid = int(row["trend_plan_id"])
return tid if tid > 0 else None
except (TypeError, ValueError):
return None
def order_had_roll_fills(conn, order_monitor_id) -> bool:
try:
oid = int(order_monitor_id)
except (TypeError, ValueError):
return False
if oid <= 0:
return False
try:
row = conn.execute(
"""SELECT 1 FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id
WHERE g.order_monitor_id=? AND l.status='filled'
LIMIT 1""",
(oid,),
).fetchone()
return row is not None
except Exception:
return False
def _row_monitor_type(row, default_manual: str) -> str:
if row is None:
return default_manual
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "monitor_type" in keys:
mt = (row["monitor_type"] or "").strip()
if mt:
return mt
return default_manual
def _row_key_signal_type(row) -> str:
if row is None:
return ""
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
if "key_signal_type" not in keys:
return ""
return (row["key_signal_type"] or "").strip()
def order_monitor_source_type(row, *, default_manual: str = "下单监控") -> str:
"""展示/平仓记录:趋势保本移交单来源为「趋势回调」,非「下单监控」。"""
if trend_plan_id_from_monitor_row(row) is not None:
return MONITOR_TYPE_TREND_PULLBACK
mt = _row_monitor_type(row, default_manual)
if mt != default_manual:
return mt
kst = _row_key_signal_type(row)
if kst in (
MONITOR_TYPE_TREND_PULLBACK,
TREND_HANDOFF_KEY_SIGNAL,
TREND_HANDOFF_TRADE_NOTE,
ENTRY_REASON_TREND_PULLBACK,
):
return MONITOR_TYPE_TREND_PULLBACK
return mt
def apply_order_monitor_source_labels(item: dict, *, default_manual: str = "下单监控") -> dict:
"""实例页 / 中控 API:统一修正 order_monitors 展示用 monitor_type。"""
out = dict(item or {})
out["monitor_type"] = order_monitor_source_type(out, default_manual=default_manual)
return out
def trade_record_monitor_type(conn, order_row, *, default_manual: str = "下单监控") -> str:
"""平仓写入 trade_records 时:曾顺势加仓则标「顺势加仓」,否则沿用监控单来源类型。"""
oid = None
try:
keys = order_row.keys() if hasattr(order_row, "keys") else []
if "id" in keys and order_row["id"] is not None:
oid = int(order_row["id"])
except Exception:
oid = None
if oid and order_had_roll_fills(conn, oid):
return MONITOR_TYPE_ROLL
return order_monitor_source_type(order_row, default_manual=default_manual)
def entry_reason_for_monitor_type(monitor_type: str | None) -> str:
mt = (monitor_type or "").strip()
if mt == MONITOR_TYPE_TREND_PULLBACK:
return ENTRY_REASON_TREND_PULLBACK
if mt == MONITOR_TYPE_ROLL:
return ENTRY_REASON_ROLL
return ""
def order_monitor_excluded_from_position_limit(conn, row) -> bool:
"""趋势回调不计入 MAX_ACTIVE_POSITIONS;顺势加仓在已有持仓上操作,单独放行。"""
return order_monitor_source_type(row) == MONITOR_TYPE_TREND_PULLBACK
def count_position_limit_active_monitors(conn) -> int:
"""计入仓位上限冻结的活跃监控数(不含趋势回调、顺势加仓)。"""
try:
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
except Exception:
return 0
n = 0
for row in rows:
if not order_monitor_excluded_from_position_limit(conn, row):
n += 1
return n
+97
View File
@@ -0,0 +1,97 @@
"""趋势回调:各交易所止损刷新、市价加/平仓(通过 app 模块能力探测)。"""
from __future__ import annotations
import time
from typing import Any
def _m(cfg: dict) -> Any:
return cfg["app_module"]
def trend_refresh_stop_only(cfg: dict, exchange_symbol: str, direction: str, stop_loss: float) -> None:
m = _m(cfg)
if hasattr(m, "_gate_place_stop_loss_only_position"):
if hasattr(m, "cancel_gate_swap_trigger_orders"):
m.cancel_gate_swap_trigger_orders(exchange_symbol)
m._gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss)
return
if hasattr(m, "_binance_place_stop_loss_only"):
m._binance_place_stop_loss_only(exchange_symbol, direction, stop_loss)
return
if hasattr(m, "_okx_place_stop_loss_only"):
m._okx_place_stop_loss_only(exchange_symbol, direction, stop_loss)
return
raise RuntimeError("当前实例未配置趋势回调止损挂单能力")
def trend_market_add(cfg: dict, exchange_symbol: str, direction: str, contracts: float, leverage: int):
m = _m(cfg)
ex = m.exchange
m.ensure_markets_loaded()
ex.set_leverage(int(leverage), exchange_symbol)
side = "buy" if direction == "long" else "sell"
if hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=False)
elif hasattr(m, "build_binance_order_params"):
params = m.build_binance_order_params(direction, reduce_only=False)
elif hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=False)
else:
params = {}
order_params = params if params is not None else {}
return ex.create_order(exchange_symbol, "market", side, float(contracts), None, order_params)
def trend_market_close(cfg: dict, exchange_symbol: str, direction: str, pos_qty: float, leverage: int):
m = _m(cfg)
ex = m.exchange
m.ensure_markets_loaded()
ex.set_leverage(int(leverage), exchange_symbol)
side = "sell" if direction == "long" else "buy"
amt = float(ex.amount_to_precision(exchange_symbol, float(pos_qty)))
if hasattr(m, "close_exchange_order"):
row = {
"exchange_symbol": exchange_symbol,
"symbol": exchange_symbol,
"direction": direction,
"order_amount": amt,
}
return m.close_exchange_order(row)
if hasattr(m, "build_gate_order_params"):
params = m.build_gate_order_params(direction, reduce_only=True)
return ex.create_order(exchange_symbol, "market", side, amt, None, params)
if hasattr(m, "build_binance_order_params"):
for params in m._binance_market_close_param_candidates(direction):
try:
return ex.create_order(exchange_symbol, "market", side, amt, None, params)
except Exception as e:
if not m._is_binance_close_param_retryable(str(e)):
raise
raise RuntimeError("平仓失败")
if hasattr(m, "build_okx_order_params"):
params = m.build_okx_order_params(direction, reduce_only=True)
return ex.create_order(exchange_symbol, "market", side, amt, None, params)
return ex.create_order(exchange_symbol, "market", side, amt, None, {"reduceOnly": True})
def trend_replace_tpsl(cfg: dict, order_row: dict, stop_loss: float, take_profit: float) -> None:
"""趋势保本移交:先撤条件单再挂保本止损 + 计划止盈(与下单监控一致)。"""
m = _m(cfg)
fn = getattr(m, "replace_active_monitor_tpsl_on_exchange", None)
if not callable(fn):
raise RuntimeError("当前实例未配置止盈止损同步能力")
fn(order_row, float(stop_loss), float(take_profit))
def cancel_symbol_orders(cfg: dict, exchange_symbol: str) -> None:
m = _m(cfg)
if hasattr(m, "cancel_all_open_orders_for_symbol"):
m.cancel_all_open_orders_for_symbol(exchange_symbol)
return
if hasattr(m, "cancel_gate_swap_trigger_orders"):
m.cancel_gate_swap_trigger_orders(exchange_symbol)
if hasattr(m, "cancel_binance_futures_open_orders"):
m.cancel_binance_futures_open_orders(exchange_symbol)
if hasattr(m, "cancel_okx_swap_open_orders"):
m.cancel_okx_swap_open_orders(exchange_symbol)
+695
View File
@@ -0,0 +1,695 @@
"""趋势回调策略:纯计算与校验(无 ccxt / Flask)。各所 adapter 负责张数精度与下单。"""
from __future__ import annotations
import json
from typing import Any, Callable, Optional, Tuple
AmountPreciseFn = Callable[[str, float], Optional[float]]
def calc_risk_fraction(direction: str, entry_price: float, stop_loss: float) -> Optional[float]:
try:
entry = float(entry_price)
sl = float(stop_loss)
if entry <= 0 or sl <= 0:
return None
if (direction or "long").strip().lower() == "short":
risk = sl - entry
else:
risk = entry - sl
if risk <= 0:
return None
return risk / entry
except (TypeError, ValueError):
return None
def trend_effective_margin_capital(plan: dict) -> float:
"""按已开仓张数占计划总张数比例折算保证金(首仓/部分补仓时的盈亏估算)。"""
try:
plan_margin = float(plan.get("plan_margin_capital") or 0)
target = float(plan.get("target_order_amount") or 0)
open_amt = float(plan.get("order_amount_open") or 0)
except (TypeError, ValueError):
return float((plan or {}).get("plan_margin_capital") or 0)
if plan_margin <= 0:
return 0.0
if target > 0 and open_amt > 0:
return round(plan_margin * min(1.0, open_amt / target), 8)
try:
first = float(plan.get("first_order_amount") or 0)
except (TypeError, ValueError):
first = 0.0
if target > 0 and first > 0:
return round(plan_margin * min(1.0, first / target), 8)
return plan_margin
def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool:
"""做空:价升触达/越过档位即应补仓;做多:价跌触达/越过档位。"""
d = (direction or "long").strip().lower()
try:
pf = float(mark_price)
lv = float(level)
except (TypeError, ValueError):
return False
if d == "long":
return pf <= lv
return pf >= lv
def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]:
direction = (direction or "long").strip().lower()
if direction == "long":
if not (float(stop_loss) < float(add_upper)):
return "做多:止损价须低于补仓上沿"
else:
if not (float(stop_loss) > float(add_upper)):
return "做空:止损价须高于补仓下沿"
return None
def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]:
"""在 (止损, 补仓区间远侧边界) 内生成 n_legs 个触发价(不含端点)。"""
sl, upper = float(sl), float(upper)
out: list[float] = []
if n_legs <= 0:
return out
direction = (direction or "long").strip().lower()
if direction == "long":
if upper <= sl:
return out
span = upper - sl
for i in range(1, n_legs + 1):
t = i / float(n_legs + 1)
out.append(sl + t * span)
out.sort(reverse=True)
else:
if sl <= upper:
return out
span = sl - upper
for i in range(1, n_legs + 1):
t = i / float(n_legs + 1)
out.append(upper + t * span)
out.sort()
return [round(p, 10) for p in out]
def pick_dca_legs_and_per_leg(
exchange_symbol: str,
remainder_total: float,
want_legs: int,
amount_precise: AmountPreciseFn,
min_amount: float = 0.0,
) -> Tuple[int, float]:
"""按最小张数约束自动减少档位数。返回 (有效档数, 每档参考张数)。"""
legs = max(1, int(want_legs))
rem = float(remainder_total)
min_amt = float(min_amount or 0.0)
while legs >= 1:
per = rem / legs
per_p = amount_precise(exchange_symbol, per)
if per_p is None or per_p <= 0:
legs -= 1
continue
if min_amt and per_p + 1e-12 < min_amt:
legs -= 1
continue
return legs, per_p
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, 0.0
return 1, one
def build_leg_amounts_json(
exchange_symbol: str,
remainder_total: float,
want_legs: int,
amount_precise: AmountPreciseFn,
min_amount: float = 0.0,
) -> Tuple[int, str, float]:
"""拆分补仓张数 JSON。返回 (档位数, json列表, 每档参考)。"""
rem = amount_precise(exchange_symbol, float(remainder_total))
if rem is None or rem <= 0:
return 0, "[]", 0.0
n, _ = pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs, amount_precise, min_amount)
if n <= 0:
return 0, "[]", 0.0
if n <= 1:
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
unit = amount_precise(exchange_symbol, rem / n)
if unit is None or unit <= 0:
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
parts: list[float] = []
acc = 0.0
for _ in range(n - 1):
parts.append(unit)
acc += unit
last = amount_precise(exchange_symbol, max(0.0, rem - acc))
if last is None or last <= 0:
one = amount_precise(exchange_symbol, rem)
if one is None or one <= 0:
return 0, "[]", 0.0
return 1, json.dumps([one]), one
parts.append(last)
return n, json.dumps(parts), unit
def compute_trend_plan_core(
*,
direction: str,
stop_loss: float,
add_upper: float,
risk_percent: float,
snapshot_usdt: float,
leverage: int,
live_price: float,
target_order_amount: float,
exchange_symbol: str,
dca_legs: int,
amount_precise: AmountPreciseFn,
min_amount: float = 0.0,
full_margin_buffer_ratio: float = 0.95,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""在已有 target_order_amount 时组装预览 payload(张数由调用方 prepare_order_amount 计算)。"""
rf = calc_risk_fraction(direction, add_upper, stop_loss)
if rf is None or rf <= 0:
return None, "止损与补仓区间边界组合无法计算风险比例"
risk_budget = float(snapshot_usdt) * (float(risk_percent) / 100.0)
notional = risk_budget / rf
margin_plan = notional / float(leverage)
margin_plan = min(margin_plan, float(snapshot_usdt) * float(full_margin_buffer_ratio))
if margin_plan <= 0:
return None, "计划保证金过小"
first_amt = amount_precise(exchange_symbol, float(target_order_amount) * 0.5)
if first_amt is None or first_amt <= 0:
return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆"
remainder_total = amount_precise(exchange_symbol, max(0.0, float(target_order_amount) - float(first_amt)))
if remainder_total is None:
remainder_total = 0.0
n_legs, leg_json, per_ref = build_leg_amounts_json(
exchange_symbol, remainder_total, dca_legs, amount_precise, min_amount
)
if n_legs <= 0:
return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距"
grid = build_grid_prices(direction, stop_loss, add_upper, n_legs)
if len(grid) != n_legs:
return None, "补仓网格生成失败"
try:
leg_list = json.loads(leg_json)
except Exception:
leg_list = []
payload = {
"direction": direction,
"stop_loss": float(stop_loss),
"add_upper": float(add_upper),
"risk_percent": float(risk_percent),
"snapshot_available_usdt": float(snapshot_usdt),
"live_price_ref": float(live_price),
"plan_margin_capital": float(margin_plan),
"target_order_amount": float(target_order_amount),
"first_order_amount": float(first_amt),
"remainder_total": float(remainder_total),
"dca_legs": int(n_legs),
"per_leg_amount": float(per_ref),
"grid_prices_json": json.dumps(grid),
"leg_amounts_json": leg_json,
"grid": grid,
"leg_amounts": leg_list,
}
return payload, None
def calc_planned_reward_risk_ratio(
direction: str, entry_price: float, stop_loss: float, take_profit: float
) -> Optional[float]:
"""盈亏比(reward/risk),与四所 calc_rr_ratio 口径一致。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
tp = float(take_profit)
if entry <= 0 or sl <= 0 or tp <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
risk = sl - entry
reward = entry - tp
else:
risk = entry - sl
reward = tp - entry
if risk <= 0 or reward <= 0:
return None
return round(reward / risk, 4)
except (TypeError, ValueError):
return None
def calc_take_profit_for_rr(
direction: str, entry_price: float, stop_loss: float, reward_risk_ratio: float
) -> Optional[float]:
"""按统一止损与目标 RR 反推止盈价。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
rr = float(reward_risk_ratio)
if entry <= 0 or sl <= 0 or rr <= 0:
return None
direction = (direction or "long").strip().lower()
if direction == "short":
risk = sl - entry
if risk <= 0:
return None
return round(entry - rr * risk, 10)
risk = entry - sl
if risk <= 0:
return None
return round(entry + rr * risk, 10)
except (TypeError, ValueError):
return None
def calc_risk_budget_usdt(snapshot_usdt: float, risk_percent: float) -> Optional[float]:
"""计划止损金额 U = 可用快照 × 风险比例。"""
try:
snap = float(snapshot_usdt)
rp = float(risk_percent)
if snap <= 0 or rp <= 0:
return None
return round(snap * rp / 100.0, 4)
except (TypeError, ValueError):
return None
def calc_money_reward_risk_ratio(profit_u: float, risk_u: float) -> Optional[float]:
"""金额盈亏比 = 止盈盈利 U / 止损金额 U。"""
try:
r = float(risk_u)
p = float(profit_u)
if r <= 0:
return None
return round(p / r, 4)
except (TypeError, ValueError):
return None
def calc_tp_profit_usdt(
direction: str,
avg_entry: float,
take_profit_price: float,
contracts: float,
contract_size: float = 1.0,
) -> Optional[float]:
"""到达止盈价时,按累计张数与加仓后均价的盈利 U。"""
try:
from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt
return estimate_linear_swap_upnl_usdt(
direction, float(avg_entry), float(take_profit_price), float(contracts), float(contract_size)
)
except (TypeError, ValueError):
return None
def weighted_avg_entry(legs: list[tuple[float, float]]) -> Optional[float]:
"""按 (成交价, 张数) 加权均价。"""
total = 0.0
cost = 0.0
for price, amount in legs or []:
try:
p = float(price)
a = float(amount)
except (TypeError, ValueError):
continue
if a <= 0:
continue
total += a
cost += p * a
if total <= 0:
return None
return cost / total
def parse_leg_fill_prices(plan: dict) -> list[float]:
"""首仓 + 各档补仓实际成交价列表。"""
try:
raw = json.loads((plan or {}).get("leg_fill_prices_json") or "[]")
if not isinstance(raw, list):
return []
out: list[float] = []
for item in raw:
try:
out.append(float(item))
except (TypeError, ValueError):
continue
return out
except Exception:
return []
def append_leg_fill_price_json(existing_json: str | None, fill_px: float) -> str:
fills = parse_leg_fill_prices({"leg_fill_prices_json": existing_json})
fills.append(float(fill_px))
return json.dumps(fills, ensure_ascii=False, separators=(",", ":"))
def trend_leg_grid_price(plan: dict, leg_idx: int) -> Optional[float]:
"""补仓 leg_idx(1..N) 的计划网格触发价;首仓返回 None。"""
if leg_idx <= 0:
return None
try:
grid = [float(x) for x in json.loads((plan or {}).get("grid_prices_json") or "[]")]
except Exception:
grid = []
gi = leg_idx - 1
if 0 <= gi < len(grid):
return float(grid[gi])
return None
def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]:
"""
四所统一:单档展示价 = leg_fill_prices_json 实际记录,否则计划网格(首仓用均价/参考价)。
禁止为凑均价反推虚构成交价。
"""
p = plan or {}
fills = parse_leg_fill_prices(p)
if len(fills) > leg_idx:
return float(fills[leg_idx])
if leg_idx == 0:
try:
return float(p.get("avg_entry_price"))
except (TypeError, ValueError):
pass
try:
ref = p.get("live_price_ref")
if ref not in (None, ""):
return float(ref)
except (TypeError, ValueError):
pass
return None
return trend_leg_grid_price(p, leg_idx)
def reconcile_trend_leg_fill_prices(plan: dict) -> list[float]:
"""首仓(0)+已补仓(1..legs_done) 展示价列表(四所共用 trend_leg_display_price)。"""
p = plan or {}
if int(p.get("first_order_done") or 0) == 0:
return []
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
result: list[float] = []
for leg_idx in range(legs_done + 1):
px = trend_leg_display_price(p, leg_idx)
result.append(float(px) if px is not None else 0.0)
return result
def calc_trend_plan_money_metrics(plan: dict) -> dict:
"""运行中计划头部:按快照风险金额计算盈亏比(止盈盈利 U / 风险 U)。"""
out = {"money_rr": None, "risk_amount_u": None}
p = plan or {}
try:
direction = (p.get("direction") or "long").strip().lower()
user_tp = float(p.get("take_profit"))
avg = float(p.get("avg_entry_price"))
open_amt = float(p.get("order_amount_open") or p.get("first_order_amount") or 0)
snapshot = float(p.get("snapshot_available_usdt"))
risk_percent = float(p.get("risk_percent"))
except (TypeError, ValueError):
return out
if avg <= 0 or open_amt <= 0:
return out
risk_u = calc_risk_budget_usdt(snapshot, risk_percent)
if risk_u is None or risk_u <= 0:
return out
out["risk_amount_u"] = risk_u
try:
contract_size = float(p.get("contract_size") or 1.0)
if contract_size <= 0:
contract_size = 1.0
except (TypeError, ValueError):
contract_size = 1.0
profit_u = calc_tp_profit_usdt(direction, avg, user_tp, open_amt, contract_size)
out["money_rr"] = calc_money_reward_risk_ratio(profit_u, risk_u)
return out
def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]:
"""
预览:表单止盈价下每档累计持仓的盈利 U;止损金额 = 快照×风险;盈亏比按金额对比。
返回 (增强后的 preview 字段, 表格行列表,含首仓行)。
"""
p = dict(preview or {})
direction = (p.get("direction") or "long").strip().lower()
try:
ref = float(p.get("live_price_ref"))
sl = float(p.get("stop_loss"))
user_tp = float(p.get("take_profit"))
first_amt = float(p.get("first_order_amount"))
snapshot = float(p.get("snapshot_available_usdt"))
risk_percent = float(p.get("risk_percent"))
except (TypeError, ValueError):
return p, []
risk_u = calc_risk_budget_usdt(snapshot, risk_percent)
if risk_u is None or risk_u <= 0:
return p, []
try:
contract_size = float(p.get("contract_size") or 1.0)
if contract_size <= 0:
contract_size = 1.0
except (TypeError, ValueError):
contract_size = 1.0
p["preview_risk_amount_u"] = risk_u
p["preview_take_profit_price"] = user_tp
p["preview_unified_stop_loss"] = sl
try:
grid = json.loads(p.get("grid_prices_json") or "[]")
if not isinstance(grid, list):
grid = []
except Exception:
grid = []
try:
leg_amounts = json.loads(p.get("leg_amounts_json") or "[]")
if not isinstance(leg_amounts, list):
leg_amounts = []
except Exception:
leg_amounts = []
def _row_dict(
*,
i: int,
label: str,
price: float,
leg_contracts: float,
cum_contracts: float,
avg: float,
is_first: bool,
) -> dict:
profit_u = calc_tp_profit_usdt(direction, avg, user_tp, cum_contracts, contract_size)
rr_money = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None
return {
"i": i,
"label": label,
"price": price,
"contracts": leg_contracts,
"cum_contracts": cum_contracts,
"avg_entry": avg,
"take_profit_price": user_tp,
"profit_u": profit_u,
"risk_u": risk_u,
"rr": rr_money,
"stop_loss_price": sl,
"take_profit": profit_u,
"stop_loss": risk_u,
"is_first": is_first,
}
cum_contracts = first_amt
first_profit = calc_tp_profit_usdt(direction, ref, user_tp, cum_contracts, contract_size)
first_rr = calc_money_reward_risk_ratio(first_profit, risk_u) if first_profit is not None else None
p["preview_first_profit_u"] = first_profit
p["preview_target_rr"] = first_rr
p["preview_first_take_profit"] = user_tp
rows: list[dict] = [
_row_dict(
i=0,
label="首仓",
price=ref,
leg_contracts=first_amt,
cum_contracts=cum_contracts,
avg=ref,
is_first=True,
)
]
accumulated: list[tuple[float, float]] = [(ref, first_amt)]
for i, pair in enumerate(zip(grid, leg_amounts), 1):
try:
price = float(pair[0])
leg_contracts = float(pair[1])
except (TypeError, ValueError):
continue
accumulated.append((price, leg_contracts))
avg = weighted_avg_entry(accumulated)
if avg is None:
continue
cum_contracts += leg_contracts
rows.append(
_row_dict(
i=i,
label=f"补仓{i}",
price=price,
leg_contracts=leg_contracts,
cum_contracts=cum_contracts,
avg=avg,
is_first=False,
)
)
return p, rows
def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]:
"""
四所统一补仓表 enrich(实例策略页 + 中控 monitor 共用)。
触发价:实际成交价或计划网格;末档加仓后均价用持仓均价;禁止反推虚构成交价。
"""
if not levels:
return levels
p = plan or {}
direction = (p.get("direction") or "long").strip().lower()
try:
sl = float(p.get("stop_loss"))
user_tp = float(p.get("take_profit"))
first_amt = float(p.get("first_order_amount"))
snapshot = float(p.get("snapshot_available_usdt"))
risk_percent = float(p.get("risk_percent"))
except (TypeError, ValueError):
return levels
risk_u = calc_risk_budget_usdt(snapshot, risk_percent)
if risk_u is None or risk_u <= 0:
return levels
try:
legs_done = int(p.get("legs_done") or 0)
except (TypeError, ValueError):
legs_done = 0
first_done = int(p.get("first_order_done") or 0) != 0
try:
target_avg = float(p.get("avg_entry_price"))
except (TypeError, ValueError):
target_avg = None
ref_raw = p.get("live_price_ref")
if ref_raw in (None, ""):
ref_raw = p.get("avg_entry_price")
try:
ref = float(ref_raw)
except (TypeError, ValueError):
return levels
try:
contract_size = float(p.get("contract_size") or 1.0)
if contract_size <= 0:
contract_size = 1.0
except (TypeError, ValueError):
contract_size = 1.0
out: list[dict] = []
accumulated: list[tuple[float, float]] = []
cum_contracts = 0.0
for lv in levels:
row = dict(lv)
is_first = row.get("leg_key") == "first" or row.get("label") == "首仓" or row.get("i") == 0
row_cum = cum_contracts
if is_first:
try:
amt_f = float(row.get("contracts") if row.get("contracts") is not None else first_amt)
except (TypeError, ValueError):
amt_f = first_amt
if first_done:
fill_px = trend_leg_display_price(p, 0)
if fill_px is None:
try:
fill_px = float(p.get("avg_entry_price") or ref)
except (TypeError, ValueError):
fill_px = ref
accumulated = [(float(fill_px), amt_f)]
cum_contracts = amt_f
row_cum = cum_contracts
row["price"] = fill_px
if target_avg is not None and legs_done == 0:
row["avg_entry"] = target_avg
else:
row["avg_entry"] = float(fill_px)
else:
accumulated = [(ref, amt_f)]
cum_contracts = amt_f
row_cum = cum_contracts
row["avg_entry"] = ref
else:
try:
leg_num = int(row.get("i") or 0)
except (TypeError, ValueError):
leg_num = 0
grid_trigger = row.get("price")
try:
grid_trigger_f = float(grid_trigger) if grid_trigger is not None else None
except (TypeError, ValueError):
grid_trigger_f = None
try:
leg_contracts = float(row.get("contracts") or 0)
except (TypeError, ValueError):
leg_contracts = 0.0
done = row.get("status") == "done" or (leg_num > 0 and leg_num <= legs_done)
if done and leg_contracts > 0:
fill_px = trend_leg_display_price(p, leg_num)
if fill_px is None:
fill_px = grid_trigger_f if grid_trigger_f is not None else ref
row["price"] = fill_px
accumulated.append((fill_px, leg_contracts))
cum_contracts += leg_contracts
row_cum = cum_contracts
if leg_num == legs_done and target_avg is not None:
row["avg_entry"] = target_avg
else:
avg = weighted_avg_entry(accumulated)
if avg is not None:
row["avg_entry"] = avg
elif grid_trigger_f is not None and leg_contracts > 0:
row["price"] = grid_trigger_f
projected = accumulated + [(grid_trigger_f, leg_contracts)]
avg = weighted_avg_entry(projected)
if avg is not None:
row["avg_entry"] = avg
row_cum = cum_contracts + leg_contracts
elif grid_trigger_f is not None:
row["price"] = grid_trigger_f
avg_entry = row.get("avg_entry")
if avg_entry is not None and row_cum > 0:
profit_u = calc_tp_profit_usdt(
direction, float(avg_entry), user_tp, row_cum, contract_size
)
row["take_profit_price"] = user_tp
row["profit_u"] = profit_u
row["risk_u"] = risk_u
row["rr"] = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None
row["take_profit"] = profit_u
row["stop_loss"] = risk_u
row["stop_loss_price"] = sl
out.append(row)
return out
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
"""策略交易页:主站 index.html 所需数据(顺势加仓等)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.strategy.strategy_db import init_strategy_tables
from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def count_active_trend_plans(conn, count_fn: Optional[Callable] = None) -> int:
if callable(count_fn):
return int(count_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 fetch_roll_page_data(
conn,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
roll_cfg: dict | None = None,
) -> dict[str, Any]:
init_strategy_tables(conn)
monitors = []
for row in conn.execute(
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall():
monitors.append(_row_to_dict(row))
roll_groups = []
for row in conn.execute(
"""SELECT g.* FROM roll_groups g
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
WHERE g.status='active'
ORDER BY g.id DESC"""
).fetchall():
roll_groups.append(_row_to_dict(row))
active_gids = {int(g["id"]) for g in roll_groups if g.get("id") is not None}
roll_legs = []
for row in conn.execute(
"SELECT * FROM roll_legs ORDER BY id DESC LIMIT 80"
).fetchall():
leg = _row_to_dict(row)
gid = leg.get("roll_group_id")
if gid is not None and int(gid) not in active_gids:
continue
leg["status_label"] = roll_leg_status_label(leg.get("status"))
roll_legs.append(leg)
roll_legs = roll_legs[:50]
out = {
"roll_monitors": monitors,
"roll_groups": roll_groups,
"roll_legs": roll_legs,
"roll_trend_active": count_active_trend_plans(conn, count_active_trends),
"default_risk_percent": default_risk_percent,
}
if roll_cfg:
from lib.strategy.strategy_roll_ui_lib import enrich_roll_page_data
enrich_roll_page_data(conn, out, roll_cfg)
return out
DEFAULT_TREND_DISABLED_NOTE = (
"趋势回调(预览、自动补仓、程序止盈)仅在 Gate 趋势机器人实例 "
"crypto_monitor_gate_bot,常见端口 5002)中启用。"
"币安 / Gate 主站 / OKX 可使用本页「顺势加仓」;完整趋势回调请打开该实例。"
)
def strategy_render_extras(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 策略相关页变量(含策略交易记录)。"""
if page == "strategy_records":
from lib.strategy.strategy_records_register import load_strategy_records_page
return load_strategy_records_page(conn)
return strategy_page_template_vars(
conn,
page,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
trend_disabled_note=trend_disabled_note,
request_obj=request_obj,
trend_cfg=trend_cfg,
)
def strategy_page_template_vars(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 在 conn.close() 前合并进 render_template 的变量。"""
if page not in ("strategy", "strategy_trend", "strategy_roll"):
return {}
roll_cfg = None
try:
from flask import current_app
roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg")
except Exception:
roll_cfg = None
out = fetch_roll_page_data(
conn,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None,
)
if trend_cfg and request_obj is not None:
from lib.strategy.strategy_trend_register import load_trend_page_context
out.update(load_trend_page_context(conn, request_obj, trend_cfg))
elif page == "strategy_trend":
out["trend_disabled_note"] = trend_disabled_note or DEFAULT_TREND_DISABLED_NOTE
return out
+192
View File
@@ -0,0 +1,192 @@
"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(四所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from lib.common.wechat_notify_lib import wechat_direction_label
def _send(cfg: dict[str, Any], content: str) -> None:
fn = cfg.get("send_wechat")
if callable(fn):
try:
fn(content)
return
except Exception:
pass
m = cfg.get("app_module")
if m is not None:
sw = getattr(m, "send_wechat_msg", None)
if callable(sw):
try:
sw(content)
except Exception:
pass
def _account(cfg: dict[str, Any]) -> str:
fn = cfg.get("wechat_account_label")
if callable(fn):
try:
return str(fn()).strip() or _exchange(cfg)
except Exception:
pass
return _exchange(cfg)
def _exchange(cfg: dict[str, Any]) -> str:
return str(cfg.get("exchange_display") or "").strip() or "交易账户"
def _dir_text(cfg: dict[str, Any], direction: str) -> str:
fn = cfg.get("wechat_direction_text")
if callable(fn):
try:
return str(fn(direction))
except Exception:
pass
return wechat_direction_label(direction)
def _fmt_price(cfg: dict[str, Any], symbol: str, price: Any) -> str:
if price is None or price == "":
return ""
fn = cfg.get("format_price") or cfg.get("price_fmt")
if callable(fn):
try:
return str(fn(symbol, price))
except Exception:
pass
m = cfg.get("app_module")
pf = getattr(m, "format_price_for_symbol", None) if m else None
if callable(pf):
try:
return str(pf(symbol, price))
except Exception:
pass
try:
return str(round(float(price), 8))
except (TypeError, ValueError):
return str(price)
def _fmt_pnl(pnl: Any) -> str:
if pnl is None:
return ""
try:
v = float(pnl)
return f"{'+' if v > 0 else ''}{round(v, 2)} U"
except (TypeError, ValueError):
return str(pnl)
def notify_trend_plan_started(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
leverage: int,
stop_loss: float,
take_profit: float,
add_upper: float,
risk_percent: float,
dca_legs: int,
first_order_amount: float,
avg_entry: Optional[float] = None,
snapshot_usdt: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 趋势回调计划已开始",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}|杠杆 **{int(leverage or 1)}x**",
f"- 止损:{_fmt_price(cfg, sym, stop_loss)}|止盈:{_fmt_price(cfg, sym, take_profit)}",
f"- 补仓区:{_fmt_price(cfg, sym, add_upper)}|补仓档 **{int(dca_legs or 0)}** 档",
f"- 风险:**{risk_percent}%**|首仓张数:**{first_order_amount}**",
]
if avg_entry is not None:
lines.append(f"- 首仓成交价:{_fmt_price(cfg, sym, avg_entry)}")
if snapshot_usdt is not None:
try:
lines.append(f"- 启动时合约可用:**{round(float(snapshot_usdt), 2)} U**")
except (TypeError, ValueError):
pass
lines.append("- 说明:交易所已挂止损;止盈由程序监控;结束/保本将另行推送")
_send(cfg, "\n".join(lines))
def notify_trend_plan_ended(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
end_type: str,
result_label: Optional[str] = None,
exit_price: Optional[float] = None,
pnl_amount: Optional[float] = None,
extra: Optional[str] = None,
) -> None:
sym = symbol or ""
res = (result_label or end_type or "").strip()
lines = [
f"# 🏁 {sym} 趋势回调计划已结束",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束方式:**{end_type}**",
f"- 结果:**{res}**",
]
if exit_price is not None:
lines.append(f"- 离场参考价:{_fmt_price(cfg, sym, exit_price)}")
if pnl_amount is not None:
lines.append(f"- 本单盈亏:**{_fmt_pnl(pnl_amount)}**")
if extra:
lines.append(f"- {extra}")
_send(cfg, "\n".join(lines))
def notify_roll_group_started(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
order_monitor_id: int,
initial_take_profit: Optional[float] = None,
initial_stop_loss: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 滚仓计划已开始",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**|绑定下单监控 **#{order_monitor_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 首仓止盈(锁定):{_fmt_price(cfg, sym, initial_take_profit)}",
f"- 当前止损:{_fmt_price(cfg, sym, initial_stop_loss)}",
"- 说明:顺势加仓为人工触发;组结束(无持仓/监控结案)将另行推送",
]
_send(cfg, "\n".join(lines))
def notify_roll_group_ended(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
reason: str,
leg_count: int = 0,
) -> None:
sym = symbol or ""
lines = [
f"# 🏁 {sym} 滚仓计划已结束",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束原因:**{reason}**",
f"- 已完成滚仓腿数:**{int(leg_count or 0)}**",
]
_send(cfg, "\n".join(lines))
@@ -0,0 +1,23 @@
<details class="tip-collapse gate-top-tips-collapse">
<summary class="tip-collapse-summary">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8
<span class="tip-collapse-hint">· 划转规则</span>
</summary>
<div class="tip-collapse-body rule-tip gate-transfer-tip">
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt }}U:不足从 {{ auto_transfer_from }} 划入、超出划回 {{ auto_transfer_from }}<strong>持仓中不划转</strong>并微信通知)
</div>
</details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
<select name="from_account">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
</select>
<button type="submit">手动划转</button>
</form>
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 关键位放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
</svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</button>
</div>
<div class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
<div class="row" style="margin-top:10px">
<label>币种</label>
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
<label>关键位</label>
<select id="key-id">
<option value="">无(仅看K线)</option>
{% for k in key_list %}
<option value="{{ k.id }}" {% if selected_key and k.id == selected_key.id %}selected{% endif %}>#{{ k.id }} {{ k.symbol }} {{ k.monitor_type }} {{ '做多' if k.direction == 'long' else '做空' }}</option>
{% endfor %}
</select>
<label>周期</label>
<select id="timeframe">
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label>K线数</label>
<select id="kline-limit">
<option value="100" {% if default_kline_limit == 100 %}selected{% endif %}>100</option>
<option value="200" {% if default_kline_limit == 200 %}selected{% endif %}>200</option>
</select>
<button id="manual-refresh" type="button">刷新</button>
<span id="load-status" class="status"></span>
</div>
</div>
<div class="card">
<div class="meta">
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
<div class="meta-item"><div class="k">监控类型</div><div class="v" id="m-type">-</div></div>
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
<div class="meta-item"><div class="k">上沿/阻力</div><div class="v" id="m-upper">-</div></div>
<div class="meta-item"><div class="k">下沿/支撑</div><div class="v" id="m-lower">-</div></div>
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
<div class="meta-item"><div class="k">距上沿</div><div class="v" id="m-updiff">-</div></div>
<div class="meta-item"><div class="k">距下沿</div><div class="v" id="m-lowdiff">-</div></div>
</div>
</div>
<div class="card"><div id="chart-wrap"><div id="chart"></div></div></div>
</div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/static/focus_chart_page.js?v=2"></script>
<script>
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
const keySelect = document.getElementById("key-id");
const symbolInput = document.getElementById("symbol-input");
const tfSelect = document.getElementById("timeframe");
const limitSelect = document.getElementById("kline-limit");
const statusEl = document.getElementById("load-status");
const updatedAtEl = document.getElementById("updated-at");
const chartHost = document.getElementById("chart");
const FCP = window.FocusChartPage;
const keyMap = {};
{% for k in key_list %}
keyMap["{{ k.id }}"] = "{{ k.symbol }}";
{% endfor %}
let fc = null;
function ensureChart(){
if(fc && fc.ensureSeries()) return true;
if(!window.LightweightCharts){
statusEl.className = "status err";
statusEl.innerText = "图表库加载失败";
return false;
}
fc = FCP.createFocusChart(chartHost);
if(!fc || !fc.ensureSeries()){
statusEl.className = "status err";
statusEl.innerText = "K线序列初始化失败";
return false;
}
return true;
}
function syncSymbolByKey(){
const keyId = keySelect.value;
if(keyId && keyMap[keyId]) symbolInput.value = keyMap[keyId];
}
async function loadKeyKline(){
if(!ensureChart()) return;
const keyId = keySelect.value;
const symbol = (symbolInput.value || "").trim().toUpperCase();
const timeframe = tfSelect.value;
const limit = limitSelect.value;
if(!symbol && !keyId){
statusEl.className = "status err";
statusEl.innerText = "请先输入币种或选择关键位";
return;
}
statusEl.className = "status";
statusEl.innerText = "加载中...";
try{
const qs = new URLSearchParams();
if(keyId) qs.set("key_id", keyId);
if(symbol) qs.set("symbol", symbol);
qs.set("timeframe", timeframe);
qs.set("limit", limit);
const resp = await fetch(`/api/key_kline?${qs.toString()}`);
const data = await resp.json();
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
else FCP.setActivePriceTick(data.price_tick);
const candles = Array.isArray(data.candles) ? data.candles : [];
if(!candles.length){
statusEl.className = "status err";
statusEl.innerText = "暂无K线数据";
return;
}
fc.candleSeries.setData(candles);
fc.resetPriceLines();
fc.addLine(data.current_price, FCP.lineTitle("现价", data.current_price_display), "#42a5f5");
if(data.key_monitor){
const km = data.key_monitor;
fc.addLine(km.upper, FCP.lineTitle("上沿", km.upper_display), "#ffb84d");
fc.addLine(km.lower, FCP.lineTitle("下沿", km.lower_display), "#4cd97f");
}
fc.chart.timeScale().fitContent();
FCP.paintKeyMeta(data);
updatedAtEl.innerText = data.updated_at || "--";
statusEl.className = "status";
statusEl.innerText = `已加载 ${candles.length} 根K线`;
}catch(err){
statusEl.className = "status err";
statusEl.innerText = err && err.message ? err.message : "加载失败";
}
}
document.getElementById("manual-refresh").addEventListener("click", loadKeyKline);
keySelect.addEventListener("change", ()=>{ syncSymbolByKey(); loadKeyKline(); });
symbolInput.addEventListener("change", ()=>{
if(symbolInput.value.trim()) keySelect.value = "";
loadKeyKline();
});
tfSelect.addEventListener("change", loadKeyKline);
limitSelect.addEventListener("change", loadKeyKline);
syncSymbolByKey();
loadKeyKline();
setInterval(loadKeyKline, refreshMs);
</script>
</body>
</html>
@@ -0,0 +1,327 @@
<style>
.key-monitor-dual-grid{align-items:stretch}
.key-monitor-dual-grid>.card{
height:100%;
min-height:0;
display:flex;
flex-direction:column;
}
.key-panel-scroll.panel-scroll.pos-list{
display:block;
flex:1 1 auto;
min-height:0;
overflow-x:hidden;
overflow-y:auto;
padding-bottom:6px;
-webkit-overflow-scrolling:touch;
scrollbar-gutter:stable;
}
.key-monitor-panel-scroll{min-height:200px}
.key-history-panel-scroll{
flex:0 0 auto;
max-height:calc(8 * 42px + 7 * 8px);
min-height:calc(3 * 42px + 2 * 8px);
}
.key-panel-scroll.panel-scroll.pos-list .key-row-collapse{flex-shrink:0}
.key-panel-scroll.panel-scroll.pos-list::-webkit-scrollbar{width:8px}
.key-panel-scroll.panel-scroll.pos-list::-webkit-scrollbar-thumb{background:#3a4660;border-radius:4px}
.key-panel-scroll.panel-scroll.pos-list::-webkit-scrollbar-track{background:transparent}
.key-row-collapse{border:1px solid #2a3348;border-radius:10px;background:#141923}
.key-row-collapse:not([open]){overflow:hidden}
.key-row-collapse[open]{overflow:visible}
.key-row-collapse+.key-row-collapse{margin-top:8px}
.key-row-collapse-summary{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;cursor:pointer;list-style:none;font-size:.8rem;color:#c5cde0;line-height:1.45}
.key-row-collapse-summary::-webkit-details-marker{display:none}
.key-row-collapse-summary::before{content:"▸";flex:0 0 auto;color:#6d7a99;transition:transform .15s ease}
.key-row-collapse[open]>.key-row-collapse-summary::before{transform:rotate(90deg)}
.key-row-summary-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px}
.key-row-summary-title{display:flex;align-items:center;gap:6px;flex:0 1 auto;flex-wrap:wrap;min-width:0}
.key-row-summary-title strong{font-size:.88rem;color:#fff}
.key-row-summary-line{color:#9aa8c4;font-size:.76rem;word-break:break-word}
.key-row-summary-live{
flex:1 1 auto;
min-width:0;
color:#8fc8ff;
font-size:.72rem;
text-align:right;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.key-row-summary-live.key-row-summary-pending{color:#4cd97f;font-weight:600}
.key-history-panel-scroll .key-row-summary-main{justify-content:flex-start}
.key-row-summary-actions{flex:0 0 auto;display:flex;gap:6px;align-items:center}
.key-row-collapse-body{padding:0 12px 16px;border-top:1px solid #232b3d}
.key-row-collapse-body .pos-meta{margin-top:10px;margin-bottom:10px}
.key-row-collapse-body .pos-grid{margin-bottom:8px}
.key-history-alert{font-size:.75rem;color:#aab;margin-top:8px;margin-bottom:2px;padding-bottom:4px;white-space:pre-wrap;word-break:break-word;line-height:1.5}
.key-history-outcome-badge{font-size:.7rem;font-weight:600;padding:1px 7px;border-radius:4px;line-height:1.35}
.key-row-collapse.key-history-success{border-color:rgba(76,217,127,.42);background:rgba(18,32,26,.92)}
.key-row-collapse.key-history-success .key-row-collapse-summary{color:#c8f0d6}
.key-row-collapse.key-history-success .key-row-summary-title strong{color:#e8fff0}
.key-row-collapse.key-history-success .key-history-brief,.key-row-collapse.key-history-success .key-history-outcome-badge{color:#4cd97f;background:rgba(76,217,127,.12);border:1px solid rgba(76,217,127,.28)}
.key-row-collapse.key-history-manual{border-color:rgba(136,146,176,.45);background:rgba(22,24,32,.95)}
.key-row-collapse.key-history-manual .key-history-brief,.key-row-collapse.key-history-manual .key-history-outcome-badge{color:#9aa8c4;background:rgba(136,146,176,.12);border:1px solid rgba(136,146,176,.28)}
.key-row-collapse.key-history-failed{border-color:rgba(232,160,144,.4);background:rgba(36,22,24,.95)}
.key-row-collapse.key-history-failed .key-row-collapse-summary{color:#e8cfc8}
.key-row-collapse.key-history-failed .key-history-brief,.key-row-collapse.key-history-failed .key-history-outcome-badge{color:#e8a090;background:rgba(232,160,144,.1);border:1px solid rgba(232,160,144,.28)}
.key-rule-table-wrap{overflow-x:auto;margin:0 -2px}
.key-rule-table{width:100%;min-width:620px;border-collapse:collapse;font-size:.6rem;line-height:1.3}
.key-rule-table th,.key-rule-table td{border:1px solid #2a3348;padding:4px 6px;vertical-align:top;text-align:left}
.key-rule-table th{background:rgba(0,0,0,.28);color:#9ab;font-weight:600;white-space:nowrap;font-size:.58rem}
.key-rule-table td{color:#c5cde0}
.key-rule-table .key-rule-type{color:#fff;font-weight:600;line-height:1.25;white-space:nowrap}
.key-rule-table .key-rule-sub{color:#8fc8ff;font-size:.54rem;font-weight:500}
.key-rule-cell{word-break:break-word}
.key-rule-foot{margin:6px 0 0;font-size:.56rem;color:#8892b0;line-height:1.35}
.key-rule-foot code{font-size:.54rem;color:#8fc8ff}
</style>
{% macro key_monitor_type_label(k) -%}
{%- if k.monitor_type in ['关键阻力位','关键支撑位','关键支撑阻力'] -%}关键支撑阻力{%- else -%}{{ k.monitor_type }}{%- endif -%}
{%- endmacro %}
{% macro key_direction_label(k) -%}
{% if k.direction == 'watch' %}双向{% elif k.direction == 'long' %}做多{% else %}做空{% endif %}
{%- endmacro %}
{% macro key_sl_tp_mode_label(k) -%}
{% if (k.sl_tp_mode or 'standard') == 'standard' %}标准突破{% elif k.sl_tp_mode == 'box_1p5' %}箱体1R·止盈1.5H{% else %}趋势单{% endif %}
{%- endmacro %}
{% macro key_monitor_brief(k) -%}
上{{ k.upper }} / 下{{ k.lower }} · 提醒 {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}
{%- if k.monitor_type in ['箱体突破','收敛突破'] %} · {{ key_sl_tp_mode_label(k) }}{% endif %}
{%- if k.breakeven_enabled %} · 保本开{% else %} · 保本关{% endif %}
{%- endmacro %}
{% macro key_history_outcome_kind(h) -%}
{%- set r = (h.close_reason or '')|trim -%}
{%- if r in ['fib_filled', 'false_breakout_filled', 'trigger_entry_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success
{%- elif r == 'manual' -%}manual
{%- elif r -%}failed
{%- else -%}neutral
{%- endif -%}
{%- endmacro %}
{% macro key_history_outcome_label(h) -%}
{%- set r = (h.close_reason or '')|trim -%}
{%- if r == 'fib_filled' -%}斐波成交
{%- elif r == 'false_breakout_filled' -%}假突破成交
{%- elif r == 'trigger_entry_filled' -%}触价成交
{%- elif r == 'key_level_alert_done' -%}提醒完成
{%- elif r == 'alerts_complete' -%}提醒已满
{%- elif r == 'auto_opened' -%}自动开仓
{%- elif r == 'manual' -%}手动删除
{%- elif r == 'fib_invalidate' -%}斐波失效
{%- elif r == 'box_opposite_break' -%}反向突破失效
{%- elif r == 'trigger_tp_invalidate' -%}触价止盈失效
{%- elif r == 'trigger_sl_invalidate' -%}触价止损失效
{%- elif r == 'trigger_entry_expired' -%}触价过期
{%- elif r == 'trigger_exchange_failed' -%}触价下单失败
{%- elif r == 'false_breakout_expired' -%}假突破过期
{%- elif r == 'fib_plan_invalid' -%}计划无效
{%- elif r == 'rr_insufficient' -%}盈亏比不足
{%- elif r == 'exchange_failed' -%}下单失败
{%- else -%}{{ r or '—' }}
{%- endif -%}
{%- endmacro %}
{% macro key_history_brief(h) -%}
{{ key_history_outcome_label(h) }} · {{ (h.closed_at or '-')[:16] }} · 上{{ h.upper }} / 下{{ h.lower }} · 提醒 {{ h.notification_count or 0 }}
{%- endmacro %}
<div class="dual-panel-grid key-monitor-dual-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">关键位监控</h2>
{% if focus_key_id %}
<a href="/key_focus?key_id={{ focus_key_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(默认200根)</a>
{% else %}
<a href="/key_focus" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">输入币种查看K线</a>
{% endif %}
</div>
<form id="key-form" action="/add_key" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select name="type" id="key-type-select" required>
{% if position_sizing_mode != 'full_margin' %}
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option>
<option value="假突破">假突破(BTC/ETH</option>
{% endif %}
<option value="回调触价开仓">回调触价开仓</option>
<option value="突破触价开仓">突破触价开仓</option>
<option value="关键支撑阻力">关键支撑阻力</option>
</select>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="trigger_entry" id="key-trigger-entry" step="0.0001" placeholder="计划入场价" style="display:none">
<input name="trigger_sl" id="key-trigger-sl" step="0.0001" placeholder="止损价" style="display:none">
<input name="trigger_tp" id="key-trigger-tp" step="0.0001" placeholder="止盈价" style="display:none">
<input name="upper" id="key-upper" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" id="key-lower" step="0.0001" placeholder="下沿/支撑" required>
<select name="sl_tp_mode" id="key-sl-tp-mode" title="止盈止损方案">
<option value="standard">标准突破</option>
<option value="box_1p5">箱体1R·止盈1.5H</option>
<option value="trend_manual">趋势单·自填止盈</option>
</select>
<input name="manual_take_profit" id="key-manual-tp" step="0.0001" placeholder="趋势单止盈价" style="display:none">
<label id="key-breakeven-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.85rem;color:#9aa">
<input type="checkbox" name="breakeven_enabled" value="1" id="key-breakeven-cb"> 移动保本
</label>
<span id="key-time-close-wrap" class="key-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.85rem;color:#9aa">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="key-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="key-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
<button type="submit">添加</button>
</form>
<details class="tip-collapse key-rule-collapse">
<summary class="tip-collapse-summary">关键位监控规则说明</summary>
<div class="tip-collapse-body rule-tip">
{% include 'key_monitor_rule_tips.html' %}
</div>
</details>
<div class="panel-scroll pos-list key-panel-scroll key-monitor-panel-scroll">
{% for k in key %}
<details class="key-row-collapse" id="key-row-{{ k.id }}">
<summary class="key-row-collapse-summary">
<span class="key-row-summary-main">
<span class="key-row-summary-title">
<strong>{{ k.symbol }}</strong>
{% if k.time_close_enabled and k.time_close_hours %}
<span class="pos-symbol-time-close pos-meta-on">时间平仓 {{ k.time_close_hours }}h</span>
{% endif %}
{% if k.direction == 'watch' %}
<span class="pos-side-badge" style="background:#2a3152;color:#9ab">双向</span>
{% else %}
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ key_direction_label(k) }}</span>
{% endif %}
<span class="badge direction">{{ key_monitor_type_label(k) }}</span>
</span>
<span class="key-row-summary-live" id="key-summary-live-{{ k.id }}">现价 — · 门控 —</span>
</span>
<span class="key-row-summary-actions">
<button type="button" class="table-del" onclick="event.preventDefault(); event.stopPropagation(); deleteKeyMonitor({{ k.id }})"></button>
</span>
</summary>
<div class="key-row-collapse-body">
<div class="key-row-summary-line">{{ key_monitor_brief(k) }}</div>
<div class="pos-meta">
<span class="pos-meta-item">上沿: {{ k.upper }}</span>
<span class="pos-meta-item">下沿: {{ k.lower }}</span>
{% if k.fib_entry_price and k.monitor_type in ['回调触价开仓','突破触价开仓','触价开仓'] %}<span class="pos-meta-item">E: {{ k.fib_entry_price }} / SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% elif k.fib_entry_price %}<span class="pos-meta-item">挂E: {{ k.fib_entry_price }}</span>{% endif %}
{% if k.monitor_type == '假突破' and k.fib_stop_loss %}<span class="pos-meta-item">SL: {{ k.fib_stop_loss }} / TP: {{ k.fib_take_profit }}</span>{% endif %}
<span class="pos-meta-item">已提醒: {{ k.notification_count or 0 }}/{{ k.max_notify or 3 }}</span>
{% if k.monitor_type in ['箱体突破','收敛突破'] %}
<span class="pos-meta-item">方案: {{ key_sl_tp_mode_label(k) }}</span>
{% endif %}
<span class="pos-meta-item">保本: {{ '开' if k.breakeven_enabled else '关' }}</span>
</div>
<div class="pos-grid">
<div class="pos-cell"><span class="pos-label">现价</span><span class="pos-value" id="key-price-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距上沿</span><span class="pos-value" id="key-up-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">距下沿</span><span class="pos-value" id="key-low-diff-{{ k.id }}">-</span></div>
<div class="pos-cell"><span class="pos-label">门控</span><span class="pos-value" id="key-gate-{{ k.id }}" style="color:#9aa">-</span></div>
</div>
<div class="pos-meta"><span class="pos-meta-item" id="key-gate-metrics-{{ k.id }}" style="color:#8fc8ff"></span></div>
</div>
</details>
{% else %}
<div class="pos-empty">暂无监控中的关键位</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 style="margin-bottom:8px">关键位历史</h2>
<div class="sub" style="font-size:.72rem;color:#8892b0;margin-bottom:8px">失效或已结案的关键位 · 点击展开详情</div>
<div class="panel-scroll pos-list key-panel-scroll key-history-panel-scroll">
{% for h in key_history %}
<details class="key-row-collapse key-history-{{ key_history_outcome_kind(h) }}">
<summary class="key-row-collapse-summary">
<span class="key-row-summary-main">
<span class="key-row-summary-title">
<strong>{{ h.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if h.direction == 'long' else 'pos-side-short' }}">{{ key_direction_label(h) }}</span>
<span class="badge direction">{{ key_monitor_type_label(h) }}</span>
<span class="key-history-outcome-badge">{{ key_history_outcome_label(h) }}</span>
</span>
</span>
<span class="key-row-summary-actions">
<button type="button" class="table-del" onclick="event.preventDefault(); event.stopPropagation(); deleteKeyHistory({{ h.id }})">删除</button>
</span>
</summary>
<div class="key-row-collapse-body">
<div class="key-row-summary-line key-history-brief">{{ key_history_brief(h) }}</div>
<div class="pos-meta">
<span class="pos-meta-item">类型: {{ key_monitor_type_label(h) }}</span>
<span class="pos-meta-item">结案: {{ key_history_outcome_label(h) }}{% if h.close_reason %} ({{ h.close_reason }}){% endif %}</span>
<span class="pos-meta-item">时间: {{ h.closed_at or '—' }}</span>
</div>
<div class="pos-meta">
<span class="pos-meta-item">上沿: {{ h.upper }}</span>
<span class="pos-meta-item">下沿: {{ h.lower }}</span>
<span class="pos-meta-item">提醒次数: {{ h.notification_count or 0 }}</span>
</div>
{% if h.last_alert_message %}
<div class="key-history-alert">{{ h.last_alert_message }}</div>
{% endif %}
</div>
</details>
{% else %}
<div class="pos-empty">暂无历史</div>
{% endfor %}
</div>
</div>
</div>
<script>
function keySummaryIsPending(snap){
if(!snap) return false;
const gs = String(snap.gate_summary || "");
if(gs.includes("标记价将失效")) return false;
const gm = String(snap.gate_metrics || "");
if(gm.includes("限价单") || gm.includes("挂单")) return true;
if(/等待成交/.test(gs)) return true;
if(/触价待触发/.test(gs)) return true;
if(/挂E=/.test(gs) && !gs.includes("将失效")) return true;
return false;
}
function paintKeyMonitorSummary(id, snap){
const el = document.getElementById(`key-summary-live-${id}`);
if(!el || !snap) return;
const px = snap.price_display || (Number.isFinite(Number(snap.price)) ? Number(snap.price).toFixed(6) : "—");
const gate = snap.gate_summary || "—";
el.innerText = `现价 ${px} · 门控 ${gate}`;
el.classList.toggle("key-row-summary-pending", keySummaryIsPending(snap));
}
document.querySelectorAll(".key-row-collapse").forEach((row)=>{
row.addEventListener("toggle", ()=>{
if(!row.open) return;
requestAnimationFrame(()=>{
const body = row.querySelector(".key-row-collapse-body");
const panel = row.closest(".key-panel-scroll");
if(body && panel){
const bodyRect = body.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
if(bodyRect.bottom > panelRect.bottom - 8){
panel.scrollTop += bodyRect.bottom - panelRect.bottom + 16;
} else if(bodyRect.top < panelRect.top + 8){
panel.scrollTop -= panelRect.top - bodyRect.top + 16;
}
} else {
row.scrollIntoView({block:"nearest", behavior:"smooth"});
}
});
});
});
</script>
<script src="/static/key_monitor_form.js?v=2"></script>
@@ -0,0 +1,59 @@
{% set r = key_rule_ctx %}
<div class="key-rule-table-wrap">
<table class="key-rule-table">
<thead>
<tr>
<th>类型</th>
<th>填写</th>
<th>门控</th>
<th>止盈止损</th>
<th>执行</th>
</tr>
</thead>
<tbody>
<tr>
<td class="key-rule-type">箱体突破<br><span class="key-rule-sub">收敛突破</span></td>
<td class="key-rule-cell">方向必选;填 H/L<br>方案:标准 / 1R·1.5H / 趋势<br>可勾移动保本</td>
<td class="key-rule-cell">{{ r.tf }} 两根闭合 K{{ r.breakout_bar }}/{{ r.confirm_bar }}<br>突破 &gt;{{ r.amp_min_pct }}%;确认在箱外<br>&gt;前{{ r.vol_ma_bars }}均×{{ r.vol_ratio_min }}<br>成交 Top{{ r.vol_rank_max }}RR &gt;{{ r.min_rr }}<br>标记价先破反向边界→失效</td>
<td class="key-rule-cell">标准:SL 极值外{{ r.stop_outside_pct }}%TP=E±H<br>1RSL=E∓HTP=E∓1.5H<br>趋势:SL 极值外{{ r.trend_stop_outside_pct }}%TP 自填</td>
<td class="key-rule-cell">门控过→市价开仓→下单监控<br>满仓不可再加</td>
</tr>
<tr>
<td class="key-rule-type">斐波回调<br><span class="key-rule-sub">0.618 / 0.786</span></td>
<td class="key-rule-cell">方向 + H/L 波段<br>系统算 E/SL/TP</td>
<td class="key-rule-cell">多:E=HrΔ,SL=LTP=H<br>空:E=L+rΔ,SL=HTP=L<br>RR &gt;{{ r.min_rr }};先触 TP 侧失效</td>
<td class="key-rule-cell">公式固定 SL/TP<br>成交后挂所</td>
<td class="key-rule-cell">挂限价等成交<br>成交→下单监控</td>
</tr>
<tr>
<td class="key-rule-type">假突破<br><span class="key-rule-sub">BTC / ETH</span></td>
<td class="key-rule-cell">空填高点 / 多填低点<br>同币仅 1 条</td>
<td class="key-rule-cell">外侧 {{ r.fb_offset_pct }}% 限价<br>SL {{ r.fb_sl_pct }}%RR {{ r.fb_rr }}<br>有效 {{ r.fb_valid_hours }}h</td>
<td class="key-rule-cell">自动 E/SL/TP<br>可保本</td>
<td class="key-rule-cell">即挂限价<br>成交/过期→历史</td>
</tr>
<tr>
<td class="key-rule-type">回调触价开仓</td>
<td class="key-rule-cell">方向 + 入场 E / 止损 SL / 止盈 TP<br>可勾移动保本、时间平仓</td>
<td class="key-rule-cell">RR &gt;{{ r.min_rr }};做多 SL&lt;E&lt;TP<br>标记价回调触 E(多≤E / 空≥E)后下一轮询市价开<br>先触 TP 侧失效;有效 {{ r.trigger_entry_validity_hours }}h</td>
<td class="key-rule-cell">程序盯价,无交易所挂单<br>成交后挂所 TP/SL → 下单监控</td>
<td class="key-rule-cell">占当日开仓意图<br>全仓模式可用</td>
</tr>
<tr>
<td class="key-rule-type">突破触价开仓</td>
<td class="key-rule-cell">方向 + 突破价 E / 止损 SL / 止盈 TP<br>可勾移动保本、时间平仓</td>
<td class="key-rule-cell">RR &gt;{{ r.min_rr }};做多 SL&lt;E&lt;TP<br>标记价<strong>穿越</strong> E 立即市价开(多向上 / 空向下)<br>先触 TP 或 SL 侧失效;有效 {{ r.trigger_entry_validity_hours }}h</td>
<td class="key-rule-cell">程序盯价,无交易所挂单<br>成交后挂所 TP/SL → 下单监控</td>
<td class="key-rule-cell">占当日开仓意图<br>全仓模式可用</td>
</tr>
<tr>
<td class="key-rule-type">关键支撑阻力</td>
<td class="key-rule-cell">双向;填上/下沿</td>
<td class="key-rule-cell">{{ r.tf }} 收盘破上沿或下沿<br>上沿优先</td>
<td class="key-rule-cell">无(仅提醒)</td>
<td class="key-rule-cell">微信 ≤{{ r.alert_max }} 次<br>间隔 ≥{{ r.alert_interval_min }} 分</td>
</tr>
</tbody>
</table>
</div>
<p class="key-rule-foot">阈值来自 <code>.env</code>,修改后重启实例。</p>
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 实盘下单放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
</svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</button>
</div>
<div class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">实盘下单放大(100根K线)</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
{% if orders %}
<div class="row" style="margin-top:10px">
<label>订单</label>
<select id="order-id">
{% for o in orders %}
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
</option>
{% endfor %}
</select>
<label>周期</label>
<select id="timeframe">
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<button id="manual-refresh" type="button">刷新</button>
<span id="load-status" class="status"></span>
</div>
{% else %}
<div class="empty">当前没有激活订单,无法展示放大K线。</div>
{% endif %}
</div>
{% if orders %}
<div class="card">
<div class="meta">
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
<div class="meta-item"><div class="k">移动保本</div><div class="v" id="m-breakeven">-</div></div>
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
<div class="meta-item meta-item--emph meta-item--pnl"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
</div>
</div>
<div class="card">
<div id="chart-wrap"><div id="chart"></div></div>
</div>
{% endif %}
</div>
{% if orders %}
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/static/focus_chart_page.js?v=2"></script>
<script>
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
const orderSelect = document.getElementById("order-id");
const tfSelect = document.getElementById("timeframe");
const statusEl = document.getElementById("load-status");
const updatedAtEl = document.getElementById("updated-at");
const chartHost = document.getElementById("chart");
const FCP = window.FocusChartPage;
let fc = null;
function ensureChart(){
if(fc && fc.ensureSeries()) return true;
if(!window.LightweightCharts){
statusEl.className = "status err";
statusEl.innerText = "图表库加载失败";
return false;
}
fc = FCP.createFocusChart(chartHost);
if(!fc || !fc.ensureSeries()){
statusEl.className = "status err";
statusEl.innerText = "K线序列初始化失败";
return false;
}
return true;
}
async function loadOrderKline(){
if(!ensureChart()) return;
const orderId = orderSelect.value;
const timeframe = tfSelect.value;
if(!orderId) return;
statusEl.className = "status";
statusEl.innerText = "加载中...";
try{
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
const data = await resp.json();
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
else FCP.setActivePriceTick(data.price_tick);
const candles = Array.isArray(data.candles) ? data.candles : [];
if(!candles.length){
statusEl.className = "status err";
statusEl.innerText = "暂无K线数据";
return;
}
fc.candleSeries.setData(candles);
fc.resetPriceLines();
const o = data.order || {};
fc.addLine(o.trigger_price, FCP.lineTitle("成交价", o.trigger_price_display), "#42a5f5");
fc.addLine(o.stop_loss, FCP.lineTitle("止损", o.stop_loss_display), "#ff6666");
fc.addLine(o.take_profit, FCP.lineTitle("止盈", o.take_profit_display), "#4cd97f");
const markPx = o.current_price;
if(markPx) fc.addLine(markPx, FCP.lineTitle("现价", o.current_price_display), "#ffb74d");
fc.chart.timeScale().fitContent();
FCP.paintOrderMeta(o);
updatedAtEl.innerText = data.updated_at || "--";
statusEl.className = "status";
statusEl.innerText = `已加载 ${candles.length} 根K线`;
}catch(err){
statusEl.className = "status err";
statusEl.innerText = err && err.message ? err.message : "加载失败";
}
}
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
orderSelect.addEventListener("change", loadOrderKline);
tfSelect.addEventListener("change", loadOrderKline);
loadOrderKline();
setInterval(loadOrderKline, refreshMs);
</script>
{% endif %}
</body>
</html>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满、单日开仓达上限,或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满、单日开仓达上限,或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、单日开仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,21 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}{% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00{% endif %}{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }}BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -0,0 +1,5 @@
<div id="order-plan-preview" class="order-plan-preview">
<span id="order-risk-preview" class="order-preview-risk">预估风险:<strong></strong></span>
<span id="order-profit-preview" class="order-preview-profit">预估盈利:<strong></strong></span>
<span id="order-rr-preview" class="order-preview-rr">预估盈亏比:<strong></strong></span>
</div>
@@ -0,0 +1,284 @@
{% set mf = money_fmt|default(funds_fmt) %}
<style>
.strategy-records-page{
padding:10px clamp(14px,2.2vw,22px) 22px;
box-sizing:border-box;
}
.strategy-records-page h2{margin:0 0 8px;color:#dbe4ff}
.strategy-records-tip{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:12px}
.sr-filters{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:center;padding:12px 14px;background:#141a2a;border:1px solid #2a3150;border-radius:10px;margin-bottom:16px}
.sr-filters label{font-size:.76rem;color:#8b95b8;display:flex;align-items:center;gap:6px}
.sr-filters select,.sr-filters input[type=datetime-local]{padding:5px 8px;background:#0f1424;border:1px solid #304164;border-radius:6px;color:#dbe4ff;font-size:.78rem}
.sr-chip-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.sr-chip{padding:5px 12px;border:1px solid #304164;border-radius:16px;background:#151a2a;color:#9aa3c4;font-size:.74rem;cursor:pointer;user-select:none}
.sr-chip.active{background:#2a3f6c;color:#dbe4ff;border-color:#4a6a9a}
.sr-panels{display:grid;grid-template-columns:repeat(2,minmax(280px,1fr));gap:14px}
@media (max-width:960px){.sr-panels{grid-template-columns:1fr}}
.sr-panel{background:#141a2a;border:1px solid #2a3150;border-radius:12px;padding:12px 14px;min-height:120px;min-width:0}
.sr-panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.sr-panel-title{font-size:.92rem;font-weight:700;color:#f0f2ff}
.sr-panel-title.trend{color:#6ab8ff}
.sr-panel-title.roll{color:#ffb020}
.sr-panel-count{font-size:.72rem;color:#8892b0}
.sr-list{display:flex;flex-direction:column;gap:8px;max-height:62vh;overflow:auto;min-width:0}
.sr-item{border:1px solid #243050;border-radius:10px;background:#0f1424;overflow:visible}
.sr-item.sr-hidden{display:none}
.sr-summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 12px;cursor:pointer;font-size:.78rem;color:#cfd3ef !important;line-height:1.45;min-height:2.4rem;min-width:0}
.sr-summary > span{flex:0 1 auto;min-width:0;color:inherit}
.sr-summary .sr-sym{color:#f0f2ff !important;flex-shrink:0}
.sr-summary .sr-dca-tag{color:#8892b0 !important}
.sr-summary .sr-pnl.pos{color:#4cd97f !important}
.sr-summary .sr-pnl.neg{color:#ff6666 !important}
.sr-summary:hover{background:rgba(42,63,108,.2)}
.sr-summary::before{content:"▸";color:#6ab8ff;margin-right:2px;transition:transform .15s}
.sr-item.sr-open .sr-summary::before{transform:rotate(90deg)}
.sr-summary .sr-sym{font-weight:600;color:#f0f2ff}
.sr-summary .sr-pnl.pos{color:#4cd97f}
.sr-summary .sr-pnl.neg{color:#ff6666}
.sr-summary .sr-dca-tag{font-size:.7rem;color:#8892b0}
.sr-detail{display:none;padding:0 12px 12px;border-top:1px dashed #2a3558;font-size:.76rem;color:#cfd3ef}
.sr-item.sr-open .sr-detail{display:block}
.sr-detail-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 12px;margin:10px 0}
.sr-detail-grid .lbl{color:#8b95b8;font-size:.7rem}
.sr-detail-grid .val{color:#f0f2ff}
.sr-dca-table{width:100%;border-collapse:collapse;font-size:.72rem;margin-top:8px}
.sr-dca-table th,.sr-dca-table td{padding:5px 8px;border-bottom:1px solid #243050;text-align:left}
.sr-dca-table th{color:#6a7598}
.sr-dca-table .st-done{color:#4cd97f}
.sr-dca-table .st-pending{color:#9aa3c4}
.sr-empty{padding:20px;text-align:center;color:#8892b0;font-size:.8rem}
</style>
<div class="strategy-records-page card full">
<h2>策略交易记录</h2>
<p class="strategy-records-tip">
数据库保留最近 <strong>{{ strategy_records_limit|default(100) }}</strong> 条结束快照(按结束时间排序)。
趋势回调与顺势加仓分栏展示;点击行展开详情。结束计划、保本移交、止盈止损会自动写入。
</p>
<div class="sr-filters" id="sr-filters">
<label>币种
<select id="sr-filter-symbol">
<option value="">全部</option>
{% for sym in strategy_record_symbols %}
<option value="{{ sym }}">{{ sym }}</option>
{% endfor %}
</select>
</label>
<label>时间
<select id="sr-filter-time">
<option value="desc">最新优先</option>
<option value="asc">最早优先</option>
</select>
</label>
<div class="sr-chip-row">
<span style="font-size:.72rem;color:#8b95b8">筛选</span>
<button type="button" class="sr-chip" data-filter="profit">盈利</button>
<button type="button" class="sr-chip" data-filter="loss">亏损</button>
<button type="button" class="sr-chip" data-filter="no_dca">未补仓</button>
<button type="button" class="sr-chip" data-filter="dca">补仓</button>
<button type="button" class="sr-chip" id="sr-filter-reset" style="border-style:dashed">重置</button>
</div>
</div>
<div class="sr-panels">
<div class="sr-panel" data-panel="trend">
<div class="sr-panel-head">
<span class="sr-panel-title trend">趋势回调记录</span>
<span class="sr-panel-count" id="sr-trend-count">{{ strategy_trend_records|length }} 条</span>
</div>
<div class="sr-list" id="sr-trend-list">
{% for s in strategy_trend_records %}
{% set snap = s.snapshot or {} %}
{% set dca = snap.dca_levels if snap.dca_levels is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
{% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %}
<div class="sr-item" data-symbol="{{ s.filter_symbol or '' }}" data-pnl="{{ s.filter_pnl or '' }}" data-dca-tag="{{ s.dca_tag or '' }}" data-dca-done="{{ s.dca_done|default(0) }}" data-sort-ts="{{ s.sort_ts or '' }}">
<div class="sr-summary" role="button" tabindex="0">
<span class="sr-sym">#{{ s.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span>
<span>{{ s.result_label or '—' }}</span>
<span class="sr-pnl {% if pnl is not none %}{% if pnl|float > 0 %}pos{% elif pnl|float < 0 %}neg{% endif %}{% endif %}">{% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %}</span>
<span class="sr-dca-tag">补仓 {{ s.summary_dca or '—' }}</span>
<span style="color:#8892b0">{{ (s.closed_at or '')[:16] }}</span>
</div>
<div class="sr-detail">
<div class="sr-detail-grid">
<div><div class="lbl">计划 ID</div><div class="val">{{ s.source_id or '—' }}</div></div>
<div><div class="lbl">开仓</div><div class="val">{{ (s.opened_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">结束</div><div class="val">{{ (s.closed_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">均价</div><div class="val">{% if snap.avg_entry_price is not none %}{{ price_fmt(sym, snap.avg_entry_price) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">止损</div><div class="val">{% if snap.stop_loss is not none %}{{ price_fmt(sym, snap.stop_loss) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">止盈</div><div class="val">{% if snap.take_profit is not none %}{{ price_fmt(sym, snap.take_profit) }}{% else %}—{% endif %}</div></div>
<div><div class="lbl">风险%</div><div class="val">{{ snap.risk_percent if snap.risk_percent is defined else '—' }}</div></div>
<div><div class="lbl">杠杆</div><div class="val">{{ snap.leverage if snap.leverage is defined else '—' }}x</div></div>
<div><div class="lbl">计划保证金</div><div class="val">{% if snap.plan_margin_capital is not none %}{{ mf(snap.plan_margin_capital) }}U{% else %}—{% endif %}</div></div>
</div>
{% if dca and dca|length %}
<table class="sr-dca-table">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>状态</th></tr>
{% for lv in dca %}
<tr>
<td>{{ lv.label or lv.leg_key }}</td>
<td>{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.contracts is not none %}{{ lv.contracts }}{% else %}—{% endif %}</td>
<td class="{% if lv.status == 'done' %}st-done{% else %}st-pending{% endif %}">{{ lv.status_label or '—' }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% else %}
<div class="sr-empty sr-empty-default">暂无趋势回调结束记录</div>
{% endfor %}
</div>
</div>
<div class="sr-panel" data-panel="roll">
<div class="sr-panel-head">
<span class="sr-panel-title roll">顺势加仓记录</span>
<span class="sr-panel-count" id="sr-roll-count">{{ strategy_roll_records|length }} 条</span>
</div>
<div class="sr-list" id="sr-roll-list">
{% for s in strategy_roll_records %}
{% set snap = s.snapshot or {} %}
{% set group = snap.group if snap.group is defined else {} %}
{% set legs = snap.legs if snap.legs is defined else [] %}
{% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %}
{% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %}
<div class="sr-item" data-symbol="{{ s.filter_symbol or '' }}" data-pnl="{{ s.filter_pnl or '' }}" data-dca-tag="{{ s.dca_tag or '' }}" data-dca-done="{{ s.dca_done|default(0) }}" data-sort-ts="{{ s.sort_ts or '' }}">
<div class="sr-summary" role="button" tabindex="0">
<span class="sr-sym">#{{ s.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if s.direction == 'long' else 'direction-short' }}">{{ '做多' if s.direction == 'long' else '做空' }}</span>
<span>{{ s.result_label or '—' }}</span>
<span class="sr-pnl {% if pnl is not none %}{% if pnl|float > 0 %}pos{% elif pnl|float < 0 %}neg{% endif %}{% endif %}">{% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %}</span>
<span class="sr-dca-tag">成交 {{ s.summary_dca or '—' }}</span>
<span style="color:#8892b0">{{ (s.closed_at or '')[:16] }}</span>
</div>
<div class="sr-detail">
<div class="sr-detail-grid">
<div><div class="lbl">组 ID</div><div class="val">{{ s.source_id or '—' }}</div></div>
<div><div class="lbl">创建</div><div class="val">{{ (s.opened_at or group.created_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">结束</div><div class="val">{{ (s.closed_at or '')[:16] or '—' }}</div></div>
<div><div class="lbl">状态</div><div class="val">{{ s.status_at_close or group.status or '—' }}</div></div>
<div><div class="lbl">杠杆</div><div class="val">{{ group.leverage if group.leverage is defined else '—' }}x</div></div>
<div><div class="lbl">备注</div><div class="val">{{ group.message if group.message is defined else '—' }}</div></div>
</div>
{% if legs and legs|length %}
<table class="sr-dca-table">
<tr><th>腿次</th><th>挂单价</th><th>张数</th><th>状态</th></tr>
{% for leg in legs %}
<tr>
<td>{{ leg.leg_index or loop.index }}</td>
<td>{% if leg.limit_price is not none %}{{ price_fmt(sym, leg.limit_price) }}{% else %}—{% endif %}</td>
<td>{% if leg.order_amount is not none %}{{ leg.order_amount }}{% else %}—{% endif %}</td>
<td>{{ leg.status_label or leg.status or '—' }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% else %}
<div class="sr-empty sr-empty-default">暂无顺势加仓结束记录</div>
{% endfor %}
</div>
</div>
</div>
</div>
<script>
(function(){
const symbolSel = document.getElementById("sr-filter-symbol");
const timeSel = document.getElementById("sr-filter-time");
const chips = document.querySelectorAll(".sr-chip[data-filter]");
const resetBtn = document.getElementById("sr-filter-reset");
const active = new Set();
function itemMatches(el){
const sym = (symbolSel && symbolSel.value) || "";
if(sym && (el.getAttribute("data-symbol")||"").toUpperCase() !== sym.toUpperCase()) return false;
if(active.has("profit") && el.getAttribute("data-pnl") !== "profit") return false;
if(active.has("loss") && el.getAttribute("data-pnl") !== "loss") return false;
if(active.has("no_dca")){
const tag = el.getAttribute("data-dca-tag") || "";
const done = parseInt(el.getAttribute("data-dca-done")||"0",10);
if(tag !== "no_dca" && done > 0) return false;
}
if(active.has("dca")){
const done = parseInt(el.getAttribute("data-dca-done")||"0",10);
if(!(done > 0)) return false;
}
return true;
}
function sortList(listEl){
if(!listEl) return;
const asc = timeSel && timeSel.value === "asc";
const items = Array.from(listEl.querySelectorAll(".sr-item"));
items.sort((a,b)=>{
const ta = a.getAttribute("data-sort-ts") || "";
const tb = b.getAttribute("data-sort-ts") || "";
if(ta === tb) return 0;
return asc ? (ta < tb ? -1 : 1) : (ta > tb ? -1 : 1);
});
items.forEach(it => listEl.appendChild(it));
}
function applyFilters(){
["sr-trend-list","sr-roll-list"].forEach(id=>{
const list = document.getElementById(id);
if(!list) return;
sortList(list);
let visible = 0;
list.querySelectorAll(".sr-item").forEach(el=>{
const ok = itemMatches(el);
el.classList.toggle("sr-hidden", !ok);
if(ok) visible++;
});
const panel = list.closest(".sr-panel");
const cnt = panel && panel.querySelector(".sr-panel-count");
if(cnt) cnt.textContent = visible + " 条";
let empty = list.querySelector(".sr-empty-filter");
if(!visible && !list.querySelector(".sr-empty-default")){
if(!empty){
empty = document.createElement("div");
empty.className = "sr-empty sr-empty-filter";
empty.textContent = "无符合筛选的记录";
list.appendChild(empty);
}
} else if(empty) empty.remove();
});
}
chips.forEach(ch=>{
ch.addEventListener("click", ()=>{
const k = ch.getAttribute("data-filter");
if(active.has(k)) active.delete(k); else active.add(k);
ch.classList.toggle("active", active.has(k));
applyFilters();
});
});
if(symbolSel) symbolSel.addEventListener("change", applyFilters);
if(timeSel) timeSel.addEventListener("change", applyFilters);
if(resetBtn) resetBtn.addEventListener("click", ()=>{
active.clear();
chips.forEach(c=>c.classList.remove("active"));
if(symbolSel) symbolSel.value = "";
if(timeSel) timeSel.value = "desc";
applyFilters();
});
document.querySelectorAll(".sr-summary").forEach(sum=>{
const toggle = ()=>{
const item = sum.closest(".sr-item");
if(item) item.classList.toggle("sr-open");
};
sum.addEventListener("click", toggle);
sum.addEventListener("keydown", e=>{
if(e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
});
applyFilters();
})();
</script>
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · {{ exchange_display }}</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
<meta name="theme-color" content="#0b0d14">
</head>
<body>
<div class="container" style="max-width:1100px;margin:0 auto;padding:16px">
{% with messages = get_flashed_messages() %}{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}{% endwith %}
{% include 'strategy_roll_panel.html' %}
<p class="rule-tip" style="margin-top:12px"><a href="/strategy/roll/docs" style="color:#8fc8ff">顺势加仓完整逻辑说明</a></p>
</div>
<script src="/static/strategy_roll.js?v=5"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · 详细说明 · {{ exchange_display }}</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<meta name="theme-color" content="#0b0d14">
</head>
<body class="roll-doc-page">
<div class="container roll-doc-container">
<div class="doc-nav roll-doc-nav">
<a href="/strategy">← 返回策略交易</a>
&nbsp;·&nbsp;
<a href="/strategy/roll">顺势加仓</a>
</div>
<article class="doc-body roll-doc-body">
{{ doc_html|safe }}
</article>
</div>
</body>
</html>
@@ -0,0 +1,103 @@
<div class="strategy-panel-inner" id="strategy-roll-panel">
<h2 style="margin:0 0 8px">顺势加仓</h2>
<details class="tip-collapse strategy-roll-rule-collapse" open>
<summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary>
<div class="tip-collapse-body rule-tip">
<strong>仅人工提交</strong>;须先在「实盘下单」有同向持仓。仅<strong>以损定仓</strong>模式可用。<br>
做多/做空各最多滚仓 <strong>3</strong> 次(仅计已成交腿);止盈<strong>锁定首仓</strong>不变。<br>
风险比例读取所选监控单,<strong>不可手改</strong>;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%)。<br>
斐波/突破为<strong>程序监控</strong>(mark 价穿越触发),触价后市价加仓;填写后直接点「执行滚仓」(无需预览)。同时仅允许 <strong>1</strong> 条监控中腿,提交后<strong>不可修改</strong>,可删除。<br>
手动平仓后滚仓监控自动结束;<strong>已成交腿历史保留</strong>供复盘。<br>
<a href="/strategy/roll/docs" target="_blank" rel="noopener" class="roll-doc-link">→ 顺势加仓完整逻辑说明</a><br>
{% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div>
</details>
<div id="roll-risk-banner" class="rule-tip roll-risk-banner">
当前风险:请选择持仓币种
</div>
<form id="roll-form" action="{{ url_for('strategy_roll_execute') }}" method="post" class="form-row" data-add-mode="market">
<select name="symbol" id="roll-symbol" required>
<option value="">选择持仓币种</option>
{% for o in roll_monitors %}
<option value="{{ o.symbol }}"
data-direction="{{ o.direction }}"
data-monitor-id="{{ o.id }}"
data-risk-percent="{{ o.risk_percent or default_risk_percent }}">
{{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }} · 风险{{ o.risk_percent or default_risk_percent }}%
</option>
{% endfor %}
</select>
<input type="hidden" name="direction" id="roll-direction" value="long">
<select name="add_mode" id="roll-add-mode" onchange="var f=document.getElementById('roll-form');if(f){f.setAttribute('data-add-mode',this.value);if(window.syncRollFormMode)syncRollFormMode(f,this.value);}">
<option value="market">市价加仓</option>
<option value="fib_618">斐波 0.618</option>
<option value="fib_786">斐波 0.786</option>
<option value="breakout">突破加仓</option>
</select>
<span class="roll-field roll-field-fib">
<input name="fib_upper" id="roll-fib-upper" step="any" placeholder="上沿 H">
<input name="fib_lower" id="roll-fib-lower" step="any" placeholder="下沿 L">
</span>
<span class="roll-field roll-field-breakout">
<input name="breakthrough_price" id="roll-breakout" step="any" placeholder="突破价">
</span>
<input name="new_stop_loss" id="roll-stop-loss" type="number" min="0" step="any" placeholder="新止损价" required>
<button type="button" id="roll-preview-btn" class="roll-preview-btn" {% if roll_trend_active %}disabled{% endif %}>预览</button>
<button type="submit" id="roll-submit-btn" {% if roll_trend_active %}disabled style="opacity:.5" data-trend-locked="1"{% endif %} disabled>执行滚仓</button>
</form>
<div id="roll-preview-box" class="rule-tip roll-preview-box" style="display:none" role="status" aria-live="polite">
<div id="roll-preview-text"></div>
<div id="roll-countdown" class="roll-countdown" style="display:none"></div>
</div>
<h3 class="roll-section-title">活跃滚仓组</h3>
<div class="table-wrap">
<table>
<tr><th>ID</th><th>币种</th><th>方向</th><th>腿数</th><th>首仓TP</th><th>当前SL</th><th>当前均价</th><th>止盈盈利U</th></tr>
{% for g in roll_groups %}
<tr>
<td>{{ g.id }}</td>
<td>{{ g.symbol }}</td>
<td>{{ g.direction }}</td>
<td>{{ g.leg_count }}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}</td>
<td>{% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %}</td>
<td>{% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
<h3 class="roll-section-title">最近滚仓腿</h3>
<div class="table-wrap">
<table>
<tr><th>#</th><th></th><th>方式</th><th>张数</th><th>触发/限价</th><th>新SL</th><th>状态</th><th>操作</th></tr>
{% for leg in roll_legs %}
<tr>
<td>{{ leg.leg_index }}</td>
<td>{{ leg.roll_group_id }}</td>
<td>{{ leg.add_mode }}</td>
<td>{% if leg.amount %}{{ leg.amount }}{% else %}—{% endif %}</td>
<td>{% if leg.limit_price %}{{ leg.limit_price }}{% elif leg.breakthrough_price %}{{ leg.breakthrough_price }}{% elif leg.fill_price %}{{ leg.fill_price }}{% else %}—{% endif %}</td>
<td>{{ leg.new_stop_loss }}</td>
<td>{{ leg.status_label or leg.status }}</td>
<td>
{% if leg.status == 'pending' %}
<form action="{{ url_for('strategy_roll_cancel_leg', leg_id=leg.id) }}" method="post" style="margin:0" onsubmit="return confirm('确认删除本条滚仓监控?')">
<button type="submit" style="padding:2px 8px;font-size:.75rem">删除</button>
</form>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
</div>
@@ -0,0 +1,4 @@
<div class="strategy-subnav top-nav" style="margin:0 0 12px;padding-bottom:8px;border-bottom:1px solid #2a3150">
<a href="/strategy/trend" class="{% if page == 'strategy_trend' %}active{% endif %}">趋势回调</a>
<a href="/strategy/roll" class="{% if page == 'strategy_roll' %}active{% endif %}">顺势加仓</a>
</div>
@@ -0,0 +1,47 @@
<style>
.trade-panels-row,.dual-panel-grid,.strategy-trading-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.strategy-trading-grid .card{min-height:320px;display:flex;flex-direction:column}
.strategy-trading-grid .panel-scroll{flex:1;overflow:auto;max-height:78vh}
.strategy-panel-inner.trend-card{display:flex;flex-direction:column;gap:12px}
.trend-running-plans{padding-top:14px;border-top:1px solid #2a3150}
.running-plans-stack{display:flex;flex-direction:column;gap:12px;margin-top:10px}
.plan-position-card{background:#141a2a;border:1px solid #2a3150;border-radius:12px;padding:12px 14px}
.plan-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px}
.plan-card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:1rem;font-weight:700;color:#f0f2ff}
.plan-card-meta{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:10px}
.plan-card-meta .accent{color:#6ab8ff}
.plan-card-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 14px;margin-bottom:10px}
.plan-cell{display:flex;flex-direction:column;gap:3px}
.plan-cell .lbl{font-size:.72rem;color:#8b95b8}
.plan-cell .val{color:#f0f2ff;font-size:.88rem;font-weight:500}
.plan-cell .val.pnl-profit{color:#4cd97f}
.plan-cell .val.pnl-loss{color:#ff6666}
.plan-cell .val.pnl-neutral{color:#cfd3ef}
.btn-close-plan{padding:7px 14px;background:#5c1e2a;color:#ffb4b4;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap;display:inline-block}
.btn-close-plan:hover{filter:brightness(1.08)}
.plan-dca-block{margin-top:12px;padding-top:10px;border-top:1px dashed #2a3558}
.plan-dca-title{font-size:.74rem;color:#8b95b8;margin-bottom:8px;letter-spacing:.02em}
.plan-dca-table{width:100%;border-collapse:collapse;font-size:.76rem}
.plan-dca-table th,.plan-dca-table td{padding:6px 8px;border-bottom:1px solid #243050;text-align:left}
.plan-dca-table th{color:#6a7598;font-weight:600}
.plan-dca-table .st-done{color:#4cd97f}
.plan-dca-table .st-pending{color:#9aa3c4}
@media (max-width:720px){
.plan-card-grid{grid-template-columns:1fr}
}
@media (max-width:1200px){
.trade-panels-row,.dual-panel-grid,.strategy-trading-grid{grid-template-columns:1fr}
}
</style>
<div class="dual-panel-grid trade-panels-row strategy-trading-grid" style="grid-column:1/-1;align-items:stretch">
<div class="card strategy-panel-trend" style="display:flex;flex-direction:column;min-height:320px">
<div class="panel-scroll" style="flex:1;max-height:78vh;overflow:auto">
{% include 'strategy_trend_panel.html' %}
</div>
</div>
<div class="card strategy-panel-roll" style="display:flex;flex-direction:column;min-height:320px">
<div class="panel-scroll" style="flex:1;max-height:78vh;overflow:auto">
{% include 'strategy_roll_panel.html' %}
</div>
</div>
</div>
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>趋势回调 · {{ exchange_display }}</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e6e8ef;padding:24px}
a{color:#8fc8ff;margin-right:10px}
.box{max-width:640px;background:#151a2a;border:1px solid #2a3150;padding:20px;border-radius:10px}
</style>
</head>
<body>
<p><a href="/trade">← 实盘下单</a> <a href="/strategy/roll">顺势加仓</a></p>
<div class="box">
<h1>趋势回调</h1>
<p>{{ trend_note }}</p>
<p style="color:#8892b0;font-size:.9rem">趋势回调含自动补仓档位,仅在 Gate 趋势机器人(crypto_monitor_gate_bot)实例中运行。</p>
</div>
</body>
</html>
@@ -0,0 +1,16 @@
{% include 'strategy_subnav.html' %}
<div class="card trend-card" style="grid-column:1/-1">
<h2 style="margin-bottom:8px">趋势回调</h2>
<details class="tip-collapse strategy-trend-disabled-collapse">
<summary class="tip-collapse-summary">趋势回调说明(本实例未启用)</summary>
<div class="tip-collapse-body rule-tip">
{{ trend_disabled_note }}<br><br>
趋势回调含自动补仓档位与预览执行,仅在 <strong>Gate 趋势机器人</strong><code>crypto_monitor_gate_bot</code>)实例中运行。
请访问该实例同一菜单「策略交易 → 趋势回调」,或常用地址 <code>:5002/strategy/trend</code>
</div>
</details>
<p style="margin-top:12px;font-size:.85rem">
<a href="/trade" style="color:#8fc8ff">返回实盘下单</a>
<a href="/strategy/roll" style="color:#8fc8ff">顺势加仓(本实例可用)</a>
</p>
</div>
@@ -0,0 +1,208 @@
{% set mf = money_fmt|default(funds_fmt) %}
{% macro amt_disp(sym, val) %}{% if amt_fmt is defined %}{{ amt_fmt(sym, val) }}{% else %}{{ val }}{% endif %}{% endmacro %}
<div class="strategy-panel-inner trend-card">
<h2 style="margin-bottom:8px">趋势回调</h2>
<details class="tip-collapse strategy-trend-rule-collapse">
<summary class="tip-collapse-summary">趋势回调规则说明</summary>
<div class="tip-collapse-body rule-tip">
<strong>生成预览</strong>:读取合约 USDT <strong>可用余额快照</strong>并计算计划(不下单)。预览有效期 <strong>{{ trend_pullback_preview_ttl }} 秒</strong><br>
<strong>确认执行</strong>:市价首仓 50% + 挂交易所止损;首仓后可<strong>手动保本</strong>(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为<strong>上沿</strong>、做空为<strong>下沿</strong>;程序可能因最小张数自动减档)市价补仓;<strong>止盈由程序监控</strong><br>
确认执行时若当前可用余额与预览快照相对偏差 &gt; <strong>{{ trend_preview_max_drift_pct }}%</strong> 会拒绝并要求重新预览。
</div>
</details>
{% if trend_dca_probes %}
{% for p in trend_dca_probes %}
{% if p.trigger_reached and p.block_reason %}
<div class="rule-tip" style="margin-bottom:10px;border-color:#a55;background:#2a1818;color:#ffb4b4">
<strong>计划 #{{ p.plan_id }}</strong> 标记价 {{ p.mark_price }} 已触达补仓触发价 {{ p.next_trigger }},但未自动补仓:
{{ p.block_reason }}。
{% if not live_trading_enabled %}
请在 <code>crypto_monitor_gate_bot/.env</code> 设置 <code>LIVE_TRADING_ENABLED=true</code> 后重启 PM2 进程 <strong>crypto_gate_bot</strong>(不是 manual-agent-gate-bot)。
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endif %}
<form id="trend-pullback-form" action="{{ url_for('preview_trend_pullback') }}" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 ETH/USDT" required>
<select name="direction" id="trend-direction" required>
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆(必填)" required>
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" placeholder="风险%相对可用快照" title="默认5:最坏亏损约≤可用余额×5%">
<input name="sl" step="any" placeholder="止损价" required>
<input name="add_upper" id="trend-add-upper" step="any" placeholder="补仓上沿价" required>
<input name="take_profit" step="any" placeholder="止盈价(固定)" required>
<button type="submit" {% if can_trade_trend is defined %}{% if not can_trade_trend %}disabled style="opacity:.5;cursor:not-allowed"{% endif %}{% elif not can_trade %}disabled style="opacity:.5;cursor:not-allowed"{% endif %}>生成预览</button>
</form>
<script>
(function(){
const dirSel = document.getElementById("trend-direction");
const addInp = document.getElementById("trend-add-upper");
function syncAddUpperPlaceholder(){
if(!addInp || !dirSel) return;
const d = (dirSel.value || "long").toLowerCase();
addInp.placeholder = d === "short" ? "补仓下沿价" : "补仓上沿价";
}
if(dirSel){
dirSel.addEventListener("change", syncAddUpperPlaceholder);
syncAddUpperPlaceholder();
}
})();
</script>
{% if trend_preview %}
<div style="margin-top:14px;padding:12px;background:#141a2e;border:1px solid #2a3150;border-radius:8px">
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;margin-bottom:8px">
<strong style="color:#dbe4ff">当前预览(剩余 <span id="trend-preview-ttl">{{ trend_pullback_preview_ttl }}</span>s</strong>
<span style="font-size:.8rem;color:#9aa" data-expires-ms="{{ preview_expires_ms }}">倒计时加载中…</span>
</div>
<div style="font-size:.82rem;color:#cfd3ef;line-height:1.55;margin-bottom:10px">
{{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x
预览可用快照 <strong>{{ mf(trend_preview.snapshot_available_usdt) }}</strong> U 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }}
计划保证金≈{{ mf(trend_preview.plan_margin_capital) }} U 总张≈{{ amt_disp(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_disp(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_disp(trend_preview.symbol, trend_preview.remainder_total) }}<br>
止损价 {{ price_fmt(trend_preview.symbol, trend_preview.preview_unified_stop_loss or trend_preview.stop_loss) }} 止损金额 {% if trend_preview.preview_risk_amount_u is not none %}{{ mf(trend_preview.preview_risk_amount_u) }}U{% else %}—{% endif %}(快照×风险{{ trend_preview.risk_percent }}%)| {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} 止盈价 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} 首仓盈亏比 {% if trend_preview.preview_target_rr is not none %}{{ '%.2f'|format(trend_preview.preview_target_rr) }}{% else %}—{% endif %}
</div>
<div class="table-wrap" style="margin-bottom:10px">
<table>
<tr><th>档位</th><th>触发/参考价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利(U)</th><th>止损(U)</th><th>盈亏比</th></tr>
{% for row in trend_preview_levels %}
<tr>
<td>{{ row.label or row.i }}</td>
<td>{{ price_fmt(trend_preview.symbol, row.price) }}</td>
<td>{{ amt_disp(trend_preview.symbol, row.contracts) }}</td>
<td>{% if row.avg_entry is not none %}{{ price_fmt(trend_preview.symbol, row.avg_entry) }}{% else %}—{% endif %}</td>
<td>{% if row.profit_u is not none %}{{ mf(row.profit_u) }}{% else %}—{% endif %}</td>
<td>{% if row.risk_u is not none %}{{ mf(row.risk_u) }}{% else %}—{% endif %}</td>
<td>{% if row.rr is not none %}{{ '%.2f'|format(row.rr) }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="form-row" style="gap:10px;align-items:center">
<form action="{{ url_for('execute_trend_pullback') }}" method="post" style="display:inline">
<input type="hidden" name="preview_id" value="{{ trend_preview.id }}">
<button type="submit" onclick="return confirm('确认按预览参数实盘下单?')">确认执行(实盘)</button>
</form>
<form action="{{ url_for('cancel_trend_pullback_preview') }}" method="post" style="display:inline">
<input type="hidden" name="preview_id" value="{{ trend_preview.id }}">
<button type="submit" style="background:#2f2134;color:#ffb2b2">取消预览</button>
</form>
</div>
</div>
<script>
(function(){
const el = document.querySelector("[data-expires-ms]");
if(!el) return;
const exp = parseInt(el.getAttribute("data-expires-ms")||"0",10);
function tick(){
const left = Math.max(0, Math.floor((exp - Date.now()) / 1000));
el.innerText = left > 0 ? ("剩余 " + left + " 秒") : "已过期,请重新生成预览";
const span = document.getElementById("trend-preview-ttl");
if(span) span.innerText = String(left);
if(left <= 0) return;
setTimeout(tick, 1000);
}
tick();
})();
</script>
{% elif trend_preview_expired %}
<div class="rule-tip" style="margin-top:12px;color:#ff8f8f">该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」。</div>
{% endif %}
<div class="trend-running-plans">
<h3 style="margin:0 0 10px;font-size:.95rem;color:#b8c4ff">运行中的计划</h3>
<div class="running-plans-stack">
{% for t in trend_plans %}
{% set sym = t.exchange_symbol or t.symbol %}
{% set calc = namespace(pnlpct=None) %}
{% if t.floating_pnl is not none and t.plan_margin_capital is not none and t.plan_margin_capital|float > 0 %}
{% set calc.pnlpct = (t.floating_pnl|float) / (t.plan_margin_capital|float) * 100 %}
{% endif %}
<div class="plan-position-card">
<div class="plan-card-head">
<div class="plan-card-title">
<span>#{{ t.id }} {{ sym }}</span>
<span class="badge {{ 'direction-long' if t.direction == 'long' else 'direction-short' }}">{{ '做多' if t.direction == 'long' else '做空' }}</span>
</div>
<a href="/stop_trend_pullback/{{ t.id }}" class="btn-close-plan" onclick="return confirm('结束计划:市价平仓并撤掉该合约全部挂单,确定?')">结束计划</a>
</div>
<div class="plan-card-meta">
来源: 趋势回调计划 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %}
<span class="accent">{{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }}</span>
| 已补仓 <strong>{{ t.legs_done }}/{{ t.dca_legs }}</strong>
</div>
<div class="plan-card-grid plan-card-grid--metrics">
<div class="plan-cell">
<span class="lbl">均价</span>
<span class="val">{% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %}</span>
</div>
<div class="plan-cell">
<span class="lbl">止损</span>
<span class="val">{{ price_fmt(sym, t.stop_loss) }}</span>
</div>
<div class="plan-cell">
<span class="lbl">止盈</span>
<span class="val">{{ price_fmt(sym, t.take_profit) }}</span>
</div>
<div class="plan-cell">
<span class="lbl">盈亏比</span>
<span class="val">{% if t.money_rr is not none %}{{ '%.2f'|format(t.money_rr) }}:1{% elif t.planned_rr is not none %}{{ '%.2f'|format(t.planned_rr) }}:1{% else %}—{% endif %}</span>
</div>
<div class="plan-cell">
<span class="lbl">标记价</span>
<span class="val">{% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %}</span>
</div>
<div class="plan-cell">
<span class="lbl">浮盈亏</span>
<span class="val {% if t.floating_pnl is not none %}{% if t.floating_pnl > 0 %}pnl-profit{% elif t.floating_pnl < 0 %}pnl-loss{% else %}pnl-neutral{% endif %}{% endif %}">
{% if t.floating_pnl is not none %}
{{ mf(t.floating_pnl) }}U{% if calc.pnlpct is not none %} ({{ '%+.2f'|format(calc.pnlpct) }}%){% endif %}
{% else %}—{% endif %}
</span>
</div>
</div>
{% if t.dca_levels %}
<div class="plan-dca-block">
<div class="plan-dca-title">补仓计划明细</div>
<table class="plan-dca-table">
<tr><th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利(U)</th><th>止损(U)</th><th>盈亏比</th><th>状态</th></tr>
{% for lv in t.dca_levels %}
<tr>
<td>{{ lv.label }}</td>
<td>{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}</td>
<td>{% if lv.contracts is not none %}{{ amt_disp(sym, lv.contracts) }}{% else %}—{% endif %}</td>
<td>{% if lv.avg_entry is not none %}{{ price_fmt(sym, lv.avg_entry) }}{% else %}—{% endif %}</td>
<td>{% if lv.profit_u is not none %}{{ mf(lv.profit_u) }}{% else %}—{% endif %}</td>
<td>{% if lv.risk_u is not none %}{{ mf(lv.risk_u) }}{% else %}—{% endif %}</td>
<td>{% if lv.rr is not none %}{{ '%.2f'|format(lv.rr) }}{% else %}—{% endif %}</td>
<td class="{% if lv.status == 'done' %}st-done{% else %}st-pending{% endif %}">{{ lv.status_label }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<div class="plan-card-meta" style="margin-top:8px">
<form action="{{ url_for('trend_pullback_breakeven', pid=t.id) }}" method="post" class="form-row" style="margin:0;align-items:center" onsubmit="return confirm('确认保本?将结束本趋势计划,持仓移交「下单监控」(备注趋势回调计划),并在交易所同时挂保本止损与计划止盈;后续平仓会写入交易记录。');">
<label style="font-size:.78rem;color:#cfd3ef;display:flex;align-items:center;gap:6px">
保本移交 偏移%
<input name="breakeven_offset_pct" type="number" min="0" step="0.01" value="{{ trend_manual_breakeven_offset_pct }}" style="width:72px;padding:4px 8px">
</label>
<button type="submit" style="padding:6px 12px;background:#1f4a3a;color:#8fc8ff">保本移交下单监控</button>
{% if t.breakeven_applied %}<span style="color:#6ab88a;font-size:.75rem">已保本 {{ (t.breakeven_applied_at or '')[:16] }}</span>{% endif %}
</form>
</div>
<div class="plan-card-meta" style="margin-bottom:0">
快照可用: {% if t.snapshot_available_usdt is not none %}{{ mf(t.snapshot_available_usdt) }}U{% else %}—{% endif %}
计划保证金≈{% if t.plan_margin_capital is not none %}{{ mf(t.plan_margin_capital) }}U{% else %}—{% endif %}
杠杆: {{ t.leverage }}x
</div>
</div>
{% else %}
<div class="plan-position-card" style="color:#8892b0;text-align:center;padding:16px">暂无运行中的趋势回调计划</div>
{% endfor %}
</div>
</div>
</div>