Custom Calendar
A Calendar is the SDK's single source of truth for trading-day arithmetic. The backtest engine consults it to determine which days are sessions, when sessions open and close, and how to advance from one trading day to the next. Two reference implementations ship with the SDK (NYSEExchangeCalendar, LSEExchangeCalendar). This page covers the interface contract and both paths to writing your own: implementing Calendar from scratch for always-open venues, or subclassing ExchangeCalendar for regulated exchanges.
Contract
The Calendar interface has six methods:
interface Calendar {
isOpen(t: Date): boolean;
next(t: Date): Date;
previous(t: Date): Date;
sessions(range: DateRange): ReadonlyArray<Date>;
schedule(range: DateRange): ReadonlyArray<Session>;
isEarlyClose(t: Date): boolean;
}Method invariants
| Method | Invariant |
|---|---|
isOpen(t) | Returns true only when t is strictly inside a session: [session.open, session.close) |
next(t) | Returns the midnight-UTC Date of the next trading day strictly after t. Never returns a holiday or weekend. |
previous(t) | Symmetric inverse of next. |
sessions(range) | Ascending array of midnight-UTC Date values for every trading day in [range.from, range.to). |
schedule(range) | Same dates as sessions plus open / close UTC instants per session. |
isEarlyClose(t) | true if the session containing t ends before the exchange's normal close time. |
UTC-midnight convention. next, previous, and the date field of Session must all be midnight-UTC Date objects (e.g. new Date('2024-06-03T00:00:00.000Z')). The engine uses these as stable keys for session lookup.
Path A: implement Calendar from scratch
Use this path for venues with no concept of holidays, exchange hours, or weekends — most commonly, crypto markets.
class Crypto24x7Calendar implements Calendar {
isOpen(_t: Date): boolean { return true; }
next(t: Date): Date {
return new Date(midnightUTC(t).getTime() + MS_DAY);
}
previous(t: Date): Date {
return new Date(midnightUTC(t).getTime() - MS_DAY);
}
sessions(range: DateRange): ReadonlyArray<Date> {
const out: Date[] = [];
let d = midnightUTC(range.from);
while (d.getTime() < midnightUTC(range.to).getTime()) {
out.push(d);
d = new Date(d.getTime() + MS_DAY);
}
return out;
}
schedule(range: DateRange): ReadonlyArray<Session> {
return this.sessions(range).map(date => ({
date,
open: date,
close: new Date(date.getTime() + MS_DAY),
}));
}
isEarlyClose(_t: Date): boolean { return false; }
}Path B: subclass ExchangeCalendar
ExchangeCalendar is an abstract base that provides all six Calendar methods. You override only the hooks that describe your exchange's rules. It uses luxon internally to convert local open/close times to UTC, so you must set this.tz to an IANA timezone name.
The nine overridable hooks (all have no-op / sensible defaults):
| Hook | Purpose | Default |
|---|---|---|
regularHolidays() | HolidayRule[] — recurrence-based full-day closures | [] |
adhocHolidays() | Set<string> of literal 'YYYY-MM-DD' closures | new Set() |
specialCloses() | SpecialClose[] — recurrence-based early-close rules | [] |
specialClosesAdhoc() | Map<string, TimeOfDay> of literal early closes | new Map() |
specialOpens() | SpecialOpen[] — recurrence-based late-open rules | [] |
specialOpensAdhoc() | Map<string, TimeOfDay> of literal late opens | new Map() |
regularOpen(date) | Default session open time | { h: 9, m: 30 } |
regularClose(date) | Default session close time | { h: 16, m: 0 } |
weekmask(date) | Set<number> of active JS weekday numbers (0=Sun…6=Sat) | {1,2,3,4,5} (Mon-Fri) |
For both specialCloses and specialOpens: adhoc overrides win over rule-driven overrides, and both win over the regular* hooks.
A minimal subclass with no holidays and standard Mon–Fri hours:
import { ExchangeCalendar } from '@livefolio/sdk';
class SimpleExchangeCalendar extends ExchangeCalendar {
readonly name = 'SIMPLE';
readonly tz = 'Europe/London';
// All hooks retain their defaults — Mon-Fri, 09:30–16:00 local time.
}To add Good Friday and Christmas Day as holidays:
import { ExchangeCalendar, nearestWorkday } from '@livefolio/sdk';
import type { HolidayRule } from '@livefolio/sdk';
class MyExchangeCalendar extends ExchangeCalendar {
readonly name = 'MY';
readonly tz = 'Europe/Paris';
protected override regularHolidays(): ReadonlyArray<HolidayRule> {
return [
{ name: 'Good Friday', resolve: (y) => easterPlus(y, -2) },
{ name: 'Christmas', resolve: (y) => nearestWorkday(new Date(Date.UTC(y, 11, 25))) },
];
}
}See src/calendars/nyse.ts for a complete production example with adhoc holidays, early closes, variable session times across eras, and a variable weekmask.
TZ handling
ExchangeCalendar stores this.tz (e.g. 'America/New_York') and uses luxon's DateTime.fromObject({…}, { zone: this.tz }) to convert { h, m } local times to UTC instants. When implementing Calendar from scratch, you are responsible for the conversion. Return UTC-midnight Dates from next and previous, and UTC instants from Session.open / Session.close.
Sample: Crypto24x7Calendar
The full runnable sample is at scripts/docs/guides-runtime/custom-calendar.ts:
npx tsx scripts/docs/guides-runtime/custom-calendar.ts// Custom Calendar — guide sample
// Demonstrates two approaches:
// (a) Implementing Calendar from scratch for a 24x7 crypto market.
// (b) Subclassing ExchangeCalendar for a regulated exchange.
//
// npx tsx scripts/docs/guides-runtime/custom-calendar.ts
import { fromSpec, runBacktest, FeatureRuntime, MemoryFeatureCache, BacktestExecutor } from '@livefolio/sdk';
import type { Calendar, Session, DateRange, DataFeed, Asset, Bar, Frequency, TacticalSpec } from '@livefolio/sdk';
// ─── Approach (a): Calendar from scratch for a 24x7 market ──────────────────
//
// Crypto exchanges never close. Every calendar day is a session. There are no
// weekends, no holidays, no early closes. `next` / `previous` simply step
// one day forward or back. `isOpen` always returns true.
//
// Contract checklist:
// - next(t) / previous(t) return midnight-UTC Dates
// - sessions(range) is ascending, half-open [range.from, range.to)
// - schedule(range) returns Session objects with open / close UTC instants
// - isOpen(t) true only while a session is active (here: always)
// - isEarlyClose(t) — crypto never closes early
const MS_DAY = 86_400_000;
function midnightUTC(t: Date): Date {
return new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate()));
}
/**
* Crypto24x7Calendar — every day is a session, 00:00–24:00 UTC.
* Use this as a starting point for any always-open venue.
*/
class Crypto24x7Calendar implements Calendar {
isOpen(_t: Date): boolean {
return true;
}
next(t: Date): Date {
return new Date(midnightUTC(t).getTime() + MS_DAY);
}
previous(t: Date): Date {
return new Date(midnightUTC(t).getTime() - MS_DAY);
}
sessions(range: DateRange): ReadonlyArray<Date> {
const out: Date[] = [];
let d = midnightUTC(range.from);
const end = midnightUTC(range.to).getTime();
while (d.getTime() < end) {
out.push(d);
d = new Date(d.getTime() + MS_DAY);
}
return out;
}
schedule(range: DateRange): ReadonlyArray<Session> {
return this.sessions(range).map((date) => ({
date,
open: date, // 00:00 UTC
close: new Date(date.getTime() + MS_DAY), // 24:00 UTC (= next midnight)
}));
}
isEarlyClose(_t: Date): boolean {
return false;
}
}
// ─── Approach (b): Subclass ExchangeCalendar ─────────────────────────────────
//
// For a regulated exchange you typically want to extend ExchangeCalendar and
// override only the abstract hooks that differ from its defaults. See:
// src/calendars/nyse.ts — full example with holidays + special closes
// src/calendars/lse.ts — European exchange with different hours
//
// The 9 overridable hooks are:
// regularHolidays() — array of HolidayRule (recurrence-based closures)
// adhocHolidays() — Set<string> of literal "YYYY-MM-DD" closures
// specialCloses() — array of SpecialClose (early-close recurrence rules)
// specialClosesAdhoc()— Map<string, TimeOfDay> of literal early closes
// specialOpens() — array of SpecialOpen (late-open recurrence rules)
// specialOpensAdhoc() — Map<string, TimeOfDay> of literal late opens
// regularOpen(date) — default open time (TimeOfDay); default 09:30
// regularClose(date) — default close time (TimeOfDay); default 16:00
// weekmask(date) — Set<number> of active JS weekday numbers; default Mon-Fri
//
// ExchangeCalendar uses luxon to localise open/close times into the exchange
// timezone (this.tz). Always set `tz` to an IANA timezone name.
//
// A minimal exchange subclass — no holidays, standard Mon-Fri 09:30–16:00:
//
// import { ExchangeCalendar } from '@livefolio/sdk';
//
// class SimpleExchangeCalendar extends ExchangeCalendar {
// readonly name = 'SIMPLE';
// readonly tz = 'Europe/London';
// }
// ─── Drive a backtest with Crypto24x7Calendar ────────────────────────────────
const BTCUSD = { id: 'crypto:BTCUSD', symbol: 'BTCUSD' };
// Synthetic daily bars (no weekend skipping — crypto trades 7 days a week).
function makeCryptoBars(startIso: string, count: number): Bar[] {
const bars: Bar[] = [];
let price = 40_000;
let t = new Date(`${startIso}T00:00:00Z`);
for (let i = 0; i < count; i++) {
price = price * (1 + Math.sin(i / 15) * 0.02 + 0.0005);
bars.push({
t: new Date(t),
open: price * 0.999,
high: price * 1.015,
low: price * 0.985,
close: price,
volume: 50_000,
});
t = new Date(t.getTime() + MS_DAY);
}
return bars;
}
const CRYPTO_BARS = makeCryptoBars('2024-01-01', 200);
const dataFeed: DataFeed = {
bars: async function* (asset: Asset, range: DateRange, _freq: Frequency) {
if (asset.id !== 'crypto:BTCUSD') throw new Error(`no fixture for ${asset.id}`);
for (const b of CRYPTO_BARS) {
if (b.t >= range.from && b.t < range.to) yield b;
}
},
};
const spec: TacticalSpec = {
kind: 'tactical/v1',
universe: [BTCUSD],
rebalance: { frequency: 'Weekly' },
features: [{ id: 'btc_price', kind: 'price', asset: BTCUSD }],
rules: { op: 'allocate', weights: { 'crypto:BTCUSD': 1.0 } },
};
const calendar = new Crypto24x7Calendar();
const featureCache = new MemoryFeatureCache();
const range: DateRange = { from: new Date('2024-02-01T00:00:00Z'), to: new Date('2024-07-01T00:00:00Z') };
const runtime = new FeatureRuntime({ dataFeed, featureCache, range, freq: '1d' });
const executor = new BacktestExecutor({
calendar,
nextOpen: async (asset, t) => {
if (asset.id !== 'crypto:BTCUSD') throw new Error(`no fixture for ${asset.id}`);
const next = CRYPTO_BARS.find((b) => b.t.getTime() > t.getTime());
if (!next) throw new Error(`no bar after ${t.toISOString()}`);
return { t: next.t, price: next.open };
},
});
const strategy = fromSpec(spec, { runtime, calendar });
const result = await runBacktest({
strategy,
range,
initialPortfolio: { cash: 100_000, positions: [], t: range.from },
dataFeed,
executor,
calendar,
});
// Sanity-check: crypto should have a session every day in the range.
const days = calendar.sessions(range);
console.log(`Crypto24x7: sessions in range = ${days.length}`);
console.log(`Backtest snapshots = ${result.snapshots.length}`);
console.log(`Rebalances = ${result.snapshots.filter((s) => s.orders.length > 0).length}`);Things to verify
- [ ]
next(t)never returnstitself — it must strictly advance. - [ ]
sessions(range)respects the half-open interval: includesrange.from, excludesrange.to. - [ ]
previous(next(t))equalstfor any trading dayt(round-trip property). - [ ]
isOpenreturnsfalsefor dates that are holidays or weekends. - [ ] All dates returned by
next/previous/sessionsare midnight-UTC (getUTCHours() === 0). - [ ] Your implementation compiles:
npm run docs:check. - [ ] Integration: call
calendar.sessions(range)directly to inspect the session count before wiring into a backtest.
What's next
- DataFeed alignment — ensure your
DataFeedonly yields bars on days your calendar considers open. A mismatch causes the engine to skip bars or attempt indicator calculations on sparse data. BacktestExecutorneeds aCalendarfor next-session resolution. Pass the same instance to both. See Custom Executor.- API reference —
Calendar·ExchangeCalendar·NYSEExchangeCalendar·LSEExchangeCalendar.