refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
|
||||
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
|
||||
|
||||
__all__ = ["StrategyExchangeAdapter"]
|
||||
@@ -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"))
|
||||
@@ -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="手动平仓,滚仓监控已结束"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 "做空"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>突破 >{{ r.amp_min_pct }}%;确认在箱外<br>量 >前{{ r.vol_ma_bars }}均×{{ r.vol_ratio_min }}<br>成交 Top{{ r.vol_rank_max }};RR >{{ r.min_rr }}<br>标记价先破反向边界→失效</td>
|
||||
<td class="key-rule-cell">标准:SL 极值外{{ r.stop_outside_pct }}%,TP=E±H<br>1R:SL=E∓H,TP=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=H−rΔ,SL=L,TP=H<br>空:E=L+rΔ,SL=H,TP=L<br>RR >{{ 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 >{{ r.min_rr }};做多 SL<E<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 >{{ r.min_rr }};做多 SL<E<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
·
|
||||
<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>
|
||||
确认执行时若当前可用余额与预览快照相对偏差 > <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>
|
||||
Reference in New Issue
Block a user