package mujica import ( "fmt" "math" "strings" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // ProfileBin is the volume traded between two prices. type ProfileBin struct { Low, High, Volume float64 } // VolumeProfileOf spreads each candle's volume evenly over its low-high range into n equal price bins. func VolumeProfileOf(candles []Candle, n int) []ProfileBin { if n < 1 { panic("mujica: VolumeProfileOf needs at least one bin") } if len(candles) == 0 { return nil } lo, hi := math.Inf(1), math.Inf(-1) for _, c := range candles { lo, hi = min(lo, c.Low), max(hi, c.High) } if hi == lo { hi = lo + 1 } w := (hi - lo) / float64(n) bins := make([]ProfileBin, n) for i := range bins { bins[i] = ProfileBin{Low: lo + float64(i)*w, High: lo + float64(i+1)*w} } for _, c := range candles { span := c.High - c.Low for i := range bins { if span == 0 { if c.Low >= bins[i].Low && (c.Low < bins[i].High || i == n-1) { bins[i].Volume += c.Volume break } continue } overlap := min(c.High, bins[i].High) - max(c.Low, bins[i].Low) if overlap > 0 { bins[i].Volume += c.Volume * overlap / span } } } return bins } // ValueArea returns the point of control (the fullest bin) and the bins [lo, hi] around it holding frac of the volume. func ValueArea(bins []ProfileBin, frac float64) (poc, lo, hi int) { if len(bins) == 0 || !(frac > 0 && frac <= 1) { panic("mujica: ValueArea needs bins and a fraction in (0, 1]") } var total float64 for i, b := range bins { total += b.Volume if b.Volume > bins[poc].Volume { poc = i } } lo, hi = poc, poc in := bins[poc].Volume for in < frac*total { // Grow toward the fuller neighbor, as the market-profile rule does. down, up := -1.0, -1.0 if lo > 0 { down = bins[lo-1].Volume } if hi < len(bins)-1 { up = bins[hi+1].Volume } if down < 0 && up < 0 { break } if up >= down { hi++ in += up } else { lo-- in += down } } return poc, lo, hi } // VolumeProfileOptions configure a VolumeProfile. type VolumeProfileOptions struct { Frame ChartFrame // ValueArea is the share of volume in the value area; zero is 0.7. ValueArea float64 Decimals int } // VolumeProfile draws volume by price as horizontal bars, marking the point of control and the value area. func VolumeProfile(c *ui.Context, bins []ProfileBin, opts VolumeProfileOptions) ChartResult { frac := opts.ValueArea if frac == 0 { frac = 0.7 } dec := priceDecimals("VolumeProfile", opts.Decimals) ch := newChart(c, opts.Frame) k := Tokens(c) va := chartMemo(ch, "va", [2]any{chartID(bins), frac}, func() [4]float64 { vmax := 0.0 for i, b := range bins { if !(b.High > b.Low) || b.Volume < 0 || i > 0 && b.Low < bins[i-1].High-1e-9 { panic(fmt.Sprintf("mujica: VolumeProfile bin %d is empty, negative or out of order", i)) } vmax = max(vmax, b.Volume) } if len(bins) == 0 { return [4]float64{} } poc, lo, hi := ValueArea(bins, frac) return [4]float64{float64(poc), float64(lo), float64(hi), vmax} }) poc, vlo, vhi, vmax := int(va[0]), int(va[1]), int(va[2]), va[3] inArea, outArea := k.AccentText, k.TextMuted keys := []chartKey{{Name: msg(c, "volume_profile.value_area"), Color: inArea, Shape: 1}, {Name: msg(c, "volume_profile.other"), Color: outArea, Shape: 4}} size := FontSize(c, theme.CaptionSize) geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { plot := chartInset(r, 60, 26) return plot, internal.Linear{D0: 0, D1: max(vmax, 1), R0: float64(plot.X), R1: float64(plot.X + plot.W)}, internal.Linear{D0: bins[0].Low, D1: bins[len(bins)-1].High, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} } spec := chartSpec{n: len(bins), keys: keys} spec.move = func(i int, key ui.Key) int { if key == ui.KeyDown || key == ui.KeyLeft { return max(i-1, 0) } return min(i+1, len(bins)-1) } spec.pick = func(r ui.Rect, x, y float32) int { plot, _, ys := geom(r) if !plot.Contains(x, y) { return -1 } v := ys.Invert(y) for i, b := range bins { if v >= b.Low && v <= b.High { return i } } return -1 } spec.readout = func(i int) string { b := bins[i] parts := []string{priceUngrouped(b.Low, dec) + "–" + priceUngrouped(b.High, dec), msg(c, "candlestick_chart.volume") + " " + statisticFormat(b.Volume, 0)} if i == poc { parts = append(parts, msg(c, "volume_profile.poc")) } if i >= vlo && i <= vhi { parts = append(parts, msg(c, "volume_profile.value_area")) } return strings.Join(parts, " · ") } spec.draw = func(p *ui.Painter, r ui.Rect, active int) { plot, xs, ys := geom(r) _, _, step := internal.Nice(ys.D0, ys.D1, 5) internal.AxisY(p, plot, ys, internal.Ticks(ys.D0, ys.D1, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k) _, _, xstep := internal.Nice(0, xs.D1, 3) var tx []float32 var tl []string for _, t := range internal.Ticks(0, xs.D1, xstep) { tx, tl = append(tx, xs.Map(t)), append(tl, volumeShort(t, xstep)) } internal.AxisX(p, plot, tx, tl, size, k) for i, b := range bins { y0, y1 := ys.Map(b.High), ys.Map(b.Low) bar := ui.Rect{X: plot.X, Y: y0 + 1, W: xs.Map(b.Volume) - plot.X, H: max(1, y1-y0-2)} // Value-area bars are filled, the rest outlined. if i >= vlo && i <= vhi { p.Fill(bar, inArea.Alpha(0.75), 0) } else { p.Stroke(bar, outArea, 0, 1) } if i == active { p.Stroke(ui.Rect{X: bar.X - 1, Y: bar.Y - 1, W: bar.W + 2, H: bar.H + 2}, k.Text, 0, 1.5) } } for _, line := range []struct { v float64 key string }{{(bins[poc].Low + bins[poc].High) / 2, "volume_profile.poc"}, {bins[vhi].High, "volume_profile.vah"}, {bins[vlo].Low, "volume_profile.val"}} { y := internal.Snap(p, ys.Map(line.v)) p.Line(plot.X, y, plot.X+plot.W, y, 1, k.Warning) mktTag(p, plot.X+plot.W, y, msg(c, line.key)+" "+priceUngrouped(line.v, dec), size, k.Warning, k.Surface) } } res, _ := ch.build(spec) return res }