diff --git a/docs/cookbook-nyse.md b/docs/cookbook-nyse.md new file mode 100644 index 0000000000..81a6f4e643 --- /dev/null +++ b/docs/cookbook-nyse.md @@ -0,0 +1,12 @@ +## New York Stock Exchange time zone + +This is an example of using `Temporal.TimeZone` for a custom purpose that is not a standard time zone in use somewhere in the world. + +`NYSETimeZone` is a time zone where there are no valid `Temporal.PlainDateTime` values except when the market is open. +When the market is closed, instants are disambiguated to the opening bell of the next market day. + +> **NOTE**: This is a very specialized use of Temporal and is not something you would normally need to do. + +```javascript +{{cookbook/stockExchangeTimeZone.mjs}} +``` diff --git a/docs/cookbook.md b/docs/cookbook.md index 1463f2eca9..765aeb78dd 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -465,3 +465,9 @@ Since they are generally larger than these cookbook recipes, they're on their ow Extend Temporal to support arbitrarily-large years (e.g., **+635427810-02-02**) for astronomical purposes. → [Extra-expanded years](cookbook-expandedyears.md) + +### New York Stock Exchange time zone + +An example of using `Temporal.TimeZone` for other purposes than a standard time zne. + +→ [NYSE time zone](cookbook-nyse.md) diff --git a/docs/cookbook/stockExchangeTimeZone.mjs b/docs/cookbook/stockExchangeTimeZone.mjs new file mode 100644 index 0000000000..11ebd854e4 --- /dev/null +++ b/docs/cookbook/stockExchangeTimeZone.mjs @@ -0,0 +1,216 @@ +/** + * This sample implements a custom time zone that only allows PlainDateTime + * values that are during the times that the New York Stock Exchange is open, + * which is usually Monday through Friday 9:30 a.m. to 4:00 p.m. in + * America/New_York. A more complete implementation would include market + * holidays. + * + * `Temporal.Instants` when the market is closed will be disambiguated to the + * start of the next day that the market is open. This makes it easy to + * determine, for any instant, what market day that instant corresponds to, by + * simply converting the instant to a ZonedDateTime in the 'NYSE' time zone. + * + * All `Temporal.Instant` values after market close on a particular market + * display should be considered to be executed as of the market open on the next + * market day. + * */ + +const tz = Temporal.TimeZone.from('America/New_York'); +const openTime = Temporal.PlainTime.from('09:30'); +const closeTime = Temporal.PlainTime.from('16:00'); +function isMarketOpenDate(date) { + return date.dayOfWeek < 6; // not a weekend +} +function isDuringMarketHours(dt) { + return isMarketOpenDate(dt) && !isBeforeMarketOpen(dt) && !isAfterMarketClose(dt); +} +function isBeforeMarketOpen(dt) { + return isMarketOpenDate(dt) && Temporal.PlainTime.compare(dt, openTime) < 0; +} +function isAfterMarketClose(dt) { + return isMarketOpenDate(dt) && Temporal.PlainTime.compare(dt, closeTime) >= 0; +} +function getNextMarketOpen(instant) { + let zdt = instant.toZonedDateTimeISO(tz); + + // keep adding days until we get to a market day, unless today is a market day + // before the market opens. + if (!isBeforeMarketOpen(zdt)) { + do { + zdt = zdt.add({ days: 1 }); + } while (!isMarketOpenDate(zdt)); + } + return zdt.toPlainDate().toZonedDateTime({ timeZone: tz, plainTime: openTime }); +} +function getNextMarketClose(instant) { + let zdt = instant.toZonedDateTimeISO(tz); + + // keep adding days until we get to a market day, unless today is a market day + // before the market closes. + if (isAfterMarketClose(zdt)) { + do { + zdt = zdt.add({ days: 1 }); + } while (!isMarketOpenDate(zdt)); + } + return zdt.toPlainDate().toZonedDateTime({ timeZone: tz, plainTime: closeTime }); +} +function getPreviousMarketOpen(instant) { + let zdt = instant.toZonedDateTimeISO(tz); + + // keep subtracting days until we get to a market day, unless today is a market day + // after the market opened. + if (!isBeforeMarketOpen(zdt)) { + do { + zdt = zdt.subtract({ days: 1 }); + } while (!isMarketOpenDate(zdt)); + } + return zdt.toPlainDate().toZonedDateTime({ timeZone: tz, plainTime: openTime }); +} +function getPreviousMarketClose(instant) { + let zdt = instant.toZonedDateTimeISO(tz); + + // keep adding days until we get to a market day, unless today is a market day + // after the market closed. + if (!isAfterMarketClose(zdt)) { + do { + zdt = zdt.subtract({ days: 1 }); + } while (!isMarketOpenDate(zdt)); + } + return zdt.toPlainDate().toZonedDateTime({ timeZone: tz, plainTime: closeTime }); +} + +class NYSETimeZone extends Temporal.TimeZone { + constructor() { + super('America/New_York'); + } + getPossibleInstantsFor(dt) { + dt = Temporal.PlainDateTime.from(dt); + const zdt = dt.toZonedDateTime(tz); + const zdtWhenMarketIsOpen = isDuringMarketHours(zdt) ? zdt : getNextMarketOpen(zdt.toInstant()); + return [zdtWhenMarketIsOpen.toInstant()]; + } + getInstantFor(dt) { + dt = Temporal.PlainDateTime.from(dt); + // `disambiguation` option is ignored. If the market is closed, then return the + // opening time of the next market day. + const zdt = dt.toZonedDateTime(tz); + const zdtWhenMarketIsOpen = isDuringMarketHours(zdt) ? zdt : getNextMarketOpen(zdt.toInstant()); + return zdtWhenMarketIsOpen.toInstant(); + } + getPlainDateTimeFor(instant) { + instant = Temporal.Instant.from(instant); + const zdt = instant.toZonedDateTimeISO(tz); + const zdtWhenMarketIsOpen = isDuringMarketHours(zdt) ? zdt : getNextMarketOpen(zdt.toInstant()); + return zdtWhenMarketIsOpen.toPlainDateTime(); + } + getNextTransition(instant) { + instant = Temporal.Instant.from(instant); + const nextOpen = getNextMarketOpen(instant); + const nextClose = getNextMarketClose(instant); + const zdtTransition = [nextOpen, nextClose].sort(Temporal.ZonedDateTime.compare)[0]; + return zdtTransition.toInstant(); + } + getPreviousTransition(instant) { + instant = Temporal.Instant.from(instant); + const prevOpen = getPreviousMarketOpen(instant); + const prevClose = getPreviousMarketClose(instant); + const zdtTransition = [prevOpen, prevClose].sort(Temporal.ZonedDateTime.compare)[1]; + return zdtTransition.toInstant(); + } + getOffsetNanosecondsFor(instant) { + instant = Temporal.Instant.from(instant); + const zdt = instant.toZonedDateTimeISO(tz); + const zdtWhenMarketIsOpen = isDuringMarketHours(zdt) ? zdt : getNextMarketOpen(zdt.toInstant()); + const ns = zdt.offsetNanoseconds + zdt.until(zdtWhenMarketIsOpen, { largestUnit: 'nanoseconds' }).nanoseconds; + return ns; + } + toString() { + return 'NYSE'; + } +} + +const tzNYSE = Object.freeze(new NYSETimeZone()); + +// Monkeypatch Temporal.TimeZone.from to handle our custom time zone +let oldTzFrom; +const replacement = (item) => { + if (item === 'NYSE') return tzNYSE; + return oldTzFrom.call(Temporal.TimeZone, item); +}; +if (Temporal.TimeZone.from !== replacement) { + oldTzFrom = Temporal.TimeZone.from; + Temporal.TimeZone.from = replacement; +} + +let zdt; +let isOpen; +let date; +let inNYSE; +let nextOpen; +let todayClose; +let newDate; +let openInstant; +let closeInstant; + +// 1. What is the market day associated with the Instant of a financial transaction? +zdt = Temporal.ZonedDateTime.from('2020-11-12T18:50-08:00[America/Los_Angeles]'); +date = tzNYSE.getPlainDateTimeFor(zdt.toInstant()).toPlainDate(); +assert.equal(date.toString(), '2020-11-13'); +zdt = Temporal.ZonedDateTime.from('2020-11-12T06:50-08:00[America/Los_Angeles]'); +date = tzNYSE.getPlainDateTimeFor(zdt.toInstant()).toPlainDate(); +assert.equal(date.toString(), '2020-11-12'); +zdt = Temporal.ZonedDateTime.from('2020-11-12T01:50-08:00[America/Los_Angeles]'); +date = tzNYSE.getPlainDateTimeFor(zdt.toInstant()).toPlainDate(); +assert.equal(date.toString(), '2020-11-12'); + +// 2. Is the stock market open on a particular date? +date = Temporal.PlainDate.from('2020-11-12'); +isOpen = date.toZonedDateTime('NYSE').toPlainDate().equals(date); +assert.equal(isOpen, true); +date = Temporal.PlainDate.from('2020-11-14'); +isOpen = date.toZonedDateTime('NYSE').toPlainDate().equals(date); +assert.equal(isOpen, false); + +// 3. For a particular date, when is the next market day? +const getNextMarketDay = (date) => { + date = Temporal.PlainDate.from(date); + const zdt = date.toZonedDateTime('NYSE'); + if (zdt.toPlainDate().equals(date)) { + // It's a market day, so find the next one + return zdt.add({ days: 1 }).toPlainDate(); + } else { + // the original date wasn't a market day, so we're already on the next one! + return zdt.toPlainDate(); + } +}; +date = Temporal.PlainDate.from('2020-11-09'); +newDate = getNextMarketDay(date); +assert.equal(newDate.equals('2020-11-10'), true); +date = Temporal.PlainDate.from('2020-11-14'); +newDate = getNextMarketDay(date); +assert.equal(newDate.equals('2020-11-16'), true); + +// 4. For a particular date and time somewhere in the world, is the market open? +// If it's open, then when will it close? +// If it's closed, then when will it open next? +// Return a result in the local time zone, not NYC's time zone. +zdt = Temporal.ZonedDateTime.from('2020-11-12T18:50-08:00[America/Los_Angeles]'); +inNYSE = zdt.withTimeZone('NYSE'); +isOpen = inNYSE.toPlainDateTime().toZonedDateTime('NYSE').equals(inNYSE); +assert.equal(isOpen, false); +nextOpen = inNYSE.timeZone.getNextTransition(zdt.toInstant()).toZonedDateTimeISO(zdt.timeZone); +assert.equal(nextOpen.toString(), '2020-11-13T06:30:00-08:00[America/Los_Angeles]'); + +zdt = Temporal.ZonedDateTime.from('2020-11-12T12:50-08:00[America/Los_Angeles]'); +inNYSE = zdt.withTimeZone('NYSE'); +isOpen = inNYSE.toPlainDateTime().toZonedDateTime('NYSE').equals(inNYSE); +assert.equal(isOpen, true); +todayClose = inNYSE.timeZone.getNextTransition(zdt.toInstant()).toZonedDateTimeISO(zdt.timeZone); +assert.equal(todayClose.toString(), '2020-11-12T13:00:00-08:00[America/Los_Angeles]'); + +// 5. For any particular market date, what were the opening and closing clock times in NYC? +date = Temporal.PlainDate.from('2020-11-09'); +openInstant = date.toZonedDateTime('NYSE').toInstant(); +closeInstant = date.toZonedDateTime('NYSE').timeZone.getNextTransition(openInstant); +assert.equal(openInstant.toZonedDateTimeISO('America/New_York').toPlainTime().toString(), '09:30:00'); +assert.equal(closeInstant.toZonedDateTimeISO('America/New_York').toPlainTime().toString(), '16:00:00'); diff --git a/polyfill/package.json b/polyfill/package.json index df62a2b256..50a023d7e6 100644 --- a/polyfill/package.json +++ b/polyfill/package.json @@ -9,7 +9,7 @@ "scripts": { "coverage": "c8 report --reporter html", "test": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs", - "test-cookbook": "TEST=all npm run test-cookbook-one", + "test-cookbook": "TEST=all npm run test-cookbook-one && TEST=stockExchangeTimeZone npm run test-cookbook-one", "test-cookbook-one": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.cookbook.mjs ../docs/cookbook/$TEST.mjs", "test262": "./ci_test.sh", "codecov:tests": "NODE_V8_COVERAGE=coverage/tmp npm run test && c8 report --reporter=text-lcov > coverage/tests.lcov && codecov -F tests -f coverage/tests.lcov",