diff --git a/README.md b/README.md
index f07b016..3d56773 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ cd crypto_monitor
| `crypto_monitor_gate_bot/` | Gate.io 永续(机器人;含趋势回调等) | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) · [趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) |
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
| `manual_trading_hub/` | 多账户中控(监控 + 紧急全平 + 登录;**不在中控网页下单**) | [README.md](./manual_trading_hub/README.md) · [使用说明.md](./manual_trading_hub/使用说明.md) · [部署文档.md](./manual_trading_hub/部署文档.md) · [常见问题.md](./manual_trading_hub/常见问题.md) |
+| 根目录 `strategy_*.py` | **策略交易**(趋势回调 + 顺势加仓共用逻辑) | [策略交易说明.md](./策略交易说明.md) |
前四列为四个 **`crypto_monitor_*`** 交易/监控应用;`manual_trading_hub` 与四者 **进程独立**,无需改四者代码即可并行使用。
diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py
index d85e836..dbe2e4d 100644
--- a/crypto_monitor_binance/app.py
+++ b/crypto_monitor_binance/app.py
@@ -1453,11 +1453,20 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
+ from strategy_db import init_strategy_tables
+
+ init_strategy_tables(conn)
conn.commit()
conn.close()
init_db()
+from strategy_config import build_strategy_config
+from strategy_register import attach_strategy_templates, register_strategy_trading
+
+attach_strategy_templates(app, _REPO_ROOT)
+register_strategy_trading(app, build_strategy_config(sys.modules[__name__]))
+
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html
index 91032ad..2c0805c 100644
--- a/crypto_monitor_binance/templates/index.html
+++ b/crypto_monitor_binance/templates/index.html
@@ -218,6 +218,8 @@
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index 6ac0767..4fae3e0 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -1451,11 +1451,20 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
+ from strategy_db import init_strategy_tables
+
+ init_strategy_tables(conn)
conn.commit()
conn.close()
init_db()
+from strategy_config import build_strategy_config
+from strategy_register import attach_strategy_templates, register_strategy_trading
+
+attach_strategy_templates(app, _REPO_ROOT)
+register_strategy_trading(app, build_strategy_config(sys.modules[__name__]))
+
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html
index 68fd217..553d7fe 100644
--- a/crypto_monitor_gate/templates/index.html
+++ b/crypto_monitor_gate/templates/index.html
@@ -218,6 +218,8 @@
diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py
index 5be183f..f867eb6 100644
--- a/crypto_monitor_gate_bot/app.py
+++ b/crypto_monitor_gate_bot/app.py
@@ -1511,6 +1511,9 @@ def init_db():
except Exception:
pass
+ from strategy_db import init_strategy_tables
+
+ init_strategy_tables(conn)
conn.commit()
conn.close()
@@ -2839,12 +2842,11 @@ def parse_and_compute_trend_pullback_plan(form_dict):
return None, "杠杆格式错误"
if leverage <= 0 or risk_percent <= 0:
return None, "杠杆与风险比例必须大于0"
- if direction == "long":
- if not (stop_loss < add_upper):
- return None, "做多:止损价须低于补仓上沿"
- else:
- if not (stop_loss > add_upper):
- return None, "做空:止损价须高于补仓下沿"
+ from strategy_trend_lib import validate_trend_bounds
+
+ bound_err = validate_trend_bounds(direction, stop_loss, add_upper)
+ if bound_err:
+ return None, bound_err
snap = get_available_trading_usdt()
if snap is None or snap <= 0:
return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型"
@@ -2873,10 +2875,21 @@ def parse_and_compute_trend_pullback_plan(form_dict):
)
if remainder_total is None:
remainder_total = 0.0
- n_legs, leg_json, per_ref = _trend_build_leg_amounts_json(exchange_symbol, remainder_total, TREND_PULLBACK_DCA_LEGS)
+ from strategy_trend_lib import build_grid_prices, build_leg_amounts_json
+
+ ensure_markets_loaded()
+ market = exchange.market(exchange_symbol)
+ min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0)
+ n_legs, leg_json, per_ref = build_leg_amounts_json(
+ exchange_symbol,
+ remainder_total,
+ TREND_PULLBACK_DCA_LEGS,
+ _safe_amount_to_precision,
+ min_amt,
+ )
if n_legs <= 0:
return None, "剩余计划张数不足以拆出补仓档(低于交易所最小张数),请提高风险比例、放宽止损与补仓区间间距,或减少补仓档数"
- grid = _trend_build_grid_prices(direction, stop_loss, add_upper, n_legs)
+ grid = build_grid_prices(direction, stop_loss, add_upper, n_legs)
if len(grid) != n_legs:
return None, "补仓网格生成失败"
opened_at = app_now_str()
@@ -5278,7 +5291,7 @@ def render_main_page(page="trade"):
preview_expires_ms = None
trend_preview_expired = False
trend_preview_id_arg = ""
- if page == "trade":
+ if page == "strategy_trend":
_trend_cleanup_stale_previews(conn)
trend_preview_id_arg = (request.args.get("preview_id") or "").strip()
if trend_preview_id_arg:
@@ -6182,17 +6195,17 @@ def preview_trend_pullback():
if not okp:
conn.close()
flash(reasonp)
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
flash(reason_live)
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
payload, err = parse_and_compute_trend_pullback_plan(request.form)
if err:
conn.close()
flash(err)
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
pid = str(uuid.uuid4())
exp_ms = int(time.time() * 1000) + int(TREND_PULLBACK_PREVIEW_TTL_SECONDS) * 1000
created = app_now_str()
@@ -6231,7 +6244,7 @@ def preview_trend_pullback():
conn.commit()
conn.close()
flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。")
- return redirect(url_for("trade_page", preview_id=pid))
+ return redirect(url_for("strategy_trend_page", preview_id=pid))
@app.route("/execute_trend_pullback", methods=["POST"])
@@ -6240,7 +6253,7 @@ def execute_trend_pullback():
pid = (request.form.get("preview_id") or "").strip()
if not pid:
flash("缺少预览 ID")
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
conn = get_db()
_trend_cleanup_stale_previews(conn)
pr = conn.execute("SELECT * FROM trend_pullback_previews WHERE id=?", (pid,)).fetchone()
@@ -6248,30 +6261,30 @@ def execute_trend_pullback():
if not pr or int(pr["expires_at_ms"] or 0) < now_ms:
conn.close()
flash("预览已过期或不存在,请重新生成预览")
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
okp, reasonp = precheck_trend_pullback_start(conn)
if not okp:
conn.close()
flash(reasonp)
- return redirect(url_for("trade_page", preview_id=pid))
+ return redirect(url_for("strategy_trend_page", preview_id=pid))
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
conn.close()
flash(reason_live)
- return redirect(url_for("trade_page", preview_id=pid))
+ return redirect(url_for("strategy_trend_page", preview_id=pid))
snap_prev = float(pr["snapshot_available_usdt"] or 0)
snap_now = get_available_trading_usdt()
if snap_now is None or snap_now <= 0:
conn.close()
flash("无法读取当前合约可用余额,请稍后重试")
- return redirect(url_for("trade_page", preview_id=pid))
+ return redirect(url_for("strategy_trend_page", preview_id=pid))
drift_pct = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0
if drift_pct > float(TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT):
conn.close()
flash(
f"当前可用余额与预览快照偏差 {drift_pct:.2f}%,超过允许 {TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT}% ,请重新生成预览"
)
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
symbol = pr["symbol"]
exchange_symbol = pr["exchange_symbol"]
direction = pr["direction"] or "long"
@@ -6293,7 +6306,7 @@ def execute_trend_pullback():
if live_price is None:
conn.close()
flash("获取实时价格失败")
- return redirect(url_for("trade_page", preview_id=pid))
+ return redirect(url_for("strategy_trend_page", preview_id=pid))
try:
o1 = place_exchange_order(exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None)
fill1 = resolve_order_entry_price(o1, exchange_symbol, live_price)
@@ -6301,7 +6314,7 @@ def execute_trend_pullback():
except Exception as e:
conn.close()
flash(friendly_exchange_error(e, available_usdt=snap_now))
- return redirect(url_for("trade_page", preview_id=pid))
+ return redirect(url_for("strategy_trend_page", preview_id=pid))
now = app_now()
trading_day = get_trading_day(now)
opened_at = app_now_str()
@@ -6356,7 +6369,7 @@ def execute_trend_pullback():
f"趋势回调已执行:可用余额(执行时){round(snap, 2)}U;计划保证金约 {round(margin_plan, 2)}U;"
f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。"
)
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
@app.route("/cancel_trend_pullback_preview", methods=["POST"])
@@ -6373,7 +6386,7 @@ def cancel_trend_pullback_preview():
conn.commit()
conn.close()
flash("已取消预览")
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
@app.route("/trend_pullback_breakeven/", methods=["POST"])
@@ -6388,7 +6401,7 @@ def trend_pullback_breakeven(pid):
raise ValueError
except ValueError:
flash("保本偏移% 格式无效")
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
conn = get_db()
row = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,)
@@ -6396,7 +6409,7 @@ def trend_pullback_breakeven(pid):
if not row:
conn.close()
flash("未找到运行中的趋势回调计划")
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
ok, err = apply_trend_pullback_manual_breakeven(conn, row, offset_pct=offset_pct)
conn.commit()
conn.close()
@@ -6404,7 +6417,7 @@ def trend_pullback_breakeven(pid):
flash("已手动保本:交易所止损已按均价+偏移更新")
else:
flash(err or "手动保本失败")
- return redirect(url_for("trade_page"))
+ return redirect(url_for("strategy_trend_page"))
@app.route("/stop_trend_pullback/")
@@ -7358,6 +7371,19 @@ except Exception as _hub_err:
print(f"[hub_bridge] gate_bot: {_hub_err}")
+def strategy_trend_page():
+ return render_main_page("strategy_trend")
+
+
+from strategy_config import build_strategy_config
+from strategy_register import attach_strategy_templates, register_strategy_trading
+
+attach_strategy_templates(app, _REPO_ROOT)
+_strategy_cfg = build_strategy_config(sys.modules[__name__], trend_enabled=True)
+_strategy_cfg["render_trend_page"] = login_required(strategy_trend_page)
+register_strategy_trading(app, _strategy_cfg)
+
+
# 启动
if __name__ == "__main__":
threading.Thread(target=background_task, daemon=True).start()
diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html
index 6c24e13..cbb0a79 100644
--- a/crypto_monitor_gate_bot/templates/index.html
+++ b/crypto_monitor_gate_bot/templates/index.html
@@ -205,6 +205,8 @@
@@ -367,7 +369,9 @@
-
+
+
+ {% elif page == 'strategy_trend' %}
趋势回调策略
@@ -549,8 +553,6 @@
-
-
{% endif %}
{% if page == 'records' %}
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index 79c6636..b24b5e3 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -1318,11 +1318,20 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
+ from strategy_db import init_strategy_tables
+
+ init_strategy_tables(conn)
conn.commit()
conn.close()
init_db()
+from strategy_config import build_strategy_config
+from strategy_register import attach_strategy_templates, register_strategy_trading
+
+attach_strategy_templates(app, _REPO_ROOT)
+register_strategy_trading(app, build_strategy_config(sys.modules[__name__]))
+
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html
index 3f971cf..23cd7da 100644
--- a/crypto_monitor_okx/templates/index.html
+++ b/crypto_monitor_okx/templates/index.html
@@ -154,6 +154,8 @@
diff --git a/strategy_config.py b/strategy_config.py
new file mode 100644
index 0000000..a6973e8
--- /dev/null
+++ b/strategy_config.py
@@ -0,0 +1,114 @@
+"""各交易所 app 模块 → strategy_register 配置(统一工厂)。"""
+from __future__ import annotations
+
+from typing import Any
+
+
+def build_strategy_config(app_module: Any, *, trend_enabled: bool = False, trend_disabled_note: str = "") -> dict:
+ m = 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"
+ params = {}
+ if hasattr(m, "build_gate_order_params"):
+ params = m.build_gate_order_params(direction, reduce_only=False)
+ return m.exchange.create_order(ex_sym, "limit", side, float(amount), float(price), params or None)
+
+ 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
+
+ note = trend_disabled_note or (
+ "趋势回调(自动补仓)请在 Gate 趋势机器人实例使用:/strategy/trend"
+ )
+ return {
+ "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": lambda e: m.friendly_exchange_error(e, available_usdt=m.get_available_trading_usdt())
+ if "friendly_exchange_error" in dir(m)
+ else str(e),
+ "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,
+ }
diff --git a/strategy_db.py b/strategy_db.py
new file mode 100644
index 0000000..2880302
--- /dev/null
+++ b/strategy_db.py
@@ -0,0 +1,43 @@
+"""策略交易相关表结构(各所 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)
+)
+"""
+
+
+def init_strategy_tables(conn) -> None:
+ conn.execute(ROLL_GROUPS_SQL)
+ conn.execute(ROLL_LEGS_SQL)
diff --git a/strategy_exchange_base.py b/strategy_exchange_base.py
new file mode 100644
index 0000000..f22fb95
--- /dev/null
+++ b/strategy_exchange_base.py
@@ -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]: ...
diff --git a/strategy_exchange_binance.py b/strategy_exchange_binance.py
new file mode 100644
index 0000000..1ff115f
--- /dev/null
+++ b/strategy_exchange_binance.py
@@ -0,0 +1,4 @@
+"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
+from strategy_exchange_base import StrategyExchangeAdapter
+
+__all__ = ["StrategyExchangeAdapter"]
diff --git a/strategy_exchange_gate.py b/strategy_exchange_gate.py
new file mode 100644
index 0000000..8fddaf0
--- /dev/null
+++ b/strategy_exchange_gate.py
@@ -0,0 +1,9 @@
+"""
+Gate.io USDT 永续 — 策略交易交易所侧能力。
+
+实现方式:各 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
+ccxt 下单、精度、换 TP/SL;本文件为文档与类型锚点,避免在四个 app 重复实现滚仓公式。
+"""
+from strategy_exchange_base import StrategyExchangeAdapter
+
+__all__ = ["StrategyExchangeAdapter"]
diff --git a/strategy_exchange_okx.py b/strategy_exchange_okx.py
new file mode 100644
index 0000000..de54fc0
--- /dev/null
+++ b/strategy_exchange_okx.py
@@ -0,0 +1,4 @@
+"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
+from strategy_exchange_base import StrategyExchangeAdapter
+
+__all__ = ["StrategyExchangeAdapter"]
diff --git a/strategy_register.py b/strategy_register.py
new file mode 100644
index 0000000..9ab733b
--- /dev/null
+++ b/strategy_register.py
@@ -0,0 +1,358 @@
+"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。"""
+from __future__ import annotations
+
+import os
+from functools import wraps
+from typing import Any, Callable, Optional
+
+from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
+from jinja2 import ChoiceLoader, FileSystemLoader
+
+from strategy_db import init_strategy_tables
+from strategy_roll_lib import preview_roll
+
+
+def attach_strategy_templates(app: Flask, repo_root: str) -> None:
+ strat_dir = os.path.join(repo_root, "strategy_templates")
+ if not os.path.isdir(strat_dir):
+ return
+ existing = app.jinja_loader
+ loaders = [FileSystemLoader(strat_dir)]
+ if existing is not None:
+ if isinstance(existing, ChoiceLoader):
+ loaders = list(existing.loaders) + loaders
+ else:
+ loaders.insert(0, existing)
+ app.jinja_loader = ChoiceLoader(loaders)
+
+
+def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
+ """cfg 由各市面 app 注入回调(仅 API / DB 差异)。"""
+
+ login_required = cfg["login_required"]
+ get_db = cfg["get_db"]
+ trend_enabled = bool(cfg.get("trend_enabled"))
+ render_trend_page = cfg.get("render_trend_page")
+
+ def _lr(f):
+ return login_required(f)
+
+ if trend_enabled and callable(render_trend_page):
+ app.add_url_rule(
+ "/strategy/trend",
+ endpoint="strategy_trend_page",
+ view_func=_lr(render_trend_page),
+ )
+ else:
+
+ @_lr
+ @app.route("/strategy/trend")
+ def strategy_trend_disabled_page():
+ return render_template(
+ "strategy_trend_disabled.html",
+ exchange_display=cfg.get("exchange_display", ""),
+ trend_note=cfg.get(
+ "trend_disabled_note",
+ "趋势回调(自动补仓)当前仅在 Gate 趋势机器人实例中启用。",
+ ),
+ )
+
+ @_lr
+ @app.route("/strategy/roll")
+ def strategy_roll_page():
+ conn = get_db()
+ 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 * FROM roll_groups WHERE status='active' ORDER BY id DESC"
+ ).fetchall():
+ roll_groups.append(_row_to_dict(row))
+ legs = []
+ for row in conn.execute(
+ "SELECT * FROM roll_legs ORDER BY id DESC LIMIT 50"
+ ).fetchall():
+ legs.append(_row_to_dict(row))
+ trend_n = _count_active_trends(conn, cfg)
+ conn.close()
+ return render_template(
+ "strategy_roll.html",
+ page="strategy_roll",
+ exchange_display=cfg.get("exchange_display", ""),
+ monitors=monitors,
+ roll_groups=roll_groups,
+ roll_legs=legs,
+ trend_active=trend_n,
+ default_risk_percent=cfg.get("default_risk_percent", 2),
+ price_fmt=cfg.get("price_fmt"),
+ )
+
+ @_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"):
+ flash(
+ f"预览:加仓约 {err['preview'].get('add_amount_display', '-')} 张,"
+ f"合并均价 {err['preview'].get('avg_entry_after', '-')},"
+ f"触及新止损亏损约 {err['preview'].get('loss_at_sl_usdt', '-')}U"
+ )
+ else:
+ flash(err.get("msg") or "预览失败")
+ return redirect(url_for("strategy_roll_page"))
+
+ @_lr
+ @app.route("/strategy/roll/execute", methods=["POST"])
+ def strategy_roll_execute():
+ data = request.form
+ ok, msg = _roll_execute(cfg, data)
+ flash(msg)
+ return redirect(url_for("strategy_roll_page"))
+
+ # 趋势回调:仍由各市面 app 注册原有路由;导航指向 /strategy/trend
+
+
+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 _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict:
+ symbol = cfg["normalize_symbol_input"](data.get("symbol") or "")
+ if not symbol:
+ return {"ok": False, "msg": "请选择或填写币种"}
+ 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 {"ok": False, "msg": "存在运行中的趋势回调计划,请先结束后再滚仓"}
+ mon = _get_active_monitor(conn, cfg, symbol, direction)
+ if not mon:
+ conn.close()
+ return {"ok": False, "msg": "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"}
+ rg, legs_done = _get_or_create_roll_group_meta(conn, mon)
+ conn.close()
+ pos = cfg["get_position"](ex_sym, direction)
+ qty = float(pos.get("contracts") or 0)
+ if qty <= 0:
+ return {"ok": False, "msg": "交易所无该方向持仓,无法滚仓"}
+ entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
+ if entry <= 0:
+ return {"ok": False, "msg": "无法获取持仓均价"}
+ tp0 = float(mon.get("take_profit") or rg.get("initial_take_profit") or 0)
+ add_mode = (data.get("add_mode") or "market").strip().lower()
+ try:
+ new_sl = float(data.get("new_stop_loss") or data.get("sl"))
+ risk_pct = float(data.get("risk_percent") or cfg.get("default_risk_percent", 2))
+ except (TypeError, ValueError):
+ return {"ok": False, "msg": "止损或风险%格式错误"}
+ conn_cap = get_db()
+ try:
+ capital = float(cfg["get_trading_capital_usdt"](conn_cap))
+ finally:
+ conn_cap.close()
+ live = cfg["get_price"](symbol)
+ fib_u = fib_l = 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"))
+ except (TypeError, ValueError):
+ return {"ok": False, "msg": "斐波上沿/下沿格式错误"}
+ preview, err = preview_roll(
+ direction=direction,
+ symbol=symbol,
+ qty_existing=qty,
+ entry_existing=entry,
+ initial_take_profit=tp0,
+ add_mode=add_mode,
+ new_stop_loss=new_sl,
+ risk_percent=risk_pct,
+ capital_base_usdt=capital,
+ add_price=float(live) if live else None,
+ fib_upper=fib_u,
+ fib_lower=fib_l,
+ legs_done=legs_done,
+ )
+ if err:
+ return {"ok": False, "msg": err}
+ amt_raw = float(preview["add_amount_raw"])
+ amt_p = cfg["amount_to_precision"](ex_sym, amt_raw)
+ preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw
+ price_fmt = cfg.get("price_fmt")
+ if callable(price_fmt):
+ preview["add_price_display"] = price_fmt(symbol, preview["add_price"])
+ preview["new_sl_display"] = price_fmt(symbol, preview["new_stop_loss"])
+ preview["tp_display"] = price_fmt(symbol, preview["initial_take_profit"])
+ return {"ok": True, "preview": preview}
+
+
+def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
+ 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"]
+ amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"]))
+ if amount is None or amount <= 0:
+ return False, "加仓张数低于交易所最小精度"
+ leverage = int(data.get("leverage") or 0) or int(cfg.get("default_leverage", lambda s: 5)(symbol))
+ conn = get_db()
+ init_strategy_tables(conn)
+ mon = _get_active_monitor(conn, cfg, symbol, direction)
+ if not mon:
+ conn.close()
+ return False, "监控单已不存在"
+ rg, legs_done = _get_or_create_roll_group_meta(conn, mon)
+ new_sl = float(preview["new_stop_loss"])
+ tp0 = float(preview["initial_take_profit"])
+ try:
+ if add_mode == "market":
+ 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"])
+ status = "filled"
+ oid = str(order.get("id") or "") if isinstance(order, dict) else ""
+ else:
+ price = cfg["price_to_precision"](ex_sym, float(preview["add_price"]))
+ order = cfg["limit_add"](ex_sym, direction, amount, price, leverage)
+ oid = str(order.get("id") or "") if isinstance(order, dict) else ""
+ conn.execute(
+ """INSERT INTO roll_legs (
+ roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_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"),
+ price,
+ amount,
+ new_sl,
+ oid,
+ "pending",
+ cfg["app_now_str"](),
+ ),
+ )
+ conn.execute(
+ "UPDATE roll_groups SET leg_count=?, updated_at=? WHERE id=?",
+ (legs_done + 1, cfg["app_now_str"](), rg["id"]),
+ )
+ conn.commit()
+ conn.close()
+ return True, f"已挂限价加仓单 #{oid},成交后请在页面点「同步持仓并更新止损」"
+ 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,
+ 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,
+ 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()
+ conn.close()
+ return True, f"滚仓第 {legs_done + 1} 腿已市价成交,交易所止损已更新,止盈仍为首仓 {tp0}"
+ except Exception as e:
+ conn.close()
+ fe = cfg.get("friendly_error")
+ return False, fe(e) if callable(fe) else str(e)
+
+
+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]:
+ 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)
+ return d, int(d.get("leg_count") or 0)
+ 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")}, 0
+
+
diff --git a/strategy_roll_lib.py b/strategy_roll_lib.py
new file mode 100644
index 0000000..94accab
--- /dev/null
+++ b/strategy_roll_lib.py
@@ -0,0 +1,170 @@
+"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;斐波仅算限价。"""
+from __future__ import annotations
+
+from typing import Any, Optional, Tuple
+
+from fib_key_monitor_lib import calc_fib_plan, fib_ratio_from_type
+
+ROLL_MAX_LEGS_LONG = 3
+ROLL_MAX_LEGS_SHORT = 3
+FIB_MODES = frozenset({"fib_618", "fib_786"})
+
+
+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 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 solve_add_amount_for_total_risk(
+ direction: str,
+ qty_existing: float,
+ entry_existing: float,
+ add_price: float,
+ new_stop: float,
+ risk_budget_usdt: float,
+) -> Tuple[Optional[float], Optional[str]]:
+ """
+ 已知合并后若触及 new_stop 总亏损=risk_budget,反推本次加仓张数 Q2。
+ long: (avg - SL) * (Q1+Q2) = B => Q2 = (B - Q1*(E1-SL)) / (E2-SL)
+ short: (SL - avg) * (Q1+Q2) = B => Q2 = (B - 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)
+ except (TypeError, ValueError):
+ return None, "参数格式错误"
+ if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0:
+ return None, "持仓或风险预算无效"
+ direction = (direction or "long").strip().lower()
+ if direction == "short":
+ denom = sl - e2
+ numer = b - q1 * (sl - e1)
+ if denom <= 0:
+ return None, "做空:新止损须高于限价加仓价"
+ else:
+ denom = e2 - sl
+ numer = b - q1 * (e1 - sl)
+ if denom <= 0:
+ return None, "做多:新止损须低于限价/市价加仓价"
+ q2 = numer / denom
+ if q2 <= 0:
+ return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)"
+ return q2, None
+
+
+def preview_roll(
+ *,
+ direction: str,
+ symbol: str,
+ qty_existing: float,
+ entry_existing: float,
+ initial_take_profit: float,
+ add_mode: str,
+ new_stop_loss: float,
+ risk_percent: float,
+ capital_base_usdt: float,
+ add_price: Optional[float] = None,
+ fib_upper: Optional[float] = None,
+ fib_lower: Optional[float] = None,
+ legs_done: int = 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").strip().lower()
+ if mode == "market":
+ if add_price is None or add_price <= 0:
+ return None, "市价加仓需要有效参考价"
+ entry_add = float(add_price)
+ mode_label = "市价"
+ elif mode in FIB_MODES:
+ if fib_upper is None or fib_lower is None:
+ return None, "斐波限价须填写上沿与下沿"
+ entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
+ if err:
+ return None, err
+ mode_label = "斐波0.618" if "618" in mode else "斐波0.786"
+ else:
+ return None, "加仓方式无效"
+ try:
+ sl = float(new_stop_loss)
+ tp = float(initial_take_profit)
+ except (TypeError, ValueError):
+ return None, "止损/止盈格式错误"
+ if sl <= 0 or tp <= 0:
+ return None, "止损与首仓止盈须大于0"
+ if direction == "long":
+ if sl >= entry_add:
+ return None, "做多:新止损须低于加仓价"
+ if tp <= entry_existing:
+ return None, "做多:首仓止盈须高于当前持仓均价参考"
+ else:
+ if sl <= entry_add:
+ return None, "做空:新止损须高于加仓价"
+ if tp >= entry_existing:
+ return None, "做空:首仓止盈须低于当前持仓均价参考"
+ risk_budget = float(capital_base_usdt) * (float(risk_percent) / 100.0)
+ q2_raw, err = solve_add_amount_for_total_risk(
+ direction, qty_existing, entry_existing, entry_add, sl, risk_budget
+ )
+ if err:
+ return None, err
+ q2 = float(q2_raw)
+ new_qty = qty_existing + q2
+ new_avg = (qty_existing * entry_existing + q2 * entry_add) / new_qty
+ if direction == "long":
+ loss_at_sl = (new_avg - sl) * new_qty
+ reward_at_tp = (tp - new_avg) * new_qty
+ else:
+ loss_at_sl = (sl - new_avg) * new_qty
+ reward_at_tp = (new_avg - tp) * new_qty
+ return {
+ "symbol": symbol,
+ "direction": direction,
+ "add_mode": mode,
+ "add_mode_label": mode_label,
+ "add_price": round(entry_add, 10),
+ "new_stop_loss": sl,
+ "initial_take_profit": tp,
+ "risk_percent": float(risk_percent),
+ "risk_budget_usdt": round(risk_budget, 4),
+ "add_amount_raw": q2,
+ "qty_after": new_qty,
+ "avg_entry_after": round(new_avg, 10),
+ "loss_at_sl_usdt": round(loss_at_sl, 4),
+ "reward_at_tp_usdt": round(reward_at_tp, 4),
+ "legs_done": int(legs_done),
+ "leg_index_next": int(legs_done) + 1,
+ "fib_upper": fib_upper,
+ "fib_lower": fib_lower,
+ }, None
diff --git a/strategy_templates/strategy_roll.html b/strategy_templates/strategy_roll.html
new file mode 100644
index 0000000..46b2cd0
--- /dev/null
+++ b/strategy_templates/strategy_roll.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+ 顺势加仓 · {{ exchange_display }}
+
+
+
+
+
策略交易 · 顺势加仓 {{ exchange_display }}
+
+ {% with messages = get_flashed_messages() %}{% if messages %}
{{ messages[0] }}
{% endif %}{% endwith %}
+
+
+
规则说明
+
+ 仅人工加仓,程序不会自动触发。须先在「实盘下单」有同向持仓。
+ 做多最多滚仓 3 次;止盈锁定首仓不变;每次填写新统一止损,总风险%按「合并持仓打到新止损≈账户风险」反推张数。
+ 斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。
+ {% if trend_active %}当前有运行中的趋势回调计划,请先结束后再滚仓。{% endif %}
+
+
+
建议执行前用浏览器开发者工具 POST /strategy/roll/preview 查看 JSON 预览(或将加入页面内预览按钮)。
+
+
+
+
活跃滚仓组
+
+ | ID | 币种 | 方向 | 腿数 | 首仓TP | 当前SL |
+ {% for g in roll_groups %}
+
+ | {{ g.id }} |
+ {{ g.symbol }} |
+ {{ g.direction }} |
+ {{ g.leg_count }} |
+ {% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %} |
+ {% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %} |
+
+ {% else %}
+ | 暂无 |
+ {% endfor %}
+
+
+
+
+
最近滚仓腿
+
+ | # | 组 | 方式 | 张数 | 新SL | 状态 |
+ {% for leg in roll_legs %}
+
+ | {{ leg.leg_index }} |
+ {{ leg.roll_group_id }} |
+ {{ leg.add_mode }} |
+ {{ leg.amount }} |
+ {{ leg.new_stop_loss }} |
+ {{ leg.status }} |
+
+ {% else %}
+ | 暂无 |
+ {% endfor %}
+
+
+
+
+
diff --git a/strategy_templates/strategy_trend_disabled.html b/strategy_templates/strategy_trend_disabled.html
new file mode 100644
index 0000000..557004d
--- /dev/null
+++ b/strategy_templates/strategy_trend_disabled.html
@@ -0,0 +1,20 @@
+
+
+
+
+ 趋势回调 · {{ exchange_display }}
+
+
+
+ ← 实盘下单 顺势加仓
+
+
趋势回调
+
{{ trend_note }}
+
趋势回调含自动补仓档位,仅在 Gate 趋势机器人(crypto_monitor_gate_bot)实例中运行。
+
+
+
diff --git a/strategy_trend_lib.py b/strategy_trend_lib.py
new file mode 100644
index 0000000..98b6c21
--- /dev/null
+++ b/strategy_trend_lib.py
@@ -0,0 +1,193 @@
+"""趋势回调策略:纯计算与校验(无 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 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
diff --git a/策略交易说明.md b/策略交易说明.md
new file mode 100644
index 0000000..4381e33
--- /dev/null
+++ b/策略交易说明.md
@@ -0,0 +1,118 @@
+# 策略交易说明
+
+本文档说明仓库根目录 **共用策略逻辑** 与四个 `crypto_monitor_*` 实例中的 **策略交易** 入口(导航栏「策略·趋势回调」「策略·顺势加仓」)。
+
+---
+
+## 一、架构(精简共用)
+
+```
+strategy_trend_lib.py # 趋势回调:网格价、补仓拆分、边界校验(纯计算)
+strategy_roll_lib.py # 顺势加仓:总风险反推、斐波限价、最多 3 腿(纯计算)
+strategy_db.py # roll_groups / roll_legs 表结构
+strategy_config.py # 各所 app → 统一回调配置(交易所 API)
+strategy_register.py # Flask 路由:/strategy/trend、/strategy/roll
+strategy_exchange_*.py # 适配器说明(实际下单仍走各所 app 的 ccxt)
+strategy_templates/ # 顺势加仓页、趋势禁用提示页
+```
+
+| 层级 | 职责 |
+|------|------|
+| **lib** | 不算 ccxt、不写库 |
+| **config** | 把 `place_exchange_order`、`replace_active_monitor_tpsl_on_exchange` 等接到统一 cfg |
+| **各所 app** | `.env`、DB、`init_db`、PM2、微信、监控轮询 |
+
+部署时各实例 `PYTHONPATH` 需包含仓库根目录(`ecosystem.config.cjs` 中 `PYTHONPATH=..`)。
+
+---
+
+## 二、导航与页面
+
+| 路由 | 名称 | 说明 |
+|------|------|------|
+| `/strategy/trend` | 趋势回调 | **完整功能仅在 `crypto_monitor_gate_bot`**;其它所显示说明页 |
+| `/strategy/roll` | 顺势加仓 | **四所均可用**(须已有同向持仓) |
+| `/trade` | 实盘下单 | 首仓、以损定仓、移动保本(不变) |
+
+---
+
+## 三、趋势回调(延续 Gate 趋势机器人逻辑)
+
+- **位置**:`crypto_monitor_gate_bot` → **策略·趋势回调**(原「交易执行」页内区块已迁出)。
+- **行为**:与《[crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md)》一致——预览 → 确认执行 → 首仓 50% + 交易所止损 + 多档 **自动** 市价补仓 + 程序监控止盈。
+- **共用代码**:`parse_and_compute_trend_pullback_plan` 中网格/拆档已改为调用 `strategy_trend_lib`。
+- **互斥**:与「机器人下单监控」持仓上限、运行中趋势计划互斥(逻辑未改)。
+
+其它三所打开 `/strategy/trend` 会提示:请使用 Gate 趋势机器人实例。
+
+---
+
+## 四、顺势加仓(滚仓,仅人工)
+
+### 4.1 原则
+
+- **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单)。
+- **全币种**(与各所合约列表一致)。
+- **止盈**:全程使用 **首仓** `order_monitors.take_profit`,滚仓不改止盈。
+- **止损**:每次人工填写 **新统一止损**;成交后调用各所 **先撤后挂** TP/SL(止盈仍为首仓)。
+- **总风险%**:按「合并持仓 + 新止损」反推本次加仓张数,使触及新止损时亏损约 **账户基数 × 风险%**(默认 2%,可在表单修改)。
+- **做多**最多滚仓 **3** 次(首仓不计入,仅计 `roll_legs` 已成交次数);做空默认同样 3 次(见 `strategy_roll_lib.ROLL_MAX_LEGS_SHORT`)。
+
+### 4.2 斐波限价
+
+- 填写 **上沿 H、下沿 L**(H > L),仅用于计算限价加仓价(与 `fib_key_monitor_lib.calc_fib_plan` 的 **entry** 一致)。
+- **做多**:下沿 = 结构止损侧;**做空**:上沿 = 结构止损侧。
+- 可选 **0.618** 或 **0.786**;与关键位自动单的 TP(H/L 对侧)**不同**,滚仓 TP 锁定首仓。
+
+### 4.3 前置条件
+
+1. 在 **实盘下单** 已有同 symbol、同方向 **active** `order_monitors`。
+2. 交易所有同向持仓(读 `get_live_position_contracts`)。
+3. 无 **active** `trend_pullback_plans`(与趋势回调互斥)。
+
+### 4.4 数据表(各所 `crypto.db`)
+
+- `roll_groups`:绑定 `order_monitor_id`、首仓 TP/SL、当前 SL、已滚仓次数。
+- `roll_legs`:每腿方式(市价 / 斐波0.618 / 斐波0.786)、张数、新 SL、状态(`filled` / `pending`)。
+
+`init_db()` 时自动 `CREATE TABLE IF NOT EXISTS`(`strategy_db.init_strategy_tables`)。
+
+### 4.5 操作步骤
+
+1. 打开 **策略·顺势加仓** `/strategy/roll`。
+2. 选择持仓币种、方向、加仓方式,填写 H/L(斐波时)、**新统一止损**、总风险%。
+3. 点击 **执行滚仓**(市价立即加仓并更新止损;限价则挂委托,成交后需再处理止损——当前版本限价 pending 后提示手动同步)。
+4. 查看页底 **滚仓腿历史**。
+
+可选:对表单字段 POST `/strategy/roll/preview`(JSON)查看 `strategy_roll_lib.preview_roll` 结果。
+
+---
+
+## 五、升级与重启
+
+```bash
+cd /opt/crypto_monitor
+git pull
+# 四所 PM2 若用到滚仓/趋势 lib,建议重启
+pm2 restart crypto_binance crypto_gate crypto_gate_bot crypto_okx manual-trading-hub
+```
+
+仅改 Python 库、未改模板时,重启对应 Flask 进程即可。
+
+---
+
+## 六、相关文档
+
+| 文档 | 内容 |
+|------|------|
+| [crypto_monitor_gate_bot/趋势回调策略说明.md](./crypto_monitor_gate_bot/趋势回调策略说明.md) | 趋势回调细则 |
+| [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) | 中控(不含策略交易) |
+| [fib_key_monitor_lib.py](./fib_key_monitor_lib.py) | 斐波公式共用 |
+
+---
+
+## 七、后续可增强(未实现)
+
+- 滚仓页内嵌预览按钮、限价成交后一键同步止损。
+- 趋势回调计划逻辑进一步迁入 `strategy_trend_lib` + 各所 adapter 类(当前仅拆出网格/拆档计算)。
+- Binance / Gate 主站 / OKX 移植趋势回调自动补仓(需复制 `check_trend_pullback_plans` 轮询)。