|
| 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 | +} |
0 commit comments