From 79f2d1c5db36fd4cf018b777e5b996f5808cc681 Mon Sep 17 00:00:00 2001 From: murcikan-scottlogic <106308869+murcikan-scottlogic@users.noreply.github.com> Date: Tue, 21 Jun 2022 14:27:52 +0100 Subject: [PATCH] feat: added a weekly pattern discontinuity provider * feat: initial skipWeeklyPattern provider * feat: split weeklyPattern into local and utc * chore: fixed jest config for d3 module imports * chore: split functions into own files * chore: name refactoring * chore: fixed formatting * chore: fixed bugs & added tests * chore: fixed build errors * chore: using timezone-mock in test * chore: removed timezone-mock * chore: removed timezone specific tests * chore: setting TZ in jest config + refinements * chore: fixed prettier issue, actioned PR comments * chore: fixed typo * chore: corrected check-box value * chore: fixed linting errors --- examples/discontinuous-week-axis/README.md | 21 ++ .../__tests__/index.js | 8 + examples/discontinuous-week-axis/index.html | 23 ++ examples/discontinuous-week-axis/index.js | 111 ++++++++ .../discontinuous-week-axis/screenshot.png | Bin 0 -> 40252 bytes examples/discontinuous-week-axis/style.css | 6 + packages/d3fc-discontinuous-scale/index.js | 2 + .../package-lock.json | 199 +++++++++++++- .../src/discontinuity/skipUtcWeeklyPattern.js | 13 + .../src/discontinuity/skipWeeklyPattern.js | 202 ++++++++++++++ .../skipWeeklyPattern/constants.js | 4 + .../skipWeeklyPattern/dateTimeUtility.js | 63 +++++ .../skipWeeklyPattern/nonTradingTimeRange.js | 114 ++++++++ .../skipWeeklyPattern/tradingDay.js | 121 ++++++++ .../discontinuity/skipUtcWeeklyPatternSpec.js | 192 +++++++++++++ .../nonTradingTimeRangeSpec.js | 80 ++++++ .../skipWeeklyPattern/tradingDaySpec.js | 41 +++ .../discontinuity/skipWeeklyPatternSpec.js | 260 ++++++++++++++++++ scripts/jest/jest.config.js | 2 + 19 files changed, 1451 insertions(+), 11 deletions(-) create mode 100644 examples/discontinuous-week-axis/README.md create mode 100644 examples/discontinuous-week-axis/__tests__/index.js create mode 100644 examples/discontinuous-week-axis/index.html create mode 100644 examples/discontinuous-week-axis/index.js create mode 100644 examples/discontinuous-week-axis/screenshot.png create mode 100644 examples/discontinuous-week-axis/style.css create mode 100644 packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js create mode 100644 packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js create mode 100644 packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js create mode 100644 packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js create mode 100644 packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js create mode 100644 packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js create mode 100644 packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js create mode 100644 packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js create mode 100644 packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js create mode 100644 packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js diff --git a/examples/discontinuous-week-axis/README.md b/examples/discontinuous-week-axis/README.md new file mode 100644 index 000000000..38ecf62ac --- /dev/null +++ b/examples/discontinuous-week-axis/README.md @@ -0,0 +1,21 @@ +# Discontinuous Axis - Removing Weekly Pattern + +This is another example that demonstrates how to render a financial candlestick chart on a discontinuous scale that skips a predefined weekly pattern. Try clicking the checkbox to observe the difference, when checked, the time ranges, where no trading occurs, are removed from the chart. + +This example demonstrates how the D3FC discontinuous scale can be used to adapt a D3 time scale adding in discontinuity provider that skips predefined time ranges on any particular day. + +for a non-trading pattern: + +{ + Monday: [["07:45", "08:30"], ["13:20", "19:00"]], + Tuesday: [["07:45", "08:30"], ["13:20", "19:00"]], + Wednesday: [["07:45", "08:30"], ["13:20", "19:00"]], + Thursday: [["07:45", "08:30"], ["13:20", "19:00"]], + Friday: [["07:45", "08:30"], ["13:20", "EOD"]], + Saturday: [["SOD", "EOD"]], + Sunday: [["SOD", "19:00"]] +}; + +const skipWeeklyPatternScale = fc + .scaleDiscontinuous(d3.scaleTime()) + .discontinuityProvider(fc.discontinuitySkipWeeklyPattern(nonTradingHoursPattern)); diff --git a/examples/discontinuous-week-axis/__tests__/index.js b/examples/discontinuous-week-axis/__tests__/index.js new file mode 100644 index 000000000..393830fb9 --- /dev/null +++ b/examples/discontinuous-week-axis/__tests__/index.js @@ -0,0 +1,8 @@ +it('should match the image snapshot', async () => { + await d3fc.loadExample(module); + const image = await page.screenshot({ + omitBackground: true + }); + expect(image).toMatchImageSnapshot(); + await d3fc.saveScreenshot(module, image); +}); diff --git a/examples/discontinuous-week-axis/index.html b/examples/discontinuous-week-axis/index.html new file mode 100644 index 000000000..4b9ef1ddf --- /dev/null +++ b/examples/discontinuous-week-axis/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/examples/discontinuous-week-axis/index.js b/examples/discontinuous-week-axis/index.js new file mode 100644 index 000000000..7ab6bba2a --- /dev/null +++ b/examples/discontinuous-week-axis/index.js @@ -0,0 +1,111 @@ +const checkbox = document.getElementById('skip'); + +// define non-trading time ranges for any day of the week +const nonTradingHoursPattern = { + Monday: [ + ['07:45', '08:30'], + ['13:20', '19:00'] + ], + Tuesday: [ + ['07:45', '08:30'], + ['13:20', '19:00'] + ], + Wednesday: [ + ['07:45', '08:30'], + ['13:20', '19:00'] + ], + Thursday: [ + ['07:45', '08:30'], + ['13:20', '19:00'] + ], + Friday: [ + ['07:45', '08:30'], + ['13:20', 'EOD'] + ], + Saturday: [['SOD', 'EOD']], + Sunday: [['SOD', '19:00']] +}; + +// create discontinous date range +const dates = d3.timeMinute + .range(new Date(2018, 0, 1), new Date(2018, 0, 8)) + .filter(dt => { + const dow = dt.getDay(); + switch (dow) { + case 1: + case 2: + case 3: + case 4: + case 5: + if ( + (dt.getHours() === 7 && dt.getMinutes() >= 45) || + (dt.getHours() === 8 && dt.getMinutes() < 30) || + (dt.getHours() === 13 && dt.getMinutes() >= 20) || + (dt.getHours() >= 14 && dow !== 5 && dt.getHours() < 19) || + (dt.getHours() >= 14 && dow === 5) + ) { + return false; + } + return true; + case 6: + return false; + case 0: + return dt.getHours() >= 19; + } + }); + +// create some test data that skips weekends +const data = fc.randomFinancial()(dates.length); + +for (let i = 0; i < dates.length; i++) { + // console.log(`Changing: ${data[i].date} to ${dates[i]}`); + data[i].date = dates[i]; +} + +// use the date to determine the x extent, padding by one day at each end +const xExtent = fc + .extentDate() + .accessors([d => d.date]) + .padUnit('domain') + .pad([60 * 1000, 60 * 1000]); + +// compute the y extent from the high / low values, padding by 10% +const yExtent = fc + .extentLinear() + .accessors([d => d.high, d => d.low]) + .pad([0.1, 0.1]); + +// Create the gridlines and series +const gridlines = fc.annotationSvgGridline(); +const candlestick = fc.seriesSvgCandlestick(); + +// add them to the chart via a multi-series +const multi = fc.seriesSvgMulti().series([gridlines, candlestick]); + +// adapt the d3 time scale in a discontinuous scale that skips weekends +const skipWeeklyPatternScale = fc + .scaleDiscontinuous(d3.scaleTime()) + .discontinuityProvider( + fc.discontinuitySkipWeeklyPattern(nonTradingHoursPattern) + ); + +function renderChart() { + // create a chart + const chart = fc + .chartCartesian( + checkbox.checked ? skipWeeklyPatternScale : d3.scaleTime(), + d3.scaleLinear() + ) + .xDomain(xExtent(data)) + .yDomain(yExtent(data)) + .xTicks(30) + .svgPlotArea(multi); + + // render the chart + d3.select('#chart') + .datum(data) + .call(chart); +} + +renderChart(); +checkbox.addEventListener('click', renderChart); diff --git a/examples/discontinuous-week-axis/screenshot.png b/examples/discontinuous-week-axis/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..8bed6835b349903d60727056fd52a79f097c59ae GIT binary patch literal 40252 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®9xYm3J3Du5K@+ww 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�?Gb0^mOuxUW@n3OvJO(~NY8pKw4 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 nkNA&_;y<&C|7+#yKgaAOLQ0%z!yW9)wcXu=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "peer": true, + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "peer": true, + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==", + "peer": true, + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "peer": true, + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "peer": true, + "engines": { + "node": ">=12" + } + } + }, + "dependencies": { + "@d3fc/d3fc-rebind": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@d3fc/d3fc-rebind/-/d3fc-rebind-6.0.1.tgz", + "integrity": "sha512-+ryBZ53ALMffbADwnFAtTYQJcT7PE5BwpducGYS0X6Jux6ESnp+fP+cDQvBGbDBOVqaziGnfeLeJXjtMnZujmQ==" + }, + "d3-array": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.6.tgz", + "integrity": "sha512-DCbBBNuKOeiR9h04ySRBMW52TFVc91O9wJziuyXw6Ztmy8D3oZbmCkOO3UHKC7ceNJsN2Mavo9+vwV8EAEUXzA==", + "peer": true, + "requires": { + "internmap": "1 - 2" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "peer": true + }, + "d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "peer": true + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "peer": true, + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "peer": true, + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==", + "peer": true, + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "peer": true, + "requires": { + "d3-time": "1 - 3" + } + }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "peer": true + } + } } diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js new file mode 100644 index 000000000..5f5efd97c --- /dev/null +++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipUtcWeeklyPattern.js @@ -0,0 +1,13 @@ +import { utcDay, utcMillisecond } from 'd3-time'; +import { base } from './skipWeeklyPattern'; +import { dateTimeUtility } from './skipWeeklyPattern/dateTimeUtility'; + +export const utcDateTimeUtility = dateTimeUtility( + (date, hh, mm, ss, ms) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), hh, mm, ss, ms)), + date => date.getUTCDay(), + date => [date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()], + utcDay, + utcMillisecond +); + +export default (nonTradingUtcHoursPattern) => base(nonTradingUtcHoursPattern, utcDateTimeUtility); \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js new file mode 100644 index 000000000..e63f26d79 --- /dev/null +++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern.js @@ -0,0 +1,202 @@ +import { timeDay, timeMillisecond } from 'd3-time'; +import { tradingDay } from './skipWeeklyPattern/tradingDay'; +import { dayBoundary, millisPerDay } from './skipWeeklyPattern/constants'; +import { dateTimeUtility } from './skipWeeklyPattern/dateTimeUtility'; + +export const localDateTimeUtility = dateTimeUtility( + (date, hh, mm, ss, ms) => new Date(date.getFullYear(), date.getMonth(), date.getDate(), hh, mm, ss, ms), + date => date.getDay(), + date => [date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()], + timeDay, + timeMillisecond +); + +/** + * Discontinuity provider implemenation that works with 'non-trading' periods during a trading day + * @typedef { Object } WeeklyPatternDiscontinuityProvider + * @property { function(Date): Date } clampUp - When given a value, if it falls within a discontinuity (i.e. an excluded domain range) it should be shifted forwards to the discontinuity boundary. Otherwise, it should be returned unchanged. + * @property { function(Date): Date } clampDown - When given a value, if it falls within a discontinuity it should be shifted backwards to the discontinuity boundary. Otherwise, it should be returned unchanged. + * @property { function(Date, Date): number } distance - When given a pair of values, this function returns the distance between the, in domain units, minus any discontinuities. discontinuities. + * @property { function(Date, number): Date } offset - When given a value and an offset, the value should be advanced by the offset value, skipping any discontinuities, to return the final value. + * @property { function(): WeeklyPatternDiscontinuityProvider } copy - Creates a copy of the discontinuity provider. + */ + +/** + * Creates WeeklyPatternDiscontinuityProvider + * @param {Object} nonTradingPattern - contains raw 'non-trading' time ranges for each day of the week + * @param {DateTimeUtility} dateTimeUtility - uses local or utc dates + * @returns { WeeklyPatternDiscontinuityProvider } WeeklyPatternDiscontinuityProvider + */ +export const base = (nonTradingPattern, dateTimeUtility) => { + + const getDayPatternOrDefault = (day) => nonTradingPattern[day] === undefined ? [] : nonTradingPattern[day]; + + const tradingDays = [ + tradingDay(getDayPatternOrDefault('Sunday'), dateTimeUtility), + tradingDay(getDayPatternOrDefault('Monday'), dateTimeUtility), + tradingDay(getDayPatternOrDefault('Tuesday'), dateTimeUtility), + tradingDay(getDayPatternOrDefault('Wednesday'), dateTimeUtility), + tradingDay(getDayPatternOrDefault('Thursday'), dateTimeUtility), + tradingDay(getDayPatternOrDefault('Friday'), dateTimeUtility), + tradingDay(getDayPatternOrDefault('Saturday'), dateTimeUtility)]; + + const totalTradingWeekMilliseconds = tradingDays.reduce((total, tradingDay) => total + tradingDay.totalTradingTimeInMiliseconds, 0); + + if (totalTradingWeekMilliseconds === 0) { + throw 'Trading pattern must yield at least 1 ms of trading time'; + } + + const instance = { tradingDays, totalTradingWeekMilliseconds }; + + /** + * When given a value falls within a discontinuity (i.e. an excluded domain range) it should be shifted forwards to the discontinuity boundary. + * Otherwise, it should be returns unchanged. + * @param {Date} date - date to clamp up + * @returns {Date} + */ + instance.clampUp = (date) => { + const tradingDay = tradingDays[dateTimeUtility.getDay(date)]; + + for (const range of tradingDay.nonTradingTimeRanges) { + if (range.isInRange(date)) { + + return range.endTime === dayBoundary + ? instance.clampUp(dateTimeUtility.getStartOfNextDay(date)) + : dateTimeUtility.setTime(date, range.endTime); + } + } + + return date; + }; + + /** + * When given a value, if it falls within a discontinuity it should be shifted backwards to the discontinuity boundary. Otherwise, it should be returned unchanged. + * @param {Date} date - date to clamp down + * @returns {Date} + */ + instance.clampDown = (date) => { + const tradingDay = tradingDays[dateTimeUtility.getDay(date)]; + + for (const range of tradingDay.nonTradingTimeRanges) { + if (range.isInRange(date)) { + + return range.startTime === dayBoundary + ? instance.clampDown(dateTimeUtility.getEndOfPreviousDay(date)) + : dateTimeUtility.setTime(date, range.startTime, -1); + } + } + return date; + }; + + /** + * When given a pair of values, this function returns the distance between the, in domain units, minus any discontinuities. discontinuities. + * @param {Date} startDate + * @param {Date} endDate + * @returns {number} - the number of milliseconds between the dates + */ + instance.distance = (startDate, endDate) => { + + if (startDate.getTime() === endDate.getTime()) { + return 0; + } + + let [start, end, factor] = startDate <= endDate + ? [startDate, endDate, 1] + : [endDate, startDate, -1]; + + // same day distance + if (dateTimeUtility.dayInterval(start).getTime() === dateTimeUtility.dayInterval(end).getTime()) { + return instance.tradingDays[dateTimeUtility.getDay(start)].totalTradingMillisecondsBetween(start, end); + } + + // combine any trading time left in the day after startDate + // and any trading time from midnight up until the endDate + let total = instance.tradingDays[dateTimeUtility.getDay(start)].totalTradingMillisecondsBetween(start, dateTimeUtility.dayInterval.offset(dateTimeUtility.dayInterval(start), 1)) + + instance.tradingDays[dateTimeUtility.getDay(end)].totalTradingMillisecondsBetween(dateTimeUtility.dayInterval(end), end); + + // startDate and endDate are consecutive days + if (dateTimeUtility.dayInterval.count(start, end) === 1) { + return total; + } + + // move the start date to following day + start = dateTimeUtility.dayInterval.offset(dateTimeUtility.dayInterval(start), 1); + // floor endDate to remove 'time component' + end = dateTimeUtility.dayInterval(end); + + return factor * dateTimeUtility.dayInterval.range(start, end) + .reduce((runningTotal, currentDay, currentIndex, arr) => { + + const nextDay = currentIndex < arr.length - 1 + ? arr[currentIndex + 1] + : dateTimeUtility.dayInterval.offset(currentDay, 1); + const isDstBoundary = (nextDay - currentDay) !== millisPerDay; + const tradingDay = instance.tradingDays[dateTimeUtility.getDay(currentDay)]; + return runningTotal += isDstBoundary + ? tradingDay.totalTradingMillisecondsBetween(currentDay, nextDay) + : tradingDay.totalTradingTimeInMiliseconds; + + }, total); + }; + + /** + * When given a value and an offset in milliseconds, the value should be advanced by the offset value, skipping any discontinuities, to return the final value. + * @param {Date} date + * @param {number} ms + */ + instance.offset = (date, ms) => { + date = ms >= 0 + ? instance.clampUp(date) + : instance.clampDown(date); + + const isDstBoundary = (d) => (dateTimeUtility.dayInterval.offset(d) - dateTimeUtility.dayInterval(d)) !== millisPerDay; + + const moveToDayBoundary = (tradingDay, date, ms) => { + + if (ms < 0) { + const dateFloor = dateTimeUtility.dayInterval(date); + const distanceToStartOfDay = tradingDay.totalTradingMillisecondsBetween(dateFloor, date); + + return Math.abs(ms) <= distanceToStartOfDay + ? tradingDay.offset(date, ms) + : [instance.clampDown(dateTimeUtility.msInterval.offset(dateFloor, -1)), ms + distanceToStartOfDay + 1]; + + } else { + const nextDate = dateTimeUtility.getStartOfNextDay(date); + const distanceToDayBoundary = tradingDay.totalTradingMillisecondsBetween(date, nextDate); + + return ms < distanceToDayBoundary + ? tradingDay.offset(date, ms) + : [instance.clampUp(nextDate), ms - distanceToDayBoundary]; + } + }; + + if (ms === 0) + return date; + + const moveDateDelegate = ms < 0 + ? (date, remainingMs, tradingDayMs) => [instance.clampDown(dateTimeUtility.dayInterval.offset(date, -1)), remainingMs + tradingDayMs] + : (date, remainingMs, tradingDayMs) => [instance.clampUp(dateTimeUtility.dayInterval.offset(date)), remainingMs - tradingDayMs]; + + let tradingDay = instance.tradingDays[dateTimeUtility.getDay(date)]; + [date, ms] = moveToDayBoundary(tradingDay, date, ms); + while (ms !== 0) { + tradingDay = instance.tradingDays[dateTimeUtility.getDay(date)]; + if (isDstBoundary(date)) { + [date, ms] = moveToDayBoundary(tradingDay, date, ms); + } else { + [date, ms] = Math.abs(ms) >= tradingDay.totalTradingTimeInMiliseconds + ? moveDateDelegate(date, ms, tradingDay.totalTradingTimeInMiliseconds) + : moveToDayBoundary(tradingDay, date, ms); + } + } + + return date; + }; + + instance.copy = () => instance; + + return instance; +}; + +export default (nonTradingHoursPattern) => base(nonTradingHoursPattern, localDateTimeUtility); \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js new file mode 100644 index 000000000..de0223abd --- /dev/null +++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/constants.js @@ -0,0 +1,4 @@ +export const millisPerDay = 24 * 3600 * 1000; +export const dayBoundary = "00:00:00.000"; +export const SOD = 'SOD'; +export const EOD = 'EOD'; \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js new file mode 100644 index 000000000..b22561f17 --- /dev/null +++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/dateTimeUtility.js @@ -0,0 +1,63 @@ +/** + * Object that helps with working with time strings and dates + * @typedef { Object } DateTimeUtility + * @property { function(Date): string } getTimeString - get's the time string for date as 'hh:mm:ss.fff' + * @property { function(Date , string, number): Date } setTime - set the time for date as + * @property { function(Date): Date } getStartOfNextDay - returns the start of the next day i.e. 00:00:00.000 + * @property { function(Date): Date } getEndOfPreviousDay - returns the 'End' of the previous day i.e. one ms before midnight + */ + +/** + * + * @param {function(Date, number, number, number, number): Date } setTimeForDate - sets a time on a Date object given hh, mm, ss & ms time compononets + * @param {function(Date): number } getDay + * @param {function(Date): number[] } getTimeComponentArray + * @param {function} dayInterval - d3-time timeDay or utcDay + * @param {function} msInterval - d3-time timeMillisecond or utcMillisecond + * @returns {DateTimeUtility} + */ +export const dateTimeUtility = (setTimeForDate, getDay, getTimeComponentArray, dayInterval, msInterval) => { + const utility = {}; + utility.getTimeComponentArrayFromString = (timeString) => [timeString.slice(0, 2), timeString.slice(3, 5), timeString.slice(6, 8), timeString.slice(9, 12)]; + /** + * Returns the local time part of a given Date instance as 'hh:mm:ss.fff' + * @param {Date} date - Data instance + * @returns {string} time string. + */ + utility.getTimeString = date => { + const [hh, mm, ss, ms] = getTimeComponentArray(date).map(x => x.toString(10).padStart(2, '0')); + return `${hh}:${mm}:${ss}.${ms.padStart(3, '0')}`; + }; + + /** + * Returns the combined local date and time string + * @param {Date} date - Data instance + * @param {string} timeString - string as 'hh:mm:ss.fff' + * @param {number} offsetInmilliSeconds - additional offset in millisends. Default = 0; e.g. -1 is one millisecond before time specified by timeString; + * @returns {Date} - combined date and time. + */ + utility.setTime = (date, timeString, offsetInmilliSeconds = 0) => { + const [hh, mm, ss, ms] = utility.getTimeComponentArrayFromString(timeString); + return msInterval.offset(setTimeForDate(date, hh, mm, ss, ms), offsetInmilliSeconds); + }; + + /** + * Returns the start of the next day i.e. 00:00:00.000 + * @param {Date} date - Data instance + * @returns {Date}. + */ + utility.getStartOfNextDay = (date) => dayInterval.offset(dayInterval.floor(date), 1); + + /** + * Returns the end of the previous day (1ms before midnight) i.e. 23:59:59.999 + * @param {Date} date - Data instance + * @returns {Date}. + */ + utility.getEndOfPreviousDay = (date) => msInterval.offset(dayInterval.floor(date), -1); + + utility.dayInterval = dayInterval; + utility.msInterval = msInterval; + utility.getDay = getDay; + + return utility; +}; \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js new file mode 100644 index 000000000..d6de36824 --- /dev/null +++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/nonTradingTimeRange.js @@ -0,0 +1,114 @@ +import { EOD, SOD, dayBoundary, millisPerDay } from './constants'; + +/** + * Attempts to parse and format a time string into a fixed lenght string 'hh:mm:ss.fff' + * @param {string} timeString - string representation of time 'hh:mm:ss.fff' e.g. '09:30' or '00:00:00.000' + * @returns {int[]} array of parsed time components [hh, mm, ss, ms] or throws. + */ +export function standardiseTimeString(timeString) { + + if (arguments.length !== 1 || typeof timeString !== 'string') { + throw 'Expected single argument of type string'; + } + + const isPositiveIntegerUpTo = (toCheck, upperBound) => { + if (!Number.isInteger(toCheck)) + return false; + + return toCheck >= 0 && toCheck <= upperBound; + }; + + const result = [0, 0, 0, 0]; + const time_components = timeString.split(":"); + + if (time_components.length < 2 || time_components.length > 3) { + throw 'Expected an argument wiht 2 or 3 colon delimited parts.'; + } + + result[0] = isPositiveIntegerUpTo(parseInt(time_components[0], 10), 23) + ? parseInt(time_components[0], 10) + : function () { throw `'Hours' component must be an int between 0 and 23, but was '${time_components[0]}'`; }(); + + result[1] = isPositiveIntegerUpTo(parseInt(time_components[1], 10), 59) + ? parseInt(time_components[1], 10) + : function () { throw `'Minutes' component must be an int between 0 and 59, but was '${time_components[1]}'`; }(); + + if (time_components.length === 3) { + const ms_components = time_components[2].split(".").map(x => parseInt(x, 10)); + + result[2] = isPositiveIntegerUpTo(ms_components[0], 59) + ? ms_components[0] + : function () { throw `'Seconds' component must be an int between 0 and 59, but was '${ms_components[0]}'`; }(); + + if (ms_components.length === 2) { + result[3] = isPositiveIntegerUpTo(ms_components[1], 999) + ? ms_components[1] + : function () { throw `'Miliseconds' component must be an int between 0 and 999, but was '${ms_components[1]}'`; }(); + } + } + + return `${result[0].toString(10).padStart(2, '0')}:${result[1].toString(10).padStart(2, '0')}:${result[2].toString(10).padStart(2, '0')}.${result[3].toString(10).padStart(3, '0')}`; +} + +/** + * @typedef { Object } nonTradingTimeRange + * @property { string } startTime - Start time string with fixed format 'hh:mm:ss.fff' + * @property { string } endTime - End time string with fixed format 'hh:mm:ss.fff' + * @property { int } lenghtInMs - Absolute length in MS i.e. only valid on non-Daylight saving boundaries + */ + +/** + * Represents a single continous Non-Trading time interval within a single day. You must denote day boundries as: + * SOD - start of day + * EOD - end of day + * @constructor + * @param { string[] } timeRangeTuple - Time range as a tuple of time strings e.g. ["07:45", "08:30"), ["SOD", "08:30:20") or ["19:00:45.500", "EOD"). + * @param { import('./dateTimeUtility').DateTimeUtility } dateTimeUtility + * @returns { nonTradingTimeRange } + */ +export function nonTradingTimeRange(timeRangeTuple, dateTimeUtility) { + + if (arguments.length != 2 || + !Array.isArray(timeRangeTuple) + || timeRangeTuple.length !== 2 + || typeof timeRangeTuple[0] !== 'string' + || typeof timeRangeTuple[1] !== 'string') { + throw `Expected argument is a single string[] of length 2.`; + } + + if (timeRangeTuple[0] === SOD) { + timeRangeTuple[0] = dayBoundary; + } + + if (timeRangeTuple[1] === EOD) { + timeRangeTuple[1] = dayBoundary; + } + + const startTime = standardiseTimeString(timeRangeTuple[0]); + const endTime = standardiseTimeString(timeRangeTuple[1]); + + if (endTime !== dayBoundary && startTime > endTime) { + throw `Time range start time '${startTime}' must be before end time '${endTime}' or both must equal ${dayBoundary}`; + } + + const lenghtInMs = dateTimeUtility.setTime(new Date(endTime === dayBoundary ? millisPerDay : 0), endTime) - dateTimeUtility.setTime(new Date(0), startTime); + const instance = { startTime, endTime, lenghtInMs }; + + /** + * Returns if given date's time portion is within this discontinuity time range instance + * @param { Date } date - date + * @returns { boolean } + */ + instance.isInRange = (date) => { + const time = dateTimeUtility.getTimeString(date); + + if (instance.startTime <= time + && (instance.endTime === dayBoundary || instance.endTime > time)) { + return true; + } + + return false; + }; + + return instance; +} \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js new file mode 100644 index 000000000..3075a857c --- /dev/null +++ b/packages/d3fc-discontinuous-scale/src/discontinuity/skipWeeklyPattern/tradingDay.js @@ -0,0 +1,121 @@ +import { nonTradingTimeRange } from './nonTradingTimeRange'; +import { dayBoundary, millisPerDay } from './constants'; + +/** + * Represents a Trading day + * @param { string[][] } rawDiscontinuityTimeRanges - Array of time range tuples e.g. [["07:45", "08:30"), ["19:00:45.500", "EOD")] + * @param { import('./dateTimeUtility').DateTimeUtility } dateTimeUtility + */ +export const tradingDay = (rawDiscontinuityTimeRanges, dateTimeUtility) => { + const nonTradingTimeRanges = rawDiscontinuityTimeRanges + .map(rawRange => nonTradingTimeRange(rawRange, dateTimeUtility)) + .sort((a, b) => a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0); + const totalTradingTimeInMiliseconds = millisPerDay - nonTradingTimeRanges.reduce((total, range) => total + range.lenghtInMs, 0); + + const totalTradingMillisecondsBetween = (intervalStart, intervalEnd) => { + + if (intervalStart.getTime() === intervalEnd.getTime()) { + return 0; + } + + // ensure arguments are on the same day or intervalEnd is the next day boundary + if (dateTimeUtility.dayInterval(intervalStart).getTime() !== dateTimeUtility.dayInterval(intervalEnd).getTime() + && dateTimeUtility.getStartOfNextDay(intervalStart).getTime() !== intervalEnd.getTime()) { + throw `tradingDay.totalTradingMillisecondsBetween arguments must be on the same day or intervalEnd must be the start of the next day instead: intervalStart: '${intervalStart}'; intervalEnd: '${intervalEnd}'`; + } + + let total = 0; + + const relevantDiscontinuityRanges = nonTradingTimeRanges.filter(range => { + return range.endTime === dayBoundary || + dateTimeUtility.setTime(intervalStart, range.endTime) >= intervalStart; + }); + + for (const nonTradingRange of relevantDiscontinuityRanges) { + const nonTradingStart = dateTimeUtility.setTime(intervalStart, nonTradingRange.startTime); + const nonTradingEnd = nonTradingRange.endTime === dayBoundary + ? dateTimeUtility.getStartOfNextDay(intervalStart) + : dateTimeUtility.setTime(intervalStart, nonTradingRange.endTime); + + // both intervalStart and intervalEnd are before the start of this non-trading range + if (intervalStart < nonTradingStart && intervalEnd < nonTradingStart) { + return total + dateTimeUtility.msInterval.count(intervalStart, intervalEnd); + } + + // intervalStart is before the start of this non-trading time range + if (intervalStart < nonTradingStart) { + total += dateTimeUtility.msInterval.count(intervalStart, nonTradingStart); + } + + // interval ends within non-trading range + if (intervalEnd < nonTradingEnd) { + return total; + } + + // set interval start to the end of non-trading range + intervalStart = nonTradingEnd; + } + + // add any interval time still left after iterating through all non-trading ranges + return total + dateTimeUtility.msInterval.count(intervalStart, intervalEnd); + }; + + const offset = (date, ms) => { + if (ms === 0) { + return [date, ms]; + } + + let offsetDate = dateTimeUtility.msInterval.offset(date, ms); + + const nonTradingRanges = (ms > 0) + ? nonTradingTimeRanges.filter(range => dateTimeUtility.setTime(date, range.startTime) >= date) + : nonTradingTimeRanges.filter(range => dateTimeUtility.setTime(date, range.startTime) < date).reverse(); + + if (nonTradingRanges.length === 0) { + return [dateTimeUtility.msInterval.offset(date, ms), 0]; + } + + if (ms > 0) { + for (const nonTradingRange of nonTradingRanges) { + + const rangeStart = dateTimeUtility.setTime(date, nonTradingRange.startTime); + + if (rangeStart <= offsetDate) { + // offsetDate is within non-trading range + ms -= dateTimeUtility.msInterval.count(date, rangeStart); + date = nonTradingRange.endTime === dayBoundary + ? dateTimeUtility.getStartOfNextDay(date) + : dateTimeUtility.setTime(date, nonTradingRange.endTime); + offsetDate = dateTimeUtility.msInterval.offset(date, ms); + } + } + + ms -= dateTimeUtility.msInterval.count(date, offsetDate); + + } else { + + for (const nonTradingRange of nonTradingRanges) { + const endTime = nonTradingRange.endTime === dayBoundary + ? dateTimeUtility.getStartOfNextDay(date) + : dateTimeUtility.setTime(date, nonTradingRange.endTime); + + if (offsetDate < endTime) { + // offsetDate is within non-trading range + ms += dateTimeUtility.msInterval.count(endTime, date) + 1; + date = dateTimeUtility.msInterval.offset(dateTimeUtility.setTime(date, nonTradingRange.startTime), - 1); + offsetDate = dateTimeUtility.msInterval.offset(date, ms); + } + } + + ms += dateTimeUtility.msInterval.count(offsetDate, date); + } + + if (ms !== 0) { + throw 'tradingDay.offset was called with an offset that spans more than a day'; + } + + return [offsetDate, ms]; + }; + + return { totalTradingTimeInMiliseconds, nonTradingTimeRanges, totalTradingMillisecondsBetween, offset }; +}; diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js new file mode 100644 index 000000000..6ac4106d9 --- /dev/null +++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipUtcWeeklyPatternSpec.js @@ -0,0 +1,192 @@ +import { default as skipUtcWeeklyPattern } from '../../src/discontinuity/skipUtcWeeklyPattern'; +import { utcMillisecond } from 'd3-time'; + +const nonTradingHoursPattern = +{ + Monday: [["13:20", "19:00"], ["07:45", "08:30"]], + Tuesday: [["07:45", "08:30"], ["13:20", "19:00"]], + Wednesday: [["07:45", "08:30"], ["13:20", "19:00"]], + Thursday: [["07:45", "08:30"], ["13:20", "19:00"]], + Friday: [["07:45", "08:30"], ["13:20", "EOD"]], + Saturday: [["SOD", "EOD"]], + Sunday: [["SOD", "19:00"]] +}; + +const tradingWeekWithoutDiscontinuities = {}; + +const mondayFirstStartBoundary = new Date(Date.UTC(2018, 0, 1, 7, 45)); +const mondayFirstEndBoundary = new Date(Date.UTC(2018, 0, 1, 8, 30)); +const fridaySecondStartBoundary = new Date(Date.UTC(2018, 0, 5, 13, 20)); +const sundayEndBoundary = new Date(Date.UTC(2018, 0, 7, 19)); + +describe('skipUtcWeeklyPattern', () => { + const sut = skipUtcWeeklyPattern(nonTradingHoursPattern); + + it('has 7 trading days', () => { + expect(sut.tradingDays.length).toBe(7); + }); + + describe('clampUp', () => { + + it('should do nothing 1ms before non trading period', () => { + const expected = utcMillisecond.offset(mondayFirstStartBoundary, - 1); + const actual = sut.clampUp(expected); + expect(actual).toEqual(expected); + }); + + it('should do nothing if provided with a valid trading date', () => { + const expected = mondayFirstEndBoundary; + const actual = sut.clampUp(expected); + expect(actual).toEqual(expected); + }); + + it('should advance to end of non trading period', () => { + const expected = mondayFirstEndBoundary; + const actual = sut.clampUp(mondayFirstStartBoundary); + expect(actual).toEqual(expected); + }); + + it('should advance from Friday 13:20 to Sunday 7:00pm', () => { + const expected = sundayEndBoundary; + const actual = sut.clampUp(fridaySecondStartBoundary); + expect(actual).toEqual(expected); + }); + }); + + describe('clampDown', () => { + + it('should do nothing one ms before non trading period', () => { + const expected = utcMillisecond.offset(mondayFirstStartBoundary, - 1); + const actual = sut.clampDown(expected); + expect(actual).toEqual(expected); + }); + + it('should do nothing if provided with a valid trading hour', () => { + const expected = mondayFirstEndBoundary; + const actual = sut.clampDown(expected); + expect(actual).toEqual(expected); + }); + + it('should clamp down to one millisecond before start of non-trading period', () => { + const expected = utcMillisecond.offset(mondayFirstStartBoundary, -1); + const actual = sut.clampDown(mondayFirstStartBoundary); + expect(actual).toEqual(expected); + }); + + it('should clamp down from Sunday 6:59:59.999pm to 1ms before Friday 13:20', () => { + const expected = utcMillisecond.offset(fridaySecondStartBoundary, -1); + const actual = sut.clampDown(utcMillisecond.offset(sundayEndBoundary, -1)); + expect(actual).toEqual(expected); + }); + }); + + describe('distance', () => { + it('should return totalTradingWeekMilliseconds', () => { + expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 8)))).toBe(sut.totalTradingWeekMilliseconds); + }); + + it('should return 52 * totalTradingWeekMilliseconds', () => { + expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 11, 31)))).toBe(52 * sut.totalTradingWeekMilliseconds); + }); + + it('should return negative 52 * totalTradingWeekMilliseconds', () => { + expect(sut.distance(new Date(Date.UTC(2018, 11, 31)), new Date(Date.UTC(2018, 0, 1)))).toBe(-52 * sut.totalTradingWeekMilliseconds); + }); + + it('on DST boundaries should return 24 * 3600 * 1000', () => { + const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities); + expect(sut.distance(new Date(Date.UTC(2022, 2, 27)), new Date(Date.UTC(2022, 2, 28)))).toBe(24 * 3600 * 1000); + expect(sut.distance(new Date(Date.UTC(2022, 9, 30)), new Date(Date.UTC(2022, 9, 31)))).toBe(24 * 3600 * 1000); + }); + + it('should return 23 * 3600 * 1000 for a DST sunday as it skips missing hour', () => { + const sut = skipUtcWeeklyPattern({ Sunday: [["1:0", "2:0"]] }); + expect(sut.distance(new Date(Date.UTC(2022, 2, 27)), new Date(Date.UTC(2022, 2, 28)))).toBe(23 * 3600 * 1000); + }); + + it('should return 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => { + const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities); + expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 8)))).toBe(7 * 24 * 3600 * 1000); + }); + + it('should return 52 * 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => { + const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities); + expect(sut.distance(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 11, 31)))).toBe(52 * 7 * 24 * 3600 * 1000); + }); + }); + + describe('offset', () => { + it('0 offset should return same date', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), 0)).toEqual(new Date(Date.UTC(2018, 0, 1))); + }); + + it('-1ms offset should return end of previous day', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), -1)).toEqual(new Date(Date.UTC(2017, 11, 31, 23, 59, 59, 999))); + }); + + it('-1ms offset at the of non-trading period should return start of non-trading period', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 8, 30)), -1)).toEqual(new Date(Date.UTC(2018, 0, 1, 7, 44, 59, 999))); + }); + + it('-2ms offset should return end of previous day - 1ms', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), -2)).toEqual(new Date(Date.UTC(2017, 11, 31, 23, 59, 59, 998))); + }); + + it('-1ms offset after 1ms into trading day should return start of day', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 1)), -1)).toEqual(new Date(Date.UTC(2018, 0, 1))); + }); + + it('should return start of next day when offset is 24hr', () => { + const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities); + expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), 24 * 3600 * 1000)).toEqual(new Date(Date.UTC(2018, 0, 2))); + }); + + it('should return start of previous day when offset is -24hr', () => { + const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities); + expect(sut.offset(new Date(Date.UTC(2018, 0, 2)), - 24 * 3600 * 1000)).toEqual(new Date(Date.UTC(2018, 0, 1))); + }); + + it('1ms offset at start of non-trading period should return end of non-trading period', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 7, 45)), 1)).toEqual(new Date(Date.UTC(2018, 0, 1, 8, 30, 0, 1))); + }); + + it('should return start of next week when offset is totalTradingWeekMilliseconds', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), sut.totalTradingWeekMilliseconds)).toEqual(new Date(Date.UTC(2018, 0, 8))); + }); + + it('should return start of 53rd week when offset is 52 * totalTradingWeekMilliseconds', () => { + expect(sut.offset(new Date(Date.UTC(2018, 0, 1)), 52 * sut.totalTradingWeekMilliseconds)).toEqual(new Date(Date.UTC(2018, 11, 31))); + }); + + it('on a DST Sunday boundary should return 1h into next trading period when offset is 1h', () => { + expect(sut.offset(new Date(Date.UTC(2022, 2, 27, 1)), 3600 * 1000)).toEqual(new Date(Date.UTC(2022, 2, 27, 20))); + }); + + it('on a DST boundry day should return next day when offset is 24 hours', () => { + const sut = skipUtcWeeklyPattern(tradingWeekWithoutDiscontinuities); + expect(sut.offset(new Date(Date.UTC(2022, 2, 27)), 24 * 3600 * 1000)).toEqual(new Date(Date.UTC(2022, 2, 28))); + }); + + it('should return next day on a DST boundry when offset is 23 hours', () => { + const sut = skipUtcWeeklyPattern({ Sunday: [["1:0", "2:0"]] }); + expect(sut.offset(new Date(Date.UTC(2022, 2, 27)), 23 * 3600 * 1000)).toEqual(new Date(Date.UTC(2022, 2, 28))); + }); + + it('should return end of second non-trading range', () => { + const offset = utcMillisecond.count(new Date(Date.UTC(2018, 0, 1, 8, 30)), new Date(Date.UTC(2018, 0, 1, 13, 20))); + expect(sut.offset(new Date(Date.UTC(2018, 0, 1, 7, 45)), offset)).toEqual(new Date(Date.UTC(2018, 0, 1, 19, 0, 0, 0))); + }); + + it('should return 1ms before Friday 13:20 when offset = -1ms on Sunday 7:00pm', () => { + const expected = utcMillisecond.offset(fridaySecondStartBoundary, -1); + const actual = sut.offset(sundayEndBoundary, -1); + expect(actual).toEqual(expected); + }); + }); + + describe('copy', () => { + it('should return same object', () => { + expect(sut.copy() === sut.copy()).toBeTruthy(); + }); + }); +}); diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js new file mode 100644 index 000000000..7f4aa3517 --- /dev/null +++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/nonTradingTimeRangeSpec.js @@ -0,0 +1,80 @@ +import { nonTradingTimeRange, standardiseTimeString } from "../../../src/discontinuity/skipWeeklyPattern/nonTradingTimeRange"; +import { utcDateTimeUtility } from "../../../src/discontinuity/skipUtcWeeklyPattern"; +import { localDateTimeUtility } from "../../../src/discontinuity/skipWeeklyPattern"; + +describe('nonTradingTimeRange', () => { + it('throws for no arguments', () => { + expect(() => nonTradingTimeRange()).toThrow(); + }); + + it('throws for more than 1 argument', () => { + expect(() => nonTradingTimeRange("a", "b")).toThrow(); + }); + + it('throws for argument that is not string[]', () => { + expect(() => nonTradingTimeRange("a")).toThrow(); + }); + + it('throws for argument that is not string[]', () => { + expect(() => nonTradingTimeRange([1, 2], utcDateTimeUtility)).toThrow(); + }); + + it('throws for argument that is not string[]', () => { + expect(() => nonTradingTimeRange([new Date(), new Date()], utcDateTimeUtility)).toThrow(); + }); + + it('throws for string[] argument when lenght != 2', () => { + expect(() => nonTradingTimeRange(["a"], utcDateTimeUtility)).toThrow(); + }); + + it('throws for string[] argument with invalid time string', () => { + expect(() => nonTradingTimeRange(["", ""], utcDateTimeUtility)).toThrow(); + }); + + it('should return lenght = 1 for SOD and "00:00:00.001" ', () => { + expect(nonTradingTimeRange(["SOD", "00:00:00.001"], utcDateTimeUtility).lenghtInMs).toEqual(1); + expect(nonTradingTimeRange(["SOD", "00:00:00.001"], localDateTimeUtility).lenghtInMs).toEqual(1); + }); + + it('should return total milliseconds per day for timeRange [SOD, EOD)', () => { + expect(nonTradingTimeRange(["SOD", "EOD"], utcDateTimeUtility).lenghtInMs).toEqual(1000 * 60 * 60 * 24); + expect(nonTradingTimeRange(["SOD", "EOD"], localDateTimeUtility).lenghtInMs).toEqual(1000 * 60 * 60 * 24); + }); +}); + +describe('standardiseTimeString', () => { + it('throws for no arguments', () => { + expect(() => standardiseTimeString()).toThrow(); + }); + + it('throws for more than 1 argument', () => { + expect(() => standardiseTimeString("a", "b")).toThrow(); + }); + + it('throws for argument that is not a valid time', () => { + expect(() => standardiseTimeString("25:00:00.000")).toThrow(); + }); + + it('throws for argument that is not a valid time', () => { + expect(() => standardiseTimeString("24:00:00.000")).toThrow(); + }); + + it('throws for argument that is not a valid time', () => { + expect(() => standardiseTimeString("20")).toThrow(); + }); + + it('parses argument formatted as h:m as expected', () => { + const actual = standardiseTimeString("8:3"); + expect(actual).toEqual("08:03:00.000"); + }); + + it('parses argument formatted as h:mm:s as expected', () => { + const actual = standardiseTimeString("1:59:7"); + expect(actual).toEqual("01:59:07.000"); + }); + + it('parses argument formatted as h:mm:s:ff as expected', () => { + const actual = standardiseTimeString("1:59:9.56"); + expect(actual).toEqual("01:59:09.056"); + }); +}); \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js new file mode 100644 index 000000000..ee955419e --- /dev/null +++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPattern/tradingDaySpec.js @@ -0,0 +1,41 @@ +import { tradingDay } from '../../../src/discontinuity/skipWeeklyPattern/tradingDay'; +import { localDateTimeUtility } from '../../../src/discontinuity/skipWeeklyPattern'; +import { utcDateTimeUtility } from '../../../src/discontinuity/skipUtcWeeklyPattern'; + +describe('tradingDay', () => { + describe('totalTradingMillisecondsBetween', () => { + it(' should return 0 ms for non trading day', () => { + expect(tradingDay([["SOD", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(0); + expect(tradingDay([["SOD", "EOD"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(0); + }); + + it('should return 1 ms', () => { + expect(tradingDay([["0:0:0.1", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(1); + expect(tradingDay([["0:0:0.1", "EOD"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(1); + }); + + it('should return 2 ms', () => { + expect(tradingDay([["0:0:0.1", "23:59:59.999"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(2); + expect(tradingDay([["0:0:0.1", "23:59:59.999"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(2); + }); + + it('should return 2 ms - one at start and one at the end of the day', () => { + expect(tradingDay([["0:0:0.1", "23:59:59.998"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 1, 23, 59, 59, 999))).toBe(2); + expect(tradingDay([["0:0:0.1", "23:59:59.998"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 1, 23, 59, 59, 999)))).toBe(2); + }); + + it('should skip multiple non-trading time ranges and return 3 ms', () => { + expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 2))).toBe(3); + expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "EOD"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 2)))).toBe(3); + }); + + it('should skip multiple non-trading time ranges and return 4 ms', () => { + expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "23:59:59.998"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1), new Date(2018, 0, 1, 23, 59, 59, 999))).toBe(4); + expect(tradingDay([["0:0:0.1", "0:0:0.2"], ["0:0:0.3", "0:0:0.4"], ["0:0:0.5", "23:59:59.998"]], utcDateTimeUtility).totalTradingMillisecondsBetween(new Date(Date.UTC(2018, 0, 1)), new Date(Date.UTC(2018, 0, 1, 23, 59, 59, 999)))).toBe(4); + }); + + it('should return 1', () => { + expect(tradingDay([["7:00", "7:30"], ["8:00", "EOD"]], localDateTimeUtility).totalTradingMillisecondsBetween(new Date(2018, 0, 1, 7, 59, 59, 999), new Date(2018, 0, 2))); + }); + }); +}); \ No newline at end of file diff --git a/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js new file mode 100644 index 000000000..7e4c61c32 --- /dev/null +++ b/packages/d3fc-discontinuous-scale/test/discontinuity/skipWeeklyPatternSpec.js @@ -0,0 +1,260 @@ +import { default as skipWeeklyPattern } from '../../src/discontinuity/skipWeeklyPattern'; +import { timeMillisecond } from 'd3-time'; + +const nonTradingHoursPattern = +{ + Monday: [["13:20", "19:00"], ["07:45", "08:30"]], + Tuesday: [["07:45", "08:30"], ["13:20", "19:00"]], + Wednesday: [["07:45", "08:30"], ["13:20", "19:00"]], + Thursday: [["07:45", "08:30"], ["13:20", "19:00"]], + Friday: [["07:45", "08:30"], ["13:20", "EOD"]], + Saturday: [["SOD", "EOD"]], + Sunday: [["SOD", "19:00"]] +}; + +const mondayFirstStartBoundary = new Date(2018, 0, 1, 7, 45); +const mondayFirstEndBoundary = new Date(2018, 0, 1, 8, 30); +const fridaySecondStartBoundary = new Date(2018, 0, 5, 13, 20); +const sundayEndBoundary = new Date(2018, 0, 7, 19); + +describe('skipWeeklyPattern', () => { + const sut = skipWeeklyPattern(nonTradingHoursPattern); + + it('has 7 trading days', () => { + expect(sut.tradingDays.length).toBe(7); + }); + + it('should throw due to no trading periods', () => { + expect(() => skipWeeklyPattern({ + Monday: [["SOD", "EOD"]], Tuesday: [["SOD", "EOD"]], + Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], + Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] + })).toThrow(); + }); + + describe('clampUp', () => { + + it('should do nothing 1ms before non trading period', () => { + const expected = timeMillisecond.offset(mondayFirstStartBoundary, - 1); + const actual = sut.clampUp(expected); + expect(actual).toEqual(expected); + }); + + it('should do nothing if provided with a valid trading date', () => { + const expected = mondayFirstEndBoundary; + const actual = sut.clampUp(expected); + expect(actual).toEqual(expected); + }); + + it('should advance to end of non trading period', () => { + const expected = mondayFirstEndBoundary; + const actual = sut.clampUp(mondayFirstStartBoundary); + expect(actual).toEqual(expected); + }); + + it('should advance from Friday 13:20 to Sunday 7:00pm', () => { + const expected = sundayEndBoundary; + const actual = sut.clampUp(fridaySecondStartBoundary); + expect(actual).toEqual(expected); + }); + + it('should advance to single trading ms', () => { + const sut = skipWeeklyPattern({ Sunday: [["SOD", "23:59:59.999"]], Monday: [["SOD", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]] }); + expect(sut.clampUp(new Date(2018, 0, 1))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + expect(sut.clampUp(new Date(2018, 0, 2))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + expect(sut.clampUp(new Date(2018, 0, 3))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + expect(sut.clampUp(new Date(2018, 0, 4))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + expect(sut.clampUp(new Date(2018, 0, 5))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + expect(sut.clampUp(new Date(2018, 0, 6))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + expect(sut.clampUp(new Date(2018, 0, 7))).toEqual(new Date(2018, 0, 7, 23, 59, 59, 999)); + }); + }); + + describe('clampDown', () => { + + it('should do nothing one ms before non trading period', () => { + const expected = timeMillisecond.offset(mondayFirstStartBoundary, - 1); + const actual = sut.clampDown(expected); + expect(actual).toEqual(expected); + }); + + it('should do nothing if provided with a valid trading hour', () => { + const expected = mondayFirstEndBoundary; + const actual = sut.clampDown(expected); + expect(actual).toEqual(expected); + }); + + it('should clamp down to one millisecond before start of non-trading period', () => { + const expected = timeMillisecond.offset(mondayFirstStartBoundary, -1); + const actual = sut.clampDown(mondayFirstStartBoundary); + expect(actual).toEqual(expected); + }); + + it('should clamp down from Sunday 6:59:59.999pm to 1ms before Friday 13:20', () => { + const expected = timeMillisecond.offset(fridaySecondStartBoundary, -1); + const actual = sut.clampDown(timeMillisecond.offset(sundayEndBoundary, -1)); + expect(actual).toEqual(expected); + }); + + it('should clamp down to single trading ms', () => { + const expected = new Date(2018, 0, 1); + const sut = skipWeeklyPattern({ Monday: [["00:00:00.001", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] }); + expect(sut.clampDown(new Date(2018, 0, 1, 0, 0, 0, 1))).toEqual(expected); + expect(sut.clampDown(new Date(2018, 0, 2))).toEqual(expected); + expect(sut.clampDown(new Date(2018, 0, 3))).toEqual(expected); + expect(sut.clampDown(new Date(2018, 0, 4))).toEqual(expected); + expect(sut.clampDown(new Date(2018, 0, 5))).toEqual(expected); + expect(sut.clampDown(new Date(2018, 0, 6))).toEqual(expected); + expect(sut.clampDown(new Date(2018, 0, 7))).toEqual(expected); + }); + }); + + describe('distance', () => { + it('should return totalTradingWeekMilliseconds', () => { + expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 0, 8))).toBe(sut.totalTradingWeekMilliseconds); + }); + + it('should return 52 * totalTradingWeekMilliseconds', () => { + expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 11, 31))).toBe(52 * sut.totalTradingWeekMilliseconds); + }); + + it('should return negative 52 * totalTradingWeekMilliseconds', () => { + expect(sut.distance(new Date(2018, 11, 31), new Date(2018, 0, 1))).toBe(-52 * sut.totalTradingWeekMilliseconds); + }); + + it('should return 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => { + const sut = skipWeeklyPattern({}); + expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 0, 8))).toBe(7 * 24 * 3600 * 1000); + }); + + it('should return 52 * 7 * 24 * 3600 * 1000 for trading week without non-trading periods', () => { + const sut = skipWeeklyPattern({}); + expect(sut.distance(new Date(2018, 0, 1), new Date(2018, 11, 31))).toBe(52 * 7 * 24 * 3600 * 1000); + }); + + it('should return 0 between consecutive non-trading ranges spanning multiple days', () => { + expect(sut.distance(fridaySecondStartBoundary, sundayEndBoundary)).toEqual(0); + expect(sut.distance(sundayEndBoundary, fridaySecondStartBoundary)).toEqual(-0); + }); + + it('should return -1', () => { + const expected = -1; + const sut = skipWeeklyPattern({ Monday: [["00:00:00.001", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] }); + expect(sut.distance(new Date(2018, 0, 8), new Date(2018, 0, 1))).toEqual(expected); + expect(sut.distance(new Date(2018, 0, 9), new Date(2018, 0, 2))).toEqual(expected); + expect(sut.distance(new Date(2018, 0, 10), new Date(2018, 0, 3))).toEqual(expected); + expect(sut.distance(new Date(2018, 0, 11), new Date(2018, 0, 4))).toEqual(expected); + expect(sut.distance(new Date(2018, 0, 12), new Date(2018, 0, 5))).toEqual(expected); + expect(sut.distance(new Date(2018, 0, 13), new Date(2018, 0, 6))).toEqual(expected); + expect(sut.distance(new Date(2018, 0, 14), new Date(2018, 0, 7))).toEqual(expected); + }); + + it('on DST boundaries should return 0hr or 1hr', () => { + const sut = skipWeeklyPattern({}); + expect(sut.distance(new Date(2022, 2, 27, 1), new Date(2022, 2, 27, 2))).toBe(0); + expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(2 * 3600 * 1000); + }); + + it('on DST boundary when clocks go back should return 0', () => { + const sut = skipWeeklyPattern({ Sunday: [["1:0", "2:0"]] }); + expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(0); + }); + + it('on DST boundary when clocks go back should return 90min', () => { + const sut = skipWeeklyPattern({ Sunday: [["1:15", "1:45"]] }); + expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(90 * 60000); + }); + + it(`KNOWN BUG: + on DST boundary when clocks go forward should return 50min (1h:10min - 15min) when non-trading period falls within 'wall clock' change + instead it returns 55min i.e. the non-trading period 1:55-2:00 is only counted once rather than twice`, () => { + const sut = skipWeeklyPattern({ Sunday: [["1:55", "2:05"]] }); + expect(sut.distance(new Date(2022, 9, 30, 1), new Date(2022, 9, 30, 2))).toBe(/*50*/ 55 * 60000); + }); + + it('on DST boundaries should return 22hr or 24hr in single non-trading hour in day', () => { + const sut = skipWeeklyPattern({ Sunday: [["7:45", "8:45"]] }); + expect(sut.distance(new Date(2022, 2, 27), new Date(2022, 2, 28))).toBe(22 * 3600 * 1000); + expect(sut.distance(new Date(2022, 9, 30), new Date(2022, 9, 31))).toBe(24 * 3600 * 1000); + }); + }); + + describe('offset', () => { + it('0 offset should return same date', () => { + expect(sut.offset(new Date(2018, 0, 1), 0)).toEqual(new Date(2018, 0, 1)); + }); + + it('0 offset in non-trading range clamps up', () => { + expect(sut.offset(new Date(2018, 0, 1, 7, 45), 0)).toEqual(new Date(2018, 0, 1, 8, 30)); + }); + + it('should return end of previous day when offset = -1ms on day boundary ', () => { + expect(sut.offset(new Date(2018, 0, 1), -1)).toEqual(new Date(2017, 11, 31, 23, 59, 59, 999)); + }); + + it('-2ms offset should return end of previous day - 1ms', () => { + expect(sut.offset(new Date(2018, 0, 1), -2)).toEqual(new Date(2017, 11, 31, 23, 59, 59, 998)); + }); + + it('-1ms offset after 1ms into trading day should return start of day', () => { + expect(sut.offset(new Date(2018, 0, 1, 0, 0, 0, 1), -1)).toEqual(new Date(2018, 0, 1)); + }); + + it('should return 1ms before the start of this period when offset = -1ms at the end of non-trading period', () => { + expect(sut.offset(new Date(2018, 0, 1, 8, 30), -1)).toEqual(new Date(2018, 0, 1, 7, 44, 59, 999)); + }); + + it('should return start of next day when offset is 24hr', () => { + const sut = skipWeeklyPattern({}); + expect(sut.offset(new Date(2018, 0, 1), 24 * 3600 * 1000)).toEqual(new Date(2018, 0, 2)); + }); + + it('should return start of previous day when offset is -24hr', () => { + const sut = skipWeeklyPattern({}); + expect(sut.offset(new Date(2018, 0, 2), - 24 * 3600 * 1000)).toEqual(new Date(2018, 0, 1)); + }); + + it('should return end of non-trading period when offset is 1ms at start of non-trading period', () => { + expect(sut.offset(new Date(2018, 0, 1, 7, 45), 1)).toEqual(new Date(2018, 0, 1, 8, 30, 0, 1)); + }); + + it('should return start of next week when offset is totalTradingWeekMilliseconds', () => { + expect(sut.offset(new Date(2018, 0, 1), sut.totalTradingWeekMilliseconds)).toEqual(new Date(2018, 0, 8)); + }); + + it('should return start of 53rd week when offset is 52 * totalTradingWeekMilliseconds', () => { + expect(sut.offset(new Date(2018, 0, 1), 52 * sut.totalTradingWeekMilliseconds)).toEqual(new Date(2018, 11, 31)); + }); + + it('should return end of second non-trading range when offset covers entire trading period sandwiched between 2 non-trading ones', () => { + const offset = timeMillisecond.count(new Date(2018, 0, 1, 8, 30), new Date(2018, 0, 1, 13, 20)); + expect(sut.offset(new Date(2018, 0, 1, 7, 45), offset)).toEqual(new Date(2018, 0, 1, 19, 0, 0, 0)); + }); + + it('should return 1ms before Friday 13:20 when offset = -1ms on Sunday 7:00pm', () => { + expect(sut.offset(sundayEndBoundary, -1)).toEqual(timeMillisecond.offset(fridaySecondStartBoundary, -1)); + }); + + it('should return Sunday 7pm when offset = 1ms on Friday 13:20', () => { + expect(sut.offset(timeMillisecond.offset(fridaySecondStartBoundary, -1), 1)).toEqual(sundayEndBoundary); + }); + + it('should return Monday 22nd when offset = 2ms on any when day since it skips the 8th and the 15th', () => { + const expected = new Date(2018, 0, 22); + const sut = skipWeeklyPattern({ Monday: [["00:00:00.001", "EOD"]], Tuesday: [["SOD", "EOD"]], Wednesday: [["SOD", "EOD"]], Thursday: [["SOD", "EOD"]], Friday: [["SOD", "EOD"]], Saturday: [["SOD", "EOD"]], Sunday: [["SOD", "EOD"]] }); + expect(sut.offset(new Date(2018, 0, 1, 1), 2)).toEqual(expected); + expect(sut.offset(new Date(2018, 0, 2), 2)).toEqual(expected); + expect(sut.offset(new Date(2018, 0, 3), 2)).toEqual(expected); + expect(sut.offset(new Date(2018, 0, 4), 2)).toEqual(expected); + expect(sut.offset(new Date(2018, 0, 5), 2)).toEqual(expected); + expect(sut.offset(new Date(2018, 0, 6), 2)).toEqual(expected); + expect(sut.offset(new Date(2018, 0, 7), 2)).toEqual(expected); + }); + }); + + describe('copy', () => { + it('should return same object', () => { + expect(sut.copy() === sut.copy()).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index 1741d40d4..5ad8ceab6 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -1,6 +1,8 @@ +process.env.TZ = 'Europe/London'; module.exports = { rootDir: '../../', roots: ['/packages'], setupFilesAfterEnv: [require.resolve('./setup.js')], + moduleNameMapper: { '^d3-(.*)$': `d3-$1/dist/d3-$1` }, testMatch: ['**/test/**/*[sS]pec.js'] };