From 103615d7a990a305ca5e78c792eb42bb3fb92b8b Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 23 May 2026 10:48:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=AD=96=E7=95=A5=E4=BA=A4?= =?UTF-8?q?=E6=98=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + crypto_monitor_binance/app.py | 9 + crypto_monitor_binance/templates/index.html | 2 + crypto_monitor_gate/app.py | 9 + crypto_monitor_gate/templates/index.html | 2 + crypto_monitor_gate_bot/app.py | 78 ++-- crypto_monitor_gate_bot/templates/index.html | 8 +- crypto_monitor_okx/app.py | 9 + crypto_monitor_okx/templates/index.html | 2 + strategy_config.py | 114 ++++++ strategy_db.py | 43 +++ strategy_exchange_base.py | 48 +++ strategy_exchange_binance.py | 4 + strategy_exchange_gate.py | 9 + strategy_exchange_okx.py | 4 + strategy_register.py | 358 ++++++++++++++++++ strategy_roll_lib.py | 170 +++++++++ strategy_templates/strategy_roll.html | 106 ++++++ .../strategy_trend_disabled.html | 20 + strategy_trend_lib.py | 193 ++++++++++ 策略交易说明.md | 118 ++++++ 21 files changed, 1278 insertions(+), 29 deletions(-) create mode 100644 strategy_config.py create mode 100644 strategy_db.py create mode 100644 strategy_exchange_base.py create mode 100644 strategy_exchange_binance.py create mode 100644 strategy_exchange_gate.py create mode 100644 strategy_exchange_okx.py create mode 100644 strategy_register.py create mode 100644 strategy_roll_lib.py create mode 100644 strategy_templates/strategy_roll.html create mode 100644 strategy_templates/strategy_trend_disabled.html create mode 100644 strategy_trend_lib.py create mode 100644 策略交易说明.md 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 @@

加密货币|交易监控 + AI复盘一体化

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 预览(或将加入页面内预览按钮)。

+
+ +
+

活跃滚仓组

+ + + {% for g in roll_groups %} + + + + + + + + + {% else %} + + {% endfor %} +
ID币种方向腿数首仓TP当前SL
{{ 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 %}
暂无
+
+ +
+

最近滚仓腿

+ + + {% for leg in roll_legs %} + + + + + + + + + {% else %} + + {% endfor %} +
#方式张数新SL状态
{{ leg.leg_index }}{{ leg.roll_group_id }}{{ leg.add_mode }}{{ leg.amount }}{{ leg.new_stop_loss }}{{ leg.status }}
暂无
+
+
+ + 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` 轮询)。