Fix Gate/Binance memory regression and roll stop offset from avg.
Stop fetch_tickers fallback for volume rank and keep stale cache on failed refresh. Compute roll unified stop as merge-average plus offset percent instead of break-even. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-5
@@ -292,8 +292,7 @@ def _scores_from_binance(exchange) -> list[tuple[str, str, float]]:
|
|||||||
return _merge_scores(by_base)
|
return _merge_scores(by_base)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
tickers = exchange.fetch_tickers()
|
return []
|
||||||
return _scores_from_markets(exchange, tickers or {}, "binance")
|
|
||||||
|
|
||||||
|
|
||||||
def _scores_from_gate(exchange) -> list[tuple[str, str, float]]:
|
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)
|
return _merge_scores(by_base)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
tickers = exchange.fetch_tickers()
|
return []
|
||||||
return _scores_from_markets(exchange, tickers or {}, "gateio")
|
|
||||||
|
|
||||||
|
|
||||||
def _scores_from_markets(
|
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)
|
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(
|
def build_usdt_swap_volume_ranks(
|
||||||
exchange,
|
exchange,
|
||||||
ensure_markets_loaded: Callable[[], None],
|
ensure_markets_loaded: Callable[[], None],
|
||||||
@@ -383,8 +386,9 @@ def build_usdt_swap_volume_ranks(
|
|||||||
全市场 USDT 永续 24h 成交额排名(base -> rank)。
|
全市场 USDT 永续 24h 成交额排名(base -> rank)。
|
||||||
优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化)。
|
优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化)。
|
||||||
"""
|
"""
|
||||||
ensure_markets_loaded()
|
|
||||||
ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower()
|
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)
|
scored = _collect_scores(exchange, ex_id)
|
||||||
ranks: dict[str, int] = {}
|
ranks: dict[str, int] = {}
|
||||||
for idx, (_sym, base, _qv) in enumerate(scored, 1):
|
for idx, (_sym, base, _qv) in enumerate(scored, 1):
|
||||||
@@ -417,6 +421,7 @@ def resolve_daily_volume_rank(
|
|||||||
ensure_markets_loaded,
|
ensure_markets_loaded,
|
||||||
exchange_id=exchange_id,
|
exchange_id=exchange_id,
|
||||||
)
|
)
|
||||||
|
if total > 0 and ranks:
|
||||||
cache["ranks"] = ranks
|
cache["ranks"] = ranks
|
||||||
cache["total"] = total
|
cache["total"] = total
|
||||||
cache["version"] = cache_version
|
cache["version"] = cache_version
|
||||||
|
|||||||
@@ -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 monitor_type TEXT",
|
||||||
"ALTER TABLE order_monitors ADD COLUMN key_signal_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 trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT",
|
||||||
|
"ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL",
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
conn.execute(ddl)
|
conn.execute(ddl)
|
||||||
|
|||||||
+45
-8
@@ -9,7 +9,7 @@ from flask import Flask, flash, jsonify, redirect, request, url_for
|
|||||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
|
|
||||||
from strategy_db import init_strategy_tables
|
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:
|
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)
|
tp0 = float(mon.get("take_profit") or rg.get("initial_take_profit") or 0)
|
||||||
add_mode = (data.get("add_mode") or "market").strip().lower()
|
add_mode = (data.get("add_mode") or "market").strip().lower()
|
||||||
try:
|
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))
|
risk_pct = float(data.get("risk_percent") or cfg.get("default_risk_percent", 2))
|
||||||
except (TypeError, ValueError):
|
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()
|
conn_cap = get_db()
|
||||||
try:
|
try:
|
||||||
capital = float(cfg["get_trading_capital_usdt"](conn_cap))
|
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,
|
entry_existing=entry,
|
||||||
initial_take_profit=tp0,
|
initial_take_profit=tp0,
|
||||||
add_mode=add_mode,
|
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,
|
risk_percent=risk_pct,
|
||||||
capital_base_usdt=capital,
|
capital_base_usdt=capital,
|
||||||
add_price=float(live) if live else None,
|
add_price=float(live) if live else None,
|
||||||
@@ -243,12 +263,27 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
|
|||||||
return False, "监控单已不存在"
|
return False, "监控单已不存在"
|
||||||
rg, legs_done, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
|
rg, legs_done, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
|
||||||
new_sl = float(preview["new_stop_loss"])
|
new_sl = float(preview["new_stop_loss"])
|
||||||
|
stop_offset_pct = preview.get("stop_offset_pct")
|
||||||
tp0 = float(preview["initial_take_profit"])
|
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":
|
if add_mode == "market":
|
||||||
order = cfg["market_add"](ex_sym, direction, amount, leverage)
|
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"])
|
fill = float(cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, preview["add_price"]) or preview["add_price"])
|
||||||
status = "filled"
|
status = "filled"
|
||||||
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
|
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:
|
else:
|
||||||
price = cfg["price_to_precision"](ex_sym, float(preview["add_price"]))
|
price = cfg["price_to_precision"](ex_sym, float(preview["add_price"]))
|
||||||
order = cfg["limit_add"](ex_sym, direction, amount, price, leverage)
|
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(
|
conn.execute(
|
||||||
"""INSERT INTO roll_legs (
|
"""INSERT INTO roll_legs (
|
||||||
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
|
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
|
||||||
amount, new_stop_loss, exchange_order_id, status, created_at
|
amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
rg["id"],
|
rg["id"],
|
||||||
legs_done + 1,
|
legs_done + 1,
|
||||||
@@ -267,6 +302,7 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
|
|||||||
price,
|
price,
|
||||||
amount,
|
amount,
|
||||||
new_sl,
|
new_sl,
|
||||||
|
stop_offset_pct,
|
||||||
oid,
|
oid,
|
||||||
"pending",
|
"pending",
|
||||||
cfg["app_now_str"](),
|
cfg["app_now_str"](),
|
||||||
@@ -297,8 +333,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO roll_legs (
|
"""INSERT INTO roll_legs (
|
||||||
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
|
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
|
fill_price, amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
rg["id"],
|
rg["id"],
|
||||||
legs_done + 1,
|
legs_done + 1,
|
||||||
@@ -309,6 +345,7 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
|
|||||||
fill,
|
fill,
|
||||||
amount,
|
amount,
|
||||||
new_sl,
|
new_sl,
|
||||||
|
stop_offset_pct,
|
||||||
oid,
|
oid,
|
||||||
"filled",
|
"filled",
|
||||||
cfg["app_now_str"](),
|
cfg["app_now_str"](),
|
||||||
|
|||||||
+151
-11
@@ -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_LONG = 3
|
||||||
ROLL_MAX_LEGS_SHORT = 3
|
ROLL_MAX_LEGS_SHORT = 3
|
||||||
|
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
|
||||||
FIB_MODES = frozenset({"fib_618", "fib_786"})
|
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
|
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(
|
def solve_add_amount_for_total_risk(
|
||||||
direction: str,
|
direction: str,
|
||||||
qty_existing: float,
|
qty_existing: float,
|
||||||
@@ -90,7 +190,8 @@ def preview_roll(
|
|||||||
entry_existing: float,
|
entry_existing: float,
|
||||||
initial_take_profit: float,
|
initial_take_profit: float,
|
||||||
add_mode: str,
|
add_mode: str,
|
||||||
new_stop_loss: float,
|
new_stop_loss: Optional[float] = None,
|
||||||
|
stop_offset_pct: Optional[float] = None,
|
||||||
risk_percent: float,
|
risk_percent: float,
|
||||||
capital_base_usdt: float,
|
capital_base_usdt: float,
|
||||||
add_price: Optional[float] = None,
|
add_price: Optional[float] = None,
|
||||||
@@ -117,23 +218,39 @@ def preview_roll(
|
|||||||
else:
|
else:
|
||||||
return None, "加仓方式无效"
|
return None, "加仓方式无效"
|
||||||
try:
|
try:
|
||||||
sl = float(new_stop_loss)
|
|
||||||
tp = float(initial_take_profit)
|
tp = float(initial_take_profit)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None, "止损/止盈格式错误"
|
return None, "止盈格式错误"
|
||||||
if sl <= 0 or tp <= 0:
|
if tp <= 0:
|
||||||
return None, "止损与首仓止盈须大于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 direction == "long":
|
||||||
if sl >= entry_add:
|
|
||||||
return None, "做多:新止损须低于加仓价"
|
|
||||||
if tp <= entry_existing:
|
if tp <= entry_existing:
|
||||||
return None, "做多:首仓止盈须高于当前持仓均价参考"
|
return None, "做多:首仓止盈须高于当前持仓均价参考"
|
||||||
else:
|
else:
|
||||||
if sl <= entry_add:
|
|
||||||
return None, "做空:新止损须高于加仓价"
|
|
||||||
if tp >= entry_existing:
|
if tp >= entry_existing:
|
||||||
return None, "做空:首仓止盈须低于当前持仓均价参考"
|
return None, "做空:首仓止盈须低于当前持仓均价参考"
|
||||||
risk_budget = float(capital_base_usdt) * (float(risk_percent) / 100.0)
|
risk_budget = float(capital_base_usdt) * (float(risk_percent) / 100.0)
|
||||||
|
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(
|
q2_raw, err = solve_add_amount_for_total_risk(
|
||||||
direction, qty_existing, entry_existing, entry_add, sl, risk_budget
|
direction, qty_existing, entry_existing, entry_add, sl, risk_budget
|
||||||
)
|
)
|
||||||
@@ -141,7 +258,9 @@ def preview_roll(
|
|||||||
return None, err
|
return None, err
|
||||||
q2 = float(q2_raw)
|
q2 = float(q2_raw)
|
||||||
new_qty = qty_existing + q2
|
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":
|
if direction == "long":
|
||||||
loss_at_sl = (new_avg - sl) * new_qty
|
loss_at_sl = (new_avg - sl) * new_qty
|
||||||
reward_at_tp = (tp - new_avg) * new_qty
|
reward_at_tp = (tp - new_avg) * new_qty
|
||||||
@@ -154,11 +273,15 @@ def preview_roll(
|
|||||||
"add_mode": mode,
|
"add_mode": mode,
|
||||||
"add_mode_label": mode_label,
|
"add_mode_label": mode_label,
|
||||||
"add_price": round(entry_add, 10),
|
"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,
|
"initial_take_profit": tp,
|
||||||
"risk_percent": float(risk_percent),
|
"risk_percent": float(risk_percent),
|
||||||
"risk_budget_usdt": round(risk_budget, 4),
|
"risk_budget_usdt": round(risk_budget, 4),
|
||||||
"add_amount_raw": q2,
|
"add_amount_raw": q2,
|
||||||
|
"qty_existing": float(qty_existing),
|
||||||
|
"entry_existing": float(entry_existing),
|
||||||
"qty_after": new_qty,
|
"qty_after": new_qty,
|
||||||
"avg_entry_after": round(new_avg, 10),
|
"avg_entry_after": round(new_avg, 10),
|
||||||
"loss_at_sl_usdt": round(loss_at_sl, 4),
|
"loss_at_sl_usdt": round(loss_at_sl, 4),
|
||||||
@@ -168,3 +291,20 @@ def preview_roll(
|
|||||||
"fib_upper": fib_upper,
|
"fib_upper": fib_upper,
|
||||||
"fib_lower": fib_lower,
|
"fib_lower": fib_lower,
|
||||||
}, None
|
}, 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)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fib_key_monitor_lib import fib_invalidate_by_mark
|
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
|
from strategy_db import init_strategy_tables
|
||||||
|
|
||||||
ROLL_LEG_STATUS_LABELS = {
|
ROLL_LEG_STATUS_LABELS = {
|
||||||
@@ -218,11 +219,31 @@ def _finalize_roll_leg_fill(
|
|||||||
leg_id = int(leg["id"])
|
leg_id = int(leg["id"])
|
||||||
gid = int(group["id"])
|
gid = int(group["id"])
|
||||||
new_sl = float(leg.get("new_stop_loss") or 0)
|
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)
|
tp0 = float(group.get("initial_take_profit") or 0)
|
||||||
fill_px = float(leg.get("limit_price") or mark)
|
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(
|
conn.execute(
|
||||||
"UPDATE roll_legs SET status='filled', fill_price=? WHERE id=? AND status='pending'",
|
"UPDATE roll_legs SET status='filled', fill_price=?, new_stop_loss=? WHERE id=? AND status='pending'",
|
||||||
(fill_px, leg_id),
|
(fill_px, new_sl, leg_id),
|
||||||
)
|
)
|
||||||
if new_sl > 0:
|
if new_sl > 0:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<h2 style="margin:0 0 8px">规则说明</h2>
|
<h2 style="margin:0 0 8px">规则说明</h2>
|
||||||
<div class="rule-tip">
|
<div class="rule-tip">
|
||||||
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
|
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
|
||||||
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>新统一止损</strong>,总风险%按「合并持仓打到新止损≈账户风险」反推张数。<br>
|
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>止损偏移%</strong>(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数。<br>
|
||||||
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
|
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
|
||||||
{% if trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
|
{% if trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<input name="fib_upper" step="any" placeholder="上沿 H">
|
<input name="fib_upper" step="any" placeholder="上沿 H">
|
||||||
<input name="fib_lower" step="any" placeholder="下沿 L">
|
<input name="fib_lower" step="any" placeholder="下沿 L">
|
||||||
<input name="new_stop_loss" step="any" placeholder="新统一止损" required>
|
<input name="stop_offset_pct" type="number" min="0.01" step="0.01" value="1" placeholder="止损偏移%(合并均价)" required>
|
||||||
<input name="risk_percent" type="number" min="0.1" step="0.1" value="{{ default_risk_percent }}" placeholder="总风险%">
|
<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>
|
<button type="submit" {% if trend_active %}disabled{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary>
|
<summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary>
|
||||||
<div class="tip-collapse-body rule-tip">
|
<div class="tip-collapse-body rule-tip">
|
||||||
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
|
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
|
||||||
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>新统一止损</strong>,总风险%按「合并持仓打到新止损≈账户风险」反推张数。<br>
|
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>止损偏移%</strong>(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数。<br>
|
||||||
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
|
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
|
||||||
{% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
|
{% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<input name="fib_upper" step="any" placeholder="上沿 H">
|
<input name="fib_upper" step="any" placeholder="上沿 H">
|
||||||
<input name="fib_lower" step="any" placeholder="下沿 L">
|
<input name="fib_lower" step="any" placeholder="下沿 L">
|
||||||
<input name="new_stop_loss" step="any" placeholder="新统一止损" required>
|
<input name="stop_offset_pct" type="number" min="0.01" step="0.01" value="1" placeholder="止损偏移%(合并均价)" required>
|
||||||
<input name="risk_percent" type="number" min="0.1" step="0.1" value="{{ default_risk_percent }}" placeholder="总风险%">
|
<input name="risk_percent" type="number" min="0.1" step="0.1" value="{{ default_risk_percent }}" placeholder="总风险%">
|
||||||
<button type="submit" {% if roll_trend_active %}disabled style="opacity:.5"{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
|
<button type="submit" {% if roll_trend_active %}disabled style="opacity:.5"{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ def test_scores_from_binance_uses_fapi_lightweight_api():
|
|||||||
ex.fetch_tickers.assert_not_called()
|
ex.fetch_tickers.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scores_from_binance_skips_fetch_tickers_on_api_error():
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.id = "binance"
|
||||||
|
ex.fapiPublicGetTicker24hr.side_effect = RuntimeError("network")
|
||||||
|
scored = _scores_from_binance(ex)
|
||||||
|
assert scored == []
|
||||||
|
ex.fetch_tickers.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_scores_from_gate_uses_futures_tickers_api():
|
def test_scores_from_gate_uses_futures_tickers_api():
|
||||||
ex = MagicMock()
|
ex = MagicMock()
|
||||||
ex.id = "gateio"
|
ex.id = "gateio"
|
||||||
@@ -96,6 +105,15 @@ def test_scores_from_gate_uses_futures_tickers_api():
|
|||||||
ex.fetch_tickers.assert_not_called()
|
ex.fetch_tickers.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scores_from_gate_skips_fetch_tickers_on_api_error():
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.id = "gateio"
|
||||||
|
ex.publicFuturesGetSettleTickers.side_effect = RuntimeError("network")
|
||||||
|
scored = _scores_from_gate(ex)
|
||||||
|
assert scored == []
|
||||||
|
ex.fetch_tickers.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_daily_volume_rank_caches_result():
|
def test_resolve_daily_volume_rank_caches_result():
|
||||||
cache = {"version": 0, "updated_at": 0.0, "ranks": {}, "total": 0}
|
cache = {"version": 0, "updated_at": 0.0, "ranks": {}, "total": 0}
|
||||||
ex = MagicMock()
|
ex = MagicMock()
|
||||||
@@ -130,6 +148,31 @@ def test_resolve_daily_volume_rank_caches_result():
|
|||||||
assert ex.fapiPublicGetTicker24hr.call_count == calls
|
assert ex.fapiPublicGetTicker24hr.call_count == calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_daily_volume_rank_keeps_stale_cache_when_refresh_empty():
|
||||||
|
cache = {
|
||||||
|
"version": LIQUIDITY_RANK_CACHE_VERSION,
|
||||||
|
"updated_at": 900.0,
|
||||||
|
"ranks": {"BTC": 1},
|
||||||
|
"total": 100,
|
||||||
|
}
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.id = "binance"
|
||||||
|
ex.fapiPublicGetTicker24hr.return_value = []
|
||||||
|
|
||||||
|
rank, total = resolve_daily_volume_rank(
|
||||||
|
"BTC",
|
||||||
|
cache,
|
||||||
|
now_ts=2000.0,
|
||||||
|
ttl_sec=60.0,
|
||||||
|
exchange=ex,
|
||||||
|
ensure_markets_loaded=lambda: None,
|
||||||
|
)
|
||||||
|
assert rank == 1
|
||||||
|
assert total == 100
|
||||||
|
assert cache["updated_at"] == 900.0
|
||||||
|
ex.fetch_tickers.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_build_usdt_swap_volume_ranks():
|
def test_build_usdt_swap_volume_ranks():
|
||||||
ex = MagicMock()
|
ex = MagicMock()
|
||||||
ex.id = "binance"
|
ex.id = "binance"
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from strategy_roll_lib import (
|
||||||
|
preview_roll,
|
||||||
|
resolve_roll_stop_spec,
|
||||||
|
roll_stop_after_fill,
|
||||||
|
unified_stop_from_avg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_roll_stop_spec_treats_small_value_as_offset_pct():
|
||||||
|
mode, val = resolve_roll_stop_spec(new_stop_loss=1.0, entry_ref=63.976)
|
||||||
|
assert mode == "offset"
|
||||||
|
assert val == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_roll_stop_spec_treats_price_as_absolute():
|
||||||
|
mode, val = resolve_roll_stop_spec(new_stop_loss=64.6, entry_ref=63.976)
|
||||||
|
assert mode == "absolute"
|
||||||
|
assert val == 64.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_stop_from_avg_short_one_percent():
|
||||||
|
sl = unified_stop_from_avg("short", 63.976, 1.0)
|
||||||
|
assert abs(sl - 63.976 * 1.01) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_roll_offset_mode_not_breakeven():
|
||||||
|
preview, err = preview_roll(
|
||||||
|
direction="short",
|
||||||
|
symbol="HYPE/USDT",
|
||||||
|
qty_existing=3.0,
|
||||||
|
entry_existing=65.0,
|
||||||
|
initial_take_profit=60.0,
|
||||||
|
add_mode="market",
|
||||||
|
stop_offset_pct=1.0,
|
||||||
|
risk_percent=2.0,
|
||||||
|
capital_base_usdt=1000.0,
|
||||||
|
add_price=64.0,
|
||||||
|
legs_done=1,
|
||||||
|
)
|
||||||
|
assert err is None
|
||||||
|
assert preview["stop_mode"] == "offset"
|
||||||
|
assert preview["stop_offset_pct"] == 1.0
|
||||||
|
avg = preview["avg_entry_after"]
|
||||||
|
sl = preview["new_stop_loss"]
|
||||||
|
assert sl > avg * 1.009
|
||||||
|
assert sl < avg * 1.011
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_stop_after_fill_recomputes_from_actual_fill():
|
||||||
|
sl = roll_stop_after_fill(
|
||||||
|
"short",
|
||||||
|
qty_before=3.0,
|
||||||
|
entry_before=65.0,
|
||||||
|
add_qty=5.0,
|
||||||
|
fill_price=63.5,
|
||||||
|
stop_offset_pct=1.0,
|
||||||
|
)
|
||||||
|
avg = (3 * 65.0 + 5 * 63.5) / 8.0
|
||||||
|
assert abs(sl - avg * 1.01) < 1e-6
|
||||||
Reference in New Issue
Block a user