增加策略交易

This commit is contained in:
dekun
2026-05-23 10:48:50 +08:00
parent ee5dc614e0
commit 103615d7a9
21 changed files with 1278 additions and 29 deletions
+1
View File
@@ -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` 与四者 **进程独立**,无需改四者代码即可并行使用。
+9
View File
@@ -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
@@ -218,6 +218,8 @@
<div class="top-nav">
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/strategy/trend" class="{% if page == 'strategy_trend' %}active{% endif %}">策略·趋势回调</a>
<a href="/strategy/roll" class="{% if page == 'strategy_roll' %}active{% endif %}">策略·顺势加仓</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
+9
View File
@@ -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
+2
View File
@@ -218,6 +218,8 @@
<div class="top-nav">
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/strategy/trend" class="{% if page == 'strategy_trend' %}active{% endif %}">策略·趋势回调</a>
<a href="/strategy/roll" class="{% if page == 'strategy_roll' %}active{% endif %}">策略·顺势加仓</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
+52 -26
View File
@@ -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/<int:pid>", 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/<int:pid>")
@@ -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()
+5 -3
View File
@@ -205,6 +205,8 @@
</div>
<div class="top-nav">
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">交易执行</a>
<a href="/strategy/trend" class="{% if page == 'strategy_trend' %}active{% endif %}">策略·趋势回调</a>
<a href="/strategy/roll" class="{% if page == 'strategy_roll' %}active{% endif %}">策略·顺势加仓</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
@@ -367,7 +369,9 @@
</div>
</div>
</div>
</div>
</div>
{% elif page == 'strategy_trend' %}
<div class="card trend-card">
<h2 style="margin-bottom:8px">趋势回调策略</h2>
<div class="rule-tip">
@@ -549,8 +553,6 @@
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if page == 'records' %}
+9
View File
@@ -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
+2
View File
@@ -154,6 +154,8 @@
<div class="header"><h1>加密货币|交易监控 + AI复盘一体化</h1></div>
<div class="top-nav">
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">交易执行</a>
<a href="/strategy/trend" class="{% if page == 'strategy_trend' %}active{% endif %}">策略·趋势回调</a>
<a href="/strategy/roll" class="{% if page == 'strategy_roll' %}active{% endif %}">策略·顺势加仓</a>
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
</div>
+114
View File
@@ -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,
}
+43
View File
@@ -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)
+48
View File
@@ -0,0 +1,48 @@
"""交易所策略适配器接口(各所 app 注入 ccxt 实现)。"""
from __future__ import annotations
from typing import Any, Optional, Protocol
class StrategyExchangeAdapter(Protocol):
exchange_key: str
def normalize_symbol(self, raw: str) -> str: ...
def normalize_exchange_symbol(self, symbol: str) -> str: ...
def get_mark_price(self, symbol: str) -> Optional[float]: ...
def get_position(self, exchange_symbol: str, direction: str) -> dict[str, Any]:
"""返回 {contracts, entry_price, leverage?}。"""
...
def amount_to_precision(self, exchange_symbol: str, amount: float) -> Optional[float]: ...
def price_to_precision(self, exchange_symbol: str, price: float) -> Optional[float]: ...
def market_add(
self, exchange_symbol: str, direction: str, amount: float, leverage: int
) -> dict[str, Any]: ...
def limit_add(
self,
exchange_symbol: str,
direction: str,
amount: float,
price: float,
leverage: int,
) -> dict[str, Any]: ...
def cancel_order(self, exchange_symbol: str, order_id: str) -> None: ...
def replace_position_tpsl(
self,
exchange_symbol: str,
direction: str,
stop_loss: float,
take_profit: float,
order_monitor_row: Any = None,
) -> None: ...
def ensure_live_ready(self) -> tuple[bool, str]: ...
+4
View File
@@ -0,0 +1,4 @@
"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+9
View File
@@ -0,0 +1,9 @@
"""
Gate.io USDT 永续 策略交易交易所侧能力
实现方式 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
ccxt 下单精度 TP/SL本文件为文档与类型锚点避免在四个 app 重复实现滚仓公式
"""
from strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+4
View File
@@ -0,0 +1,4 @@
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
+358
View File
@@ -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
+170
View File
@@ -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
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · {{ exchange_display }}</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e6e8ef;margin:0;padding:16px}
.container{max-width:1100px;margin:0 auto}
.top-nav{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
.card{background:#151a2a;border:1px solid #2a3150;border-radius:10px;padding:14px;margin-bottom:12px}
.form-row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
.form-row input,.form-row select{padding:8px 10px;border-radius:6px;border:1px solid #3a4a66;background:#0f1420;color:#eee;min-width:120px}
.form-row button{padding:8px 14px;border-radius:8px;border:none;background:#2d6a4f;color:#fff;cursor:pointer}
.rule-tip{font-size:.8rem;color:#8892b0;line-height:1.5;margin-bottom:10px}
.flash{background:#1f2a44;border:1px solid #3a5a8a;padding:10px;border-radius:8px;margin-bottom:12px}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th,td{border-bottom:1px solid #2a3150;padding:6px 8px;text-align:left}
</style>
</head>
<body>
<div class="container">
<h1>策略交易 · 顺势加仓 <span style="font-size:.85rem;color:#8fc8ff">{{ exchange_display }}</span></h1>
<div class="top-nav">
<a href="/trade">实盘下单</a>
<a href="/strategy/trend">趋势回调</a>
<a href="/strategy/roll" class="active">顺势加仓</a>
<a href="/records">交易复盘</a>
</div>
{% with messages = get_flashed_messages() %}{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}{% endwith %}
<div class="card">
<h2 style="margin:0 0 8px">规则说明</h2>
<div class="rule-tip">
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>新统一止损</strong>,总风险%按「合并持仓打到新止损≈账户风险」反推张数。<br>
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
{% if trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div>
<form action="{{ url_for('strategy_roll_execute') }}" method="post" class="form-row">
<select name="symbol" required>
<option value="">选择持仓币种</option>
{% for o in monitors %}
<option value="{{ o.symbol }}">{{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }}</option>
{% endfor %}
</select>
<select name="direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<select name="add_mode">
<option value="market">市价加仓</option>
<option value="fib_618">限价 斐波0.618</option>
<option value="fib_786">限价 斐波0.786</option>
</select>
<input name="fib_upper" step="any" placeholder="上沿 H">
<input name="fib_lower" step="any" placeholder="下沿 L">
<input name="new_stop_loss" step="any" placeholder="新统一止损" required>
<input name="risk_percent" type="number" min="0.1" step="0.1" value="{{ default_risk_percent }}" placeholder="总风险%">
<button type="submit" {% if trend_active %}disabled{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
</form>
<p class="rule-tip">建议执行前用浏览器开发者工具 POST <code>/strategy/roll/preview</code> 查看 JSON 预览(或将加入页面内预览按钮)。</p>
</div>
<div class="card">
<h3>活跃滚仓组</h3>
<table>
<tr><th>ID</th><th>币种</th><th>方向</th><th>腿数</th><th>首仓TP</th><th>当前SL</th></tr>
{% for g in roll_groups %}
<tr>
<td>{{ g.id }}</td>
<td>{{ g.symbol }}</td>
<td>{{ g.direction }}</td>
<td>{{ g.leg_count }}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
<div class="card">
<h3>最近滚仓腿</h3>
<table>
<tr><th>#</th><th></th><th>方式</th><th>张数</th><th>新SL</th><th>状态</th></tr>
{% for leg in roll_legs %}
<tr>
<td>{{ leg.leg_index }}</td>
<td>{{ leg.roll_group_id }}</td>
<td>{{ leg.add_mode }}</td>
<td>{{ leg.amount }}</td>
<td>{{ leg.new_stop_loss }}</td>
<td>{{ leg.status }}</td>
</tr>
{% else %}
<tr><td colspan="6" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
</div>
</body>
</html>
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>趋势回调 · {{ exchange_display }}</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e6e8ef;padding:24px}
a{color:#8fc8ff;margin-right:10px}
.box{max-width:640px;background:#151a2a;border:1px solid #2a3150;padding:20px;border-radius:10px}
</style>
</head>
<body>
<p><a href="/trade">← 实盘下单</a> <a href="/strategy/roll">顺势加仓</a></p>
<div class="box">
<h1>趋势回调</h1>
<p>{{ trend_note }}</p>
<p style="color:#8892b0;font-size:.9rem">趋势回调含自动补仓档位,仅在 Gate 趋势机器人(crypto_monitor_gate_bot)实例中运行。</p>
</div>
</body>
</html>
+193
View File
@@ -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
+118
View File
@@ -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` 轮询)。