{adamcoding}
Part II
10
Chapter 10

Shapes of Your Own

The problem

Chapter 7 ended with this, and I called it horrible:

go
bar := []float64{100.0, 102.5, 99.5, 101.0}
fmt.Println(bar[1])

Is bar[1] the high or the open? You have to remember. Get the order wrong and nothing complains - you just compute nonsense with great confidence.

A struct gives the pieces names.

Defining one

go
type Bar struct {
	Timestamp time.Time
	Open      float64
	High      float64
	Low       float64
	Close     float64
	Volume    int64
}

Now:

go
bar := Bar{
	Timestamp: time.Now(),
	Open:      100.0,
	High:      102.5,
	Low:       99.5,
	Close:     101.0,
	Volume:    1_500_000,
}

fmt.Println(bar.High)          // 102.5
fmt.Println(bar.Close - bar.Open)

bar.High is unambiguous, and if you typo it as bar.Higj the compiler stops you. That's the whole win, and it's a big one.

You can write the fields positionally, Bar{t, 100.0, 102.5, ...}, and you shouldn't. Add a field later and every positional literal silently means something different.

Capital letters matter

Open not open. In Go, a name starting with a capital letter is visible outside its package; a lowercase name is private to it. No public or private keywords - the case of the first letter is the access control.

This applies to everything: types, functions, struct fields, methods.

Zero values

Declare a struct without initialising it and every field gets its type's zero value:

go
var b Bar
fmt.Printf("%+v\n", b)
// {Timestamp:0001-01-01 00:00:00 +0000 UTC Open:0 High:0 Low:0 Close:0 Volume:0}

Numbers are 0, strings are "", booleans are false, pointers and slices and maps are nil. There is no "uninitialised garbage" in Go, and no undefined. A struct you haven't filled in is always in a defined state.

That's useful and occasionally dangerous: a Bar with all zeros is a valid-looking bar with a price of zero. Chapter 6's validation exists for exactly this.

Methods

A function attached to a type:

go
func (b Bar) Range() float64 {
	return b.High - b.Low
}

func (b Bar) IsUp() bool {
	return b.Close > b.Open
}

func (b Bar) Body() float64 {
	return math.Abs(b.Close - b.Open)
}

The (b Bar) before the name is the receiver - the value the method was called on. Use them like this:

go
fmt.Printf("%.2f\n", bar.Range())
if bar.IsUp() {
	fmt.Println("green bar")
}

A method is really just a function whose first argument moved in front of the name. bar.Range() and Range(bar) do the same work; the method form reads better and lets Go group behaviour with data.

Structs inside structs

go
type Trade struct {
	Symbol   string
	Side     int          // +1 long, -1 short
	Entry    Bar
	Exit     Bar
	Quantity float64
	RiskPerUnit float64
}

func (t Trade) PnL() float64 {
	return float64(t.Side) * (t.Exit.Close - t.Entry.Close) * t.Quantity
}

func (t Trade) RMultiple() float64 {
	risk := t.RiskPerUnit * t.Quantity
	if risk == 0 {
		return 0
	}
	return t.PnL() / risk
}

t.Entry.Close reaches through two levels. This is how real programs get organised: small structs composing into bigger ones, each with methods that only know about their own level.

Constructors

Go has no new Bar(...) syntax. The convention is a function starting with New:

go
func NewTrade(symbol string, side int, entry Bar, qty, riskPerUnit float64) (Trade, error) {
	if side != 1 && side != -1 {
		return Trade{}, fmt.Errorf("side must be +1 or -1, got %d", side)
	}
	if qty <= 0 {
		return Trade{}, fmt.Errorf("quantity must be positive, got %f", qty)
	}
	return Trade{
		Symbol: symbol, Side: side, Entry: entry,
		Quantity: qty, RiskPerUnit: riskPerUnit,
	}, nil
}

This is where validation lives, so an invalid Trade can't come into existence in the first place.

See It Work: what your struct really costs

Structs occupy memory, and how much may surprise you. unsafe.Sizeof tells you - the package is named to discourage casual use, but reading sizes is harmless:

go
package main

import (
	"fmt"
	"unsafe"
)

type Wasteful struct {
	IsActive bool    // 1 byte
	Price    float64 // 8 bytes
	IsFilled bool    // 1 byte
}

type Tidy struct {
	Price    float64 // 8 bytes
	IsActive bool    // 1 byte
	IsFilled bool    // 1 byte
}

func main() {
	fmt.Println("Wasteful:", unsafe.Sizeof(Wasteful{}))
	fmt.Println("Tidy:    ", unsafe.Sizeof(Tidy{}))
}
Wasteful: 24
Tidy:     16

Same three fields. Same types. Eight bytes different.

The reason is alignment: the machine wants an 8-byte value to sit at an address divisible by 8, so the compiler inserts invisible padding to make that happen.

   Wasteful                          Tidy
   ┌──┬───────┬────────┐             ┌────────┬──┬──┬──────┐
   │b │ pad×7 │ float64│             │ float64│b │b │pad×6 │
   ├──┴───────┼────────┤             └────────┴──┴──┴──────┘
   │b │ pad×7 │                        8 bytes  1  1   6    = 16
   └──┴───────┘
     1 + 7 + 8 + 1 + 7 = 24

Grouping the small fields together lets them share one gap instead of each getting their own.

At three fields this is trivia. At 10 million Bar values in memory it's the difference between 240 MB and 160 MB. Order struct fields from largest type to smallest and you get the saving for free.

Try it on your own Bar and Trade. Reorder the fields and watch the number move.

See It Work: copies are not free

Every time you pass a struct to a function, Go copies it:

go
type Small struct{ A, B float64 }

type Large struct{ Data [128]float64 }     // 1KB

func takesSmall(s Small) float64 { return s.A }
func takesLarge(l Large) float64 { return l.Data[0] }
func takesLargePtr(l *Large) float64 { return l.Data[0] }

func BenchmarkSmall(b *testing.B) {
	s := Small{1, 2}
	for i := 0; i < b.N; i++ { _ = takesSmall(s) }
}

func BenchmarkLarge(b *testing.B) {
	l := Large{}
	for i := 0; i < b.N; i++ { _ = takesLarge(l) }
}

func BenchmarkLargePtr(b *testing.B) {
	l := Large{}
	for i := 0; i < b.N; i++ { _ = takesLargePtr(&l) }
}

The small struct and the pointer version cost about the same. The large-by-value version is markedly slower, because each call copies a kilobyte for no reason.

That &l is a pointer, and it's the whole of Chapter 11.

Comparing structs

If every field is comparable, == works and compares field by field:

go
a := Bar{Open: 100, Close: 101}
b := Bar{Open: 100, Close: 101}
fmt.Println(a == b)        // true

But structs containing slices or maps are not comparable, and == on them won't compile. And remember Chapter 2: comparing floats for exact equality is usually wrong anyway.

Exercises

10.1 Define Bar and write methods Range(), Body(), UpperWick(), LowerWick() and IsDoji(threshold float64) - a doji being a bar whose body is small relative to its range.

10.2 Write Validate() error on Bar, reusing the multi-problem approach from exercise 6.5.

10.3 Define Position with symbol, quantity, average entry price, and methods MarketValue(price float64), UnrealisedPnL(price float64) and IsLong().

10.4 Run the unsafe.Sizeof experiment on your Bar and Trade. Reorder fields to minimise size and record the before and after. How much would you save on a million bars?

10.5 Write Summary holding trade-count, expectancy, win rate and max drawdown, plus String() string that formats it nicely. (Naming the method String makes fmt.Println use it automatically - find out why.)

10.6 Harder. Write Aggregate(bars []Bar, n int) []Bar combining every n consecutive bars into one. The combined open is the first open, close is the last close, high is the highest high, low is the lowest low, volume is the sum. This is how a daily chart is built from hourly data.


Solutions

10.1

go
func (b Bar) Range() float64 { return b.High - b.Low }
func (b Bar) Body() float64  { return math.Abs(b.Close - b.Open) }

func (b Bar) UpperWick() float64 {
	return b.High - math.Max(b.Open, b.Close)
}
func (b Bar) LowerWick() float64 {
	return math.Min(b.Open, b.Close) - b.Low
}
func (b Bar) IsDoji(threshold float64) bool {
	r := b.Range()
	if r == 0 {
		return true
	}
	return b.Body()/r < threshold
}

The r == 0 guard matters - a bar where every price is identical is real, and dividing by its range isn't.

10.4 A naively ordered Bar with time.Time, five numbers and a bool typically wastes 8 bytes. Over a million bars that's 8 MB for nothing.

10.5 Implementing String() string satisfies the fmt.Stringer interface, and fmt checks for it. That's Chapter 14 arriving early.

10.6

go
func Aggregate(bars []Bar, n int) []Bar {
	if n < 1 {
		return nil
	}
	var out []Bar
	for i := 0; i < len(bars); i += n {
		end := i + n
		if end > len(bars) {
			end = len(bars)
		}
		chunk := bars[i:end]

		agg := Bar{
			Timestamp: chunk[0].Timestamp,
			Open:      chunk[0].Open,
			High:      chunk[0].High,
			Low:       chunk[0].Low,
			Close:     chunk[len(chunk)-1].Close,
		}
		for _, b := range chunk {
			if b.High > agg.High { agg.High = b.High }
			if b.Low < agg.Low   { agg.Low = b.Low }
			agg.Volume += b.Volume
		}
		out = append(out, agg)
	}
	return out
}

Initialise high and low from the first bar, not from 0 - starting Low at 0 gives you a low of 0 on every aggregated bar.