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:
dekun
2026-06-24 00:21:07 +08:00
parent 7f8ae97a98
commit f63f8810e6
9 changed files with 343 additions and 37 deletions
+10 -5
View File
@@ -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,6 +421,7 @@ def resolve_daily_volume_rank(
ensure_markets_loaded,
exchange_id=exchange_id,
)
if total > 0 and ranks:
cache["ranks"] = ranks
cache["total"] = total
cache["version"] = cache_version
+1
View File
@@ -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)
+45 -8
View File
@@ -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"](),
+151 -11
View File
@@ -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,23 +218,39 @@ 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)
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
)
@@ -141,7 +258,9 @@ def preview_roll(
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)
+23 -2
View File
@@ -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(
+2 -2
View File
@@ -48,7 +48,7 @@
<h2 style="margin:0 0 8px">规则说明</h2>
<div class="rule-tip">
<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>
{% if trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div>
@@ -70,7 +70,7 @@
</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="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="总风险%">
<button type="submit" {% if trend_active %}disabled{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
</form>
+2 -2
View File
@@ -4,7 +4,7 @@
<summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary>
<div class="tip-collapse-body rule-tip">
<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>
{% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div>
@@ -27,7 +27,7 @@
</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="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="总风险%">
<button type="submit" {% if roll_trend_active %}disabled style="opacity:.5"{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
</form>
+43
View File
@@ -84,6 +84,15 @@ def test_scores_from_binance_uses_fapi_lightweight_api():
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():
ex = MagicMock()
ex.id = "gateio"
@@ -96,6 +105,15 @@ def test_scores_from_gate_uses_futures_tickers_api():
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():
cache = {"version": 0, "updated_at": 0.0, "ranks": {}, "total": 0}
ex = MagicMock()
@@ -130,6 +148,31 @@ def test_resolve_daily_volume_rank_caches_result():
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():
ex = MagicMock()
ex.id = "binance"
+59
View File
@@ -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