Skip to content

Commit 7183c3d

Browse files
committed
feat: parameterize reference engine for frozen robustness checks
1 parent a809db2 commit 7183c3d

1 file changed

Lines changed: 101 additions & 41 deletions

File tree

research/regime-trend-v1/reference-engine.ts

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,30 @@ export type Candle = {
1111

1212
export type ExitReason = "initial_stop" | "trailing_stop" | "trend_exit";
1313

14+
export type StrategyParameters = {
15+
emaFast: number;
16+
emaSlow: number;
17+
donchianLookback: number;
18+
atrLength: number;
19+
atrFloor: number;
20+
initialStopAtr: number;
21+
trailingStopAtr: number;
22+
commission: number;
23+
slippage: number;
24+
};
25+
26+
export const REGIME_TREND_V1_DEFAULTS: Readonly<StrategyParameters> = Object.freeze({
27+
emaFast: 50,
28+
emaSlow: 200,
29+
donchianLookback: 20,
30+
atrLength: 14,
31+
atrFloor: 0.005,
32+
initialStopAtr: 2.5,
33+
trailingStopAtr: 3,
34+
commission: 0.001,
35+
slippage: 0.0005
36+
});
37+
1438
export type SignalRecord = {
1539
signalIndex: number;
1640
signalTimestamp: Timestamp;
@@ -28,7 +52,7 @@ export type StopUpdate = {
2852

2953
export type TradeLedger = {
3054
strategy_id: "regime-trend-v1";
31-
implementation_version: "typescript-reference-v1.0.1";
55+
implementation_version: string;
3256
dataset_hash: string;
3357
symbol: string;
3458
timeframe: "4h";
@@ -75,16 +99,20 @@ export type BacktestResult = {
7599
openPosition: OpenPosition | null;
76100
};
77101

78-
const EMA_FAST = 50;
79-
const EMA_SLOW = 200;
80-
const DONCHIAN = 20;
81-
const ATR_LENGTH = 14;
82-
const ATR_FLOOR = 0.005;
83-
const INITIAL_STOP_ATR = 2.5;
84-
const TRAILING_STOP_ATR = 3;
85-
const COMMISSION = 0.001;
86-
const SLIPPAGE = 0.0005;
87-
const WARMUP_INDEX = Math.max(EMA_SLOW, DONCHIAN, ATR_LENGTH) - 1;
102+
function validateParameters(parameters: StrategyParameters): void {
103+
const positiveIntegers = [parameters.emaFast, parameters.emaSlow, parameters.donchianLookback, parameters.atrLength];
104+
if (positiveIntegers.some((value) => !Number.isInteger(value) || value <= 0)) {
105+
throw new Error("Indicator lengths must be positive integers");
106+
}
107+
if (parameters.emaFast >= parameters.emaSlow) throw new Error("emaFast must be less than emaSlow");
108+
const positive = [parameters.atrFloor, parameters.initialStopAtr, parameters.trailingStopAtr];
109+
if (positive.some((value) => !Number.isFinite(value) || value <= 0)) {
110+
throw new Error("ATR parameters must be positive");
111+
}
112+
if (parameters.commission < 0 || parameters.slippage < 0) {
113+
throw new Error("Costs cannot be negative");
114+
}
115+
}
88116

89117
function assertCandle(candle: Candle, index: number): void {
90118
const values = [candle.open, candle.high, candle.low, candle.close, candle.volume];
@@ -121,7 +149,7 @@ export function ema(values: number[], length: number): Array<number | undefined>
121149
return output;
122150
}
123151

124-
export function wilderAtr(candles: Candle[], length = ATR_LENGTH): Array<number | undefined> {
152+
export function wilderAtr(candles: Candle[], length = 14): Array<number | undefined> {
125153
if (!Number.isInteger(length) || length <= 0) throw new Error("ATR length must be positive");
126154
const output: Array<number | undefined> = new Array(candles.length);
127155
if (candles.length === 0) return output;
@@ -147,10 +175,10 @@ export function wilderAtr(candles: Candle[], length = ATR_LENGTH): Array<number
147175
return output;
148176
}
149177

150-
function highestPreviousHigh(candles: Candle[], index: number): number | undefined {
151-
if (index < DONCHIAN) return undefined;
178+
function highestPreviousHigh(candles: Candle[], index: number, lookback: number): number | undefined {
179+
if (index < lookback) return undefined;
152180
let highest = Number.NEGATIVE_INFINITY;
153-
for (let cursor = index - DONCHIAN; cursor < index; cursor += 1) {
181+
for (let cursor = index - lookback; cursor < index; cursor += 1) {
154182
highest = Math.max(highest, candles[cursor].high);
155183
}
156184
return highest;
@@ -169,16 +197,29 @@ function closeTrade(args: {
169197
exitReason: ExitReason;
170198
datasetHash: string;
171199
symbol: string;
200+
commission: number;
201+
implementationVersion: string;
172202
}): TradeLedger {
173-
const { position, exitIndex, exitTimestamp, rawExitReference, exitFill, exitReason, datasetHash, symbol } = args;
174-
const exitFee = exitFill * position.quantity * COMMISSION;
203+
const {
204+
position,
205+
exitIndex,
206+
exitTimestamp,
207+
rawExitReference,
208+
exitFill,
209+
exitReason,
210+
datasetHash,
211+
symbol,
212+
commission,
213+
implementationVersion
214+
} = args;
215+
const exitFee = exitFill * position.quantity * commission;
175216
const grossPnl = (exitFill - position.entryFill) * position.quantity;
176217
const netPnl = grossPnl - position.entryFee - exitFee;
177218
const entryNotional = position.entryFill * position.quantity;
178219

179220
return {
180221
strategy_id: "regime-trend-v1",
181-
implementation_version: "typescript-reference-v1.0.1",
222+
implementation_version: implementationVersion,
182223
dataset_hash: datasetHash,
183224
symbol,
184225
timeframe: "4h",
@@ -205,16 +246,33 @@ function closeTrade(args: {
205246

206247
export function runRegimeTrendV1(
207248
candles: Candle[],
208-
options: { datasetHash?: string; symbol?: string } = {}
249+
options: {
250+
datasetHash?: string;
251+
symbol?: string;
252+
parameters?: Partial<StrategyParameters>;
253+
implementationVersion?: string;
254+
} = {}
209255
): BacktestResult {
210256
validateCandles(candles);
211257

258+
const parameters: StrategyParameters = {
259+
...REGIME_TREND_V1_DEFAULTS,
260+
...(options.parameters ?? {})
261+
};
262+
validateParameters(parameters);
263+
212264
const datasetHash = options.datasetHash ?? "synthetic";
213265
const symbol = options.symbol ?? "TESTUSDT";
266+
const implementationVersion = options.implementationVersion ?? "typescript-reference-v1.1.0";
267+
const warmupIndex = Math.max(
268+
parameters.emaSlow,
269+
parameters.donchianLookback,
270+
parameters.atrLength
271+
) - 1;
214272
const closes = candles.map((candle) => candle.close);
215-
const ema50 = ema(closes, EMA_FAST);
216-
const ema200 = ema(closes, EMA_SLOW);
217-
const atr14 = wilderAtr(candles, ATR_LENGTH);
273+
const fastEma = ema(closes, parameters.emaFast);
274+
const slowEma = ema(closes, parameters.emaSlow);
275+
const atr = wilderAtr(candles, parameters.atrLength);
218276

219277
const trades: TradeLedger[] = [];
220278
const signals: SignalRecord[] = [];
@@ -239,7 +297,9 @@ export function runRegimeTrendV1(
239297
exitFill,
240298
exitReason,
241299
datasetHash,
242-
symbol
300+
symbol,
301+
commission: parameters.commission,
302+
implementationVersion
243303
})
244304
);
245305
position = null;
@@ -251,10 +311,10 @@ export function runRegimeTrendV1(
251311
let exitedThisBar = false;
252312

253313
if (pendingEntry) {
254-
const entryFill = candle.open * (1 + SLIPPAGE);
314+
const entryFill = candle.open * (1 + parameters.slippage);
255315
const quantity = 1 / entryFill;
256-
const entryFee = entryFill * quantity * COMMISSION;
257-
const initialStop = entryFill - INITIAL_STOP_ATR * pendingEntry.atr;
316+
const entryFee = entryFill * quantity * parameters.commission;
317+
const initialStop = entryFill - parameters.initialStopAtr * pendingEntry.atr;
258318
position = {
259319
signalIndex: pendingEntry.signalIndex,
260320
signalTimestamp: pendingEntry.signalTimestamp,
@@ -275,56 +335,56 @@ export function runRegimeTrendV1(
275335

276336
if (position && pendingTrendExit) {
277337
if (candle.open <= position.activeStop) {
278-
recordExit(index, candle.open, candle.open * (1 - SLIPPAGE), stopReason(position));
338+
recordExit(index, candle.open, candle.open * (1 - parameters.slippage), stopReason(position));
279339
} else {
280-
recordExit(index, candle.open, candle.open * (1 - SLIPPAGE), "trend_exit");
340+
recordExit(index, candle.open, candle.open * (1 - parameters.slippage), "trend_exit");
281341
}
282342
exitedThisBar = true;
283343
}
284344

285345
if (position && !exitedThisBar) {
286346
const stop = position.activeStop;
287347
if (candle.open <= stop) {
288-
recordExit(index, candle.open, candle.open * (1 - SLIPPAGE), stopReason(position));
348+
recordExit(index, candle.open, candle.open * (1 - parameters.slippage), stopReason(position));
289349
exitedThisBar = true;
290350
} else if (candle.low <= stop) {
291-
recordExit(index, stop, stop * (1 - SLIPPAGE), stopReason(position));
351+
recordExit(index, stop, stop * (1 - parameters.slippage), stopReason(position));
292352
exitedThisBar = true;
293353
}
294354
}
295355

296356
if (position && !exitedThisBar) {
297357
position.highestCloseSinceEntry = Math.max(position.highestCloseSinceEntry, candle.close);
298-
const currentAtr = atr14[index];
358+
const currentAtr = atr[index];
299359
if (currentAtr !== undefined) {
300360
const previousStop = position.activeStop;
301-
const candidateStop = position.highestCloseSinceEntry - TRAILING_STOP_ATR * currentAtr;
361+
const candidateStop = position.highestCloseSinceEntry - parameters.trailingStopAtr * currentAtr;
302362
const activeStop = Math.max(previousStop, candidateStop);
303363
if (activeStop > previousStop) position.trailingActivated = true;
304364
position.activeStop = activeStop;
305365
stopUpdates.push({ index, timestamp: candle.timestamp, previousStop, candidateStop, activeStop });
306366
}
307367

308-
const fast = ema50[index];
368+
const fast = fastEma[index];
309369
if (fast !== undefined && candle.close < fast && index + 1 < candles.length) {
310370
pendingTrendExit = true;
311371
}
312372
}
313373

314-
if (!position && !pendingEntry && !exitedThisBar && index >= WARMUP_INDEX && index + 1 < candles.length) {
315-
const fast = ema50[index];
316-
const slow = ema200[index];
317-
const atr = atr14[index];
318-
const breakoutLevel = highestPreviousHigh(candles, index);
319-
if (fast !== undefined && slow !== undefined && atr !== undefined && breakoutLevel !== undefined) {
374+
if (!position && !pendingEntry && !exitedThisBar && index >= warmupIndex && index + 1 < candles.length) {
375+
const fast = fastEma[index];
376+
const slow = slowEma[index];
377+
const currentAtr = atr[index];
378+
const breakoutLevel = highestPreviousHigh(candles, index, parameters.donchianLookback);
379+
if (fast !== undefined && slow !== undefined && currentAtr !== undefined && breakoutLevel !== undefined) {
320380
const bullishRegime = fast > slow && candle.close > slow;
321381
const breakout = candle.close > breakoutLevel;
322-
const volatilityAllowed = atr / candle.close >= ATR_FLOOR;
382+
const volatilityAllowed = currentAtr / candle.close >= parameters.atrFloor;
323383
if (bullishRegime && breakout && volatilityAllowed) {
324384
pendingEntry = {
325385
signalIndex: index,
326386
signalTimestamp: candle.timestamp,
327-
atr,
387+
atr: currentAtr,
328388
breakoutLevel
329389
};
330390
signals.push(pendingEntry);

0 commit comments

Comments
 (0)