Skip to content
Trading charts

Trading charts

This page builds the kind of chart a trading app shows: candlesticks with moving averages, a volume pane underneath that zooms and hovers together with the prices, a crosshair with value tags on the axes, and tools the user draws with, such as trend lines, horizontal levels and Fibonacci retracements. It also shows how to create drawings from code and how to save and restore them.

A candlestick chart of 120 trading days with an orange 10-day and a blue 30-day moving average, a volume pane below it, a toolbar of drawing tools above, and a crosshair at Jun 18 with value tags on both axes of both panes A candlestick chart of 120 trading days with an orange 10-day and a blue 30-day moving average, a volume pane below it, a toolbar of drawing tools above, and a crosshair at Jun 18 with value tags on both axes of both panes

The sections below show the program in parts; the whole program is at the end of the page.

Price and volume panes

A chart.ChartGroup stacks several charts on top of each other and keeps them in step. Each Pane holds a chart and a relative Height: with heights 3 and 1, the price pane takes three quarters of the space.

// crosshair builds the hover behaviour both panes share: crosshair lines
// with value tags on the axes, and a text box only when the user clicks.
crosshair := func(yTag string, text func(i int) string) *component.TooltipConfig {
	return &component.TooltipConfig{
		Trigger:       component.TooltipTriggerAxis,
		Crosshair:     component.CrosshairBoth,
		CrosshairOnly: true, // no text box while hovering; click to pin one
		ShowAxisTags:  true, // value tags where the crosshair meets the axes
		AxisTagFormatter: func(x, y float64) (string, string) {
			i := int(math.Round(x))
			if i < 0 || i >= len(dates) {
				return "", ""
			}
			return dates[i], fmt.Sprintf(yTag, y)
		},
		Formatter: func(hits []interact.HitResult) string {
			return text(hits[0].DataIndex)
		},
	}
}
priceTip := crosshair("%.2f", func(i int) string {
	c := candles[i]
	return fmt.Sprintf("%s\nO %.2f  H %.2f\nL %.2f  C %.2f", dates[i], c.Open, c.High, c.Low, c.Close)
})
volTip := crosshair("%.1fM", func(i int) string {
	return fmt.Sprintf("%s\nVolume %.1fM", dates[i], volume[i])
})
grid := func(top, bottom float32) *chart.Grid {
	// Same left and right margins in every pane, so the plots line up.
	return &chart.Grid{Left: 64, Right: 24, Top: top, Bottom: bottom}
}

// Axis tooltips only find line-like series, not candles or bars, so each
// pane carries an invisible line through the values to hover on.
hidden := series.LineStyle{Color: color.Transparent}
closes := make([]float64, len(candles))
for i, c := range candles {
	closes[i] = c.Close
}

price := chart.New(chart.Option{
	Title:    &chart.Title{Text: "ACME", SubText: "Daily, 10- and 30-day moving averages"},
	Tooltip:  priceTip,
	DataZoom: &interact.ZoomConfig{Enabled: true, MinSpan: 0.1, MaxSpan: 1, ZoomSensitivity: 0.08},
	Grid:     grid(12, 30),
	Color: []color.Color{
		color.NRGBA{R: 120, G: 120, B: 120, A: 255}, // legend swatch for the candles
		color.Transparent,
		color.NRGBA{R: 255, G: 167, B: 38, A: 255}, // MA10
		color.NRGBA{R: 66, G: 165, B: 245, A: 255}, // MA30
	},
	XAxis: []axis.Config{{Type: axis.TypeCategory, Data: make([]string, len(dates))}},
	YAxis: []axis.Config{{Type: axis.TypeValue, SplitCount: 5}},
	Series: []series.Series{
		&series.CandlestickSeries{SeriesName: "ACME", Data: candles, BarWidth: 0.7},
		&series.LineSeries{Data: closes, LineStyle: hidden}, // no name: not in the legend
		&series.LineSeries{SeriesName: "MA10", Data: movingAverage(candles, 10)},
		&series.LineSeries{SeriesName: "MA30", Data: movingAverage(candles, 30)},
	},
})

vol := chart.New(chart.Option{
	Tooltip: volTip,
	// Enabled: false gives the pane a zoom window that follows the group,
	// without its own zoom buttons.
	DataZoom: &interact.ZoomConfig{Enabled: false, MinSpan: 0.1, MaxSpan: 1},
	Grid:     grid(6, 26),
	XAxis:    []axis.Config{{Type: axis.TypeCategory, Data: sparse}},
	YAxis:    []axis.Config{{Type: axis.TypeValue, SplitCount: 2}},
	Series: []series.Series{
		&series.BarSeries{Data: volume, BarWidth: 0.7},
		&series.LineSeries{Data: volume, LineStyle: hidden},
	},
})

group := chart.NewChartGroup(
	chart.Pane{Chart: price, Height: 3},
	chart.Pane{Chart: vol, Height: 1},
)

The group does two things:

  • Zoom and pan. When the user zooms or pans one pane, with the wheel, a drag or the zoom buttons, the group sets the same zoom window on the other panes. A pane only follows if it has a DataZoom. Give it one with Enabled: false, as the volume pane does: it then follows the group but has no zoom buttons and ignores the wheel.
  • Hover. When the pointer moves over one pane, the other panes show their crosshair at the same X position.

The panes line up only if you make them line up. Give every pane the same number of categories and the same Grid.Left and Grid.Right margins, so the plots have the same width and a category sits at the same X in each pane.

SetSyncEnabled(false) switches the syncing off. The group syncs only zoom changes the user makes: when you call SetZoomWindow from code, call it on every pane.

Moving averages

A moving average is an ordinary LineSeries on the same axes as the candles. Leave the first values, where the average is not defined yet, as NaN: the line starts where the data does.

// movingAverage returns the simple moving average of the closes over n days.
// The first n-1 values are NaN, which a line series leaves undrawn.
func movingAverage(candles []series.CandlestickData, n int) []float64 {
	out := make([]float64, len(candles))
	sum := 0.0
	for i, c := range candles {
		sum += c.Close
		if i >= n {
			sum -= candles[i-n].Close
		}
		if i < n-1 {
			out[i] = math.NaN()
		} else {
			out[i] = sum / float64(n)
		}
	}
	return out
}

Option.Color gives the averages their colours. The candles use their own UpColor and DownColor (green and red by default), so the palette entry at their index only colours their legend swatch.

Crosshair and axis tags

The tooltip settings in the example are the ones that suit a price chart:

  • Trigger: TooltipTriggerAxis finds the data at the pointer’s X position.
  • Crosshair: CrosshairBoth draws a vertical and a horizontal line through it.
  • ShowAxisTags puts a tag with the X and Y values where the lines meet the axes, and AxisTagFormatter writes those values. Here it turns the category index into a date.
  • CrosshairOnly hides the text box while hovering. A click pins the box, filled in by Formatter; a second click, or 15 seconds (PinDuration), removes it.
In v1.0.0 an axis tooltip only finds line and scatter series. It never finds candlestick or bar series, so a pane that holds only candles or bars shows no crosshair at all, and the group cannot show one in it either. The example works around this by adding a line series with a transparent colour through the closing prices, and another through the volumes. An unnamed series stays out of the legend.

Zooming

With DataZoom enabled, the wheel zooms around the pointer, a drag pans once you are zoomed in, and the +, − and Reset buttons sit at the top right of the pane. Zoom has the details. Three things matter for price charts in v1.0.0:

  • The Y axis keeps the range of all the data. Zooming into a quiet week does not stretch it to fill the pane.
  • Candles and bars keep the width they have when all the data is shown, so they look thin when you zoom in far.
  • A category axis writes a label under every category. With 120 days that is unreadable, so the example passes a label for every tenth day and empty strings for the rest.

To add candles as they arrive, see Live data. In a group, append to every pane, including the hidden lines, so the panes keep the same number of points.

Drawing tools

The user draws on a chart after you switch a tool on with SetDrawingTool. There is no built-in toolbar: you make one from your own buttons.

// setTooltip swaps the price pane's tooltip, keeping the rest of its option.
setTooltip := func(cfg *component.TooltipConfig) {
	opt := price.Option()
	opt.Tooltip = cfg
	price.SetOption(opt)
}
noTooltip := &component.TooltipConfig{Trigger: component.TooltipTriggerNone}

// Crosshair: plain viewing. Select: click a drawing to select it, drag it
// to move it, press Delete to remove it. A click on a chart that shows a
// tooltip pins the tooltip instead of selecting, so Select turns it off.
crosshairMode := func() {
	price.SetDrawingTool(interact.DrawingToolNone)
	setTooltip(priceTip)
	status.SetText("Crosshair")
}
selectMode := func() {
	price.SetDrawingTool(interact.DrawingToolNone)
	setTooltip(noTooltip)
	status.SetText("Select: click a drawing, drag to move, Delete to remove")
}
bar := container.NewHBox(
	widget.NewButton("Crosshair", crosshairMode),
	widget.NewButton("Select", selectMode),
)
tools := []struct {
	label string
	tool  interact.DrawingToolMode
}{
	{"H-line", interact.DrawingToolHLine},
	{"V-line", interact.DrawingToolVLine},
	{"Trend", interact.DrawingToolTrendLine},
	{"Ray", interact.DrawingToolRay},
	{"Rect", interact.DrawingToolRectangle},
	{"Channel", interact.DrawingToolChannel},
	{"Fib", interact.DrawingToolFibRetracement},
	{"Text", interact.DrawingToolTextLabel},
	{"Measure", interact.DrawingToolMeasure},
}
for _, t := range tools {
	bar.Add(widget.NewButton(t.label, func() {
		setTooltip(priceTip)
		price.SetDrawingTool(t.tool)
		status.SetText("Tool: " + t.label)
	}))
}
bar.Add(widget.NewButton("Clear", price.ClearDrawings))

These are all the tools in v1.0.0, the interact.DrawingToolMode values:

ToolHow the user draws itStored as
DrawingToolHLineOne click; a horizontal line across the plot at that priceHLineDrawing
DrawingToolVLineOne click; a vertical line at that point in timeVLineDrawing
DrawingToolTrendLineTwo clicks; a segment between themTrendLineDrawing
DrawingToolRayTwo clicks; a line from the first through the second, to the edge of the plotRayDrawing
DrawingToolRectangleTwo clicks on opposite corners; a shaded boxRectangleDrawing
DrawingToolChannelThree clicks: two for a line, a third for the parallel lineChannelDrawing
DrawingToolFibRetracementTwo clicks, at the high and the low; dashed lines at 0, 23.6, 38.2, 50, 61.8, 78.6 and 100 % with their pricesFibRetracementDrawing
DrawingToolTextLabelOne click, then type; Enter or another click finishesTextLabelDrawing
DrawingToolMeasureDrag; shows the change in X, in price and in percent while you dragnothing: it disappears when you let go
DrawingToolNoneSwitches drawing off

While a two- or three-click drawing is in progress, a preview follows the pointer. A new drawing takes its colour from a fixed set of six, picked by how many drawings the chart already has.

A tool stays active after a drawing is finished, so the user can draw several in a row. The example switches back to selecting after each one.

Selecting, moving and deleting

With no tool active, a click within 10 pixels of a drawing selects it: it is drawn thicker, with square handles at its points. Dragging a selected drawing moves it. Delete or Backspace removes it, and Escape deselects it, or cancels a drawing that is still in progress. The chart takes keyboard focus when it is clicked, so these keys work right after a click.

A click on a chart that is showing a tooltip pins the tooltip instead of selecting a drawing. With an axis tooltip, which is showing almost everywhere over the plot, the user cannot select drawings at all. The example has a Select mode that sets TooltipTriggerNone while the user edits drawings, and puts the crosshair back for the drawing tools. Clicks with a drawing tool active are not affected.

Events

price.Events.On(interact.EventDrawingCreated, func(evt interface{}) {
	e := evt.(interact.DrawingEvent)
	// Tools stay active after a drawing. The new drawing is selected,
	// so switch to Select mode to let the user move or delete it.
	selectMode()
	status.SetText(fmt.Sprintf("Created %s at %v", e.DrawingID, e.Drawing.DataPoints()))
})
price.Events.On(interact.EventDrawingSelected, func(evt interface{}) {
	status.SetText(fmt.Sprintf("Selected %s", evt.(interact.DrawingEvent).DrawingID))
})
price.Events.On(interact.EventDrawingDeleted, func(evt interface{}) {
	status.SetText(fmt.Sprintf("Deleted %s", evt.(interact.DrawingEvent).DrawingID))
})

Each handler receives an interact.DrawingEvent with the DrawingID and, for EventDrawingCreated, the Drawing itself:

  • EventDrawingCreated fires when the user finishes a drawing. It arrives on the UI thread, so the handler can update widgets.
  • EventDrawingSelected fires when the user clicks a drawing.
  • EventDrawingDeleted fires when the user presses Delete and when you call RemoveDrawing.

Nothing fires when a drawing is moved, when you call AddDrawing, or when ClearDrawings removes everything. To keep a saved copy current, save again when the user is done, for example when the window closes.

Drawings from code

AddDrawing puts a drawing on the chart without the mouse, for example to mark levels your app computes. X is the category index, where 0 is the first candle, and Y is the price.

A candlestick chart with drawings added from code: a red resistance line, an orange trend line along the highs, a blue ray along the lows, a blue box around a trading range, red dashed Fibonacci levels with their percentages, and an orange text label reading Lower high A candlestick chart with drawings added from code: a red resistance line, an orange trend line along the highs, a blue ray along the lows, a blue box around a trading range, red dashed Fibonacci levels with their percentages, and an orange text label reading Lower high
// X is the category index (0 is the first candle), Y is the price.
high1, high2 := extreme(data, 0, 40, true), extreme(data, 70, 100, true)
low1, low2 := extreme(data, 40, 70, false), extreme(data, 100, 120, false)
orange := color.NRGBA{R: 255, G: 152, B: 0, A: 230}
blue := color.NRGBA{R: 66, G: 165, B: 245, A: 230}

c.AddDrawing(&interact.HLineDrawing{
	DrawingBase: interact.DrawingBase{ID_: "resistance", Label_: "Resistance"},
	Y:           high1.Y,
})
c.AddDrawing(&interact.TrendLineDrawing{
	DrawingBase: interact.DrawingBase{ID_: "highs", Color_: orange},
	Start:       high1,
	End:         high2,
})
c.AddDrawing(&interact.RayDrawing{
	DrawingBase: interact.DrawingBase{ID_: "lows", Color_: blue},
	Start:       low1,
	Direction:   low2, // the ray runs from Start through this point
})
c.AddDrawing(&interact.RectangleDrawing{
	DrawingBase: interact.DrawingBase{ID_: "base", Color_: blue},
	Corner1:     interact.DataPoint{X: 45, Y: extreme(data, 45, 65, true).Y},
	Corner2:     interact.DataPoint{X: 65, Y: extreme(data, 45, 65, false).Y},
})
c.AddDrawing(&interact.FibRetracementDrawing{
	DrawingBase: interact.DrawingBase{ID_: "fib"},
	HighY:       high2.Y,
	LowY:        low2.Y,
	X:           high2.X,
})
c.AddDrawing(&interact.TextLabelDrawing{
	DrawingBase: interact.DrawingBase{ID_: "note", Color_: orange},
	Pos:         interact.DataPoint{X: high2.X - 6, Y: high2.Y + 1.5},
	Text:        "Lower high",
})

Every drawing embeds a DrawingBase with an ID_, a Color_ and a Label_. A nil colour uses the default overlay colour of the theme. The label is written next to the drawing’s first point. The rest of the fields depend on the type:

TypeFields
HLineDrawingY
VLineDrawingX
TrendLineDrawingStart, End
RayDrawingStart, and Direction, a second point the ray passes through
RectangleDrawingCorner1, Corner2
ChannelDrawingLine1Start, Line1End, and OffsetY, the price distance to the parallel line
FibRetracementDrawingHighY, LowY, and X
TextLabelDrawingPos, Text

The other methods on the chart are RemoveDrawing(id), ClearDrawings(), Drawings(), SelectDrawing(id), SelectedDrawing() and DrawingTool().

Drawings are measured against the first Y axis. A text label always has a dark background, in both themes.

Saving and restoring drawings

The chart does not save drawings for you. Every drawing can describe itself, though: Type() says which kind it is, and DataPoints() returns its points in data coordinates. That is enough to write it out and read it back.

// saved is one drawing in a form that encoding/json can write and read.
type saved struct {
	Tool   interact.DrawingToolMode
	Points []interact.DataPoint
	Label  string      `json:",omitempty"`
	Text   string      `json:",omitempty"` // text labels only
	Color  *color.NRGBA `json:",omitempty"`
}

func save(drawings []interact.Drawing) ([]byte, error) {
	out := make([]saved, 0, len(drawings))
	for _, d := range drawings {
		s := saved{Tool: d.Type(), Points: d.DataPoints(), Label: d.DrawingLabel()}
		if t, ok := d.(*interact.TextLabelDrawing); ok {
			s.Text = t.Text
		}
		if c := d.DrawingColor(); c != nil {
			n := color.NRGBAModel.Convert(c).(color.NRGBA)
			s.Color = &n
		}
		out = append(out, s)
	}
	return json.Marshal(out)
}

To restore, make an empty drawing of the saved type, then hand it the points with SetDataPoints, which reads them in the same order DataPoints wrote them:

func restore(data []byte) ([]interact.Drawing, error) {
	var in []saved
	if err := json.Unmarshal(data, &in); err != nil {
		return nil, err
	}
	var out []interact.Drawing
	for i, s := range in {
		// Use IDs of your own: the chart numbers the drawings the user makes
		// "drawing-1", "drawing-2"... and does not skip IDs already in use.
		base := interact.DrawingBase{ID_: interact.DrawingID(fmt.Sprintf("saved-%d", i+1)), Label_: s.Label}
		if s.Color != nil {
			base.Color_ = *s.Color
		}
		var d interact.Drawing
		switch s.Tool {
		case interact.DrawingToolHLine:
			d = &interact.HLineDrawing{DrawingBase: base}
		case interact.DrawingToolVLine:
			d = &interact.VLineDrawing{DrawingBase: base}
		case interact.DrawingToolTrendLine:
			d = &interact.TrendLineDrawing{DrawingBase: base}
		case interact.DrawingToolRay:
			d = &interact.RayDrawing{DrawingBase: base}
		case interact.DrawingToolRectangle:
			d = &interact.RectangleDrawing{DrawingBase: base}
		case interact.DrawingToolChannel:
			d = &interact.ChannelDrawing{DrawingBase: base}
		case interact.DrawingToolFibRetracement:
			d = &interact.FibRetracementDrawing{DrawingBase: base}
		case interact.DrawingToolTextLabel:
			d = &interact.TextLabelDrawing{DrawingBase: base, Text: s.Text}
		default:
			continue
		}
		d.SetDataPoints(s.Points)
		out = append(out, d)
	}
	return out, nil
}
saveBtn := widget.NewButton("Save", func() {
	b, err := save(c.Drawings())
	if err != nil {
		status.SetText(err.Error())
		return
	}
	stored = b // a real app writes this to a file or to preferences
	status.SetText(fmt.Sprintf("Saved %d drawings, %d bytes of JSON", len(c.Drawings()), len(b)))
})
clearBtn := widget.NewButton("Clear", func() {
	c.ClearDrawings()
	status.SetText("Cleared")
})
restoreBtn := widget.NewButton("Restore", func() {
	ds, err := restore(stored)
	if err != nil {
		status.SetText(err.Error())
		return
	}
	c.ClearDrawings()
	for _, d := range ds {
		c.AddDrawing(d)
	}
	status.SetText(fmt.Sprintf("Restored %d drawings", len(ds)))
})

Things to know when you save drawings:

  • Choose your own IDs. The chart names the drawings the user makes drawing-1, drawing-2 and so on, counting from 1 for each chart, and does not look at IDs already in use. A restored drawing called drawing-1 would share its ID with the next drawing the user makes, and RemoveDrawing would then remove the wrong one.
  • Copy before you keep. Drawings() returns the chart’s own list, and a drag moves a drawing by changing it in place.
  • X is an index. On a category axis, a saved X is a position in your data. If you later load the data starting from a different day, shift the X values by the same number of days.
  • The text of a text label is not one of its points; save Text separately, as the example does.

The whole program

charts/trading/main.go
// A trading chart: candlesticks with two moving averages above a volume
// pane, a shared crosshair and zoom, and a toolbar of drawing tools.
package main

import (
	"fmt"
	"image/color"
	"math"
	"math/rand/v2"
	"time"

	"github.com/belluikit/bellui"
	"github.com/belluikit/bellui/app"
	"github.com/belluikit/bellui/container"
	"github.com/belluikit/bellui/geo"
	"github.com/belluikit/bellui/widget"

	"github.com/belluikit/bellui/x/charts/axis"
	"github.com/belluikit/bellui/x/charts/chart"
	"github.com/belluikit/bellui/x/charts/component"
	"github.com/belluikit/bellui/x/charts/interact"
	"github.com/belluikit/bellui/x/charts/series"
)

// market returns n made-up trading days: candles, volumes and date labels.
func market(n int) ([]series.CandlestickData, []float64, []string) {
	r := rand.New(rand.NewPCG(7, 11))
	candles := make([]series.CandlestickData, n)
	volume := make([]float64, n)
	dates := make([]string, n)
	day := time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)
	price := 180.0
	for i := range n {
		for day.Weekday() == time.Saturday || day.Weekday() == time.Sunday {
			day = day.AddDate(0, 0, 1)
		}
		dates[i] = day.Format("Jan 2")
		day = day.AddDate(0, 0, 1)

		open := price
		close := open * (1 + 0.004*math.Sin(float64(i)/9) + 0.018*(r.Float64()-0.5))
		high := math.Max(open, close) * (1 + 0.01*r.Float64())
		low := math.Min(open, close) * (1 - 0.01*r.Float64())
		candles[i] = series.CandlestickData{Open: open, High: high, Low: low, Close: close}
		volume[i] = 2 + 6*r.Float64()*math.Abs(close-open)/open*40 // millions
		price = close
	}
	return candles, volume, dates
}

// movingAverage returns the simple moving average of the closes over n days.
// The first n-1 values are NaN, which a line series leaves undrawn.
func movingAverage(candles []series.CandlestickData, n int) []float64 {
	out := make([]float64, len(candles))
	sum := 0.0
	for i, c := range candles {
		sum += c.Close
		if i >= n {
			sum -= candles[i-n].Close
		}
		if i < n-1 {
			out[i] = math.NaN()
		} else {
			out[i] = sum / float64(n)
		}
	}
	return out
}


func main() {
	a := app.New()
	w := a.NewWindow("Trading chart")
	w.Resize(geo.NewSize(1000, 680))

	candles, volume, dates := market(120)

	// A category axis labels every category, so keep one label in ten.
	sparse := make([]string, len(dates))
	for i := range dates {
		if i%10 == 0 {
			sparse[i] = dates[i]
		}
	}

	// crosshair builds the hover behaviour both panes share: crosshair lines
	// with value tags on the axes, and a text box only when the user clicks.
	crosshair := func(yTag string, text func(i int) string) *component.TooltipConfig {
		return &component.TooltipConfig{
			Trigger:       component.TooltipTriggerAxis,
			Crosshair:     component.CrosshairBoth,
			CrosshairOnly: true, // no text box while hovering; click to pin one
			ShowAxisTags:  true, // value tags where the crosshair meets the axes
			AxisTagFormatter: func(x, y float64) (string, string) {
				i := int(math.Round(x))
				if i < 0 || i >= len(dates) {
					return "", ""
				}
				return dates[i], fmt.Sprintf(yTag, y)
			},
			Formatter: func(hits []interact.HitResult) string {
				return text(hits[0].DataIndex)
			},
		}
	}
	priceTip := crosshair("%.2f", func(i int) string {
		c := candles[i]
		return fmt.Sprintf("%s\nO %.2f  H %.2f\nL %.2f  C %.2f", dates[i], c.Open, c.High, c.Low, c.Close)
	})
	volTip := crosshair("%.1fM", func(i int) string {
		return fmt.Sprintf("%s\nVolume %.1fM", dates[i], volume[i])
	})
	grid := func(top, bottom float32) *chart.Grid {
		// Same left and right margins in every pane, so the plots line up.
		return &chart.Grid{Left: 64, Right: 24, Top: top, Bottom: bottom}
	}

	// Axis tooltips only find line-like series, not candles or bars, so each
	// pane carries an invisible line through the values to hover on.
	hidden := series.LineStyle{Color: color.Transparent}
	closes := make([]float64, len(candles))
	for i, c := range candles {
		closes[i] = c.Close
	}

	price := chart.New(chart.Option{
		Title:    &chart.Title{Text: "ACME", SubText: "Daily, 10- and 30-day moving averages"},
		Tooltip:  priceTip,
		DataZoom: &interact.ZoomConfig{Enabled: true, MinSpan: 0.1, MaxSpan: 1, ZoomSensitivity: 0.08},
		Grid:     grid(12, 30),
		Color: []color.Color{
			color.NRGBA{R: 120, G: 120, B: 120, A: 255}, // legend swatch for the candles
			color.Transparent,
			color.NRGBA{R: 255, G: 167, B: 38, A: 255}, // MA10
			color.NRGBA{R: 66, G: 165, B: 245, A: 255}, // MA30
		},
		XAxis: []axis.Config{{Type: axis.TypeCategory, Data: make([]string, len(dates))}},
		YAxis: []axis.Config{{Type: axis.TypeValue, SplitCount: 5}},
		Series: []series.Series{
			&series.CandlestickSeries{SeriesName: "ACME", Data: candles, BarWidth: 0.7},
			&series.LineSeries{Data: closes, LineStyle: hidden}, // no name: not in the legend
			&series.LineSeries{SeriesName: "MA10", Data: movingAverage(candles, 10)},
			&series.LineSeries{SeriesName: "MA30", Data: movingAverage(candles, 30)},
		},
	})

	vol := chart.New(chart.Option{
		Tooltip: volTip,
		// Enabled: false gives the pane a zoom window that follows the group,
		// without its own zoom buttons.
		DataZoom: &interact.ZoomConfig{Enabled: false, MinSpan: 0.1, MaxSpan: 1},
		Grid:     grid(6, 26),
		XAxis:    []axis.Config{{Type: axis.TypeCategory, Data: sparse}},
		YAxis:    []axis.Config{{Type: axis.TypeValue, SplitCount: 2}},
		Series: []series.Series{
			&series.BarSeries{Data: volume, BarWidth: 0.7},
			&series.LineSeries{Data: volume, LineStyle: hidden},
		},
	})

	group := chart.NewChartGroup(
		chart.Pane{Chart: price, Height: 3},
		chart.Pane{Chart: vol, Height: 1},
	)

	status := widget.NewLabel("Crosshair")

	// setTooltip swaps the price pane's tooltip, keeping the rest of its option.
	setTooltip := func(cfg *component.TooltipConfig) {
		opt := price.Option()
		opt.Tooltip = cfg
		price.SetOption(opt)
	}
	noTooltip := &component.TooltipConfig{Trigger: component.TooltipTriggerNone}

	// Crosshair: plain viewing. Select: click a drawing to select it, drag it
	// to move it, press Delete to remove it. A click on a chart that shows a
	// tooltip pins the tooltip instead of selecting, so Select turns it off.
	crosshairMode := func() {
		price.SetDrawingTool(interact.DrawingToolNone)
		setTooltip(priceTip)
		status.SetText("Crosshair")
	}
	selectMode := func() {
		price.SetDrawingTool(interact.DrawingToolNone)
		setTooltip(noTooltip)
		status.SetText("Select: click a drawing, drag to move, Delete to remove")
	}
	bar := container.NewHBox(
		widget.NewButton("Crosshair", crosshairMode),
		widget.NewButton("Select", selectMode),
	)
	tools := []struct {
		label string
		tool  interact.DrawingToolMode
	}{
		{"H-line", interact.DrawingToolHLine},
		{"V-line", interact.DrawingToolVLine},
		{"Trend", interact.DrawingToolTrendLine},
		{"Ray", interact.DrawingToolRay},
		{"Rect", interact.DrawingToolRectangle},
		{"Channel", interact.DrawingToolChannel},
		{"Fib", interact.DrawingToolFibRetracement},
		{"Text", interact.DrawingToolTextLabel},
		{"Measure", interact.DrawingToolMeasure},
	}
	for _, t := range tools {
		bar.Add(widget.NewButton(t.label, func() {
			setTooltip(priceTip)
			price.SetDrawingTool(t.tool)
			status.SetText("Tool: " + t.label)
		}))
	}
	bar.Add(widget.NewButton("Clear", price.ClearDrawings))

	price.Events.On(interact.EventDrawingCreated, func(evt interface{}) {
		e := evt.(interact.DrawingEvent)
		// Tools stay active after a drawing. The new drawing is selected,
		// so switch to Select mode to let the user move or delete it.
		selectMode()
		status.SetText(fmt.Sprintf("Created %s at %v", e.DrawingID, e.Drawing.DataPoints()))
	})
	price.Events.On(interact.EventDrawingSelected, func(evt interface{}) {
		status.SetText(fmt.Sprintf("Selected %s", evt.(interact.DrawingEvent).DrawingID))
	})
	price.Events.On(interact.EventDrawingDeleted, func(evt interface{}) {
		status.SetText(fmt.Sprintf("Deleted %s", evt.(interact.DrawingEvent).DrawingID))
	})

	w.SetContent(container.NewBorder(container.NewVBox(bar, status), nil, nil, nil, group))
	bellui.ShowAndRun(w)
}