Skip to content

feat(trading-strategies): add AtrTrailStrategy (ATR-sized trailing stop) - #1131

Open
bennycode wants to merge 11 commits into
mainfrom
feat/atr-trail-strategy
Open

feat(trading-strategies): add AtrTrailStrategy (ATR-sized trailing stop)#1131
bennycode wants to merge 11 commits into
mainfrom
feat/atr-trail-strategy

Conversation

@bennycode

Copy link
Copy Markdown
Owner

A simpler alternative to DynamicTrailStrategy: a trailing stop whose width is sized once from the stock's own volatility, then left alone.

What it does

  • On init(market, pair) it pulls recent history via getRecentCandles, measures ATR% (AtrPercent), and freezes the trail width at atrMultiple × ATR%.
  • From then on it's an ordinary percentage trailing stop: ratchet the peak up, exit (market sell) when close drops to peak × (1 − trailPct/100).
  • No live ATR feed and no per-candle re-sizing — that's the whole point of being simpler than DynamicTrailStrategy.

Why

Sizes a volatility-aware stop distance for the specific instrument (wide for a volatile name like STX, tight for a calm one) without hand-tuning a percentage — which is exactly what avoids the too-tight fixed-% whipsaw.

Usage

new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2', atrIntervalMillis: 86_400_000});

Notes

7 unit tests cover sizing, attach, ratchet, breach/exit, the no-history and no-init cases.

A simpler alternative to DynamicTrailStrategy: on init it pulls recent history,
measures ATR%, and freezes the trail width at atrMultiple x ATR%. From then on
it is a plain percentage trail that ratchets the peak and exits on a breach — no
live ATR feed, no re-sizing. Sizes a sensible volatility-aware stop for the
specific stock without hand-tuning a percent.

Reuses the merged building blocks: the init(market, pair) warm-up hook,
getRecentCandles, AtrPercent. Exit-only, market exit, daily ATR by default.
Registered and exported.
Place the exit sell as a LIMIT at the trail target instead of a market order, so
a gap straight through the stop can't fill far below it. The limit price is the
stop price, re-emitted each candle as the stop ratchets, until it fills.
Runs the strategy on the real STX candles: ATR sized from the history before the
2026-05-12 position, then trailed through the May shakeout to the ~925 recovery.
A 2x ATR trail is whipsawed out near 761; a 3x trail rides through and ends with
a higher portfolio value. Demonstrates volatility-sizing the stop end-to-end.
New `rolling` flag (default false): instead of freezing the trail width at init,
keep re-sizing it from a rolling ATR as live candles arrive. The ATR advances
only on completed atrIntervalMillis bars, so a daily ATR steps once per day —
intraday minute candles accumulate into the current bar and don't move the width
until the day closes. The stop only ratchets up, so a volatility spike widens the
trail for future peaks but never loosens a locked stop.

Two unit tests cover the intraday invariance and the re-size on day close.
Comment thread packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.ts Outdated
Comment thread packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.ts Outdated
- use ms('1d') instead of a hand-rolled ONE_DAY_IN_MS constant
- replace the verbose hand-rolled state type-guard with a small zod schema
  (safeParse on restore); add tests for valid restore and malformed fallback
Document that ATR-multiple trailing fits high-volatility single names, not calm
trending indices — on a low-volatility index the ATR-sized trail is too tight and
whipsaws out of a steady uptrend (URTH 2024/2025 backtest), whereas on a volatile
name the same multiple rides a shakeout through.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new exit-only trailing-stop strategy (AtrTrailStrategy) that sizes its trail width from historical ATR% (optionally rolling) and wires it into the trading-strategies package exports/registry, with unit and regression test coverage.

Changes:

  • Implement AtrTrailStrategy with init()-time ATR%-based sizing and trailing-stop exit behavior (plus optional rolling ATR re-sizing).
  • Register and export the new strategy/schema/types via StrategyRegistry and package index.ts.
  • Add focused unit tests and an STX historical regression-style backtest test.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/trading-strategies/src/strategy/StrategyRegistry.ts Registers AtrTrailStrategy so it can be created by name with schema validation.
packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.ts New ATR-sized trailing stop strategy implementation (state, init sizing, candle processing, fill handling, restore).
packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.test.ts Unit tests for sizing, attach/ratchet, breach/exit advice, restore-state validation, and rolling ATR behavior.
packages/trading-strategies/src/strategy-atr-trail/AtrTrailStxRegression.test.ts Regression backtest demonstrating behavior differences between 2x and 3x ATR trails on STX dataset.
packages/trading-strategies/src/index.ts Exports AtrTrailStrategy/schema/types from the package entrypoint.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +120 to +123
async init(market: Pick<MarketDataSource, 'getRecentCandles'>, pair: TradingPair): Promise<void> {
// ATR(n) needs 2n-1 inputs to stabilize; pull a small buffer beyond that.
const count = this.#atrInterval * 2 + 1;
const candles = await market.getRecentCandles(pair, count, this.#atrIntervalMillis);
Comment on lines +214 to +216
const reason = `ATR trailing stop: close ${candle.close.toFixed()} <= ${stop.toFixed()} (peak ${peak.toFixed()} -${trailDownPct.toFixed(2)}%).`;
this.onMessage?.(reason);
/*
A trailing stop for long positions whose width is sized from the
instrument's own volatility (multiplier x ATR%) instead of a hand-tuned
percentage. Unlike the Chandelier Exit, the peak ratchets upward only
and the emitted stop never decreases. FROZEN mode (default) sizes the
width once at ATR warm-up; ROLLING mode keeps adapting it to the live
ATR without loosening a stop already in place.
Switch from IndicatorSeries (single number) to TechnicalIndicator so
the result exposes peak, stop, trail and trailPct. The raw trail
candidate and the never-decreasing stop can diverge in ROLLING mode;
both are now visible to consumers.
…eat/atr-trail-strategy

# Conflicts:
#	packages/trading-strategies/src/index.ts
…rategy

The strategy now delegates trail-width sizing (trailPct = atrMultiple x
ATR%) to the AtrTrail indicator from trading-signals instead of the
local AtrPercent util. The indicator runs in ROLLING mode even when the
strategy's rolling config is off, because the indicator's FROZEN mode
locks the width at the first stable candle while the strategy sizes
from the full warm-up history — freezing stays the strategy's job (it
simply stops feeding candles). The peak/stop ratchet also stays in the
strategy: it is position-scoped (highest high since attach), while the
indicator's peak spans the whole feed.

Also fixes stale imports from before the trader/ extraction on main:
AllAvailableAmount, OrderAdvice and TradingSessionState now come from
'../trader/index.js'. The old '@typedtrader/exchange' import resolved
to undefined at runtime, which broke the AllAvailableAmount sentinel
check in AdviceExecutor and silently disabled the STX regression tests.
@bennycode

Copy link
Copy Markdown
Owner Author

Wired the AtrTrail indicator (#1236) into this strategy:

  • Trail-width sizing (trailPct = atrMultiple × ATR%) is now delegated to AtrTrail from trading-signals instead of the local AtrPercent util. The indicator runs in ROLLING mode internally even when this strategy's rolling config is off — its FROZEN mode locks the width at the first stable candle, but the strategy sizes from the full warm-up history, so freezing stays the strategy's job (it simply stops feeding candles after init).
  • The peak/stop ratchet stays in the strategy: it's position-scoped (highest high since attach, persisted across restarts), while the indicator's peak spans the whole feed.
  • Note: this branch temporarily contains feat(trading-signals): add AtrTrail indicator (ATR_TRAIL) #1236's commits (merged in, since feat(trading-signals): add AtrTrail indicator (ATR_TRAIL) #1236 isn't on main yet). Once feat(trading-signals): add AtrTrail indicator (ATR_TRAIL) #1236 merges, a main merge-back collapses them out of this PR's diff.
  • Fixed a merge-integration break from main's trader/ extraction: AllAvailableAmount, OrderAdvice and TradingSessionState imports from @typedtrader/exchange resolved to undefined at runtime, which broke the sentinel check in AdviceExecutor and silently disabled the STX regression tests. All imports now come from ../trader/index.js; all 14 strategy tests (incl. the 3 STX regression tests) pass again.

The STX regression test imports it; knip (test:imports) flags it as
unlisted on CI.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new strategy can orphan a session when an attached position is closed externally (no onFinish), and there is also a stated frozen-only intent mismatch with the exposed rolling mode.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 10/11 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +23 to +27
* completes, so a daily ATR steps once per day — intraday minute candles accumulate into the
* current bar but don't move the width until that day closes. The stop still only ratchets up, so
* a volatility spike can widen the trail for future peaks but never loosens a locked stop.
*/
rolling: z.boolean().default(false),
Comment on lines +204 to +206
if (!this.hasSellablePosition(state)) {
return undefined;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants