Skip to content

Commit 3bb44de

Browse files
committed
test: add Regime Trend v1 execution gates
1 parent d857b28 commit 3bb44de

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
ema,
4+
runRegimeTrendV1,
5+
type Candle
6+
} from "../research/regime-trend-v1/reference-engine";
7+
8+
const FOUR_HOURS = 4 * 60 * 60 * 1000;
9+
10+
function candle(index: number, open: number, high: number, low: number, close: number): Candle {
11+
return { timestamp: index * FOUR_HOURS, open, high, low, close, volume: 1000 };
12+
}
13+
14+
function makeTrendCandles(count: number, start = 100, growth = 1.01): Candle[] {
15+
const candles: Candle[] = [];
16+
let base = start;
17+
for (let index = 0; index < count; index += 1) {
18+
const open = base;
19+
const close = open * 1.005;
20+
candles.push(candle(index, open, open * 1.01, open * 0.99, close));
21+
base *= growth;
22+
}
23+
return candles;
24+
}
25+
26+
function makeLowVolatilityTrend(count: number): Candle[] {
27+
const candles: Candle[] = [];
28+
let base = 100;
29+
for (let index = 0; index < count; index += 1) {
30+
const open = base;
31+
const close = open * 1.0006;
32+
candles.push(candle(index, open, open * 1.0008, open * 0.9996, close));
33+
base *= 1.0008;
34+
}
35+
return candles;
36+
}
37+
38+
function makeBearishBreakoutFixture(): Candle[] {
39+
const candles: Candle[] = [];
40+
let base = 300;
41+
for (let index = 0; index < 199; index += 1) {
42+
const open = base;
43+
const close = open * 0.997;
44+
candles.push(candle(index, open, open * 1.004, open * 0.993, close));
45+
base *= 0.995;
46+
}
47+
const previousHigh = Math.max(...candles.slice(179).map((item) => item.high));
48+
const breakoutClose = previousHigh * 1.02;
49+
candles.push(candle(199, breakoutClose * 0.995, breakoutClose * 1.01, breakoutClose * 0.99, breakoutClose));
50+
candles.push(candle(200, breakoutClose, breakoutClose * 1.01, breakoutClose * 0.99, breakoutClose));
51+
return candles;
52+
}
53+
54+
function buildEntryWithInitialStop(): { candles: Candle[]; stop: number } {
55+
const signalHistory = makeTrendCandles(200);
56+
const signalResult = runRegimeTrendV1([
57+
...signalHistory,
58+
candle(200, signalHistory[199].close, signalHistory[199].close * 1.002, signalHistory[199].close * 0.998, signalHistory[199].close)
59+
]);
60+
expect(signalResult.openPosition).not.toBeNull();
61+
expect(signalResult.openPosition!.trailingActivated).toBe(false);
62+
return { candles: signalHistory.concat(candle(200, signalHistory[199].close, signalHistory[199].close * 1.002, signalHistory[199].close * 0.998, signalHistory[199].close)), stop: signalResult.openPosition!.activeStop };
63+
}
64+
65+
describe("Regime Trend v1 entry rejection gates", () => {
66+
it("rejects a breakout when the bullish EMA regime is absent", () => {
67+
const result = runRegimeTrendV1(makeBearishBreakoutFixture());
68+
expect(result.signals).toHaveLength(0);
69+
expect(result.openPosition).toBeNull();
70+
});
71+
72+
it("rejects an otherwise bullish sequence when no close exceeds the previous Donchian high", () => {
73+
const candles = makeTrendCandles(205);
74+
candles[198] = { ...candles[198], high: candles[198].high * 10 };
75+
const result = runRegimeTrendV1(candles);
76+
expect(result.signals).toHaveLength(0);
77+
});
78+
79+
it("rejects a breakout below the normalized ATR floor", () => {
80+
const result = runRegimeTrendV1(makeLowVolatilityTrend(205));
81+
expect(result.signals).toHaveLength(0);
82+
expect(result.openPosition).toBeNull();
83+
});
84+
});
85+
86+
describe("Regime Trend v1 stop and exit ordering", () => {
87+
it("uses the frozen initial stop before any trailing activation", () => {
88+
const fixture = buildEntryWithInitialStop();
89+
const stopBar = candle(201, fixture.stop * 1.01, fixture.stop * 1.02, fixture.stop * 0.99, fixture.stop * 1.005);
90+
const result = runRegimeTrendV1([...fixture.candles, stopBar]);
91+
92+
expect(result.trades).toHaveLength(1);
93+
expect(result.trades[0].exit_reason).toBe("initial_stop");
94+
expect(result.trades[0].raw_exit_reference).toBeCloseTo(fixture.stop, 10);
95+
expect(result.trades[0].exit_fill).toBeCloseTo(fixture.stop * 0.9995, 10);
96+
});
97+
98+
it("fills a gap stop from the adverse candle open", () => {
99+
const fixture = buildEntryWithInitialStop();
100+
const gapOpen = fixture.stop * 0.98;
101+
const gapBar = candle(201, gapOpen, gapOpen * 1.02, gapOpen * 0.99, gapOpen * 1.01);
102+
const result = runRegimeTrendV1([...fixture.candles, gapBar]);
103+
104+
expect(result.trades).toHaveLength(1);
105+
expect(result.trades[0].exit_reason).toBe("initial_stop");
106+
expect(result.trades[0].raw_exit_reference).toBeCloseTo(gapOpen, 10);
107+
expect(result.trades[0].exit_fill).toBeCloseTo(gapOpen * 0.9995, 10);
108+
});
109+
110+
it("executes a pending trend exit at the next open before inspecting that candle's later low", () => {
111+
const fixture = buildEntryWithInitialStop();
112+
const closes = fixture.candles.map((item) => item.close);
113+
const ema50 = ema(closes, 50);
114+
const fast = ema50[ema50.length - 1] as number;
115+
const triggerClose = Math.max(fixture.stop * 1.1, fast * 0.99);
116+
expect(triggerClose).toBeLessThan(fast);
117+
118+
const trigger = candle(201, triggerClose * 1.02, triggerClose * 1.03, Math.max(fixture.stop * 1.01, triggerClose * 0.99), triggerClose);
119+
const exitOpen = Math.max(fixture.stop * 1.05, triggerClose * 0.995);
120+
const exitBar = candle(202, exitOpen, exitOpen * 1.01, fixture.stop * 0.95, exitOpen * 0.99);
121+
const result = runRegimeTrendV1([...fixture.candles, trigger, exitBar]);
122+
123+
expect(result.trades).toHaveLength(1);
124+
expect(result.trades[0].exit_reason).toBe("trend_exit");
125+
expect(result.trades[0].raw_exit_reference).toBeCloseTo(exitOpen, 10);
126+
expect(result.trades[0].exit_fill).toBeCloseTo(exitOpen * 0.9995, 10);
127+
});
128+
129+
it("gives a gap stop priority when the pending trend-exit candle opens below the active stop", () => {
130+
const fixture = buildEntryWithInitialStop();
131+
const closes = fixture.candles.map((item) => item.close);
132+
const fast = ema(closes, 50).at(-1) as number;
133+
const triggerClose = Math.max(fixture.stop * 1.1, fast * 0.99);
134+
const trigger = candle(201, triggerClose * 1.02, triggerClose * 1.03, Math.max(fixture.stop * 1.01, triggerClose * 0.99), triggerClose);
135+
const gapOpen = fixture.stop * 0.97;
136+
const exitBar = candle(202, gapOpen, gapOpen * 1.02, gapOpen * 0.99, gapOpen * 1.01);
137+
const result = runRegimeTrendV1([...fixture.candles, trigger, exitBar]);
138+
139+
expect(result.trades).toHaveLength(1);
140+
expect(result.trades[0].exit_reason).toBe("initial_stop");
141+
expect(result.trades[0].raw_exit_reference).toBeCloseTo(gapOpen, 10);
142+
});
143+
});
144+
145+
describe("Regime Trend v1 recursive stability", () => {
146+
it("keeps stable-period signals identical when equivalent warm-up candles are prepended", () => {
147+
const core = makeTrendCandles(450);
148+
const firstClose = core[0].close;
149+
const warmup: Candle[] = Array.from({ length: 300 }, (_, index) => ({
150+
timestamp: (index - 300) * FOUR_HOURS,
151+
open: firstClose,
152+
high: firstClose * 1.01,
153+
low: firstClose * 0.99,
154+
close: firstClose,
155+
volume: 1000
156+
}));
157+
const shiftedCore = core.map((item, index) => ({ ...item, timestamp: index * FOUR_HOURS }));
158+
159+
const baseResult = runRegimeTrendV1(shiftedCore);
160+
const prependedResult = runRegimeTrendV1([...warmup, ...shiftedCore]);
161+
const stableTimestamp = shiftedCore[300].timestamp;
162+
163+
expect(prependedResult.signals.filter((signal) => signal.signalTimestamp >= stableTimestamp))
164+
.toEqual(baseResult.signals.filter((signal) => signal.signalTimestamp >= stableTimestamp));
165+
});
166+
});

0 commit comments

Comments
 (0)