diff --git a/hub_volume_rank_lib.py b/hub_volume_rank_lib.py index e5b05d7..1e7a2c4 100644 --- a/hub_volume_rank_lib.py +++ b/hub_volume_rank_lib.py @@ -292,8 +292,7 @@ def _scores_from_binance(exchange) -> list[tuple[str, str, float]]: return _merge_scores(by_base) except Exception: pass - tickers = exchange.fetch_tickers() - return _scores_from_markets(exchange, tickers or {}, "binance") + return [] def _scores_from_gate(exchange) -> list[tuple[str, str, float]]: @@ -330,8 +329,7 @@ def _scores_from_gate(exchange) -> list[tuple[str, str, float]]: return _merge_scores(by_base) except Exception: continue - tickers = exchange.fetch_tickers() - return _scores_from_markets(exchange, tickers or {}, "gateio") + return [] def _scores_from_markets( @@ -373,6 +371,11 @@ def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]: return _scores_from_markets(exchange, tickers or {}, ex_id) +def _uses_lightweight_volume_scores(exchange_id: str) -> bool: + ex_id = str(exchange_id or "").lower() + return ex_id in ("okx", "binance", "gateio", "gate", "gate_bot") + + def build_usdt_swap_volume_ranks( exchange, ensure_markets_loaded: Callable[[], None], @@ -383,8 +386,9 @@ def build_usdt_swap_volume_ranks( 全市场 USDT 永续 24h 成交额排名(base -> rank)。 优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化)。 """ - ensure_markets_loaded() ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower() + if not _uses_lightweight_volume_scores(ex_id): + ensure_markets_loaded() scored = _collect_scores(exchange, ex_id) ranks: dict[str, int] = {} for idx, (_sym, base, _qv) in enumerate(scored, 1): @@ -417,10 +421,11 @@ def resolve_daily_volume_rank( ensure_markets_loaded, exchange_id=exchange_id, ) - cache["ranks"] = ranks - cache["total"] = total - cache["version"] = cache_version - cache["updated_at"] = now_ts + if total > 0 and ranks: + cache["ranks"] = ranks + cache["total"] = total + cache["version"] = cache_version + cache["updated_at"] = now_ts except Exception: pass ranks = cache.get("ranks") or {} diff --git a/strategy_db.py b/strategy_db.py index 8aef6a7..9c5f889 100644 --- a/strategy_db.py +++ b/strategy_db.py @@ -154,6 +154,7 @@ def init_strategy_tables(conn) -> None: "ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT", "ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT", "ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT", + "ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL", ): try: conn.execute(ddl) diff --git a/strategy_register.py b/strategy_register.py index b736ed4..edf6ec2 100644 --- a/strategy_register.py +++ b/strategy_register.py @@ -9,7 +9,7 @@ from flask import Flask, flash, jsonify, redirect, request, url_for from jinja2 import ChoiceLoader, FileSystemLoader from strategy_db import init_strategy_tables -from strategy_roll_lib import preview_roll +from strategy_roll_lib import preview_roll, roll_stop_after_fill def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None: @@ -168,10 +168,29 @@ def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> di 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": "止损或风险%格式错误"} + return {"ok": False, "msg": "风险%格式错误"} + stop_offset_raw = data.get("stop_offset_pct") + if stop_offset_raw in (None, ""): + stop_offset_raw = data.get("new_stop_loss") or data.get("sl") + new_sl_abs = None + stop_offset_pct = None + if data.get("stop_offset_pct") not in (None, ""): + try: + stop_offset_pct = float(data.get("stop_offset_pct")) + except (TypeError, ValueError): + return {"ok": False, "msg": "止损偏移%格式错误"} + elif data.get("new_stop_loss") not in (None, "") or data.get("sl") not in (None, ""): + try: + new_sl_abs = float(data.get("new_stop_loss") or data.get("sl")) + except (TypeError, ValueError): + return {"ok": False, "msg": "止损格式错误"} + elif stop_offset_raw not in (None, ""): + try: + new_sl_abs = float(stop_offset_raw) + except (TypeError, ValueError): + return {"ok": False, "msg": "止损格式错误"} conn_cap = get_db() try: capital = float(cfg["get_trading_capital_usdt"](conn_cap)) @@ -193,7 +212,8 @@ def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> di entry_existing=entry, initial_take_profit=tp0, add_mode=add_mode, - new_stop_loss=new_sl, + new_stop_loss=new_sl_abs, + stop_offset_pct=stop_offset_pct, risk_percent=risk_pct, capital_base_usdt=capital, add_price=float(live) if live else None, @@ -243,12 +263,27 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: return False, "监控单已不存在" rg, legs_done, roll_is_new = _get_or_create_roll_group_meta(conn, mon) new_sl = float(preview["new_stop_loss"]) + stop_offset_pct = preview.get("stop_offset_pct") tp0 = float(preview["initial_take_profit"]) + qty_before = float(preview.get("qty_existing") or 0) + entry_before = float(preview.get("entry_existing") or 0) 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 "" + if stop_offset_pct is not None and qty_before > 0 and entry_before > 0: + new_sl = roll_stop_after_fill( + direction, + qty_before, + entry_before, + float(amount), + fill, + stop_offset_pct=float(stop_offset_pct), + ) + px_fn = cfg.get("price_to_precision") + if callable(px_fn): + new_sl = float(px_fn(ex_sym, new_sl) or new_sl) else: price = cfg["price_to_precision"](ex_sym, float(preview["add_price"])) order = cfg["limit_add"](ex_sym, direction, amount, price, leverage) @@ -256,8 +291,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: 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 (?,?,?,?,?,?,?,?,?,?,?)""", + amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( rg["id"], legs_done + 1, @@ -267,6 +302,7 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: price, amount, new_sl, + stop_offset_pct, oid, "pending", cfg["app_now_str"](), @@ -297,8 +333,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: 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 (?,?,?,?,?,?,?,?,?,?,?,?)""", + fill_price, amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( rg["id"], legs_done + 1, @@ -309,6 +345,7 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: fill, amount, new_sl, + stop_offset_pct, oid, "filled", cfg["app_now_str"](), diff --git a/strategy_roll_lib.py b/strategy_roll_lib.py index 94accab..ec90643 100644 --- a/strategy_roll_lib.py +++ b/strategy_roll_lib.py @@ -7,6 +7,7 @@ from fib_key_monitor_lib import calc_fib_plan, fib_ratio_from_type ROLL_MAX_LEGS_LONG = 3 ROLL_MAX_LEGS_SHORT = 3 +ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0 FIB_MODES = frozenset({"fib_618", "fib_786"}) @@ -42,6 +43,105 @@ 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 resolve_roll_stop_spec( + *, + new_stop_loss: Optional[float] = None, + stop_offset_pct: Optional[float] = None, + entry_ref: float = 0.0, +) -> tuple[str, float]: + """ + 解析滚仓止损输入。 + - stop_offset_pct:相对合并均价的偏移%,如 1 表示 1%(多:均价下方;空:均价上方)。 + - new_stop_loss:兼容旧版绝对止损价;若数值很小(如 1.0)且相对均价过低,视为偏移%。 + """ + if stop_offset_pct is not None: + try: + pct = float(stop_offset_pct) + if pct > 0: + return "offset", pct + except (TypeError, ValueError): + pass + if new_stop_loss is not None: + try: + sl = float(new_stop_loss) + if sl > 0: + ref = float(entry_ref or 0) + if ref > 0 and sl <= min(30.0, ref * 0.25): + return "offset", sl + return "absolute", sl + except (TypeError, ValueError): + pass + return "offset", ROLL_STOP_OFFSET_PCT_DEFAULT + + +def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float: + """合并均价 ± offset% 作为新统一止损(非保本)。""" + avg_f = float(avg) + pct = float(offset_pct) / 100.0 + if avg_f <= 0 or pct <= 0: + return 0.0 + direction = (direction or "long").strip().lower() + if direction == "short": + return avg_f * (1.0 + pct) + return avg_f * (1.0 - pct) + + +def avg_entry_after_add( + qty_existing: float, + entry_existing: float, + add_qty: float, + add_price: float, +) -> float: + q1 = float(qty_existing) + e1 = float(entry_existing) + q2 = float(add_qty) + e2 = float(add_price) + total = q1 + q2 + if total <= 0: + return 0.0 + return (q1 * e1 + q2 * e2) / total + + +def solve_add_amount_for_avg_stop_offset( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + offset_pct: float, + risk_budget_usdt: float, +) -> Tuple[Optional[float], Optional[str]]: + """ + 合并后止损 = 合并均价 ± offset%,且触及止损时总亏损 ≈ risk_budget。 + loss = offset% × (Q1·E1 + Q2·E2) => Q2 = (B/p − Q1·E1) / E2 + """ + try: + q1 = float(qty_existing) + e1 = float(entry_existing) + e2 = float(add_price) + b = float(risk_budget_usdt) + p = float(offset_pct) / 100.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0: + return None, "持仓或风险预算无效" + if p <= 0 or p >= 1: + return None, "止损偏移%须大于 0 且小于 100" + direction = (direction or "long").strip().lower() + need_notional = b / p + q2 = (need_notional - q1 * e1) / e2 + if q2 <= 0: + return None, "按当前偏移%与总风险%,无需加仓或无法再加(已满足风险上限)" + new_avg = avg_entry_after_add(q1, e1, q2, e2) + sl = unified_stop_from_avg(direction, new_avg, offset_pct) + if direction == "short": + if sl <= e2: + return None, "做空:合并后止损须高于加仓价(请减小偏移%或风险%)" + else: + if sl >= e2: + return None, "做多:合并后止损须低于加仓价(请减小偏移%或风险%)" + return q2, None + + def solve_add_amount_for_total_risk( direction: str, qty_existing: float, @@ -90,7 +190,8 @@ def preview_roll( entry_existing: float, initial_take_profit: float, add_mode: str, - new_stop_loss: float, + new_stop_loss: Optional[float] = None, + stop_offset_pct: Optional[float] = None, risk_percent: float, capital_base_usdt: float, add_price: Optional[float] = None, @@ -117,31 +218,49 @@ def preview_roll( 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" + return None, "止盈格式错误" + if tp <= 0: + return None, "首仓止盈须大于0" + stop_mode, stop_val = resolve_roll_stop_spec( + new_stop_loss=new_stop_loss, + stop_offset_pct=stop_offset_pct, + entry_ref=entry_existing, + ) 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 - ) + offset_pct: Optional[float] = None + if stop_mode == "offset": + offset_pct = float(stop_val) + q2_raw, err = solve_add_amount_for_avg_stop_offset( + direction, qty_existing, entry_existing, entry_add, offset_pct, risk_budget + ) + else: + sl = float(stop_val) + if sl <= 0: + return None, "止损须大于0" + if direction == "long": + if sl >= entry_add: + return None, "做多:新止损须低于加仓价" + else: + if sl <= entry_add: + return None, "做空:新止损须高于加仓价" + 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 + new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) + if stop_mode == "offset": + sl = unified_stop_from_avg(direction, new_avg, offset_pct) if direction == "long": loss_at_sl = (new_avg - sl) * new_qty reward_at_tp = (tp - new_avg) * new_qty @@ -154,11 +273,15 @@ def preview_roll( "add_mode": mode, "add_mode_label": mode_label, "add_price": round(entry_add, 10), - "new_stop_loss": sl, + "new_stop_loss": round(sl, 10), + "stop_offset_pct": offset_pct, + "stop_mode": stop_mode, "initial_take_profit": tp, "risk_percent": float(risk_percent), "risk_budget_usdt": round(risk_budget, 4), "add_amount_raw": q2, + "qty_existing": float(qty_existing), + "entry_existing": float(entry_existing), "qty_after": new_qty, "avg_entry_after": round(new_avg, 10), "loss_at_sl_usdt": round(loss_at_sl, 4), @@ -168,3 +291,20 @@ def preview_roll( "fib_upper": fib_upper, "fib_lower": fib_lower, }, None + + +def roll_stop_after_fill( + direction: str, + qty_before: float, + entry_before: float, + add_qty: float, + fill_price: float, + *, + stop_offset_pct: Optional[float] = None, + absolute_stop: Optional[float] = None, +) -> float: + """成交后按合并均价重算统一止损(偏移%模式)或沿用绝对止损。""" + if stop_offset_pct is not None and float(stop_offset_pct) > 0: + avg = avg_entry_after_add(qty_before, entry_before, add_qty, fill_price) + return unified_stop_from_avg(direction, avg, float(stop_offset_pct)) + return float(absolute_stop or 0) diff --git a/strategy_roll_monitor_lib.py b/strategy_roll_monitor_lib.py index f748a10..9aa09b6 100644 --- a/strategy_roll_monitor_lib.py +++ b/strategy_roll_monitor_lib.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, Optional from fib_key_monitor_lib import fib_invalidate_by_mark +from strategy_roll_lib import unified_stop_from_avg from strategy_db import init_strategy_tables ROLL_LEG_STATUS_LABELS = { @@ -218,11 +219,31 @@ def _finalize_roll_leg_fill( leg_id = int(leg["id"]) gid = int(group["id"]) new_sl = float(leg.get("new_stop_loss") or 0) + stop_offset_pct = leg.get("stop_offset_pct") tp0 = float(group.get("initial_take_profit") or 0) fill_px = float(leg.get("limit_price") or mark) + add_qty = float(leg.get("amount") or 0) + if stop_offset_pct not in (None, ""): + try: + offset_pct = float(stop_offset_pct) + except (TypeError, ValueError): + offset_pct = 0.0 + if offset_pct > 0: + pos = cfg["get_position"](ex_sym, direction) or {} + avg = float(pos.get("entry_price") or 0) + if avg <= 0 and add_qty > 0: + avg = fill_px + if avg > 0: + new_sl = unified_stop_from_avg(direction, avg, offset_pct) + px_fn = cfg.get("price_to_precision") + if callable(px_fn): + try: + new_sl = float(px_fn(ex_sym, new_sl) or new_sl) + except Exception: + pass conn.execute( - "UPDATE roll_legs SET status='filled', fill_price=? WHERE id=? AND status='pending'", - (fill_px, leg_id), + "UPDATE roll_legs SET status='filled', fill_price=?, new_stop_loss=? WHERE id=? AND status='pending'", + (fill_px, new_sl, leg_id), ) if new_sl > 0: conn.execute( diff --git a/strategy_templates/strategy_roll.html b/strategy_templates/strategy_roll.html index 281baa2..8649b79 100644 --- a/strategy_templates/strategy_roll.html +++ b/strategy_templates/strategy_roll.html @@ -48,7 +48,7 @@