A fixed -3% stop does not mean the same thing on every instrument. On a volatile altcoin, 3% is inside the normal noise band, so the stop fires on routine wiggles and the strategy never gets to express its edge. On a low-volatility index product, 3% is a wide move, so the stop sits far beyond where the strategy's own logic would have exited. The same number produces opposite behaviour depending on the thing it's attached to.
The fix is not to tune the percentage per instrument by hand. It's to derive the safety floor from the strategy's own stop calculation, so the floor protects capital without overriding the strategy's exit logic. The formula used here:
threshold = -max(3.0, abs(strategy_stop_pct) * 2.0)
Read it as two clauses. The abs(strategy_stop_pct) * 2.0 clause says: never force-close before the strategy's own stop has had a chance to work, and only intervene once the position has drifted to roughly twice the intended stop distance. The max(3.0, ...) clause says: there is always at least a 3% floor, so a strategy with a very tight stop (say 0.5%) doesn't get a 1% force-close that would fire on spread and slippage alone.
What each clause is protecting
The * 2.0 multiplier is the interesting part. A strategy that stops at 2% has a defined risk budget. If the price is at -2.5%, the strategy's stop should have already acted; if it hasn't, something is wrong (stale data, a gap, a broker reject). Waiting until -4% gives the strategy room to do its job and cuts the position only when the loss is clearly beyond what the strategy signed up for. That's the "respect the strategy" half.
The max(3.0, ...) half is the "protect capital" floor. Without it, a strategy with a 0.5% stop yields a 1% force-close threshold, which is noise-level for most liquid instruments. The force-close would become the actual stop, and the strategy's own logic would be irrelevant. The 3% minimum guarantees the force-close is a genuine backstop, not a second, tighter stop.
The result is a threshold that scales with the instrument's volatility as the strategy sees it, because strategy_stop_pct is itself volatility-derived. That's the whole point: the floor inherits the strategy's view of normal movement instead of imposing an external one.
Computing strategy_stop_pct per instrument
strategy_stop_pct must come from the strategy's own stop logic, not from a separate volatility estimate. If the strategy uses ATR-based stops, use that. If it uses a percentage of price derived from recent range, use that. The floor and the strategy must agree on what "the stop" means, or the * 2.0 multiplier is meaningless.
A common pattern is an ATR stop with a multiplier:
import pandas as pd
def atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high, low, close = df["high"], df["low"], df["close"]
prev_close = close.shift(1)
tr = pd.concat([
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
], axis=1).max(axis=1)
return tr.ewm(alpha=1 / period, adjust=False).mean()
def strategy_stop_pct(df: pd.DataFrame, atr_mult: float = 2.0,
atr_period: int = 14) -> pd.Series:
"""Stop distance as a fraction of current price."""
a = atr(df, atr_period)
return (atr_mult * a) / df["close"]
Then the floor follows directly:
def safety_floor_pct(stop_pct: pd.Series,
min_floor: float = 3.0,
mult: float = 2.0) -> pd.Series:
"""Returns positive magnitude; apply as -value when comparing PnL."""
return (stop_pct.abs() * mult).clip(lower=min_floor)
Two details matter here.
First, stop_pct is a series, not a scalar. ATR changes, so the stop distance changes, so the floor changes. Recompute it on every bar or every position update. A floor computed once at entry will be wrong by the time the position has moved.
Second, clip(lower=min_floor) implements the max() cleanly and keeps the output vectorised. For a strategy with a 2% stop, the floor is 4%. For a 0.8% stop, the floor is 3% (the minimum wins). For a 6% stop, the floor is 12%.
One caveat on the ATR version: ATR is in price units, so dividing by close converts it to a fraction. For instruments with very different price scales (a $0.02 token vs. a $400 stock), the fraction is the only comparable quantity. Keep everything in fractional terms until the final PnL comparison.
Applying it to open positions
The check itself is a simple comparison against unrealised PnL as a fraction:
def should_force_close(entry_price: float,
current_price: float,
stop_pct_now: float,
min_floor: float = 3.0,
mult: float = 2.0) -> bool:
pnl_pct = (current_price - entry_price) / entry_price * 100.0
floor = max(min_floor, abs(stop_pct_now) * mult)
return pnl_pct <= -floor
Call this on every tick or bar, passing the current stop_pct_now rather than the value at entry. If the instrument has calmed down since entry, the floor tightens; if volatility has expanded, the floor widens. That's the adaptive part, and it's what a hardcoded percentage can't do.
For a batch of positions, vectorise it:
def force_close_mask(entry: pd.Series, current: pd.Series,
stop_pct_now: pd.Series,
min_floor: float = 3.0,
mult: float = 2.0) -> pd.Series:
pnl_pct = (current - entry) / entry * 100.0
floor = (stop_pct_now.abs() * mult).clip(lower=min_floor)
return pnl_pct <= -floor
Backtesting the floor's effect on surviving trades
The number that matters is not how many trades the floor cuts. It's how many trades the floor cuts that would have recovered, and how much tail risk it removes. A floor that never fires is doing nothing; a floor that fires on trades that would have hit their own stop anyway is redundant. The useful cases are the ones in between: trades that drifted past the strategy's intended stop without triggering it, and trades that recovered.
Tag each trade with whether the floor fired and whether the trade would have been profitable without it:
def evaluate_floor(trades: pd.DataFrame) -> dict:
"""
trades columns: pnl_pct (final, without floor), floor_pnl_pct
(PnL at the moment the floor would have fired, NaN if it never fired),
would_recover (bool: final pnl_pct > 0 without floor).
"""
fired = trades["floor_pnl_pct"].notna()
cut = trades[fired]
return {
"n_trades": len(trades),
"n_fired": int(fired.sum()),
"fired_rate": fired.mean(),
"avg_pnl_fired": cut["floor_pnl_pct"].mean(),
"avg_pnl_if_held": cut["pnl_pct"].mean(),
"recovered_after_cut": int(cut["would_recover"].sum()),
"recovered_pnl_cost": cut.loc[cut["would_recover"], "pnl_pct"].sum(),
"tail_saved": (
trades.loc[~fired, "pnl_pct"].nsmallest(20).mean()
- cut["floor_pnl_pct"].mean()
if fired.any() else float("nan")
),
}
The two numbers to read together are recovered_pnl_cost and the change in the worst-case tail. If the floor cuts a handful of trades that would have recovered for a small total PnL cost, and it removes a fat left tail, it's earning its keep. If recovered_pnl_cost is large relative to the tail it removes, the multiplier is too tight: raise mult from 2.0 toward 2.5 or 3.0 and re-run. If the floor barely fires, the multiplier is too loose or the strategies' stops are already doing the work.
Also break the results down by instrument. A floor that looks good in aggregate can be doing all its work on one volatile instrument and nothing elsewhere. If one instrument accounts for most of the fired trades, its strategy_stop_pct is probably mis-specified rather than the floor being wrong.
Watch the sample size. Floor events are rare by construction, so a backtest with 200 trades may produce a dozen firings, which is not enough to conclude anything. Pool across instruments and across a long enough window that the volatility regime shifts at least once. If the floor's contribution only shows up in a single high-volatility month, you're measuring one event, not an edge.
When a hard floor must override the adaptive value
The adaptive threshold assumes strategy_stop_pct is meaningful. That assumption fails in specific, recognisable cases, and in those the hard floor must win outright.
Stale or missing volatility data. If ATR is NaN, zero, or computed from a thin book, stop_pct collapses toward zero and the adaptive floor tightens to the 3% minimum. That's benign. But if stop_pct is stale — carried over from a prior regime — the floor can be far too wide. Detect it:
def effective_floor(stop_pct_now: float,
stop_pct_prev: float,
min_floor: float = 3.0,
mult: float = 2.0,
max_floor: float = 15.0) -> float:
if not (stop_pct_now > 0):
return min_floor # bad data: fall back to hard floor
adaptive = max(min_floor, abs(stop_pct_now) * mult)
if abs(stop_pct_now - stop_pct_prev) / stop_pct_prev > 0.5:
adaptive = max(adaptive, min_floor) # regime jump: don't trust the ratio
return min(adaptive, max_floor) # hard ceiling regardless
A hard ceiling. The adaptive formula has no upper bound. A strategy with a 10% stop yields a 20% force-close threshold, which for most instruments is not risk management, it's capitulation. Cap it. The max_floor argument above enforces a ceiling that no adaptive value can exceed. The exact number depends on the instrument's typical drawdown distribution, but it should exist and it should be hardcoded, because its purpose is to override the adaptive logic when the adaptive logic produces something absurd.
Gap and liquidity events. The floor is evaluated on observed prices. If an instrument gaps through the threshold overnight, the force-close executes at a worse price than the threshold. That's unavoidable, but it means the floor's effective loss can exceed its nominal value. In backtests, model the fill at the next available price, not at the threshold, or the floor will look more protective than it is.
Strategy stop wider than the ceiling. If strategy_stop_pct * 2.0 exceeds max_floor, the ceiling wins and the strategy's stop is effectively overridden. That's the correct outcome: the strategy has asked for more risk than the account's policy allows, and the policy should win. Log these cases — they usually mean the strategy is mis-parameterised for that instrument, not that the floor is too tight.
The design principle
The floor exists to catch what the strategy's own stop missed: gaps, stale stops, broker rejects, logic errors. It should be the outer layer of defence, not the first line. Deriving it from strategy_stop_pct keeps it in the right place — wide enough that the strategy's exit logic runs first, tight enough that a runaway loss gets cut before it compounds. The hard floor and the hard ceiling bracket the adaptive value so that bad inputs can't push it into useless territory. Everything between those brackets is the strategy's own risk definition, scaled by a constant.
More notes like this ship every week on this site.
Top comments (0)