diff --git a/packages/trading-strategies/src/index.ts b/packages/trading-strategies/src/index.ts index d05b63955..1c513aac8 100644 --- a/packages/trading-strategies/src/index.ts +++ b/packages/trading-strategies/src/index.ts @@ -1,5 +1,11 @@ export * from './backtest/index.js'; export * from './strategy/index.js'; +export { + AtrTrailStrategy, + AtrTrailSchema, + type AtrTrailConfig, + type AtrTrailState, +} from './strategy-atr-trail/AtrTrailStrategy.js'; export {BuyOnceStrategy, BuyOnceSchema, type BuyOnceConfig} from './strategy-buy-once/BuyOnceStrategy.js'; /** @deprecated Use {@link BuyOnceStrategy} without `buyAt` instead. */ export { diff --git a/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.test.ts b/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.test.ts new file mode 100644 index 000000000..7a0d96e21 --- /dev/null +++ b/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.test.ts @@ -0,0 +1,185 @@ +import Big from 'big.js'; +import {describe, expect, it} from 'vitest'; +import {AlpacaBroker, AlpacaBrokerMock, CandleBatcher, OrderSide, OrderType, TradingPair} from '@typedtrader/exchange'; +import type {Candle, TradingSessionState} from '@typedtrader/exchange'; +import {AtrTrailStrategy} from './AtrTrailStrategy.js'; + +const DAY = 86_400_000; +const pair = new TradingPair('STX', 'USD'); + +const heldState: TradingSessionState = { + baseBalance: new Big(10), + counterBalance: new Big(0), + feeRates: {[OrderType.LIMIT]: new Big('0'), [OrderType.MARKET]: new Big('0')}, + tradingRules: { + base_increment: '0.0001', + base_max_size: '100000', + base_min_size: '0.0001', + counter_increment: '0.01', + counter_min_size: '1', + pair, + }, +}; + +function dailyCandle(index: number, close: number, high: number, low: number): Candle { + const openTimeInMillis = Date.UTC(2026, 0, 1) + index * DAY; + return { + base: 'STX', + close: String(close), + counter: 'USD', + high: String(high), + low: String(low), + open: String(close), + openTimeInISO: new Date(openTimeInMillis).toISOString(), + openTimeInMillis, + sizeInMillis: DAY, + volume: '1000', + }; +} + +// 30 daily candles with a steady true range of 10 around a close of 100 → ATR ≈ 10 → ATR% ≈ 10%. +const warmupCandles = Array.from({length: 30}, (_unused, index) => dailyCandle(index, 100, 105, 95)); + +function toBatched(close: number, high: number, low: number) { + return CandleBatcher.createOneMinuteBatchedCandle([dailyCandle(0, close, high, low)]); +} + +/** A 1-minute candle stamped at a specific instant, for exercising the rolling daily-ATR batching. */ +function minuteAt(openTimeInMillis: number, close: number, high: number, low: number) { + const raw: Candle = { + ...dailyCandle(0, close, high, low), + openTimeInISO: new Date(openTimeInMillis).toISOString(), + openTimeInMillis, + }; + return CandleBatcher.createOneMinuteBatchedCandle([raw]); +} + +function warmExchange() { + const exchange = new AlpacaBrokerMock({ + balances: new Map([['STX', {available: new Big('10'), hold: new Big(0)}]]), + tradingRules: AlpacaBroker.DEFAULT_CRYPTO_TRADING_RULES, + }); + exchange.setHistoricalCandles(warmupCandles); + return exchange; +} + +describe('AtrTrailStrategy', () => { + it('sizes the trail once from the historical ATR (multiple x ATR%)', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + + await strategy.init(warmExchange(), pair); + + // ATR settles at 10 on a close of 100 → 10% ATR; 2x → 20% trail. + expect(new Big(strategy.trailState.trailDownPct ?? '0').toFixed(2), 'trail = 2 x 10% ATR').toBe('20.00'); + }); + + it('holds without a stop when there is not enough history to size the ATR', async () => { + const exchange = new AlpacaBrokerMock({ + balances: new Map([['STX', {available: new Big('10'), hold: new Big(0)}]]), + tradingRules: AlpacaBroker.DEFAULT_CRYPTO_TRADING_RULES, + }); + exchange.setHistoricalCandles([dailyCandle(0, 100, 105, 95)]); + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + + await strategy.init(exchange, pair); + + expect(strategy.trailState.trailDownPct, 'no ATR -> no trail').toBeNull(); + }); + + it('attaches on the first held candle and does not exit while price holds above the stop', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + await strategy.init(warmExchange(), pair); + + // Attach: peak 200 → stop 160 (20% trail). + expect(await strategy.onCandle(toBatched(195, 200, 190), heldState)).toBeUndefined(); + expect(strategy.trailState.peakPrice).toBe('200'); + expect(strategy.trailState.stopPrice).toBe('160'); + + // Close 170 stays above the 160 stop → no exit. + expect(await strategy.onCandle(toBatched(170, 198, 168), heldState)).toBeUndefined(); + }); + + it('exits with a limit sell at the trail target when the close breaches the ATR-sized stop', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + await strategy.init(warmExchange(), pair); + + await strategy.onCandle(toBatched(195, 200, 190), heldState); // attach, stop 160 + const advice = await strategy.onCandle(toBatched(155, 196, 150), heldState); // close 155 <= 160 + + expect(advice?.side).toBe(OrderSide.SELL); + expect(advice?.type).toBe(OrderType.LIMIT); + if (advice?.type === OrderType.LIMIT) { + expect(new Big(advice.price).toFixed(), 'limit price is the trail target').toBe('160'); + } + }); + + it('ratchets the stop up as the peak rises', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + await strategy.init(warmExchange(), pair); + + await strategy.onCandle(toBatched(195, 200, 190), heldState); // peak 200, stop 160 + await strategy.onCandle(toBatched(245, 250, 240), heldState); // peak 250, stop 200 + + expect(strategy.trailState.peakPrice).toBe('250'); + expect(strategy.trailState.stopPrice).toBe('200'); + }); + + it('emits no advice before init has sized the trail', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + + expect(await strategy.onCandle(toBatched(155, 200, 150), heldState)).toBeUndefined(); + expect(strategy.trailState.peakPrice, 'no attach without a sized trail').toBe('0'); + }); + + it('has the expected strategy name', () => { + expect(AtrTrailStrategy.NAME).toBe('@typedtrader/strategy-atr-trail'); + }); + + it('restores a valid persisted state', () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + + strategy.restoreState({exited: false, peakPrice: '200', stopPrice: '160', trailDownPct: '20'}); + + expect(strategy.trailState).toMatchObject({exited: false, peakPrice: '200', stopPrice: '160', trailDownPct: '20'}); + }); + + it('falls back to defaults on a malformed persisted state', () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2'}); + + // peakPrice non-zero while stopPrice is '0' violates the coupling invariant. + strategy.restoreState({exited: false, peakPrice: '200', stopPrice: '0', trailDownPct: '20'}); + + expect(strategy.trailState).toMatchObject({exited: false, peakPrice: '0', stopPrice: '0', trailDownPct: null}); + }); + + it('rolling: a daily ATR window does not move intraday on minute candles', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2', rolling: true}); + await strategy.init(warmExchange(), pair); + const sizedAtInit = strategy.trailState.trailDownPct; + expect(sizedAtInit, 'init sized the trail').not.toBeNull(); + + // Five wild swings, all within the same UTC day → the daily bar hasn't closed yet. + const dayStart = Date.UTC(2026, 5, 1); + for (let minute = 1; minute <= 5; minute++) { + await strategy.onCandle(minuteAt(dayStart + minute * 60_000, 108, 130, 70), heldState); + } + + expect(strategy.trailState.trailDownPct, 'a turbulent day still uses the prior days ATR window').toBe(sizedAtInit); + }); + + it('rolling: re-sizes the trail once a turbulent day closes', async () => { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple: '2', rolling: true}); + await strategy.init(warmExchange(), pair); + const sizedAtInit = Number(strategy.trailState.trailDownPct); + + // Day 1 has a far wider range than the warm-up (~60 vs 10) but only counts once it closes. + await strategy.onCandle(minuteAt(Date.UTC(2026, 5, 1), 100, 160, 100), heldState); + expect(Number(strategy.trailState.trailDownPct), 'unchanged mid-day-1').toBe(sizedAtInit); + + // The day-2 candle completes day 1's bar → ATR rises → the trail widens. + await strategy.onCandle(minuteAt(Date.UTC(2026, 5, 2), 100, 130, 95), heldState); + expect(Number(strategy.trailState.trailDownPct), 'trail widened after the turbulent day closed').toBeGreaterThan( + sizedAtInit + ); + }); +}); diff --git a/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.ts b/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.ts new file mode 100644 index 000000000..404b244e1 --- /dev/null +++ b/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStrategy.ts @@ -0,0 +1,261 @@ +import Big from 'big.js'; +import {ms} from 'ms'; +import {z} from 'zod'; +import {AllAvailableAmount, CandleBatcher, OrderSide, OrderType} from '@typedtrader/exchange'; +import type { + Fill, + MarketDataSource, + OneMinuteBatchedCandle, + OrderAdvice, + PendingOrder, + TradingPair, + TradingSessionState, +} from '@typedtrader/exchange'; +import {MarketType} from '../strategy/MarketType.js'; +import {Strategy} from '../strategy/Strategy.js'; +import {AtrPercent} from '../util/AtrPercent.js'; +import {positiveNumberString} from '../util/validators.js'; + +export const AtrTrailSchema = z.object({ + /** ATR lookback used to measure volatility from history. Defaults to 14. */ + atrInterval: z.number().int().positive().default(14), + /** Candle size (ms) the warm-up history is pulled and the ATR is measured on. Defaults to daily. */ + atrIntervalMillis: z.number().int().positive().default(ms('1d')), + /** ATR multiple that sets the trail width: `trailPct = atrMultiple * ATR%`. Defaults to "3" (Chandelier convention). */ + atrMultiple: positiveNumberString.default('3'), + /** + * When `true`, keep re-sizing the trail from a rolling ATR as live candles arrive, instead of + * freezing it at `init`. The ATR window advances only when a full `atrIntervalMillis` bar + * 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), +}); + +export type AtrTrailConfig = z.input; + +export type AtrTrailState = { + /** `true` once the exit sell has fully filled; no further advice is emitted afterwards. */ + exited: boolean; + /** Highest candle high since attach, ratcheting upward only. `'0'` = not yet attached. */ + peakPrice: string; + /** Current trail target, `peakPrice * (1 - trailDownPct/100)`. `'0'` until sized and attached. */ + stopPrice: string; + /** Trail width in percent, frozen once from `atrMultiple * ATR%` during {@link AtrTrailStrategy.init}. `null` until sized. */ + trailDownPct: string | null; +}; + +const defaultState = (): AtrTrailState => ({ + exited: false, + peakPrice: '0', + stopPrice: '0', + trailDownPct: null, +}); + +const numericString = z.string().refine(canParseBig, 'must be a numeric string'); + +/** Validates a persisted state on restore so a malformed/stale snapshot falls back to defaults. */ +const AtrTrailStateSchema = z + .object({ + exited: z.boolean(), + peakPrice: numericString, + stopPrice: numericString, + trailDownPct: numericString.nullable(), + }) + // peakPrice and stopPrice are coupled — both '0' (not yet attached) or both non-zero. + .refine(state => new Big(state.peakPrice).eq(0) === new Big(state.stopPrice).eq(0), { + message: 'peakPrice and stopPrice must both be zero or both non-zero', + }); + +/** + * Exit-only trailing stop whose width is sized from the instrument's own volatility: on `init` it + * pulls recent history, measures ATR%, and sets the trail to `atrMultiple * ATR%`. By default that + * width is **frozen** — from then on it's an ordinary percentage trail that ratchets the peak up and + * exits on a breach. With `rolling: true` the ATR keeps advancing on completed `atrIntervalMillis` + * bars and the width re-sizes, while the stop still only ratchets up. The point either way is a + * sensible, volatility-aware stop distance (wide for a volatile name, tight for a calm one) without + * hand-tuning a percent. + * + * Attaches to whatever base balance exists (opened elsewhere or carried across a restart) and exits + * the full available balance with a limit sell at the trail target when `candle.close` drops to it. + * If the ATR can't be sized from history (too few candles), it holds without a stop and says so. + * + * Suitability: ATR-multiple trailing fits high-volatility single names, not calm trending indices. + * The trail is only ever as wide as the instrument's own ATR, so on a low-volatility index (e.g. a + * ~0.7% daily ATR) even a 4x trail is ~3% — too tight to survive routine pullbacks, and it whipsaws + * you out of a steady uptrend at a large opportunity cost. On a volatile name (e.g. a ~7% ATR) the + * same multiple sizes a trail wide enough to ride a shakeout through to the recovery. Backtest the + * candidate multiples on the instrument's own history before trusting one. + */ +export class AtrTrailStrategy extends Strategy { + static override NAME = '@typedtrader/strategy-atr-trail'; + static override marketTypes: readonly MarketType[] = [MarketType.BULLISH]; + + readonly #atrPercent: AtrPercent; + readonly #atrInterval: number; + readonly #atrIntervalMillis: number; + readonly #atrMultiple: Big; + readonly #atrBatcher: CandleBatcher | null; + + constructor(config: AtrTrailConfig) { + super({config, state: defaultState()}); + const parsed = AtrTrailSchema.parse(config); + this.#atrInterval = parsed.atrInterval; + this.#atrIntervalMillis = parsed.atrIntervalMillis; + this.#atrMultiple = new Big(parsed.atrMultiple); + this.#atrPercent = new AtrPercent(parsed.atrInterval); + this.#atrBatcher = parsed.rolling ? new CandleBatcher(parsed.atrIntervalMillis) : null; + } + + get #state(): AtrTrailState { + return this.getProxiedState(); + } + + /** Read-only snapshot of the current trail state. Useful for tests and diagnostics. */ + get trailState(): Readonly { + return {...this.#state}; + } + + async init(market: Pick, pair: TradingPair): Promise { + // 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); + for (const candle of candles) { + this.#atrPercent.add({ + close: parseFloat(candle.close), + high: parseFloat(candle.high), + low: parseFloat(candle.low), + }); + } + + const atrPercent = this.#atrPercent.value; + if (atrPercent === null) { + this.onMessage?.(`ATR trail could not be sized from ${candles.length} candles; holding without a stop.`); + return; + } + + const trailDownPct = this.#atrMultiple.mul(atrPercent); + this.#state.trailDownPct = trailDownPct.toFixed(); + this.onMessage?.( + `ATR trail sized to ${trailDownPct.toFixed(2)}% (${this.#atrMultiple.toFixed()}x ${atrPercent.toFixed(2)}% ATR).` + ); + } + + /** + * Rolling mode only (no-op otherwise): aggregate the live candle toward the ATR timeframe and + * re-size the trail when a bar completes. A daily ATR therefore steps once per day — intraday + * minute candles accumulate into the current bar and don't move the width until that day closes. + */ + #advanceRollingAtr(candle: OneMinuteBatchedCandle): void { + if (!this.#atrBatcher) { + return; + } + const completed = this.#atrBatcher.addToBatch(candle); + if (!completed) { + return; + } + this.#atrPercent.add({ + close: completed.close.toNumber(), + high: completed.high.toNumber(), + low: completed.low.toNumber(), + }); + const atrPercent = this.#atrPercent.value; + if (atrPercent !== null) { + this.#state.trailDownPct = this.#atrMultiple.mul(atrPercent).toFixed(); + } + } + + protected override async processCandle( + candle: OneMinuteBatchedCandle, + state: TradingSessionState + ): Promise { + if (this.#state.exited) { + return undefined; + } + + this.#advanceRollingAtr(candle); + + if (this.#state.trailDownPct === null) { + return undefined; + } + + const trailDownPct = new Big(this.#state.trailDownPct); + + if (this.#state.peakPrice === '0') { + if (!this.hasSellablePosition(state)) { + return undefined; + } + const stop = candle.high.mul(new Big(1).minus(trailDownPct.div(100))); + this.#state.peakPrice = candle.high.toFixed(); + this.#state.stopPrice = stop.toFixed(); + this.onMessage?.( + `Trail attached. Peak ${candle.high.toFixed()}, stop ${stop.toFixed()} (-${trailDownPct.toFixed(2)}%).` + ); + return undefined; + } + + if (!this.hasSellablePosition(state)) { + return undefined; + } + + const peak = candle.high.gt(this.#state.peakPrice) ? candle.high : new Big(this.#state.peakPrice); + const candidateStop = peak.mul(new Big(1).minus(trailDownPct.div(100))); + // Ratchet up only: a wider (rolling) trail can lower the candidate, but the stop never falls. + const previousStop = new Big(this.#state.stopPrice); + const stop = candidateStop.gt(previousStop) ? candidateStop : previousStop; + this.#state.peakPrice = peak.toFixed(); + this.#state.stopPrice = stop.toFixed(); + + if (candle.close.gt(stop)) { + return undefined; + } + + const reason = `ATR trailing stop: close ${candle.close.toFixed()} <= ${stop.toFixed()} (peak ${peak.toFixed()} -${trailDownPct.toFixed(2)}%).`; + this.onMessage?.(reason); + /* + * Sell at the trail target rather than at market, so a gap straight through the stop can't fill + * far below it. The limit re-emits each candle (tracking the ratcheted stop) until it fills. + */ + return { + amount: AllAvailableAmount, + amountIn: 'base', + price: stop, + reason, + side: OrderSide.SELL, + type: OrderType.LIMIT, + }; + } + + async onFill(fill: Fill, state: TradingSessionState): Promise { + if (fill.side === OrderSide.SELL && !this.hasSellablePosition(state)) { + this.#state.exited = true; + } + } + + async onOrderFilled(order: PendingOrder, _state: TradingSessionState): Promise { + if (this.#state.exited && order.side === OrderSide.SELL) { + await this.onFinish?.(); + } + } + + override restoreState(persisted: Record): void { + const parsed = AtrTrailStateSchema.safeParse(persisted); + const validated = parsed.success ? parsed.data : defaultState(); + super.restoreState(validated); + const restored = this.#state; + restored.exited = validated.exited; + restored.peakPrice = validated.peakPrice; + restored.stopPrice = validated.stopPrice; + restored.trailDownPct = validated.trailDownPct; + } +} + +function canParseBig(value: string) { + try { + new Big(value); + return true; + } catch { + return false; + } +} diff --git a/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStxRegression.test.ts b/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStxRegression.test.ts new file mode 100644 index 000000000..4fee6fc7b --- /dev/null +++ b/packages/trading-strategies/src/strategy-atr-trail/AtrTrailStxRegression.test.ts @@ -0,0 +1,76 @@ +import Big from 'big.js'; +import {uptrendStxCandles} from '@typedtrader/candles'; +import {describe, expect, it} from 'vitest'; +import {AlpacaBroker, AlpacaBrokerMock, OrderSide, TradingPair} from '@typedtrader/exchange'; +import {BacktestExecutor} from '../backtest/BacktestExecutor.js'; +import {AtrTrailStrategy} from './AtrTrailStrategy.js'; + +/* + * Real STX trade. The position is held from 2026-05-12; the ATR trail is sized from the daily + * history *before* that date, then trails through the 2026-05-18 shakeout (intraday low ~695, a + * ~17% drop from the 837.62 peak) and on to the ~925 recovery. + * + * A tight 2x ATR trail is knocked out in the dip and misses the recovery; the wider 3x ATR trail + * rides through it. This is the whole reason to size the stop from volatility instead of guessing + * a fixed percentage. + */ +const pair = new TradingPair('STX', 'USD'); +const entryIndex = uptrendStxCandles.findIndex(candle => candle.openTimeInISO.startsWith('2026-05-12')); +const priorHistory = uptrendStxCandles.slice(0, entryIndex); +const tradeWindow = uptrendStxCandles.slice(entryIndex); + +function heldStxExchange() { + const exchange = new AlpacaBrokerMock({ + balances: new Map([ + ['STX', {available: new Big('5'), hold: new Big(0)}], + ['USD', {available: new Big(0), hold: new Big(0)}], + ]), + tradingRules: AlpacaBroker.DEFAULT_CRYPTO_TRADING_RULES, + }); + // init() pulls these to size the ATR; the backtest reuses the same broker for fills. + exchange.setHistoricalCandles(priorHistory); + return exchange; +} + +async function runTrail(atrMultiple: string) { + const strategy = new AtrTrailStrategy({atrInterval: 14, atrMultiple}); + const exchange = heldStxExchange(); + await strategy.init(exchange, pair); + const result = await new BacktestExecutor({ + broker: exchange, + candles: tradeWindow, + strategy, + tradingPair: pair, + }).execute(); + return {result, strategy}; +} + +describe('AtrTrailStrategy on STX', () => { + it('a tight 2x ATR trail is whipsawed out in the May shakeout', async () => { + const {result, strategy} = await runTrail('2'); + + expect(strategy.trailState.exited, '2x trail is breached and exits').toBe(true); + const exitPrice = result.trades.find(trade => trade.side === OrderSide.SELL)?.price; + expect(exitPrice, 'a sell exit fired near the trail target').toBeDefined(); + expect(exitPrice?.toNumber()).toBeGreaterThan(745); + expect(exitPrice?.toNumber()).toBeLessThan(775); + }); + + it('a wider 3x ATR trail rides the shakeout through to the recovery', async () => { + const {result, strategy} = await runTrail('3'); + + expect(strategy.trailState.exited, '3x trail is never breached').toBe(false); + expect(result.trades, 'no exit, the position is still held').toHaveLength(0); + expect(tradeWindow[tradeWindow.length - 1].close).toBe('925.54'); + }); + + it('holding via the 3x trail ends richer than getting stopped out at 2x', async () => { + const tight = await runTrail('2'); + const wide = await runTrail('3'); + + expect( + wide.result.performance.finalPortfolioValue.gt(tight.result.performance.finalPortfolioValue), + 'riding the recovery beats the early stop-out' + ).toBe(true); + }); +}); diff --git a/packages/trading-strategies/src/strategy/StrategyRegistry.ts b/packages/trading-strategies/src/strategy/StrategyRegistry.ts index 33a46d5a4..88f4308f3 100644 --- a/packages/trading-strategies/src/strategy/StrategyRegistry.ts +++ b/packages/trading-strategies/src/strategy/StrategyRegistry.ts @@ -1,4 +1,5 @@ import type {z} from 'zod'; +import {AtrTrailStrategy, AtrTrailSchema} from '../strategy-atr-trail/AtrTrailStrategy.js'; import {BuyOnceStrategy, BuyOnceSchema} from '../strategy-buy-once/BuyOnceStrategy.js'; import { BuyBelowSellAboveStrategy, @@ -22,6 +23,10 @@ interface StrategyEntry { } const registry: Record = { + [AtrTrailStrategy.NAME]: { + create: (config: unknown) => new AtrTrailStrategy(AtrTrailSchema.parse(config ?? {})), + schema: AtrTrailSchema, + }, [BuyBelowSellAboveStrategy.NAME]: { create: (config: unknown) => new BuyBelowSellAboveStrategy(BuyBelowSellAboveSchema.parse(config)), schema: BuyBelowSellAboveSchema,