{adamcoding}
Part III
18
Chapter 18

Time

The problem

Your session filter says 09:30 to 16:00. It works perfectly for four months, then quietly stops matching the market open, and your intraday results become nonsense.

Daylight saving. It happens twice a year, in both directions, and it has eaten more strategies than any bug in this book.

time.Time

go
import "time"

now := time.Now()
fmt.Println(now)               // 2026-08-19 14:32:01.123456 +0100 IST

fmt.Println(now.Year(), now.Month(), now.Day())
fmt.Println(now.Hour(), now.Minute())
fmt.Println(now.Weekday())

A time.Time holds an instant and a location. Two values can represent the same moment in different zones.

Go's strange, memorable format

Most languages use %Y-%m-%d. Go uses an example date:

go
t, err := time.Parse("2006-01-02 15:04:05", "2024-03-15 09:30:00")

The reference time is Mon Jan 2 15:04:05 MST 2006 - which is 1, 2, 3, 4, 5, 6, 7 in American ordering (month 1, day 2, hour 3pm, minute 4, second 5, year 06, zone offset -0700). Once you notice the counting, you don't forget it.

Common layouts:

go
time.RFC3339                        // 2024-03-15T09:30:00Z
"2006-01-02"                        // 2024-03-15
"2006-01-02 15:04:05"               // 2024-03-15 09:30:00
"02/01/2006"                        // 15/03/2024 - day first

That last one matters: 03/04/2024 is ambiguous between March 4th and April 3rd, and CSV files from different sources genuinely disagree. Always check which convention your data uses, and prefer sources that use RFC3339.

Durations

go
d := 90 * time.Minute
fmt.Println(d)                      // 1h30m0s

later := now.Add(24 * time.Hour)
elapsed := later.Sub(now)
fmt.Println(elapsed.Hours())        // 24

A Duration is an int64 count of nanoseconds. 24 * time.Hour reads as English and compiles to a number.

Comparing times

go
if barTime.Before(sessionEnd) { }
if barTime.After(sessionStart) { }
if barTime.Equal(other) { }

Use `Equal`, not `==`. == compares the internal representation - including the location pointer and a monotonic clock reading - so two values representing the same instant can be unequal:

go
a := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
london, _ := time.LoadLocation("Europe/London")
b := a.In(london)

fmt.Println(a == b)          // false
fmt.Println(a.Equal(b))      // true - same instant

See It Work: the day that isn't 24 hours

This is the bug, made visible:

go
package main

import (
	"fmt"
	"time"
)

func main() {
	dublin, err := time.LoadLocation("Europe/Dublin")
	if err != nil {
		panic(err)
	}

	// The day before clocks go forward in spring 2024.
	start := time.Date(2024, 3, 30, 12, 0, 0, 0, dublin)

	byDuration := start.Add(24 * time.Hour)
	byCalendar := start.AddDate(0, 0, 1)

	fmt.Println("start:            ", start)
	fmt.Println("+24 hours:        ", byDuration)
	fmt.Println("+1 calendar day:  ", byCalendar)
	fmt.Println("difference:       ", byCalendar.Sub(byDuration))
}
start:             2024-03-30 12:00:00 +0000 GMT
+24 hours:         2024-03-31 13:00:00 +0100 IST
+1 calendar day:   2024-03-31 12:00:00 +0100 IST
difference:        -1h0m0s

Adding 24 hours and adding one day give different answers. On that date the clocks jumped forward, so the calendar day was only 23 hours of elapsed time.

Add moves by elapsed time. AddDate moves by calendar. Both are correct; they answer different questions. "The same time tomorrow" is AddDate. "24 hours of market later" is Add.

Now change the date to October 26th 2024 and run it again - the autumn transition goes the other way.

Run this once and the rule that follows will stick.

The rule

Store and compute in UTC. Convert to local time only for display, and only at the very edge of the program.

UTC has no daylight saving and no ambiguity. Every timestamp entering your system converts immediately - that's the ts.UTC() in Chapter 16's parser - and everything downstream is safe.

When you genuinely need exchange-local logic (a session filter really is defined in New York time), convert explicitly at that point:

go
func inSession(t time.Time, loc *time.Location, startHour, endHour int) bool {
	local := t.In(loc)
	h := local.Hour()
	return h >= startHour && h < endHour
}

Using t.Hour() on a UTC timestamp and comparing it to 9 and 16 works for part of the year and silently fails for the rest. That is the bug this chapter exists to prevent.

Unix timestamps

APIs send integers. Which unit?

go
fmt.Println(t.Unix())         // 1710494400        - seconds
fmt.Println(t.UnixMilli())    // 1710494400000     - milliseconds
fmt.Println(t.UnixNano())     // 1710494400000000000

Going the other way:

go
t := time.Unix(1710494400, 0).UTC()
t := time.UnixMilli(1710494400000).UTC()

Getting the unit wrong is a classic, and it fails loudly in a useful way: seconds read as milliseconds put you in 1970, milliseconds read as seconds put you in the year 56,000. A quick sanity check - is this date plausible? - catches it instantly.

Truncating to bar boundaries

go
t := time.Date(2024, 3, 15, 14, 37, 42, 0, time.UTC)

fmt.Println(t.Truncate(time.Hour))          // 14:00:00
fmt.Println(t.Truncate(15 * time.Minute))   // 14:30:00

Exactly what you need to assign a tick to its bar. Careful: Truncate works on absolute time since the zero instant, so it behaves as expected for UTC and can surprise you in a zone with a non-hour offset. One more reason for the UTC rule.

Measuring elapsed time

go
start := time.Now()
result := RunBacktest(strategy, bars)
fmt.Printf("took %v\n", time.Since(start))

time.Since(start) is shorthand for time.Now().Sub(start), and it uses the monotonic clock - so it stays correct even if the system clock is adjusted mid-measurement. For benchmarking use the tools from the interlude; for "how long did that take" in a running program, this is right.

Exercises

18.1 Run the DST demo for both the March and October transitions in your own timezone. Record the outputs.

18.2 Parse timestamps in four formats - RFC3339, 2006-01-02, 02/01/2006 and a Unix millisecond integer - into a common UTC time.Time.

18.3 Write InSession(t time.Time, loc *time.Location, start, end string) (bool, error) where start and end are "09:30" style. Test it on a DST transition day.

18.4 Write GroupByDay(bars []Bar, loc *time.Location) map[string][]Bar keyed by local date. Confirm the DST day has the right number of hourly bars - and work out what "right" is.

18.5 Show that a == b is false while a.Equal(b) is true for the same instant in two zones. Then find a case where == is true.

18.6 Take the generated prices.csv and detect gaps: any consecutive pair more than one day apart. Then account for weekends and confirm your gap detector doesn't flag every Monday.

18.7 Harder. Write ResampleToDaily(bars []Bar, loc *time.Location) []Bar aggregating hourly bars into daily ones using exchange-local day boundaries. Combine with Chapter 10's Aggregate. Verify against the DST days specifically - that's where it'll be wrong if it's wrong.


Solutions

18.4 A 24-hour instrument has 23 hourly bars on the spring transition day and 25 on the autumn one. If your code assumes 24, it's already broken and you haven't noticed.

18.5 == is true when both values have identical wall clock, identical location pointer, and identical monotonic reading - in practice, when one was copied from the other. Any time you've parsed, converted, or round-tripped through a string, use Equal.

18.6 Detecting gaps by "more than 24 hours apart" flags every weekend. Either check for gaps greater than 72 hours, or check the weekday and expect the Friday-to-Monday jump. This is a real data-validation task from the other book's Chapter 6, and it's fiddlier than it sounds.

18.7 The trap is grouping by t.UTC().Format("2006-01-02") instead of t.In(loc).Format(...). For an exchange whose day doesn't start at UTC midnight, that splits days in the wrong place - and the resulting daily bars are subtly wrong in a way that's very hard to spot on a chart.