From b1cd8bd2e36b466e113259664b4cd26dc613564a Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Mon, 1 Aug 2016 00:50:32 -0700 Subject: [PATCH] market hours tweaks. --- date.go | 64 ++++++++++++++------- date_test.go | 102 ++++++++++++++++++++++++++++++++++ examples/market_hours/main.go | 5 +- market_hours_range.go | 34 ++++++++++-- market_hours_range_test.go | 26 ++++++--- sequence.go | 35 +++++++++++- sequence_test.go | 21 ++++++- xaxis.go | 5 +- 8 files changed, 253 insertions(+), 39 deletions(-) diff --git a/date.go b/date.go index 2611ea3..e75bdaa 100644 --- a/date.go +++ b/date.go @@ -52,22 +52,22 @@ var ( var ( // NYSEOpen is when the NYSE opens. - NYSEOpen = Date.ClockTime(9, 30, 0, 0, Date.Eastern()) + NYSEOpen = Date.Time(9, 30, 0, 0, Date.Eastern()) // NYSEClose is when the NYSE closes. - NYSEClose = Date.ClockTime(16, 0, 0, 0, Date.Eastern()) + NYSEClose = Date.Time(16, 0, 0, 0, Date.Eastern()) // NASDAQOpen is when NASDAQ opens. - NASDAQOpen = Date.ClockTime(9, 30, 0, 0, Date.Eastern()) + NASDAQOpen = Date.Time(9, 30, 0, 0, Date.Eastern()) // NASDAQClose is when NASDAQ closes. - NASDAQClose = Date.ClockTime(16, 0, 0, 0, Date.Eastern()) + NASDAQClose = Date.Time(16, 0, 0, 0, Date.Eastern()) // NYSEArcaOpen is when NYSEARCA opens. - NYSEArcaOpen = Date.ClockTime(4, 0, 0, 0, Date.Eastern()) + NYSEArcaOpen = Date.Time(4, 0, 0, 0, Date.Eastern()) // NYSEArcaClose is when NYSEARCA closes. - NYSEArcaClose = Date.ClockTime(20, 0, 0, 0, Date.Eastern()) + NYSEArcaClose = Date.Time(20, 0, 0, 0, Date.Eastern()) ) // HolidayProvider is a function that returns if a given time falls on a holiday. @@ -220,17 +220,22 @@ func (d date) Eastern() *time.Location { return _eastern } -// ClockTime returns a new time.Time for the given clock components. -func (d date) ClockTime(hour, min, sec, nsec int, loc *time.Location) time.Time { +// Time returns a new time.Time for the given clock components. +func (d date) Time(hour, min, sec, nsec int, loc *time.Location) time.Time { return time.Date(0, 0, 0, hour, min, sec, nsec, loc) } +func (d date) Date(year, month, day int, loc *time.Location) time.Time { + return time.Date(year, time.Month(month), day, 12, 0, 0, 0, loc) +} + // On returns the clock components of clock (hour,minute,second) on the date components of d. func (d date) On(clock, cd time.Time) time.Time { - return time.Date(cd.Year(), cd.Month(), cd.Day(), clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(), clock.Location()) + tzAdjusted := cd.In(clock.Location()) + return time.Date(tzAdjusted.Year(), tzAdjusted.Month(), tzAdjusted.Day(), clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(), clock.Location()) } -// NoonOn is a shortcut for On(ClockTime(12,0,0), cd) a.k.a. noon on a given date. +// NoonOn is a shortcut for On(Time(12,0,0), cd) a.k.a. noon on a given date. func (d date) NoonOn(cd time.Time) time.Time { return time.Date(cd.Year(), cd.Month(), cd.Day(), 12, 0, 0, 0, cd.Location()) } @@ -252,19 +257,20 @@ func (d date) IsWeekendDay(day time.Weekday) bool { // Before returns if a timestamp is strictly before another date (ignoring hours, minutes etc.) func (d date) Before(before, reference time.Time) bool { - if before.Year() < reference.Year() { + tzAdjustedBefore := before.In(reference.Location()) + if tzAdjustedBefore.Year() < reference.Year() { return true } - if before.Month() < reference.Month() { + if tzAdjustedBefore.Month() < reference.Month() { return true } - return before.Year() == reference.Year() && before.Month() == reference.Month() && before.Day() < reference.Day() + return tzAdjustedBefore.Year() == reference.Year() && tzAdjustedBefore.Month() == reference.Month() && tzAdjustedBefore.Day() < reference.Day() } // NextMarketOpen returns the next market open after a given time. func (d date) NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvider) time.Time { - afterEastern := after.In(d.Eastern()) - todaysOpen := d.On(openTime, afterEastern) + afterLocalized := after.In(openTime.Location()) + todaysOpen := d.On(openTime, afterLocalized) if isHoliday == nil { isHoliday = defaultHolidayProvider @@ -272,7 +278,7 @@ func (d date) NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvide todayIsValidTradingDay := d.IsWeekDay(todaysOpen.Weekday()) && !isHoliday(todaysOpen) - if (afterEastern.Equal(todaysOpen) || afterEastern.Before(todaysOpen)) && todayIsValidTradingDay { + if (afterLocalized.Equal(todaysOpen) || afterLocalized.Before(todaysOpen)) && todayIsValidTradingDay { return todaysOpen } @@ -288,18 +294,18 @@ func (d date) NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvide // NextMarketClose returns the next market close after a given time. func (d date) NextMarketClose(after, closeTime time.Time, isHoliday HolidayProvider) time.Time { - afterEastern := after.In(d.Eastern()) + afterLocalized := after.In(closeTime.Location()) if isHoliday == nil { isHoliday = defaultHolidayProvider } - todaysClose := d.On(closeTime, afterEastern) - if afterEastern.Before(todaysClose) && d.IsWeekDay(todaysClose.Weekday()) && !isHoliday(todaysClose) { + todaysClose := d.On(closeTime, afterLocalized) + if afterLocalized.Before(todaysClose) && d.IsWeekDay(todaysClose.Weekday()) && !isHoliday(todaysClose) { return todaysClose } - if afterEastern.Equal(todaysClose) { //rare but it might happen. + if afterLocalized.Equal(todaysClose) { //rare but it might happen. return todaysClose } @@ -376,3 +382,21 @@ func (d date) NextHour(ts time.Time) time.Time { final := advanced.Add(-minutes) return time.Date(final.Year(), final.Month(), final.Day(), final.Hour(), 0, 0, 0, final.Location()) } + +// NextDayOfWeek returns the next instance of a given weekday after a given timestamp. +func (d date) NextDayOfWeek(after time.Time, dayOfWeek time.Weekday) time.Time { + afterWeekday := after.Weekday() + if afterWeekday == dayOfWeek { + return after.AddDate(0, 0, 7) + } + + // 1 vs 5 ~ add 4 days + if afterWeekday < dayOfWeek { + dayDelta := int(dayOfWeek - afterWeekday) + return after.AddDate(0, 0, dayDelta) + } + + // 5 vs 1, add 7-(5-1) ~ 3 days + dayDelta := 7 - int(afterWeekday-dayOfWeek) + return after.AddDate(0, 0, dayDelta) +} diff --git a/date_test.go b/date_test.go index f2b76ae..7403f35 100644 --- a/date_test.go +++ b/date_test.go @@ -12,6 +12,54 @@ func parse(v string) time.Time { return ts } +func TestDateTime(t *testing.T) { + assert := assert.New(t) + + ts := Date.Time(5, 6, 7, 8, time.UTC) + assert.Equal(05, ts.Hour()) + assert.Equal(06, ts.Minute()) + assert.Equal(07, ts.Second()) + assert.Equal(8, ts.Nanosecond()) + assert.Equal(time.UTC, ts.Location()) +} + +func TestDateDate(t *testing.T) { + assert := assert.New(t) + + ts := Date.Date(2015, 5, 6, time.UTC) + assert.Equal(2015, ts.Year()) + assert.Equal(5, ts.Month()) + assert.Equal(6, ts.Day()) + assert.Equal(time.UTC, ts.Location()) +} + +func TestDateOn(t *testing.T) { + assert := assert.New(t) + + ts := Date.On(Date.Time(5, 4, 3, 2, time.UTC), Date.Date(2016, 6, 7, Date.Eastern())) + assert.Equal(2016, ts.Year()) + assert.Equal(6, ts.Month()) + assert.Equal(7, ts.Day()) + assert.Equal(5, ts.Hour()) + assert.Equal(4, ts.Minute()) + assert.Equal(3, ts.Second()) + assert.Equal(2, ts.Nanosecond()) + assert.Equal(time.UTC, ts.Location()) + +} + +func TestDateNoonOn(t *testing.T) { + assert := assert.New(t) + noon := Date.NoonOn(time.Date(2016, 04, 03, 02, 01, 0, 0, time.UTC)) + + assert.Equal(2016, noon.Year()) + assert.Equal(4, noon.Month()) + assert.Equal(3, noon.Day()) + assert.Equal(12, noon.Hour()) + assert.Equal(0, noon.Minute()) + assert.Equal(time.UTC, noon.Location()) +} + func TestDateBefore(t *testing.T) { assert := assert.New(t) @@ -25,6 +73,17 @@ func TestDateBefore(t *testing.T) { assert.False(Date.Before(parse("2017-08-03"), parse("2016-07-01"))) } +func TestDateBeforeHandlesTimezones(t *testing.T) { + assert := assert.New(t) + + tuesdayUTC := time.Date(2016, 8, 02, 22, 00, 0, 0, time.UTC) + mondayUTC := time.Date(2016, 8, 01, 1, 00, 0, 0, time.UTC) + sundayEST := time.Date(2016, 7, 31, 22, 00, 0, 0, Date.Eastern()) + + assert.True(Date.Before(sundayEST, tuesdayUTC)) + assert.False(Date.Before(sundayEST, mondayUTC)) +} + func TestNextMarketOpen(t *testing.T) { assert := assert.New(t) @@ -44,6 +103,10 @@ func TestNextMarketOpen(t *testing.T) { assert.True(mondayOpen.Equal(Date.NextMarketOpen(afterFriday, NYSEOpen, Date.IsNYSEHoliday))) assert.True(mondayOpen.Equal(Date.NextMarketOpen(weekend, NYSEOpen, Date.IsNYSEHoliday))) + assert.Equal(Date.Eastern(), todayOpen.Location()) + assert.Equal(Date.Eastern(), tomorrowOpen.Location()) + assert.Equal(Date.Eastern(), mondayOpen.Location()) + testRegression := time.Date(2016, 07, 18, 16, 0, 0, 0, Date.Eastern()) shouldbe := time.Date(2016, 07, 19, 9, 30, 0, 0, Date.Eastern()) @@ -68,6 +131,10 @@ func TestNextMarketClose(t *testing.T) { assert.True(tomorrowClose.Equal(Date.NextMarketClose(afterClose, NYSEClose, Date.IsNYSEHoliday))) assert.True(mondayClose.Equal(Date.NextMarketClose(afterFriday, NYSEClose, Date.IsNYSEHoliday))) assert.True(mondayClose.Equal(Date.NextMarketClose(weekend, NYSEClose, Date.IsNYSEHoliday))) + + assert.Equal(Date.Eastern(), todayClose.Location()) + assert.Equal(Date.Eastern(), tomorrowClose.Location()) + assert.Equal(Date.Eastern(), mondayClose.Location()) } func TestCalculateMarketSecondsBetween(t *testing.T) { @@ -120,3 +187,38 @@ func TestDateNextHour(t *testing.T) { assert.Equal(12, next.Hour()) } + +func TestDateNextDayOfWeek(t *testing.T) { + assert := assert.New(t) + + weds := Date.Date(2016, 8, 10, time.UTC) + fri := Date.Date(2016, 8, 12, time.UTC) + sun := Date.Date(2016, 8, 14, time.UTC) + mon := Date.Date(2016, 8, 15, time.UTC) + weds2 := Date.Date(2016, 8, 17, time.UTC) + + nextFri := Date.NextDayOfWeek(weds, time.Friday) + nextSunday := Date.NextDayOfWeek(weds, time.Sunday) + nextMonday := Date.NextDayOfWeek(weds, time.Monday) + nextWeds := Date.NextDayOfWeek(weds, time.Wednesday) + + assert.Equal(fri.Year(), nextFri.Year()) + assert.Equal(fri.Month(), nextFri.Month()) + assert.Equal(fri.Day(), nextFri.Day()) + + assert.Equal(sun.Year(), nextSunday.Year()) + assert.Equal(sun.Month(), nextSunday.Month()) + assert.Equal(sun.Day(), nextSunday.Day()) + + assert.Equal(mon.Year(), nextMonday.Year()) + assert.Equal(mon.Month(), nextMonday.Month()) + assert.Equal(mon.Day(), nextMonday.Day()) + + assert.Equal(weds2.Year(), nextWeds.Year()) + assert.Equal(weds2.Month(), nextWeds.Month()) + assert.Equal(weds2.Day(), nextWeds.Day()) + + assert.Equal(time.UTC, nextFri.Location()) + assert.Equal(time.UTC, nextSunday.Location()) + assert.Equal(time.UTC, nextMonday.Location()) +} diff --git a/examples/market_hours/main.go b/examples/market_hours/main.go index e168dea..7b0bb36 100644 --- a/examples/market_hours/main.go +++ b/examples/market_hours/main.go @@ -2,14 +2,13 @@ package main import ( "net/http" - "time" "github.com/wcharczuk/go-chart" ) func drawChart(res http.ResponseWriter, req *http.Request) { - start := time.Date(2016, 07, 04, 12, 0, 0, 0, chart.Date.Eastern()) - end := time.Date(2016, 07, 06, 12, 0, 0, 0, chart.Date.Eastern()) + start := chart.Date.Date(2016, 6, 20, chart.Date.Eastern()) + end := chart.Date.Date(2016, 07, 21, chart.Date.Eastern()) xv := chart.Sequence.MarketHours(start, end, chart.NYSEOpen, chart.NYSEClose, chart.Date.IsNYSEHoliday) yv := chart.Sequence.RandomWithAverage(len(xv), 200, 10) diff --git a/market_hours_range.go b/market_hours_range.go index 19a29d6..3b7c570 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -21,6 +21,11 @@ type MarketHoursRange struct { Domain int } +// GetTimezone returns the timezone for the market hours range. +func (mhr MarketHoursRange) GetTimezone() *time.Location { + return mhr.GetMarketOpen().Location() +} + // IsZero returns if the range is setup or not. func (mhr MarketHoursRange) IsZero() bool { return mhr.Min.IsZero() && mhr.Max.IsZero() @@ -48,11 +53,13 @@ func (mhr MarketHoursRange) GetEffectiveMax() time.Time { // SetMin sets the min value. func (mhr *MarketHoursRange) SetMin(min float64) { mhr.Min = Float64ToTime(min) + mhr.Min = mhr.Min.In(mhr.GetTimezone()) } // SetMax sets the max value. func (mhr *MarketHoursRange) SetMax(max float64) { mhr.Max = Float64ToTime(max) + mhr.Max = mhr.Max.In(mhr.GetTimezone()) } // GetDelta gets the delta. @@ -99,18 +106,38 @@ func (mhr MarketHoursRange) GetMarketClose() time.Time { // GetTicks returns the ticks for the range. // This is to override the default continous ticks that would be generated for the range. func (mhr *MarketHoursRange) GetTicks(r Renderer, defaults Style, vf ValueFormatter) []Tick { - println("GetTicks() domain:", mhr.Domain) times := Sequence.MarketHours(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth := mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } + times = Sequence.MarketHourQuarters(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth = mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } - return mhr.makeTicks(vf, Sequence.MarketDayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider())) + + times = Sequence.MarketDayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + timesWidth = mhr.measureTimes(r, defaults, vf, times) + if timesWidth <= mhr.Domain { + return mhr.makeTicks(vf, times) + } + + times = Sequence.MarketDayAlternateCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + timesWidth = mhr.measureTimes(r, defaults, vf, times) + if timesWidth <= mhr.Domain { + return mhr.makeTicks(vf, times) + } + + times = Sequence.MarketDayMondayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + timesWidth = mhr.measureTimes(r, defaults, vf, times) + if timesWidth <= mhr.Domain { + return mhr.makeTicks(vf, times) + } + + return GenerateContinuousTicks(r, mhr, false, defaults, vf) + } func (mhr *MarketHoursRange) measureTimes(r Renderer, defaults Style, vf ValueFormatter, times []time.Time) int { @@ -135,13 +162,12 @@ func (mhr *MarketHoursRange) makeTicks(vf ValueFormatter, times []time.Time) []T Value: TimeToFloat64(t), Label: vf(t), } - println("make tick =>", vf(t)) } return ticks } func (mhr MarketHoursRange) String() string { - return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateMinuteFormat), mhr.Max.Format(DefaultDateMinuteFormat), mhr.Domain) + return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(time.RFC3339), mhr.Max.Format(time.RFC3339), mhr.Domain) } // Translate maps a given value into the ContinuousRange space. diff --git a/market_hours_range_test.go b/market_hours_range_test.go index 7c66589..14b20fb 100644 --- a/market_hours_range_test.go +++ b/market_hours_range_test.go @@ -43,18 +43,30 @@ func TestMarketHoursRangeTranslate(t *testing.T) { func TestMarketHoursRangeGetTicks(t *testing.T) { assert := assert.New(t) - r := &MarketHoursRange{ - Min: time.Date(2016, 07, 18, 9, 30, 0, 0, Date.Eastern()), - Max: time.Date(2016, 07, 22, 16, 00, 0, 0, Date.Eastern()), + r, err := PNG(1024, 1024) + assert.Nil(err) + + f, err := GetDefaultFont() + assert.Nil(err) + + defaults := Style{ + Font: f, + FontSize: 10, + FontColor: ColorBlack, + } + + ra := &MarketHoursRange{ + Min: Date.On(NYSEOpen, Date.Date(2016, 07, 18, Date.Eastern())), + Max: Date.On(NYSEClose, Date.Date(2016, 07, 22, Date.Eastern())), MarketOpen: NYSEOpen, MarketClose: NYSEClose, HolidayProvider: Date.IsNYSEHoliday, - Domain: 1000, + Domain: 1024, } - ticks := r.GetTicks(TimeValueFormatter) + ticks := ra.GetTicks(r, defaults, TimeValueFormatter) assert.NotEmpty(ticks) - assert.Len(ticks, 24) - assert.NotEqual(TimeToFloat64(r.Min), ticks[0].Value) + assert.Len(ticks, 5) + assert.NotEqual(TimeToFloat64(ra.Min), ticks[0].Value) assert.NotEmpty(ticks[0].Label) } diff --git a/sequence.go b/sequence.go index 461aa4d..3daa990 100644 --- a/sequence.go +++ b/sequence.go @@ -99,7 +99,7 @@ func (s sequence) MarketHourQuarters(from, to time.Time, marketOpen, marketClose if isValidTradingDay { todayOpen := Date.On(marketOpen, cursor) todayNoon := Date.NoonOn(cursor) - today2pm := Date.On(Date.ClockTime(2, 0, 0, 0, cursor.Location()), cursor) + today2pm := Date.On(Date.Time(14, 0, 0, 0, cursor.Location()), cursor) todayClose := Date.On(marketClose, cursor) times = append(times, todayOpen, todayNoon, today2pm, todayClose) } @@ -124,3 +124,36 @@ func (s sequence) MarketDayCloses(from, to time.Time, marketOpen, marketClose ti } return times } + +func (s sequence) MarketDayAlternateCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday HolidayProvider) []time.Time { + var times []time.Time + cursor := Date.On(marketOpen, from) + toClose := Date.On(marketClose, to) + for cursor.Before(toClose) || cursor.Equal(toClose) { + isValidTradingDay := !isHoliday(cursor) && Date.IsWeekDay(cursor.Weekday()) + if isValidTradingDay { + todayClose := Date.On(marketClose, cursor) + times = append(times, todayClose) + } + + cursor = cursor.AddDate(0, 0, 2) + } + return times +} + +func (s sequence) MarketDayMondayCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday HolidayProvider) []time.Time { + var times []time.Time + cursor := Date.On(marketClose, from) + toClose := Date.On(marketClose, to) + + for cursor.Equal(toClose) || cursor.Before(toClose) { + isValidTradingDay := !isHoliday(cursor) && Date.IsWeekDay(cursor.Weekday()) + if isValidTradingDay { + times = append(times, cursor) + } + println("advance to next monday", cursor.Format(DefaultDateFormat)) + cursor = Date.NextDayOfWeek(cursor, time.Monday) + println(cursor.Format(DefaultDateFormat)) + } + return times +} diff --git a/sequence_test.go b/sequence_test.go index b39b3e3..71f9cfb 100644 --- a/sequence_test.go +++ b/sequence_test.go @@ -22,5 +22,24 @@ func TestSequenceMarketHours(t *testing.T) { today := time.Date(2016, 07, 01, 12, 0, 0, 0, Date.Eastern()) mh := Sequence.MarketHours(today, today, NYSEOpen, NYSEClose, Date.IsNYSEHoliday) - assert.Len(mh, 7) + assert.Len(mh, 8) + assert.Equal(Date.Eastern(), mh[0].Location()) +} + +func TestSequenceMarketQuarters(t *testing.T) { + assert := assert.New(t) + today := time.Date(2016, 07, 01, 12, 0, 0, 0, Date.Eastern()) + mh := Sequence.MarketHourQuarters(today, today, NYSEOpen, NYSEClose, Date.IsNYSEHoliday) + assert.Len(mh, 4) + assert.Equal(9, mh[0].Hour()) + assert.Equal(30, mh[0].Minute()) + assert.Equal(Date.Eastern(), mh[0].Location()) + + assert.Equal(12, mh[1].Hour()) + assert.Equal(00, mh[1].Minute()) + assert.Equal(Date.Eastern(), mh[1].Location()) + + assert.Equal(14, mh[2].Hour()) + assert.Equal(00, mh[2].Minute()) + assert.Equal(Date.Eastern(), mh[2].Location()) } diff --git a/xaxis.go b/xaxis.go index 6c5cb58..7e7a6cf 100644 --- a/xaxis.go +++ b/xaxis.go @@ -72,7 +72,7 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic tp := xa.GetTickPosition() - var left, right, top, bottom = math.MaxInt32, 0, math.MaxInt32, 0 + var left, right, bottom = math.MaxInt32, 0, 0 for index, t := range ticks { v := t.Value tickStyle.GetTextOptions().WriteToRenderer(r) @@ -94,14 +94,13 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic break } - top = Math.MinInt(top, canvasBox.Bottom) left = Math.MinInt(left, ltx) right = Math.MaxInt(right, rtx) bottom = Math.MaxInt(bottom, ty) } return Box{ - Top: top, + Top: canvasBox.Bottom, Left: left, Right: right, Bottom: bottom,