Compare commits

..
2 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 db0ad99a76 CME evening session: first entry at 5:45 PM CT, not 5:05
Tradovate moves each account's cash record to the new trade date on its own
schedule, an idle one perhaps not until it trades, and the bot holds any
account still on the old day until 5:45. Opening at 5:05 fired the ready
accounts alone (TopstepX, the early rollers) while the rest, released at
5:45, waited an hour or more for them to go flat. A 5:45 open puts every
account in the first batch and skips the open's widest spreads. The day's
0–180 minute delay still comes on top: first entries land 5:45–8:45 PM.

Every-day firms keep their own 5:05 PM end of the roll guard; the equity
hours open is unchanged. The draw's session progress now starts at 5:45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:45:08 -05:00
Brandon LiandClaude Opus 5.5 ff97a59b49 Session open: a random 0–180 minute delay, kept across restarts
The day's delay was 1–20 minutes. It is now 0–180, so the evening open
lands anywhere from 5:05 to 8:05 PM CT. The day's roll is saved as a
setting: with a three-hour window, a restart during the wait (an update, a
crash) would otherwise roll again and could start the session early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:30:01 -05:00
5 changed files with 66 additions and 21 deletions
+17 -5
View File
@@ -705,18 +705,30 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
// Random 1–20 minute delay added to the session open, picked once per Central /** The longest random delay added to the session open, in minutes. */
const MAX_OPEN_DELAY_MIN = 180;
// Random 0–180 minute delay added to the session open, picked once per Central
// calendar day. Re-rolling on every check would move the open boundary between // calendar day. Re-rolling on every check would move the open boundary between
// polls, letting a trade slip through a moment the next check would reject. // polls, letting a trade slip through a moment the next check would reject.
// Held in runtime() so every bundle of this module agrees on the day's roll. // Held in runtime() so every bundle of this module agrees on the day's roll,
// and saved as a setting so a restart during the wait (an update, a crash)
// keeps the day's delay instead of rolling a new one.
function openJitterMinutes(centralDay: string): number { function openJitterMinutes(centralDay: string): number {
const sessionOpenJitter = runtime().sessionOpenJitter; const sessionOpenJitter = runtime().sessionOpenJitter;
let jitter = sessionOpenJitter.get(centralDay); let jitter = sessionOpenJitter.get(centralDay);
if (jitter === undefined) { if (jitter === undefined) {
jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive const [savedDay, savedMin] = (getSetting('session_open_delay') ?? '').split('|');
const saved = savedDay === centralDay ? Number(savedMin) : NaN;
if (Number.isInteger(saved) && saved >= 0 && saved <= MAX_OPEN_DELAY_MIN) {
jitter = saved;
} else {
jitter = Math.floor(Math.random() * (MAX_OPEN_DELAY_MIN + 1)); // 0–180 inclusive
setSetting('session_open_delay', `${centralDay}|${jitter}`);
console.log(`[trading-hours] session open delayed ${jitter} min today`);
}
sessionOpenJitter.clear(); // only today's entry is ever needed sessionOpenJitter.clear(); // only today's entry is ever needed
sessionOpenJitter.set(centralDay, jitter); sessionOpenJitter.set(centralDay, jitter);
console.log(`[trading-hours] session open delayed ${jitter} min today`);
} }
return jitter; return jitter;
} }
@@ -740,7 +752,7 @@ function isAfterSessionClose(firm: { tradesEveryDay?: boolean }, now: Date = new
/** /**
* Whether the firm takes no new trades right now (see isNoTradeTime). CME * Whether the firm takes no new trades right now (see isNoTradeTime). CME
* firms follow the trading_hours setting — "full_cme" or "equity_hours" — with * firms follow the trading_hours setting — "full_cme" or "equity_hours" — with
* the open pushed back by the day's 1–20 minute jitter (openJitterMinutes), so * the open pushed back by the day's 0–180 minute delay (openJitterMinutes), so
* sessions never start at the exact same time. Every-day firms ignore the * sessions never start at the exact same time. Every-day firms ignore the
* setting and only stand down around the daily roll. * setting and only stand down around the daily roll.
*/ */
+5 -4
View File
@@ -135,8 +135,9 @@ function centralMinutes(now: Date): { day: string; minutes: number } {
/** /**
* How far through the trading session `now` is: 0 at the open, 1 at the last * How far through the trading session `now` is: 0 at the open, 1 at the last
* entry. full_cme runs 17:05 → 14:55 CT the next day; equity_hours 8:35 → * entry. full_cme runs 17:45 → 14:55 CT the next day (trading-logic's
* 14:55. null outside the session. * CME_EVENING_OPEN_MIN — no imports here); equity_hours 8:35 → 14:55. null
* outside the session.
*/ */
export function sessionProgress(now: Date, mode: string): number | null { export function sessionProgress(now: Date, mode: string): number | null {
const { day, minutes } = centralMinutes(now); const { day, minutes } = centralMinutes(now);
@@ -144,8 +145,8 @@ export function sessionProgress(now: Date, mode: string): number | null {
if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null; if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null;
return (minutes - 515) / (895 - 515); return (minutes - 515) / (895 - 515);
} }
const open = 17 * 60 + 5; const open = 17 * 60 + 45;
const length = 24 * 60 - open + 14 * 60 + 55; // 21h50m const length = 24 * 60 - open + 14 * 60 + 55; // 21h10m
const since = (minutes - open + 1440) % 1440; const since = (minutes - open + 1440) % 1440;
if (since > length) return null; if (since > length) return null;
if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null; if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null;
+20 -6
View File
@@ -1553,13 +1553,27 @@ function centralClock(now: Date): { day: string; minutes: number; resetMinutes:
return { day: get('weekday'), minutes, resetMinutes }; return { day: get('weekday'), minutes, resetMinutes };
} }
/**
* The CME evening session's first entry, in Central minutes: 5:45 PM, 45
* minutes after the 5 PM roll. Tradovate moves each account's cash record to
* the new trade date on its own schedule — an idle one perhaps not until it
* trades — and the bot holds any account still on the old day until 5:45
* (tradovate-class ROLL_WINDOW_END_MIN). Opening then puts every account in
* the first batch, instead of the ready ones firing alone and the rest
* waiting for them to go flat, and skips the open's widest spreads.
*/
export const CME_EVENING_OPEN_MIN = 17 * 60 + 45;
/** Every-day firms' roll guard ends here (plus the day's delay): they have
* no Tradovate roll to wait for. */
const EVERY_DAY_OPEN_MIN = 17 * 60 + 5;
/** /**
* Whether no new trade may open right now. * Whether no new trade may open right now.
* *
* CME firms follow the trading_hours setting: * CME firms follow the trading_hours setting:
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side) * "full_cme" — Sun 5:45 PM – Fri 2:55 PM Central (see CME_EVENING_OPEN_MIN)
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side) * "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
* with the open pushed back by the day's 1–20 minute jitter. * with the open pushed back by the day's 0–180 minute delay.
* *
* Every-day firms ignore trading_hours and close only around the day's roll: * Every-day firms ignore trading_hours and close only around the day's roll:
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to * from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
@@ -1569,11 +1583,11 @@ function centralClock(now: Date): { day: string; minutes: number; resetMinutes:
*/ */
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean { export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
const { day, minutes, resetMinutes } = centralClock(now); const { day, minutes, resetMinutes } = centralClock(now);
const cmeOpen = 17 * 60 + 5 + o.jitterMin; const cmeOpen = CME_EVENING_OPEN_MIN + o.jitterMin;
if (o.everyDay) { if (o.everyDay) {
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN; const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
return minutes >= guardStart && minutes < cmeOpen; return minutes >= guardStart && minutes < EVERY_DAY_OPEN_MIN + o.jitterMin;
} }
if (o.mode === 'equity_hours') { if (o.mode === 'equity_hours') {
@@ -1583,12 +1597,12 @@ export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; j
return minutes < open || minutes >= close; return minutes < open || minutes >= close;
} }
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer. // Full CME: Sun 5:45 PM (plus the delay) – Fri 2:55 PM Central.
if (day === 'Sat') return true; if (day === 'Sat') return true;
if (day === 'Sun') return minutes < cmeOpen; if (day === 'Sun') return minutes < cmeOpen;
const cmeClose = 14 * 60 + 55; const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && minutes >= cmeClose) return true; if (day === 'Fri' && minutes >= cmeClose) return true;
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer). // Mon–Thu: block 2:55 PM – 5:45 PM Central (early stop, daily halt, Tradovate's roll).
return minutes >= cmeClose && minutes < cmeOpen; return minutes >= cmeClose && minutes < cmeOpen;
} }
+4 -3
View File
@@ -63,11 +63,12 @@ test('daily move from hourly closes; contracts; expected hold', () => {
assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null); assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null);
}); });
test('session progress: full CME 17:05 → 14:55 CT, equity hours 8:35 → 14:55', () => { test('session progress: full CME 17:45 → 14:55 CT, equity hours 8:35 → 14:55', () => {
// 2026-09-28 is a Monday; CDT is UTC−5. // 2026-09-28 is a Monday; CDT is UTC−5.
const ct = (iso) => new Date(`${iso}-05:00`); const ct = (iso) => new Date(`${iso}-05:00`);
assert.equal(sessionProgress(ct('2026-09-27T17:05'), 'full_cme'), 0); assert.equal(sessionProgress(ct('2026-09-27T17:45'), 'full_cme'), 0);
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 655 / 1310) < 1e-9); assert.equal(sessionProgress(ct('2026-09-27T17:30'), 'full_cme'), null); // Sunday before the open
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 615 / 1270) < 1e-9);
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1); assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1);
assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance
assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday
+20 -3
View File
@@ -97,7 +97,7 @@ function oldNoTrade(now, mode, jitter) {
return timeMinutes < open || timeMinutes >= close; return timeMinutes < open || timeMinutes >= close;
} }
if (day === 'Sat') return true; if (day === 'Sat') return true;
const cmeOpen = 17 * 60 + 5 + jitter; const cmeOpen = 17 * 60 + 45 + jitter;
if (day === 'Sun') return timeMinutes < cmeOpen; if (day === 'Sun') return timeMinutes < cmeOpen;
const cmeClose = 14 * 60 + 55; const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && timeMinutes >= cmeClose) return true; if (day === 'Fri' && timeMinutes >= cmeClose) return true;
@@ -113,12 +113,12 @@ function oldAfterClose(now) {
return minutes >= 14 * 60 + 55 && minutes < 17 * 60; return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
} }
test('CME trading hours are unchanged, across both DST regimes', () => { test('CME trading hours: 5:45 PM plus the delay to 2:55 PM, across both DST regimes', () => {
for (const start of [Date.UTC(2026, 8, 20), Date.UTC(2026, 0, 11)]) { // a September and a January week for (const start of [Date.UTC(2026, 8, 20), Date.UTC(2026, 0, 11)]) { // a September and a January week
for (let m = 0; m < 7 * 24 * 60; m += 7) { for (let m = 0; m < 7 * 24 * 60; m += 7) {
const now = new Date(start + m * 60_000); const now = new Date(start + m * 60_000);
for (const mode of ['full_cme', 'equity_hours']) { for (const mode of ['full_cme', 'equity_hours']) {
for (const jitter of [1, 13, 20]) { for (const jitter of [0, 1, 13, 20, 95, 180]) {
assert.equal(isNoTradeTime(now, { everyDay: false, mode, jitterMin: jitter }), oldNoTrade(now, mode, jitter), `${now.toISOString()} ${mode} ${jitter}`); assert.equal(isNoTradeTime(now, { everyDay: false, mode, jitterMin: jitter }), oldNoTrade(now, mode, jitter), `${now.toISOString()} ${mode} ${jitter}`);
} }
} }
@@ -133,6 +133,23 @@ const ct = (iso, offsetHours) => new Date(Date.parse(`${iso}Z`) + offsetHours *
const cdt = (iso) => ct(iso, 5); // Central daylight: UTC−5 const cdt = (iso) => ct(iso, 5); // Central daylight: UTC−5
const cst = (iso) => ct(iso, 6); // Central standard: UTC−6 const cst = (iso) => ct(iso, 6); // Central standard: UTC−6
test('the evening session opens at 5:45 PM CT, and a 180-minute delay makes it 8:45', () => {
const o = { everyDay: false, mode: 'full_cme', jitterMin: 180 };
assert.equal(isNoTradeTime(cdt('2026-09-28T17:45:00'), o), true); // Monday
assert.equal(isNoTradeTime(cdt('2026-09-28T20:44:00'), o), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T20:45:00'), o), false);
assert.equal(isNoTradeTime(cdt('2026-09-27T20:44:00'), o), true); // Sunday too
assert.equal(isNoTradeTime(cdt('2026-09-27T20:45:00'), o), false);
assert.equal(isNoTradeTime(cdt('2026-09-29T10:00:00'), o), false); // the day session is untouched
// No delay: 5:45 PM — not 5:05, while Tradovate rolls the accounts' days.
const now = { ...o, jitterMin: 0 };
assert.equal(isNoTradeTime(cdt('2026-09-28T17:05:00'), now), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T17:44:00'), now), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T17:45:00'), now), false);
// Every-day firms keep their own 5:05 PM end of the roll guard.
assert.equal(isNoTradeTime(cdt('2026-09-28T17:05:00'), { ...now, everyDay: true }), false);
});
test('every-day firms close only around the roll (CDT)', () => { test('every-day firms close only around the roll (CDT)', () => {
const o = { everyDay: true, mode: 'full_cme', jitterMin: 7 }; const o = { everyDay: true, mode: 'full_cme', jitterMin: 7 };
assert.equal(isNoTradeTime(cdt('2026-09-26T12:00:00'), o), false); // Saturday noon assert.equal(isNoTradeTime(cdt('2026-09-26T12:00:00'), o), false); // Saturday noon