From c4066176cf3eeb5d6641d9af31eaa7f2047492a6 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 21 Jul 2016 22:09:09 -0700 Subject: [PATCH] date, nyse market hours range. --- date/date.go | 305 +++++++++++++++++++++++++++++++++++++ date/date_test.go | 87 +++++++++++ nyse_market_hours_range.go | 65 ++++++++ util.go | 5 + 4 files changed, 462 insertions(+) create mode 100644 date/date.go create mode 100644 date/date_test.go create mode 100644 nyse_market_hours_range.go diff --git a/date/date.go b/date/date.go new file mode 100644 index 0000000..697f345 --- /dev/null +++ b/date/date.go @@ -0,0 +1,305 @@ +package date + +import ( + "sync" + "time" +) + +const ( + // AllDaysMask is a bitmask of all the days of the week. + AllDaysMask = 1<friday. +func IsWeekDay(day time.Weekday) bool { + return !IsWeekendDay(day) +} + +// IsWeekendDay returns if the day is a monday->friday. +func IsWeekendDay(day time.Weekday) bool { + return day == time.Saturday || day == time.Sunday +} + +// BeforeDate returns if a timestamp is strictly before another date (ignoring hours, minutes etc.) +func BeforeDate(before, reference time.Time) bool { + if before.Year() < reference.Year() { + return true + } + if before.Month() < reference.Month() { + return true + } + return before.Year() == reference.Year() && before.Month() == reference.Month() && before.Day() < reference.Day() +} + +// IsNYSEHoliday returns if a date was/is on a nyse holiday day. +func IsNYSEHoliday(t time.Time) bool { + te := t.In(Eastern()) + if te.Year() == 2013 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 21 + } else if te.Month() == 2 { + return te.Day() == 18 + } else if te.Month() == 3 { + return te.Day() == 29 + } else if te.Month() == 5 { + return te.Day() == 27 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 2 + } else if te.Month() == 11 { + return te.Day() == 28 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2014 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 20 + } else if te.Month() == 2 { + return te.Day() == 17 + } else if te.Month() == 4 { + return te.Day() == 18 + } else if te.Month() == 5 { + return te.Day() == 26 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 1 + } else if te.Month() == 11 { + return te.Day() == 27 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2015 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 19 + } else if te.Month() == 2 { + return te.Day() == 16 + } else if te.Month() == 4 { + return te.Day() == 3 + } else if te.Month() == 5 { + return te.Day() == 25 + } else if te.Month() == 7 { + return te.Day() == 3 + } else if te.Month() == 9 { + return te.Day() == 7 + } else if te.Month() == 11 { + return te.Day() == 26 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2016 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 18 + } else if te.Month() == 2 { + return te.Day() == 15 + } else if te.Month() == 3 { + return te.Day() == 25 + } else if te.Month() == 5 { + return te.Day() == 30 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 5 + } else if te.Month() == 11 { + return te.Day() == 24 || te.Day() == 25 + } else if te.Month() == 12 { + return te.Day() == 26 + } + } else if te.Year() == 2017 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 16 + } else if te.Month() == 2 { + return te.Day() == 20 + } else if te.Month() == 4 { + return te.Day() == 15 + } else if te.Month() == 5 { + return te.Day() == 29 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 4 + } else if te.Month() == 11 { + return te.Day() == 23 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2018 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 15 + } else if te.Month() == 2 { + return te.Day() == 19 + } else if te.Month() == 3 { + return te.Day() == 30 + } else if te.Month() == 5 { + return te.Day() == 28 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 3 + } else if te.Month() == 11 { + return te.Day() == 22 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } + return false +} + +// MarketOpen returns 0930 on a given day. +func MarketOpen(on time.Time) time.Time { + onEastern := on.In(Eastern()) + return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 9, 30, 0, 0, Eastern()) +} + +// MarketClose returns 1600 on a given day. +func MarketClose(on time.Time) time.Time { + onEastern := on.In(Eastern()) + return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 16, 0, 0, 0, Eastern()) +} + +// NextMarketOpen returns the next market open after a given time. +func NextMarketOpen(after time.Time) time.Time { + afterEastern := after.In(Eastern()) + todaysOpen := MarketOpen(afterEastern) + + if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !IsNYSEHoliday(todaysOpen) { + return todaysOpen + } + + if afterEastern.Equal(todaysOpen) { //rare but it might happen. + return todaysOpen + } + + for cursorDay := 1; cursorDay < 6; cursorDay++ { + newDay := todaysOpen.AddDate(0, 0, cursorDay) + if IsWeekDay(newDay.Weekday()) && !IsNYSEHoliday(afterEastern) { + return time.Date(newDay.Year(), newDay.Month(), newDay.Day(), 9, 30, 0, 0, Eastern()) + } + } + return Epoch //we should never reach this. +} + +// NextMarketClose returns the next market close after a given time. +func NextMarketClose(after time.Time) time.Time { + afterEastern := after.In(Eastern()) + + todaysClose := MarketClose(afterEastern) + if afterEastern.Before(todaysClose) && IsWeekDay(todaysClose.Weekday()) && !IsNYSEHoliday(todaysClose) { + return todaysClose + } + + if afterEastern.Equal(todaysClose) { //rare but it might happen. + return todaysClose + } + + for cursorDay := 1; cursorDay < 6; cursorDay++ { + newDay := todaysClose.AddDate(0, 0, cursorDay) + if IsWeekDay(newDay.Weekday()) && !IsNYSEHoliday(newDay) { + return time.Date(newDay.Year(), newDay.Month(), newDay.Day(), 16, 0, 0, 0, Eastern()) + } + } + return Epoch //we should never reach this. +} + +// CalculateMarketSecondsBetween calculates the number of seconds the market was open between two dates. +func CalculateMarketSecondsBetween(start, end time.Time) (seconds int64) { + se := start.In(Eastern()) + ee := end.In(Eastern()) + + startMarketOpen := NextMarketOpen(se) + startMarketClose := NextMarketClose(se) + + if (se.Equal(startMarketOpen) || se.After(startMarketOpen)) && se.Before(startMarketClose) { + seconds += int64(startMarketClose.Sub(se) / time.Second) + } + + cursor := NextMarketOpen(startMarketClose) + for BeforeDate(cursor, ee) { + if IsWeekDay(cursor.Weekday()) && !IsNYSEHoliday(cursor) { + close := NextMarketClose(cursor) + seconds += int64(close.Sub(cursor) / time.Second) + } + cursor = cursor.AddDate(0, 0, 1) + } + + finalMarketOpen := NextMarketOpen(cursor) + finalMarketClose := NextMarketClose(cursor) + if end.After(finalMarketOpen) { + if end.Before(finalMarketClose) { + seconds += int64(end.Sub(finalMarketOpen) / time.Second) + } else { + seconds += int64(finalMarketClose.Sub(finalMarketOpen) / time.Second) + } + } + + return +} + +// Format returns a string representation of a date. +func format(t time.Time) string { + return t.Format("2006-01-02") +} + +// Parse parses a date from a string. +func parse(str string) time.Time { + res, _ := time.Parse("2006-01-02", str) + return res +} diff --git a/date/date_test.go b/date/date_test.go new file mode 100644 index 0000000..9f9da52 --- /dev/null +++ b/date/date_test.go @@ -0,0 +1,87 @@ +package date + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" +) + +func TestBeforeDate(t *testing.T) { + assert := assert.New(t) + + assert.True(BeforeDate(parse("2015-07-02"), parse("2016-07-01"))) + assert.True(BeforeDate(parse("2016-06-01"), parse("2016-07-01"))) + assert.True(BeforeDate(parse("2016-07-01"), parse("2016-07-02"))) + + assert.False(BeforeDate(parse("2016-07-01"), parse("2016-07-01"))) + assert.False(BeforeDate(parse("2016-07-03"), parse("2016-07-01"))) + assert.False(BeforeDate(parse("2016-08-03"), parse("2016-07-01"))) + assert.False(BeforeDate(parse("2017-08-03"), parse("2016-07-01"))) +} + +func TestNextMarketOpen(t *testing.T) { + assert := assert.New(t) + + beforeOpen := time.Date(2016, 07, 18, 9, 0, 0, 0, Eastern()) + todayOpen := time.Date(2016, 07, 18, 9, 30, 0, 0, Eastern()) + + afterOpen := time.Date(2016, 07, 18, 9, 31, 0, 0, Eastern()) + tomorrowOpen := time.Date(2016, 07, 19, 9, 30, 0, 0, Eastern()) + + afterFriday := time.Date(2016, 07, 22, 9, 31, 0, 0, Eastern()) + mondayOpen := time.Date(2016, 07, 25, 9, 30, 0, 0, Eastern()) + + weekend := time.Date(2016, 07, 23, 9, 31, 0, 0, Eastern()) + + assert.True(todayOpen.Equal(NextMarketOpen(beforeOpen))) + assert.True(tomorrowOpen.Equal(NextMarketOpen(afterOpen))) + assert.True(mondayOpen.Equal(NextMarketOpen(afterFriday))) + assert.True(mondayOpen.Equal(NextMarketOpen(weekend))) + + testRegression := time.Date(2016, 07, 18, 16, 0, 0, 0, Eastern()) + shouldbe := time.Date(2016, 07, 19, 9, 30, 0, 0, Eastern()) + + assert.True(shouldbe.Equal(NextMarketOpen(testRegression))) +} + +func TestNextMarketClose(t *testing.T) { + assert := assert.New(t) + + beforeClose := time.Date(2016, 07, 18, 15, 0, 0, 0, Eastern()) + todayClose := time.Date(2016, 07, 18, 16, 00, 0, 0, Eastern()) + + afterClose := time.Date(2016, 07, 18, 16, 1, 0, 0, Eastern()) + tomorrowClose := time.Date(2016, 07, 19, 16, 00, 0, 0, Eastern()) + + afterFriday := time.Date(2016, 07, 22, 16, 1, 0, 0, Eastern()) + mondayClose := time.Date(2016, 07, 25, 16, 0, 0, 0, Eastern()) + + weekend := time.Date(2016, 07, 23, 9, 31, 0, 0, Eastern()) + + assert.True(todayClose.Equal(NextMarketClose(beforeClose))) + assert.True(tomorrowClose.Equal(NextMarketClose(afterClose))) + assert.True(mondayClose.Equal(NextMarketClose(afterFriday))) + assert.True(mondayClose.Equal(NextMarketClose(weekend))) +} + +func TestCalculateMarketSecondsBetween(t *testing.T) { + assert := assert.New(t) + + start := time.Date(2016, 07, 18, 9, 30, 0, 0, Eastern()) + end := time.Date(2016, 07, 22, 16, 00, 0, 0, Eastern()) + + shouldbe := 5 * 6.5 * 60 * 60 + + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end)) +} + +func TestCalculateMarketSecondsBetweenLTM(t *testing.T) { + assert := assert.New(t) + + start := time.Date(2015, 07, 01, 9, 30, 0, 0, Eastern()) + end := time.Date(2016, 07, 01, 9, 30, 0, 0, Eastern()) + + shouldbe := 253 * 6.5 * 60 * 60 //253 full market days since this date last year. + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end)) +} diff --git a/nyse_market_hours_range.go b/nyse_market_hours_range.go new file mode 100644 index 0000000..23b64fe --- /dev/null +++ b/nyse_market_hours_range.go @@ -0,0 +1,65 @@ +package chart + +import ( + "fmt" + "time" + + "github.com/wcharczuk/go-chart/date" +) + +// NYSEMarketHoursRange is a special type of range that compresses a time range into just the +// market (i.e. NYSE operating hours and days) range. +type NYSEMarketHoursRange struct { + Min time.Time + Max time.Time + Domain int +} + +// GetMin returns the min value. +func (mhr NYSEMarketHoursRange) GetMin() float64 { + return TimeToFloat64(mhr.Min) +} + +// GetMax returns the max value. +func (mhr NYSEMarketHoursRange) GetMax() float64 { + return TimeToFloat64(mhr.Max) +} + +// SetMin sets the min value. +func (mhr *NYSEMarketHoursRange) SetMin(min float64) { + mhr.Min = Float64ToTime(min) +} + +// SetMax sets the max value. +func (mhr *NYSEMarketHoursRange) SetMax(max float64) { + mhr.Max = Float64ToTime(max) +} + +// GetDelta gets the delta. +func (mhr NYSEMarketHoursRange) GetDelta() float64 { + min := TimeToFloat64(mhr.Min) + max := TimeToFloat64(mhr.Min) + return max - min +} + +// GetDomain gets the domain. +func (mhr NYSEMarketHoursRange) GetDomain() int { + return mhr.Domain +} + +// SetDomain sets the domain. +func (mhr *NYSEMarketHoursRange) SetDomain(domain int) { + mhr.Domain = domain +} + +func (mhr NYSEMarketHoursRange) String() string { + return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateFormat), mhr.Max.Format(DefaultDateFormat), mhr.Domain) +} + +// Translate maps a given value into the ContinuousRange space. +func (mhr NYSEMarketHoursRange) Translate(value float64) int { + valueTime := Float64ToTime(value) + deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max) + valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime) + return int(float64(valueDelta) / float64(deltaSeconds)) +} diff --git a/util.go b/util.go index 77489c2..70d093c 100644 --- a/util.go +++ b/util.go @@ -20,6 +20,11 @@ func TimeToFloat64(t time.Time) float64 { return float64(t.UnixNano()) } +// Float64ToTime returns a time from a float64. +func Float64ToTime(tf float64) time.Time { + return time.Unix(0, int64(tf)) +} + // MinAndMax returns both the min and max in one pass. func MinAndMax(values ...float64) (min float64, max float64) { if len(values) == 0 {