Skip to content

Commit d848a47

Browse files
committed
feat(trading-strategies): volatility trailing-stop utils
Reusable building blocks for sizing a trailing stop to volatility: - trailStop: percent + ATR trail-price helpers - AtrPercent: ATR as a percent of price ("usual % move per bar") - atrUnits: convert between a percentage stop and ATR multiples, plus a whippy/balanced/loose classification - TrendFilter: price-vs-moving-average gate - MarketRegimeFilter: risk-on/off gate driven by an index series
1 parent c28a3c6 commit d848a47

11 files changed

Lines changed: 563 additions & 0 deletions

packages/trading-strategies/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ export {
3737
type ProtectedStrategyState,
3838
} from './strategy-protected/ProtectedStrategy.js';
3939
export {suggestScalpOffset} from './strategy-scalp/suggestScalpOffset.js';
40+
export {atrTrailStop, percentTrailStop, trailStop, type TrailStopOptions} from './util/trailStop.js';
41+
export {TrendFilter} from './util/TrendFilter.js';
42+
export {AtrPercent, atrToPercent} from './util/AtrPercent.js';
43+
export {
44+
ATR_TRAIL_BANDS,
45+
DEFAULT_ATR_TRAIL_MULTIPLE,
46+
atrMultipleToPercent,
47+
classifyAtrMultiple,
48+
percentToAtrMultiple,
49+
type AtrRoomVerdict,
50+
} from './util/atrUnits.js';
51+
export {MarketRegimeFilter, type MarketRegimeOptions} from './util/MarketRegimeFilter.js';
4052
export * from './report/index.js';
4153
export {
4254
SP500HeatmapReport,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {describe, expect, it} from 'vitest';
2+
import {AtrPercent, atrToPercent} from './AtrPercent.js';
3+
4+
describe('atrToPercent', () => {
5+
it('expresses ATR as a percentage of price', () => {
6+
expect(atrToPercent(52.64, 740.53)).toBeCloseTo(7.11, 2);
7+
});
8+
9+
it('is scale-invariant: the same percentage for proportional price and ATR', () => {
10+
expect(atrToPercent(0.7, 10)).toBeCloseTo(atrToPercent(70, 1_000), 10);
11+
});
12+
});
13+
14+
describe('AtrPercent', () => {
15+
const candles = [
16+
{close: 10, high: 11, low: 9},
17+
{close: 11, high: 12, low: 10},
18+
{close: 12, high: 13, low: 11},
19+
{close: 13, high: 14, low: 12},
20+
] as const;
21+
22+
it('is not ready until the underlying ATR is stable', () => {
23+
const atrPercent = new AtrPercent(3);
24+
25+
atrPercent.add(candles[0]);
26+
atrPercent.add(candles[1]);
27+
28+
expect(atrPercent.isReady).toBe(false);
29+
expect(atrPercent.value).toBeNull();
30+
expect(atrPercent.atr).toBeNull();
31+
});
32+
33+
it('returns the ATR as a percent of the latest close once warmed up', () => {
34+
const atrPercent = new AtrPercent(3);
35+
36+
candles.forEach(candle => atrPercent.add(candle));
37+
38+
// ATR settles at 2 over these candles; latest close is 13 → 2 / 13 * 100.
39+
expect(atrPercent.atr).toBe(2);
40+
expect(atrPercent.value).toBeCloseTo((2 / 13) * 100, 10);
41+
});
42+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {ATR, type HighLowClose} from 'trading-signals';
2+
3+
/**
4+
* Expresses an ATR reading as a percentage of price: `atr / price * 100`. Normalizing the raw
5+
* (price-unit) ATR makes volatility comparable across instruments and timeframes — a $52 ATR
6+
* means nothing without the price, but "7% per bar" is directly comparable between a $7 and a
7+
* $700 stock.
8+
*/
9+
export function atrToPercent(atr: number, price: number) {
10+
return (atr / price) * 100;
11+
}
12+
13+
/**
14+
* Average True Range expressed as a percentage of the latest close — the "usual % move per
15+
* bar". Feed it `{high, low, close}` candles; once the underlying ATR is warmed up, {@link value}
16+
* returns the typical bar range as a percent of price.
17+
*
18+
* Built on the `ATR` indicator from `trading-signals`, so it inherits Wilder's smoothing. Useful
19+
* for sizing volatility-aware stops (see `atrTrailStop`) without hand-tuning a percentage per
20+
* symbol, and for asking "is this dip within the instrument's normal range?".
21+
*/
22+
export class AtrPercent {
23+
readonly #atr: ATR;
24+
#lastClose: number | null = null;
25+
26+
constructor(interval: number) {
27+
this.#atr = new ATR(interval);
28+
}
29+
30+
/** Push the next candle. Both the ATR and the reference close are updated. */
31+
add(candle: HighLowClose<number>): void {
32+
this.#atr.add(candle);
33+
this.#lastClose = candle.close;
34+
}
35+
36+
/** `true` once the underlying ATR has enough data to produce a stable reading. */
37+
get isReady() {
38+
return this.#atr.isStable;
39+
}
40+
41+
/** Raw ATR in price units, or `null` until warmed up. */
42+
get atr() {
43+
return this.#atr.isStable ? this.#atr.getResultOrThrow() : null;
44+
}
45+
46+
/**
47+
* ATR as a percent of the latest close (e.g. `7.1` for 7.1%), or `null` until warmed up or
48+
* before any candle has been added.
49+
*/
50+
get value() {
51+
if (!this.#atr.isStable || this.#lastClose === null || this.#lastClose === 0) {
52+
return null;
53+
}
54+
55+
return atrToPercent(this.#atr.getResultOrThrow(), this.#lastClose);
56+
}
57+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {SMA} from 'trading-signals';
2+
import {describe, expect, it} from 'vitest';
3+
import {MarketRegimeFilter} from './MarketRegimeFilter.js';
4+
5+
describe('MarketRegimeFilter', () => {
6+
it('is not ready, and not risk-on, until the trend average is stable', () => {
7+
const filter = new MarketRegimeFilter({trendMovingAverage: new SMA(2)});
8+
9+
filter.addIndexClose(100);
10+
11+
expect(filter.isReady).toBe(false);
12+
expect(filter.isRiskOn).toBe(false);
13+
});
14+
15+
it('is risk-on while the index holds above its trend line', () => {
16+
const filter = new MarketRegimeFilter({trendMovingAverage: new SMA(2)});
17+
18+
filter.addIndexClose(100);
19+
filter.addIndexClose(102);
20+
21+
expect(filter.isReady).toBe(true);
22+
expect(filter.isRiskOn).toBe(true);
23+
});
24+
25+
it('is risk-off when the index closes below its trend line', () => {
26+
const filter = new MarketRegimeFilter({trendMovingAverage: new SMA(2)});
27+
28+
filter.addIndexClose(100);
29+
filter.addIndexClose(90);
30+
31+
expect(filter.isRiskOn).toBe(false);
32+
});
33+
34+
it('tracks drawdown from the running peak close', () => {
35+
const filter = new MarketRegimeFilter({trendMovingAverage: new SMA(2)});
36+
37+
filter.addIndexClose(200);
38+
filter.addIndexClose(100);
39+
filter.addIndexClose(101);
40+
41+
expect(filter.drawdown).toBeCloseTo(0.495, 3);
42+
});
43+
44+
it('flips to risk-off on a deep drawdown even while above the trend line', () => {
45+
const guarded = new MarketRegimeFilter({maxDrawdownPct: 5, trendMovingAverage: new SMA(2)});
46+
const unguarded = new MarketRegimeFilter({trendMovingAverage: new SMA(2)});
47+
48+
// [100, 101] sits above the SMA(2) of 100.5, but 101 is ~49.5% below the 200 peak.
49+
[200, 100, 101].forEach(close => {
50+
guarded.addIndexClose(close);
51+
unguarded.addIndexClose(close);
52+
});
53+
54+
expect(unguarded.isRiskOn).toBe(true);
55+
expect(guarded.isRiskOn).toBe(false);
56+
});
57+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type {MovingAverage} from 'trading-signals';
2+
import {TrendFilter} from './TrendFilter.js';
3+
4+
export interface MarketRegimeOptions {
5+
/** Moving average over the index that defines the regime trend line (e.g. a 50-period SMA of SPY). */
6+
trendMovingAverage: MovingAverage;
7+
/**
8+
* Optional maximum drawdown from the index's running peak close, in percent. When set, the
9+
* regime flips to risk-off once the index closes more than this far below its highest close
10+
* seen so far — even while it is still above the trend line. Omit to gate on the trend line
11+
* alone.
12+
*/
13+
maxDrawdownPct?: number;
14+
}
15+
16+
/**
17+
* Market-regime gate driven by an index series (e.g. SPY or QQQ). Pure and feed-agnostic: the
18+
* caller pushes index closes from wherever it sources them, so the calculation is reusable and
19+
* unit-testable without any cross-symbol plumbing in the trading session.
20+
*
21+
* Reports "risk-on" when the index is above its trend line and (optionally) within
22+
* `maxDrawdownPct` of its running peak. This is what distinguishes a broad risk-off selloff
23+
* (honor the stop, get out) from an idiosyncratic single-name dip (a stock can wobble while the
24+
* index holds its trend — hold through it).
25+
*/
26+
export class MarketRegimeFilter {
27+
readonly #trend: TrendFilter;
28+
readonly #maxDrawdownRatio: number | null;
29+
#peakClose = 0;
30+
#lastClose = 0;
31+
#hasData = false;
32+
33+
constructor(options: MarketRegimeOptions) {
34+
this.#trend = new TrendFilter(options.trendMovingAverage);
35+
this.#maxDrawdownRatio = options.maxDrawdownPct === undefined ? null : options.maxDrawdownPct / 100;
36+
}
37+
38+
/** Push the next index closing price into the regime model. */
39+
addIndexClose(close: number): void {
40+
this.#trend.add(close);
41+
this.#lastClose = close;
42+
this.#hasData = true;
43+
44+
if (close > this.#peakClose) {
45+
this.#peakClose = close;
46+
}
47+
}
48+
49+
/** `true` once the trend moving average is warmed up. */
50+
get isReady() {
51+
return this.#trend.isReady;
52+
}
53+
54+
/** Drawdown from the running peak close as a positive ratio (`0` = at the peak, `0.1` = 10% below). */
55+
get drawdown() {
56+
if (!this.#hasData || this.#peakClose === 0) {
57+
return 0;
58+
}
59+
60+
return (this.#peakClose - this.#lastClose) / this.#peakClose;
61+
}
62+
63+
/**
64+
* `true` when the index is at or above its trend line and within the configured drawdown band.
65+
* Returns `false` until the trend moving average is warmed up — an un-warmed filter makes no
66+
* claim about the regime.
67+
*/
68+
get isRiskOn() {
69+
if (!this.#trend.isAbove(this.#lastClose)) {
70+
return false;
71+
}
72+
73+
if (this.#maxDrawdownRatio !== null && this.drawdown > this.#maxDrawdownRatio) {
74+
return false;
75+
}
76+
77+
return true;
78+
}
79+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {SMA} from 'trading-signals';
2+
import {describe, expect, it} from 'vitest';
3+
import {TrendFilter} from './TrendFilter.js';
4+
5+
describe('TrendFilter', () => {
6+
it('is not ready until the moving average is stable', () => {
7+
const filter = new TrendFilter(new SMA(3));
8+
9+
filter.add(10);
10+
filter.add(20);
11+
12+
expect(filter.isReady).toBe(false);
13+
expect(filter.value).toBeNull();
14+
});
15+
16+
it('exposes the trend line value once warmed up', () => {
17+
const filter = new TrendFilter(new SMA(3));
18+
19+
filter.add(10);
20+
filter.add(20);
21+
filter.add(30);
22+
23+
expect(filter.isReady).toBe(true);
24+
expect(filter.value).toBe(20);
25+
});
26+
27+
it('reports prices at or above the trend line as above', () => {
28+
const filter = new TrendFilter(new SMA(3));
29+
30+
[10, 20, 30].forEach(close => filter.add(close));
31+
32+
expect(filter.isAbove(25)).toBe(true);
33+
expect(filter.isAbove(20)).toBe(true);
34+
});
35+
36+
it('reports prices below the trend line as not above', () => {
37+
const filter = new TrendFilter(new SMA(3));
38+
39+
[10, 20, 30].forEach(close => filter.add(close));
40+
41+
expect(filter.isAbove(19)).toBe(false);
42+
});
43+
44+
it('makes no claim before warmup, so isAbove stays false', () => {
45+
const filter = new TrendFilter(new SMA(3));
46+
47+
filter.add(10);
48+
49+
expect(filter.isAbove(1_000)).toBe(false);
50+
});
51+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type {MovingAverage} from 'trading-signals';
2+
3+
/**
4+
* Trend gate over a moving average. Feed it closing prices; once the MA is stable it reports
5+
* whether the latest price sits above the trend line. Strategies use it to veto noise-level
6+
* exits while the instrument is still in an uptrend — the classic fix for a percentage
7+
* trailing stop getting whipsawed out on a dip that never breaks the trend.
8+
*
9+
* Accepts any `trading-signals` moving average (`SMA`, `EMA`, `WSMA`, …) via their shared
10+
* {@link MovingAverage} base. The same primitive, fed an index series instead of the position's
11+
* own price, is the building block of {@link MarketRegimeFilter}.
12+
*/
13+
export class TrendFilter {
14+
readonly #ma: MovingAverage;
15+
16+
constructor(movingAverage: MovingAverage) {
17+
this.#ma = movingAverage;
18+
}
19+
20+
/** Push the next closing price into the underlying moving average. */
21+
add(close: number): void {
22+
this.#ma.add(close);
23+
}
24+
25+
/** `true` once the moving average has enough data to produce a stable trend line. */
26+
get isReady() {
27+
return this.#ma.isStable;
28+
}
29+
30+
/** Current trend line value, or `null` until the moving average is warmed up. */
31+
get value(): number | null {
32+
return this.#ma.isStable ? this.#ma.getResultOrThrow() : null;
33+
}
34+
35+
/**
36+
* `true` when `price` is at or above the trend line. Returns `false` until the moving average
37+
* is stable — an un-warmed filter makes no claim, so callers naturally fall back to their
38+
* unfiltered behavior during warmup.
39+
*/
40+
isAbove(price: number) {
41+
return this.#ma.isStable && price >= this.#ma.getResultOrThrow();
42+
}
43+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {describe, expect, it} from 'vitest';
2+
import {atrMultipleToPercent, classifyAtrMultiple, percentToAtrMultiple} from './atrUnits.js';
3+
4+
describe('percentToAtrMultiple', () => {
5+
it('reproduces the STX statement: a 10% trail at 7.11% ATR is ~1.4x ATR', () => {
6+
expect(percentToAtrMultiple(10, 7.11)).toBeCloseTo(1.41, 2);
7+
});
8+
9+
it('throws when ATR% is not positive', () => {
10+
expect(() => percentToAtrMultiple(10, 0)).toThrowError('atrPercent must be greater than 0');
11+
});
12+
});
13+
14+
describe('atrMultipleToPercent', () => {
15+
it('returns the percentage that yields the given ATR multiple', () => {
16+
expect(atrMultipleToPercent(3, 7.11)).toBeCloseTo(21.33, 2);
17+
});
18+
19+
it('is the inverse of percentToAtrMultiple', () => {
20+
expect(atrMultipleToPercent(percentToAtrMultiple(10, 7.11), 7.11)).toBeCloseTo(10, 10);
21+
});
22+
});
23+
24+
describe('classifyAtrMultiple', () => {
25+
it('flags the STX 1.4x trail as whippy', () => {
26+
expect(classifyAtrMultiple(1.41)).toBe('whippy');
27+
});
28+
29+
it('treats the Chandelier Exit 3x default as balanced', () => {
30+
expect(classifyAtrMultiple(3)).toBe('balanced');
31+
});
32+
33+
it('flags a 4x trail as loose', () => {
34+
expect(classifyAtrMultiple(4)).toBe('loose');
35+
});
36+
37+
it('includes the lower edge in balanced and the upper edge in loose', () => {
38+
expect(classifyAtrMultiple(2)).toBe('balanced');
39+
expect(classifyAtrMultiple(3.5)).toBe('loose');
40+
});
41+
});

0 commit comments

Comments
 (0)