Poll for market open instead of single long setTimeout

macOS sleep suspends the Docker VM, causing long setTimeout calls to
never fire. Replace the one-shot wait with a polling loop that checks
getClock every 30s (capped to time-until-open), so the bot resumes
promptly after the host wakes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-02-11 11:02:49 -07:00
parent 46e7bef4b2
commit 7b1a20c768
3 changed files with 59 additions and 20 deletions

View File

@@ -9,9 +9,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getAssets: vi.fn(), getAssets: vi.fn(),
getAsset: vi.fn(), getAsset: vi.fn(),
getClock: vi.fn().mockResolvedValue({ getClock: vi.fn().mockResolvedValue({
is_open: false, is_open: true,
next_open: new Date().toISOString(), next_open: new Date().toISOString(),
next_close: new Date().toISOString(), next_close: new Date(Date.now() + 86_400_000).toISOString(),
}), }),
getLatestAsk: vi.fn(), getLatestAsk: vi.fn(),
getLatestBid: vi.fn(), getLatestBid: vi.fn(),
@@ -103,17 +103,11 @@ describe('Bot', () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
await promise; await promise;
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'market is open, running strategies');
}); });
it('skips waiting when market is already open', async () => { it('skips waiting when market is already open', async () => {
const alpaca = mockAlpaca({ const alpaca = mockAlpaca();
getClock: vi.fn().mockResolvedValue({
is_open: true,
next_open: new Date().toISOString(),
next_close: new Date().toISOString(),
}),
});
const strategy = mockStrategy(); const strategy = mockStrategy();
const bot = new Bot(alpaca, [{ strategy, capitalAllocation: 1.0 }]); const bot = new Bot(alpaca, [{ strategy, capitalAllocation: 1.0 }]);
@@ -124,5 +118,24 @@ describe('Bot', () => {
expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open'); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000); expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000);
}); });
it('waits for market open when closed', async () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const alpaca = mockAlpaca({
getClock: vi.fn()
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValue({ is_open: true, next_open: futureDate, next_close: new Date(Date.now() + 86_400_000).toISOString() }),
});
const strategy = mockStrategy();
const bot = new Bot(alpaca, [{ strategy, capitalAllocation: 1.0 }]);
const promise = bot.runDay();
await vi.advanceTimersByTimeAsync(120000);
await promise;
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000);
});
}); });
}); });

View File

@@ -69,17 +69,36 @@ describe('waitForNextOpen', () => {
vi.spyOn(console, 'debug').mockImplementation(() => {}); vi.spyOn(console, 'debug').mockImplementation(() => {});
}); });
it('calls getClock and waits until next_open', async () => { it('polls until market opens', async () => {
const futureDate = new Date(Date.now() + 60000).toISOString(); const futureDate = new Date(Date.now() + 60000).toISOString();
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: futureDate, next_close: futureDate }), getClock: vi.fn()
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValueOnce({ is_open: true, next_open: futureDate, next_close: futureDate }),
}); });
const promise = waitForNextOpen(alpaca); const promise = waitForNextOpen(alpaca, 100);
await vi.advanceTimersByTimeAsync(60000); await vi.advanceTimersByTimeAsync(300);
await promise; await promise;
expect(alpaca.getClock).toHaveBeenCalled(); expect(alpaca.getClock).toHaveBeenCalledTimes(3);
});
it('sleeps at most pollInterval even when open is far away', async () => {
const farFuture = new Date(Date.now() + 86_400_000).toISOString();
const alpaca = mockAlpaca({
getClock: vi.fn()
.mockResolvedValueOnce({ is_open: false, next_open: farFuture, next_close: farFuture })
.mockResolvedValueOnce({ is_open: true, next_open: farFuture, next_close: farFuture }),
});
const promise = waitForNextOpen(alpaca, 200);
await vi.advanceTimersByTimeAsync(300);
await promise;
// Should have polled twice: first sleep capped to 200ms, then saw is_open
expect(alpaca.getClock).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@@ -37,10 +37,17 @@ export async function isMarketOpen(alpaca: Alpaca): Promise<boolean> {
return clock.is_open; return clock.is_open;
} }
export async function waitForNextOpen(alpaca: Alpaca) { export async function waitForNextOpen(alpaca: Alpaca, pollInterval = 30_000) {
const clock = await alpaca.getClock(); while (true) {
const ms = new Date(clock.next_open).valueOf() - new Date().valueOf(); const clock = await alpaca.getClock();
logger.info(`market closed, next open: ${clock.next_open} (waiting ${ms}ms)`); if (clock.is_open) {
return wait(ms); logger.info('market is now open');
return;
}
const ms = new Date(clock.next_open).valueOf() - Date.now();
const sleepMs = Math.min(Math.max(ms, 0), pollInterval);
logger.info(`market closed, next open: ${clock.next_open} (sleeping ${sleepMs}ms)`);
await wait(sleepMs);
}
} }