Sessions and market hours
The one market-hours law: week-minute session calendars keyed by venue and asset class, declared as DST unions that can only ever under-block.
Before SessionCalendar existed, nothing in the workspace could answer "is this venue open at
ts?". crates/vike-model/src/session.rs records what that cost: the FX week existed as a prose
comment in one venue's live-smoke test, live execution discovered a closed market only as a venue
rejection, and a backtest discovered it only as a data gap — two discovery mechanisms for
one fact.
The shape is policy as data plus one pure query: a declarative table, and SessionCalendar::is_open.
There is deliberately no second session codepath — a consumer either calls that query or it is not
consulting the law.
Week-minutes, no timezone database
A calendar is a set of half-open SessionSegments over the trading week, measured in minutes since
Monday 00:00 UTC (0 .. 10_080). Timestamps are epoch-ms UTC, and the only calendar arithmetic is
the day division vike-model already owns — no chrono, no tz database, no hand-rolled civil math.
Resolution is one minute. Segments never wrap, so a session spanning Sunday into Monday is declared
as two segments, which keeps every query an ordered scan with no modular interval arithmetic.
Three rows cover the roster:
CRYPTO_24_7— binance, bybit, okx, deribit, aster, hyperliquid, polymarket. No weekly close at all; the only interruptions are unscheduled maintenance windows, which a static calendar cannot predict and which surface as rejections or feed gaps anyway.FX_WEEK— dukascopy, oanda, ig, fxcm, ctrader. The boundary tracks the New York 17:00 close, which is 21:00 UTC under EDT and 22:00 UTC under EST, so the row opens at the earlier (Sunday 21:00 UTC) and closes at the later (Friday 22:00 UTC). The weekend gap it blocks is Friday 22:00 → Sunday 21:00 UTC.US_EQUITY_REGULAR— 09:30–16:00America/New_York, Monday through Friday, declared in UTC as 13:30–21:00, the union of the EDT and EST windows.
The DST rule: under-block, never over-block
Where a real boundary moves with daylight saving, the declared segment is the union of both offsets. The module states the consequence once, for the whole table: "The gate may UNDER-block by up to one hour. It can never OVER-block." Under-blocking leaves an hour of genuinely closed time looking open — the pre-existing behaviour, since nothing blocked at all before. Over-blocking would silently delete fills that really happened in-session and quietly corrupt a backtest.
Every resolver here is fail-permissive: an unpinned session answers SessionCalendar::ALWAYS_OPEN,
the deliberate opposite of the capability tables' fail-closed default. The module argues why the two
differ — a capability we cannot confirm must not be offered, but a session we have no data for
must not silently block a venue that in reality trades fine.
Keyed by venue and asset class
The correct key is (venue, asset class), never the venue alone. Alpaca trades cash equities and
24/7 crypto on one venue and IBKR is a global multi-asset book, so "what venue is this?" cannot
answer "when is it open?".
AssetClass is a vike-catalog concept and vike-catalog depends on vike-model down-only, so
the map cannot live beside the rows without inverting the layering. It lives one layer up instead:
crates/vike-catalog/src/session.rs's session_calendar_for is the one keying site, and
vike-model takes the resolved calendar as a caller-supplied parameter. Continuous classes
(CryptoSpot, CryptoPerp, CryptoFuture, PredictionMarket) resolve 24/7 regardless of venue;
Fx and Cfd resolve the FX week; Equity and Etf resolve the US regular session for alpaca
and always-open for anything else. Listed options, futures and index products resolve 24/7 on the
crypto-native venues that is_crypto_native names — deribit, binance, bybit, okx, aster,
hyperliquid — and always-open elsewhere. So (alpaca, CryptoSpot) and (alpaca, Equity) return
different calendars from the same venue, and an IBKR equity, whose region is not inferable from
that pair, is left open rather than blocked wrongly.
session_for(venue) remains as the venue-only convenience default, and answers ALWAYS_OPEN for
the mixed-asset venues on purpose. venue_is_open composes the two for a "venue closed" badge and
is explicitly read-only: nothing in the live order path consults it today. next_open and
next_close answer "from when may I trade?", returning the given instant verbatim when it already
matches.
The backtest gate
EngineParams::session_gate is opt-in and defaults to false, which leaves every fill lane
byte-identical. Turned on, each symbol resolves to its own calendar and nothing on that symbol
fills on an event outside its session — resting orders, the tagged maker lane and protective stops
alike, on the frozen fill lanes and their opt-in queue-model tick twins. Resolution order is the
per-symbol session_calendars override, then session_for(default_venue), then always-open. That
means the (venue, asset class) keying lives in the caller: build the map from
session_calendar_for per symbol. Enabling the knob with neither a default venue nor an override is
a silent no-op, not an error — a run whose session_deferrals stays 0 is how you see it. See
the sim broker.
Deliberately not built
Holiday calendars and half-days are not modelled: every row is a plain recurring week, so a US
holiday reads as open. Non-US regional equity sessions and listed-derivative extended hours are
follow-ups, resolved fail-permissively until their rows land. SessionState has only Open and
Closed — no pre- or post-market variant, because no shipped row carries extended-hours data to
distinguish them with, and adding the variant first would be a field that is never set.
Symbol properties
The per-symbol placement grid every venue fills — tick, lot, minimums, contract size, tiered ticks and the venue's own order hold.
Symbology
Core symbols versus exchange symbols, the .P perpetual suffix and which venues use it, and the declarative field map that turns a venue payload into instruments.