Files
MujicaUI/volume_profile.go
T
2026-10-05 14:58:26 -04:00

202 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}