Managed exits (swept + OOS-verified) + bigger armed set
Replace the static -1R/+2R bracket with active, parameterised exit management shared by the backtest and live (strategy/exits.py): move to breakeven, trail under prior-bar lows or by %, scale out, or first-red exit. Swept all variants over 187 sessions with a held-out train/test split (sweep_exits.py), then an adversarial 3-judge workflow picked the robust winner OUT-OF-SAMPLE: trailLow_a1 (trail under the prior bar's low after +1R) — test avgR 0.128 vs the old static_2R 0.067 (~1.9x), win 49% vs 38%. The naive in-sample winner (trailPct10) was correctly rejected as overfit (train 0.684 -> test 0.085). - strategy/exits.py: ExitConfig + simulate_exit (backtest) + manage_live (live) - strategy/backtest/engine.py: simulate the managed exit on entry; Trade.entry_index - runtime/exit_manager.py: live manager — ratchets the broker stop UP via REPLACE/PATCH (never cancel = never naked), scales out, first-red exit - alpaca_paper/client.py: replace_order (PATCH) + partial close_position - run_live_paper.py: 'exits' step (12s); TRIGGER_MAX_ARMED 6 -> 10 - tests: exits sim/live + manager ratchet-never-cancel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
d5d79cccf7
commit
ab5a92447f
8 files changed
+890
-102
No files matched your search
+100
-98
@@ -13,6 +13,7 @@ import pandas as pd
|
||||
|
||||
from config import BacktestConfig, Config
|
||||
from strategy.evaluation.setup_evaluator import evaluate_setup
|
||||
from strategy.exits import ExitConfig, simulate_exit
|
||||
from strategy.risk.position_sizing import PositionSizingConfig, calculate_position_size
|
||||
|
||||
|
||||
@@ -31,6 +32,7 @@ class Trade:
|
||||
realized_pnl: float | None = None
|
||||
r_multiple: float | None = None
|
||||
setup_type: str | None = None
|
||||
entry_index: int | None = None # bar position of the entry-fill bar
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -68,6 +70,7 @@ class BacktestEngine:
|
||||
ready_score_pct: float = 60.0,
|
||||
criteria=None,
|
||||
min_bars: int = 10,
|
||||
exit_config: ExitConfig | None = None,
|
||||
):
|
||||
self.config = config or Config()
|
||||
self.bt: BacktestConfig = self.config.backtest
|
||||
@@ -78,6 +81,8 @@ class BacktestEngine:
|
||||
self.ready_score_pct = ready_score_pct
|
||||
self.criteria = criteria
|
||||
self.min_bars = min_bars
|
||||
# default reproduces the original static bracket (stop / +target_r / EOD)
|
||||
self.exit_config = exit_config or ExitConfig(target_r=target_r)
|
||||
|
||||
def _entry_costs(self, price: float, quantity: int) -> tuple[float, float]:
|
||||
slip = price * self.bt.slippage.base_spread_pct
|
||||
@@ -95,114 +100,111 @@ class BacktestEngine:
|
||||
) -> BacktestResult:
|
||||
result = BacktestResult(symbol=symbol)
|
||||
bars = bars.reset_index(drop=True)
|
||||
open_trade: Trade | None = None
|
||||
cfg = self.exit_config
|
||||
n = len(bars)
|
||||
|
||||
i = self.warmup_bars
|
||||
while i < len(bars) - 1:
|
||||
bar = bars.iloc[i]
|
||||
|
||||
if open_trade is not None:
|
||||
low, high = float(bar["low"]), float(bar["high"])
|
||||
exit_price = None
|
||||
reason = None
|
||||
if low <= open_trade.stop_price:
|
||||
exit_price, reason = open_trade.stop_price, "stop_loss"
|
||||
elif high >= open_trade.target_price:
|
||||
exit_price, reason = open_trade.target_price, "target"
|
||||
elif i == len(bars) - 2:
|
||||
exit_price, reason = float(bar["close"]), "session_end"
|
||||
if exit_price is not None:
|
||||
self._close(open_trade, bar, exit_price, reason, result)
|
||||
open_trade = None
|
||||
while i < n - 1:
|
||||
if i % self.eval_every != 0:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i % self.eval_every == 0:
|
||||
window = bars.iloc[: i + 1]
|
||||
# Evaluate as of the current bar's timestamp, not wall-clock
|
||||
# now() — otherwise every setup is judged against the real
|
||||
# clock and marked "late" past the entry cutoff (so a Sunday
|
||||
# backtest would show zero trades). Bars are stored UTC-naive;
|
||||
# the cutoff reasons in US/Eastern, so convert.
|
||||
eval_time = None
|
||||
try:
|
||||
from datetime import timezone as _tz
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
window = bars.iloc[: i + 1]
|
||||
# Evaluate as of the current bar's timestamp, not wall-clock now() —
|
||||
# otherwise every setup is judged against the real clock and marked
|
||||
# "late" past the entry cutoff (so a Sunday backtest would show zero
|
||||
# trades). Bars are stored UTC-naive; the cutoff reasons in
|
||||
# US/Eastern, so convert.
|
||||
eval_time = None
|
||||
try:
|
||||
from datetime import timezone as _tz
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
|
||||
last_ts = pd.Timestamp(window["timestamp"].iloc[-1]).to_pydatetime()
|
||||
if last_ts.tzinfo is None:
|
||||
last_ts = last_ts.replace(tzinfo=_tz.utc)
|
||||
eval_time = last_ts.astimezone(_ZI("America/New_York")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
except Exception: # noqa: BLE001 - fall back to now()
|
||||
eval_time = None
|
||||
|
||||
evaluation = evaluate_setup(
|
||||
window,
|
||||
previous_close=previous_close,
|
||||
avg_daily_volume=avg_daily_volume,
|
||||
evaluation_time=eval_time,
|
||||
ready_score_pct=self.ready_score_pct,
|
||||
criteria=self.criteria,
|
||||
min_bars=self.min_bars,
|
||||
last_ts = pd.Timestamp(window["timestamp"].iloc[-1]).to_pydatetime()
|
||||
if last_ts.tzinfo is None:
|
||||
last_ts = last_ts.replace(tzinfo=_tz.utc)
|
||||
eval_time = last_ts.astimezone(_ZI("America/New_York")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
result.evaluations += 1
|
||||
if evaluation.status == "ready" and evaluation.setups:
|
||||
result.signals += 1
|
||||
setup = evaluation.setups[0]
|
||||
next_bar = bars.iloc[i + 1]
|
||||
raw_entry = (
|
||||
float(next_bar["open"])
|
||||
if self.bt.entry_mode == "next_bar"
|
||||
else float(bar["close"])
|
||||
)
|
||||
stop = float(setup["stop_loss_price"])
|
||||
sizing = calculate_position_size(
|
||||
raw_entry, stop, equity=self.equity,
|
||||
config=PositionSizingConfig(),
|
||||
)
|
||||
if sizing.position_size > 0:
|
||||
slip, commission = self._entry_costs(
|
||||
raw_entry, sizing.position_size
|
||||
)
|
||||
entry = raw_entry + slip
|
||||
risk = entry - stop
|
||||
open_trade = Trade(
|
||||
trade_id=str(uuid.uuid4()),
|
||||
symbol=symbol,
|
||||
entry_time=str(next_bar.get("timestamp", i + 1)),
|
||||
entry_price=round(entry, 4),
|
||||
stop_price=round(stop, 4),
|
||||
target_price=round(entry + self.target_r * risk, 4),
|
||||
quantity=sizing.position_size,
|
||||
setup_type=setup.get("setup_type"),
|
||||
)
|
||||
open_trade.realized_pnl = -commission
|
||||
i += 1 # consumed next bar for entry
|
||||
i += 1
|
||||
except Exception: # noqa: BLE001 - fall back to now()
|
||||
eval_time = None
|
||||
|
||||
if open_trade is not None:
|
||||
last = bars.iloc[-1]
|
||||
self._close(open_trade, last, float(last["close"]), "session_end", result)
|
||||
evaluation = evaluate_setup(
|
||||
window,
|
||||
previous_close=previous_close,
|
||||
avg_daily_volume=avg_daily_volume,
|
||||
evaluation_time=eval_time,
|
||||
ready_score_pct=self.ready_score_pct,
|
||||
criteria=self.criteria,
|
||||
min_bars=self.min_bars,
|
||||
)
|
||||
result.evaluations += 1
|
||||
if not (evaluation.status == "ready" and evaluation.setups):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
result.signals += 1
|
||||
setup = evaluation.setups[0]
|
||||
entry_idx = i + 1
|
||||
next_bar = bars.iloc[entry_idx]
|
||||
raw_entry = (
|
||||
float(next_bar["open"])
|
||||
if self.bt.entry_mode == "next_bar"
|
||||
else float(bars.iloc[i]["close"])
|
||||
)
|
||||
stop = float(setup["stop_loss_price"])
|
||||
sizing = calculate_position_size(
|
||||
raw_entry, stop, equity=self.equity, config=PositionSizingConfig(),
|
||||
)
|
||||
if sizing.position_size <= 0:
|
||||
i += 1
|
||||
continue
|
||||
slip, entry_commission = self._entry_costs(raw_entry, sizing.position_size)
|
||||
entry = raw_entry + slip
|
||||
risk = entry - stop
|
||||
if risk <= 0:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Simulate the FULL managed exit forward with the shared rules (the
|
||||
# same logic the live exit manager applies), then resume scanning
|
||||
# after the exit bar so trades never overlap.
|
||||
bars_after = bars.iloc[entry_idx + 1:]
|
||||
res = simulate_exit(entry, stop, bars_after, cfg)
|
||||
qty = sizing.position_size
|
||||
gross = res.r_multiple * risk * qty
|
||||
total_commission = entry_commission
|
||||
exit_slip = 0.0
|
||||
for fll in res.fills:
|
||||
fqty = max(1, int(round(fll.frac * qty)))
|
||||
total_commission += max(
|
||||
self.bt.commission.minimum, fqty * self.bt.commission.per_share
|
||||
)
|
||||
exit_slip += fll.price * self.bt.slippage.base_spread_pct * fqty
|
||||
realized = gross - total_commission - exit_slip
|
||||
|
||||
exit_pos = min(entry_idx + 1 + res.exit_index, n - 1) if len(bars_after) else entry_idx
|
||||
trade = Trade(
|
||||
trade_id=str(uuid.uuid4()),
|
||||
symbol=symbol,
|
||||
entry_time=str(next_bar.get("timestamp", entry_idx)),
|
||||
entry_price=round(entry, 4),
|
||||
stop_price=round(stop, 4),
|
||||
target_price=round(entry + cfg.target_r * risk, 4) if cfg.target_r else round(entry, 4),
|
||||
quantity=qty,
|
||||
exit_time=str(bars.iloc[exit_pos].get("timestamp", exit_pos)),
|
||||
exit_price=round(res.fills[-1].price, 4) if res.fills else None,
|
||||
exit_reason=res.reason,
|
||||
realized_pnl=round(realized, 2),
|
||||
r_multiple=round(res.r_multiple, 3),
|
||||
setup_type=setup.get("setup_type"),
|
||||
entry_index=entry_idx,
|
||||
)
|
||||
result.trades.append(trade)
|
||||
result.total_pnl += trade.realized_pnl
|
||||
i = exit_pos + 1 # resume after the exit
|
||||
|
||||
if result.trades:
|
||||
wins = sum(1 for t in result.trades if (t.realized_pnl or 0) > 0)
|
||||
result.win_rate = wins / len(result.trades)
|
||||
return result
|
||||
|
||||
def _close(
|
||||
self, trade: Trade, bar, exit_price: float, reason: str | None,
|
||||
result: BacktestResult,
|
||||
) -> None:
|
||||
slip, commission = self._entry_costs(exit_price, trade.quantity)
|
||||
fill = exit_price - slip
|
||||
gross = (fill - trade.entry_price) * trade.quantity
|
||||
trade.exit_time = str(bar.get("timestamp", "end"))
|
||||
trade.exit_price = round(fill, 4)
|
||||
trade.exit_reason = reason
|
||||
trade.realized_pnl = round((trade.realized_pnl or 0.0) + gross - commission, 2)
|
||||
risk = trade.entry_price - trade.stop_price
|
||||
trade.r_multiple = round((fill - trade.entry_price) / risk, 3) if risk > 0 else 0.0
|
||||
result.trades.append(trade)
|
||||
result.total_pnl += trade.realized_pnl
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Trade-exit management — ONE source of truth for the backtest and live.
|
||||
|
||||
The original exit was a static bracket: a fixed stop at the opening-range low
|
||||
(-1R) and a fixed take-profit at +target_r. Once filled, nothing moved, so a
|
||||
trade could run to +1.8R and round-trip to a full loss, and losers were always
|
||||
taken at the full stop.
|
||||
|
||||
This module adds active management as pure, parameterised logic so the exact
|
||||
same rules drive the backtest (where we sweep them) and the live manager (where
|
||||
we apply the winner). Rules, each independently toggleable:
|
||||
|
||||
* breakeven — once price reaches +breakeven_at_r, move the stop to entry
|
||||
* trail — after +trail_after_r, trail the stop under each bar's low
|
||||
(prior_low) or a % off the high-water mark (pct)
|
||||
* scale-out — sell scale_out_pct of the position at +scale_out_r, run the rest
|
||||
* first-red — exit on the first bar that closes below the prior bar's low
|
||||
* target_r — final take-profit (whatever remains)
|
||||
|
||||
Intrabar convention is conservative: within a bar the STOP is assumed to fill
|
||||
before the target/scale, so the backtest never flatters a trade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pandas as pd
|
||||
|
||||
TRAIL_NONE = "none"
|
||||
TRAIL_PRIOR_LOW = "prior_low"
|
||||
TRAIL_PCT = "pct"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitConfig:
|
||||
target_r: float = 2.0 # final take-profit in R (0 = no fixed target)
|
||||
breakeven_at_r: float = 0.0 # move stop to entry once +this R is reached (0 = off)
|
||||
trail_mode: str = TRAIL_NONE # none | prior_low | pct
|
||||
trail_pct: float = 0.0 # pct trail: stop = high_water * (1 - trail_pct)
|
||||
trail_after_r: float = 1.0 # only start trailing once +this R is reached
|
||||
scale_out_r: float = 0.0 # sell scale_out_pct at +this R (0 = off)
|
||||
scale_out_pct: float = 0.5 # fraction sold at scale_out_r
|
||||
first_red_exit: bool = False # exit on first close below the prior bar's low
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: dict | None = None) -> "ExitConfig":
|
||||
v = dict(os.environ)
|
||||
if env:
|
||||
v.update(env)
|
||||
|
||||
def f(key, default):
|
||||
try:
|
||||
return float(v.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
def b(key, default):
|
||||
return v.get(key, default).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
return cls(
|
||||
target_r=f("TRADING_REWARD_MULTIPLE", "2.0"),
|
||||
breakeven_at_r=f("TRADING_EXIT_BREAKEVEN_R", "0.0"),
|
||||
trail_mode=v.get("TRADING_EXIT_TRAIL_MODE", TRAIL_NONE).strip().lower(),
|
||||
trail_pct=f("TRADING_EXIT_TRAIL_PCT", "0.0"),
|
||||
trail_after_r=f("TRADING_EXIT_TRAIL_AFTER_R", "1.0"),
|
||||
scale_out_r=f("TRADING_EXIT_SCALE_OUT_R", "0.0"),
|
||||
scale_out_pct=f("TRADING_EXIT_SCALE_OUT_PCT", "0.5"),
|
||||
first_red_exit=b("TRADING_EXIT_FIRST_RED", "0"),
|
||||
)
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = [f"target {self.target_r:g}R"]
|
||||
if self.breakeven_at_r:
|
||||
parts.append(f"BE@{self.breakeven_at_r:g}R")
|
||||
if self.trail_mode != TRAIL_NONE:
|
||||
how = f"{self.trail_pct:.1%}" if self.trail_mode == TRAIL_PCT else "prior-low"
|
||||
parts.append(f"trail {how} after {self.trail_after_r:g}R")
|
||||
if self.scale_out_r:
|
||||
parts.append(f"scale {self.scale_out_pct:.0%}@{self.scale_out_r:g}R")
|
||||
if self.first_red_exit:
|
||||
parts.append("first-red")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitFill:
|
||||
frac: float # fraction of the original position closed by this fill
|
||||
price: float
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitResult:
|
||||
r_multiple: float # realized R, weighted across partial fills
|
||||
reason: str # reason of the FINAL (position-closing) fill
|
||||
exit_index: int # index in bars_after where the position fully closed
|
||||
fills: list[ExitFill] = field(default_factory=list)
|
||||
|
||||
|
||||
def _trail_stop(stop: float, entry: float, high_water: float,
|
||||
bar_low: float, reached_r: float, cfg: ExitConfig) -> float:
|
||||
"""The stop after processing one bar — only ever ratchets UP."""
|
||||
new = stop
|
||||
if cfg.breakeven_at_r and reached_r >= cfg.breakeven_at_r:
|
||||
new = max(new, entry)
|
||||
if reached_r >= cfg.trail_after_r:
|
||||
if cfg.trail_mode == TRAIL_PRIOR_LOW:
|
||||
new = max(new, bar_low)
|
||||
elif cfg.trail_mode == TRAIL_PCT and cfg.trail_pct > 0:
|
||||
new = max(new, high_water * (1.0 - cfg.trail_pct))
|
||||
return new
|
||||
|
||||
|
||||
def simulate_exit(
|
||||
entry_price: float, init_stop: float, bars_after: pd.DataFrame, cfg: ExitConfig
|
||||
) -> ExitResult:
|
||||
"""Walk the bars AFTER entry and return the realized R for the whole trade.
|
||||
|
||||
``bars_after`` are the OHLC bars strictly after the entry bar, in order.
|
||||
The same per-bar rules are used live (see ``manage_live``).
|
||||
"""
|
||||
risk = entry_price - init_stop
|
||||
if risk <= 0 or bars_after is None or len(bars_after) == 0:
|
||||
return ExitResult(0.0, "invalid", 0, [])
|
||||
|
||||
target = entry_price + cfg.target_r * risk if cfg.target_r else None
|
||||
stop = init_stop
|
||||
high_water = entry_price
|
||||
remaining = 1.0
|
||||
realized_r = 0.0
|
||||
scaled = False
|
||||
fills: list[ExitFill] = []
|
||||
prev_low: float | None = None
|
||||
|
||||
n = len(bars_after)
|
||||
for idx in range(n):
|
||||
bar = bars_after.iloc[idx]
|
||||
high = float(bar["high"]); low = float(bar["low"]); close = float(bar["close"])
|
||||
high_water = max(high_water, high)
|
||||
reached_r = (high_water - entry_price) / risk
|
||||
|
||||
# (1) stop first (conservative intrabar ordering)
|
||||
if low <= stop:
|
||||
r = (stop - entry_price) / risk
|
||||
realized_r += remaining * r
|
||||
reason = "breakeven" if abs(stop - entry_price) < 1e-9 else (
|
||||
"trail_stop" if stop > init_stop else "stop_loss")
|
||||
fills.append(ExitFill(remaining, stop, reason))
|
||||
return ExitResult(realized_r, reason, idx, fills)
|
||||
|
||||
# (2) scale-out into strength
|
||||
if (cfg.scale_out_r and not scaled
|
||||
and high >= entry_price + cfg.scale_out_r * risk):
|
||||
frac = min(cfg.scale_out_pct, remaining)
|
||||
realized_r += frac * cfg.scale_out_r
|
||||
remaining -= frac
|
||||
scaled = True
|
||||
fills.append(ExitFill(frac, entry_price + cfg.scale_out_r * risk, "scale_out"))
|
||||
if remaining <= 1e-9:
|
||||
return ExitResult(realized_r, "scale_out", idx, fills)
|
||||
|
||||
# (3) final target on the remainder
|
||||
if target is not None and high >= target:
|
||||
realized_r += remaining * cfg.target_r
|
||||
fills.append(ExitFill(remaining, target, "target"))
|
||||
return ExitResult(realized_r, "target", idx, fills)
|
||||
|
||||
# (4) first red candle that breaks the prior bar's low
|
||||
if cfg.first_red_exit and prev_low is not None and close < prev_low:
|
||||
r = (close - entry_price) / risk
|
||||
realized_r += remaining * r
|
||||
fills.append(ExitFill(remaining, close, "first_red"))
|
||||
return ExitResult(realized_r, "first_red", idx, fills)
|
||||
|
||||
# (5) ratchet the stop for the NEXT bar
|
||||
stop = _trail_stop(stop, entry_price, high_water, low, reached_r, cfg)
|
||||
prev_low = low
|
||||
|
||||
# session end on the remainder
|
||||
last_close = float(bars_after.iloc[-1]["close"])
|
||||
realized_r += remaining * ((last_close - entry_price) / risk)
|
||||
fills.append(ExitFill(remaining, last_close, "session_end"))
|
||||
return ExitResult(realized_r, "session_end", n - 1, fills)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitDecision:
|
||||
desired_stop: float # where the protective stop SHOULD be now
|
||||
scale_out_frac: float # fraction to sell at market now (0 if none)
|
||||
exit_now: bool # full market exit now (first-red)
|
||||
reason: str
|
||||
|
||||
|
||||
def manage_live(
|
||||
entry_price: float, init_stop: float, bars_since_entry: pd.DataFrame,
|
||||
cfg: ExitConfig, already_scaled: bool = False,
|
||||
) -> ExitDecision:
|
||||
"""Compute the management action for an OPEN live position.
|
||||
|
||||
Uses the same ratchet/scale/first-red rules as ``simulate_exit`` so the live
|
||||
behaviour matches the backtest. ``bars_since_entry`` are the closed bars from
|
||||
the entry bar onward. Returns the stop the broker order should be moved to
|
||||
(only ever up), whether to scale out now, and whether to exit outright.
|
||||
"""
|
||||
risk = entry_price - init_stop
|
||||
if risk <= 0 or bars_since_entry is None or len(bars_since_entry) == 0:
|
||||
return ExitDecision(init_stop, 0.0, False, "hold")
|
||||
|
||||
stop = init_stop
|
||||
high_water = entry_price
|
||||
for idx in range(len(bars_since_entry)):
|
||||
bar = bars_since_entry.iloc[idx]
|
||||
high_water = max(high_water, float(bar["high"]))
|
||||
reached_r = (high_water - entry_price) / risk
|
||||
stop = _trail_stop(stop, entry_price, high_water, float(bar["low"]),
|
||||
reached_r, cfg)
|
||||
|
||||
last = bars_since_entry.iloc[-1]
|
||||
last_close = float(last["close"])
|
||||
reached_r = (high_water - entry_price) / risk
|
||||
|
||||
# first-red: latest bar closed below the prior bar's low
|
||||
if cfg.first_red_exit and len(bars_since_entry) >= 2:
|
||||
prior_low = float(bars_since_entry.iloc[-2]["low"])
|
||||
if last_close < prior_low and reached_r >= cfg.trail_after_r:
|
||||
return ExitDecision(stop, 0.0, True, "first_red")
|
||||
|
||||
# scale-out: high reached the scale level and we haven't yet
|
||||
scale_frac = 0.0
|
||||
if (cfg.scale_out_r and not already_scaled
|
||||
and high_water >= entry_price + cfg.scale_out_r * risk):
|
||||
scale_frac = cfg.scale_out_pct
|
||||
|
||||
return ExitDecision(stop, scale_frac, False, "trail")
|
||||
Reference in new issue
Block a user