WIP mktchart 3
This commit is contained in:
13 files changed
+794
-13
No files matched your search
+169
@@ -0,0 +1,169 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DepthLevel is one price level of an order book.
|
||||
type DepthLevel struct {
|
||||
Price, Size float64
|
||||
}
|
||||
|
||||
// DepthChartOptions configure a DepthChart.
|
||||
type DepthChartOptions struct {
|
||||
Frame ChartFrame
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// depthData is the book's cumulative sizes, bids then asks, from the mid outward.
|
||||
type depthData struct {
|
||||
price, total []float64
|
||||
nBids int
|
||||
mid, maxSize float64
|
||||
}
|
||||
|
||||
// DepthChart draws cumulative bid and ask depth as stepped areas around the mid price; arrows walk the levels.
|
||||
func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) ChartResult {
|
||||
dec := priceDecimals("DepthChart", opts.Decimals)
|
||||
ch := newChart(c, opts.Frame)
|
||||
k := Tokens(c)
|
||||
d := chartMemo(ch, "depth", [2]string{chartID(bids), chartID(asks)}, func() depthData { return depthBuild(bids, asks) })
|
||||
bidCol, askCol := trendColor(c, 1), trendColor(c, -1)
|
||||
keys := []chartKey{{Name: msg(c, "depth_chart.bids"), Color: bidCol, Shape: 1}, {Name: msg(c, "depth_chart.asks"), Color: askCol, Shape: 3}}
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := chartInset(r, 56, 26)
|
||||
lo, hi := d.price[d.nBids-1], d.price[len(d.price)-1]
|
||||
return plot, internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: 0, D1: d.maxSize * 1.05, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
spec := chartSpec{n: len(d.price), keys: keys}
|
||||
if len(bids) == 0 || len(asks) == 0 {
|
||||
spec.n = 0
|
||||
}
|
||||
// Left and Right walk by price: lower bids to the left, higher asks to the right.
|
||||
order := func(i int) int {
|
||||
if i < d.nBids {
|
||||
return d.nBids - 1 - i
|
||||
}
|
||||
return i
|
||||
}
|
||||
spec.move = func(i int, key ui.Key) int {
|
||||
pos := order(i)
|
||||
if key == ui.KeyLeft || key == ui.KeyUp {
|
||||
pos = max(pos-1, 0)
|
||||
} else {
|
||||
pos = min(pos+1, len(d.price)-1)
|
||||
}
|
||||
return order(pos)
|
||||
}
|
||||
spec.pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return internal.Nearest(len(d.price), func(i int) (float32, float32) { return xs.Map(d.price[i]), 0 }, x, 0, plot.W, true)
|
||||
}
|
||||
spec.readout = func(i int) string {
|
||||
side := msg(c, "depth_chart.bid")
|
||||
if i >= d.nBids {
|
||||
side = msg(c, "depth_chart.ask")
|
||||
}
|
||||
return strings.Join([]string{side + " " + priceUngrouped(d.price[i], dec), fmt.Sprintf(msg(c, "depth_chart.total"), statisticFormat(d.total[i], 0)),
|
||||
msg(c, "depth_chart.mid") + " " + priceUngrouped(d.mid, dec)}, " · ")
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
_, _, ystep := internal.Nice(0, d.maxSize*1.05, 4)
|
||||
internal.AxisY(p, plot, ys, internal.Ticks(0, d.maxSize*1.05, ystep), func(v float64) string { return volumeShort(v, ystep) }, size, k)
|
||||
_, _, xstep := internal.Nice(xs.D0, xs.D1, max(2, int(plot.W/90)))
|
||||
var tx []float32
|
||||
var tl []string
|
||||
for _, t := range internal.Ticks(xs.D0, xs.D1, xstep) {
|
||||
tx, tl = append(tx, xs.Map(t)), append(tl, internal.FormatTick(t, xstep))
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
base := plot.Y + plot.H
|
||||
for side, idx := range [][2]int{{0, d.nBids}, {d.nBids, len(d.price)}} {
|
||||
col := []ui.Color{bidCol, askCol}[side]
|
||||
var area, edge ui.Path
|
||||
mx := internal.Snap(p, xs.Map(d.mid))
|
||||
area.MoveTo(mx, base)
|
||||
edge.MoveTo(mx, base)
|
||||
prevY := base
|
||||
for i := idx[0]; i < idx[1]; i++ {
|
||||
x, y := internal.Snap(p, xs.Map(d.price[i])), internal.Snap(p, ys.Map(d.total[i]))
|
||||
area.LineTo(x, prevY).LineTo(x, y)
|
||||
edge.LineTo(x, prevY).LineTo(x, y)
|
||||
prevY = y
|
||||
}
|
||||
end := plot.X
|
||||
if side == 1 {
|
||||
end = plot.X + plot.W
|
||||
}
|
||||
area.LineTo(end, prevY).LineTo(end, base).Close()
|
||||
edge.LineTo(end, prevY)
|
||||
p.FillPath(&area, col.Alpha(0.2))
|
||||
p.StrokePath(&edge, 2, col)
|
||||
label := []string{msg(c, "depth_chart.bids"), msg(c, "depth_chart.asks")}[side]
|
||||
chartText(p, label, []float32{plot.X + 6, plot.X + plot.W - 6}[side], plot.Y+4, size, col, []float32{0, 1}[side], 0)
|
||||
}
|
||||
mx := internal.Snap(p, xs.Map(d.mid))
|
||||
for y := plot.Y; y < base; y += 6 {
|
||||
p.Line(mx, y, mx, min(y+3, base), 1, k.TextMuted)
|
||||
}
|
||||
chartText(p, priceUngrouped(d.mid, dec), mx, plot.Y+4, size, k.Text, 0.5, 0)
|
||||
if active >= 0 {
|
||||
x, y := xs.Map(d.price[active]), ys.Map(d.total[active])
|
||||
p.Line(internal.Snap(p, x), plot.Y, internal.Snap(p, x), base, 1, k.TextMuted)
|
||||
shape := 1
|
||||
if active >= d.nBids {
|
||||
shape = 3
|
||||
}
|
||||
internal.Marker(p, shape, x, y, 10, keys[shape/3].Color)
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// depthBuild checks the book's order and sums sizes outward from the mid.
|
||||
func depthBuild(bids, asks []DepthLevel) depthData {
|
||||
for i, l := range bids {
|
||||
if !(l.Size > 0) || i > 0 && !(l.Price < bids[i-1].Price) {
|
||||
panic(fmt.Sprintf("mujica: DepthChart bid %d needs a positive size and a price below the one before", i))
|
||||
}
|
||||
}
|
||||
for i, l := range asks {
|
||||
if !(l.Size > 0) || i > 0 && !(l.Price > asks[i-1].Price) {
|
||||
panic(fmt.Sprintf("mujica: DepthChart ask %d needs a positive size and a price above the one before", i))
|
||||
}
|
||||
}
|
||||
if len(bids) > 0 && len(asks) > 0 && !(bids[0].Price < asks[0].Price) {
|
||||
panic("mujica: DepthChart best bid must be below best ask")
|
||||
}
|
||||
var d depthData
|
||||
d.nBids = len(bids)
|
||||
var sum float64
|
||||
for _, l := range bids {
|
||||
sum += l.Size
|
||||
d.price, d.total = append(d.price, l.Price), append(d.total, sum)
|
||||
}
|
||||
d.maxSize = sum
|
||||
sum = 0
|
||||
for _, l := range asks {
|
||||
sum += l.Size
|
||||
d.price, d.total = append(d.price, l.Price), append(d.total, sum)
|
||||
}
|
||||
d.maxSize = max(d.maxSize, sum)
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
d.mid = (bids[0].Price + asks[0].Price) / 2
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func sampleBook() ([]DepthLevel, []DepthLevel) {
|
||||
var bids, asks []DepthLevel
|
||||
for i := range 12 {
|
||||
bids = append(bids, DepthLevel{Price: 99.9 - float64(i)*0.1, Size: float64(10 + i*3)})
|
||||
asks = append(asks, DepthLevel{Price: 100.1 + float64(i)*0.1, Size: float64(8 + i*4)})
|
||||
}
|
||||
return bids, asks
|
||||
}
|
||||
|
||||
func TestDepthChartCumulativeAndKeyboard(t *testing.T) {
|
||||
bids, asks := sampleBook()
|
||||
d := depthBuild(bids, asks)
|
||||
if d.mid != 100 || d.total[0] != 10 || d.total[1] != 23 || d.total[12] != 8 || d.total[13] != 20 {
|
||||
t.Fatalf("cumulative %+v", d.total[:14])
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}})
|
||||
}, 700, 360)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight) // the best bid first
|
||||
if res.Point != 0 || !tt.HasText("Bid 99.90 · cumulative 10 · Mid 100.00") {
|
||||
t.Fatalf("first level %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft) // outward to lower bids
|
||||
if res.Point != 1 || !tt.HasText("Bid 99.80 · cumulative 23") {
|
||||
t.Fatalf("lower bid %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if res.Point != 12 || !tt.HasText("Ask 100.10 · cumulative 8") {
|
||||
t.Fatalf("crossing to asks %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
for name, book := range map[string][2][]DepthLevel{
|
||||
"bid order": {{{Price: 1, Size: 1}, {Price: 2, Size: 1}}, nil},
|
||||
"crossed": {{{Price: 5, Size: 1}}, {{Price: 4, Size: 1}}},
|
||||
"size": {nil, {{Price: 4, Size: 0}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
depthBuild(book[0], book[1])
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepthChartSnapshot(t *testing.T) {
|
||||
bids, asks := sampleBook()
|
||||
snapshot(t, "depth_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth", Height: 240}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// QuotePoint is one tick of an intraday line: time, price and the volume traded since the last point.
|
||||
type QuotePoint struct {
|
||||
Time time.Time
|
||||
Price float64
|
||||
Volume float64
|
||||
}
|
||||
|
||||
// LineQuoteChartOptions configure a LineQuoteChart.
|
||||
type LineQuoteChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// PrevClose draws the baseline the day's change is measured from; required.
|
||||
PrevClose float64
|
||||
// RegularOpen and RegularClose bound the regular session; points outside are pre- and after-market. Zero draws one session.
|
||||
RegularOpen, RegularClose time.Time
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// lineQuoteSession names a point's session: -1 pre, 0 regular, 1 after.
|
||||
func lineQuoteSession(t time.Time, opts LineQuoteChartOptions) int {
|
||||
switch {
|
||||
case opts.RegularOpen.IsZero():
|
||||
return 0
|
||||
case t.Before(opts.RegularOpen):
|
||||
return -1
|
||||
case !t.Before(opts.RegularClose):
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LineQuoteChart is an intraday line with the previous close as baseline, the average price, and shaded extended sessions.
|
||||
func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptions) ChartResult {
|
||||
if !(opts.PrevClose > 0) {
|
||||
panic("mujica: LineQuoteChart needs a positive PrevClose")
|
||||
}
|
||||
if opts.RegularOpen.IsZero() != opts.RegularClose.IsZero() || opts.RegularClose.Before(opts.RegularOpen) {
|
||||
panic("mujica: LineQuoteChart needs both session bounds, in order")
|
||||
}
|
||||
dec := priceDecimals("LineQuoteChart", opts.Decimals)
|
||||
ch := newChart(c, opts.Frame)
|
||||
k := Tokens(c)
|
||||
avg := chartMemo(ch, "avg", chartID(points), func() []float64 {
|
||||
out := make([]float64, len(points))
|
||||
var pv, v float64
|
||||
for i, p := range points {
|
||||
if p.Time.IsZero() || i > 0 && !p.Time.After(points[i-1].Time) || !(p.Price > 0) || p.Volume < 0 {
|
||||
panic(fmt.Sprintf("mujica: LineQuoteChart point %d is out of order or invalid", i))
|
||||
}
|
||||
pv += p.Price * p.Volume
|
||||
v += p.Volume
|
||||
out[i] = p.Price
|
||||
if v > 0 {
|
||||
out[i] = pv / v
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
dom := chartMemo(ch, "y", chartID(points), func() [2]float64 {
|
||||
lo, hi := opts.PrevClose, opts.PrevClose
|
||||
for i, p := range points {
|
||||
lo, hi = min(lo, p.Price, avg[i]), max(hi, p.Price, avg[i])
|
||||
}
|
||||
// Symmetric around the previous close, so the baseline sits in the middle.
|
||||
d := max(hi-opts.PrevClose, opts.PrevClose-lo)
|
||||
return [2]float64{opts.PrevClose - d, opts.PrevClose + d}
|
||||
})
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := chartInset(r, 60, 26)
|
||||
plot.W -= 44 // percent axis on the right
|
||||
var t0, t1 float64
|
||||
if len(points) > 0 {
|
||||
t0, t1 = float64(points[0].Time.Unix()), float64(points[len(points)-1].Time.Unix())
|
||||
}
|
||||
return plot, internal.Linear{D0: t0, D1: t1, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: dom[0], D1: dom[1], R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
x := func(xs internal.Linear, i int) float32 { return xs.Map(float64(points[i].Time.Unix())) }
|
||||
spec := chartSpec{n: len(points), keys: []chartKey{
|
||||
{Name: msg(c, "line_quote_chart.price"), Color: k.AccentText, Shape: 0},
|
||||
{Name: msg(c, "line_quote_chart.average"), Color: k.Warning, Shape: 3},
|
||||
}}
|
||||
spec.pick = func(r ui.Rect, px, py float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !plot.Contains(px, py) {
|
||||
return -1
|
||||
}
|
||||
return internal.Nearest(len(points), func(i int) (float32, float32) { return x(xs, i), 0 }, px, 0, plot.W, true)
|
||||
}
|
||||
spec.readout = func(i int) string {
|
||||
p := points[i]
|
||||
chg := (p.Price/opts.PrevClose - 1) * 100
|
||||
parts := []string{p.Time.Format("15:04"), priceUngrouped(p.Price, dec), fmt.Sprintf("%+.2f%% %s", chg, trendWord(c, trendSign(chg))),
|
||||
msg(c, "line_quote_chart.average") + " " + priceUngrouped(avg[i], dec)}
|
||||
if s := lineQuoteSession(p.Time, opts); s != 0 {
|
||||
parts = append(parts, msg(c, map[int]string{-1: "line_quote_chart.pre", 1: "line_quote_chart.after"}[s]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
_, _, step := internal.Nice(dom[0], dom[1], 6)
|
||||
ticks := internal.Ticks(dom[0], dom[1], step)
|
||||
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
for _, t := range ticks {
|
||||
chartText(p, fmt.Sprintf("%+.2f%%", (t/opts.PrevClose-1)*100), plot.X+plot.W+6, ys.Map(t), size, trendColor(c, trendSign(t-opts.PrevClose)), 0, 0.5)
|
||||
}
|
||||
lineQuoteSessions(p, plot, xs, opts, size, c)
|
||||
var tx []float32
|
||||
var tl []string
|
||||
if len(points) > 1 {
|
||||
ts, layout := internal.TimeTicks(points[0].Time, points[len(points)-1].Time, max(2, int(plot.W/90)))
|
||||
for _, t := range ts {
|
||||
tx, tl = append(tx, xs.Map(float64(t.Unix()))), append(tl, t.Format(layout))
|
||||
}
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
by := internal.Snap(p, ys.Map(opts.PrevClose))
|
||||
for xx := plot.X; xx < plot.X+plot.W; xx += 6 {
|
||||
p.Line(xx, by, min(xx+3, plot.X+plot.W), by, 1, k.TextMuted)
|
||||
}
|
||||
price := make([]float64, len(points))
|
||||
for i, pt := range points {
|
||||
price[i] = pt.Price
|
||||
}
|
||||
at := func(i int) float32 { return x(xs, i) }
|
||||
for li, vals := range [][]float64{price, avg} {
|
||||
col := []ui.Color{k.AccentText, k.Warning}[li]
|
||||
chartRuns(vals, at, func(idx []int) {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
px, py := internal.Snap(p, at(i)), internal.Snap(p, ys.Map(vals[i]))
|
||||
if j == 0 {
|
||||
line.MoveTo(px, py)
|
||||
} else {
|
||||
line.LineTo(px, py)
|
||||
}
|
||||
}
|
||||
p.StrokePath(&line, []float32{1.75, 1.25}[li], col)
|
||||
})
|
||||
}
|
||||
if active >= 0 {
|
||||
ax := internal.Snap(p, at(active))
|
||||
p.Line(ax, plot.Y, ax, plot.Y+plot.H, 1, k.TextMuted)
|
||||
internal.Marker(p, 0, ax, ys.Map(price[active]), 9, k.AccentText)
|
||||
internal.Marker(p, 3, ax, ys.Map(avg[active]), 9, k.Warning)
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// lineQuoteSessions shades and labels the pre- and after-market spans.
|
||||
func lineQuoteSessions(p *ui.Painter, plot ui.Rect, xs internal.Linear, opts LineQuoteChartOptions, size float32, c *ui.Context) {
|
||||
if opts.RegularOpen.IsZero() {
|
||||
return
|
||||
}
|
||||
k := Tokens(c)
|
||||
x0, x1 := max(plot.X, xs.Map(float64(opts.RegularOpen.Unix()))), min(plot.X+plot.W, xs.Map(float64(opts.RegularClose.Unix())))
|
||||
for _, span := range []struct {
|
||||
a, b float32
|
||||
key string
|
||||
}{{plot.X, x0, "line_quote_chart.pre"}, {x1, plot.X + plot.W, "line_quote_chart.after"}} {
|
||||
if span.b-span.a < 1 {
|
||||
continue
|
||||
}
|
||||
p.Fill(ui.Rect{X: span.a, Y: plot.Y, W: span.b - span.a, H: plot.H}, k.SurfaceHover.Alpha(0.6), 0)
|
||||
chartText(p, msg(c, span.key), span.a+4, plot.Y+2, size, k.TextMuted, 0, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func samplePoints() ([]QuotePoint, time.Time, time.Time) {
|
||||
day := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
var pts []QuotePoint
|
||||
for m := 8 * 60; m < 18*60; m += 5 {
|
||||
price := 100 + 2*math.Sin(float64(m)/50) + float64(m-480)/200
|
||||
pts = append(pts, QuotePoint{Time: day.Add(time.Duration(m) * time.Minute), Price: math.Round(price*100) / 100, Volume: 100 + float64(m%7)*10})
|
||||
}
|
||||
return pts, day.Add(9*time.Hour + 30*time.Minute), day.Add(16 * time.Hour)
|
||||
}
|
||||
|
||||
func TestLineQuoteChartReadoutAverageSessions(t *testing.T) {
|
||||
pts, open, closeAt := samplePoints()
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = LineQuoteChart(c, pts, LineQuoteChartOptions{Frame: ChartFrame{Label: "AAPL"}, PrevClose: 100, RegularOpen: open, RegularClose: closeAt})
|
||||
}, 800, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
var pv, v float64
|
||||
pv, v = pts[0].Price*pts[0].Volume, pts[0].Volume
|
||||
want := "08:00 · " + priceUngrouped(pts[0].Price, 2)
|
||||
found := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, want) {
|
||||
found = s
|
||||
}
|
||||
}
|
||||
if found == "" || !strings.Contains(found, "Average "+priceUngrouped(pv/v, 2)) || !strings.HasSuffix(found, "Pre-market") {
|
||||
t.Fatalf("first readout %q in %q", found, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
last := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, "17:55") {
|
||||
last = s
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, "After hours") || res.Point != len(pts)-1 {
|
||||
t.Fatalf("last readout %q", last)
|
||||
}
|
||||
for name, o := range map[string]LineQuoteChartOptions{
|
||||
"prev close": {Frame: ChartFrame{Label: "x"}},
|
||||
"half bound": {Frame: ChartFrame{Label: "x"}, PrevClose: 1, RegularOpen: open},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); LineQuoteChart(c, pts, o) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineQuoteChartSnapshot(t *testing.T) {
|
||||
pts, open, closeAt := samplePoints()
|
||||
snapshot(t, "line_quote_chart", 680, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
LineQuoteChart(c, pts, LineQuoteChartOptions{Frame: ChartFrame{Label: "AAPL", Height: 260}, PrevClose: 100, RegularOpen: open, RegularClose: closeAt})
|
||||
})
|
||||
})
|
||||
}
|
||||
+28
-13
@@ -2,18 +2,33 @@ package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"},
|
||||
"candlestick_chart.volume": {"Vol", "量"},
|
||||
"heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"},
|
||||
"heikin_ashi.raw": {"Candles", "原 K 线"},
|
||||
"heikin_ashi.switch": {"Candle style", "K 线样式"},
|
||||
"compare_symbol.label": {"Compared symbols", "对比品种"},
|
||||
"compare_symbol.remove": {"Remove %s", "移除 %s"},
|
||||
"price_alert_line.tag": {"Alert %s", "预警 %s"},
|
||||
"chart_sync.link": {"Sync with other charts", "与其他图表同步"},
|
||||
"chart_sync.linked": {"Synced", "已同步"},
|
||||
"chart_sync.unlinked": {"Not synced", "未同步"},
|
||||
"indicator_pane.remove": {"Remove %s", "移除 %s"},
|
||||
"drawing_tools.note": {"Note", "标注"},
|
||||
"candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"},
|
||||
"candlestick_chart.volume": {"Vol", "量"},
|
||||
"heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"},
|
||||
"heikin_ashi.raw": {"Candles", "原 K 线"},
|
||||
"heikin_ashi.switch": {"Candle style", "K 线样式"},
|
||||
"compare_symbol.label": {"Compared symbols", "对比品种"},
|
||||
"compare_symbol.remove": {"Remove %s", "移除 %s"},
|
||||
"price_alert_line.tag": {"Alert %s", "预警 %s"},
|
||||
"chart_sync.link": {"Sync with other charts", "与其他图表同步"},
|
||||
"chart_sync.linked": {"Synced", "已同步"},
|
||||
"chart_sync.unlinked": {"Not synced", "未同步"},
|
||||
"indicator_pane.remove": {"Remove %s", "移除 %s"},
|
||||
"line_quote_chart.price": {"Price", "价格"},
|
||||
"line_quote_chart.average": {"Average", "均价"},
|
||||
"line_quote_chart.pre": {"Pre-market", "盘前"},
|
||||
"line_quote_chart.after": {"After hours", "盘后"},
|
||||
"depth_chart.bids": {"Bids", "买盘"},
|
||||
"depth_chart.asks": {"Asks", "卖盘"},
|
||||
"depth_chart.bid": {"Bid", "买"},
|
||||
"depth_chart.ask": {"Ask", "卖"},
|
||||
"depth_chart.total": {"cumulative %s", "累计 %s"},
|
||||
"depth_chart.mid": {"Mid", "中间价"},
|
||||
"volume_profile.value_area": {"Value area", "价值区"},
|
||||
"volume_profile.other": {"Outside", "区外"},
|
||||
"volume_profile.poc": {"POC", "控制点"},
|
||||
"volume_profile.vah": {"VAH", "价值区上沿"},
|
||||
"volume_profile.val": {"VAL", "价值区下沿"},
|
||||
"drawing_tools.note": {"Note", "标注"},
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestVolumeProfileOfAndValueArea(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 14, 10, 12, 400}, [5]float64{12, 12, 12, 12, 50}, [5]float64{11, 12, 10, 11, 100})
|
||||
bins := VolumeProfileOf(cs, 4) // bins of 1: 10-11, 11-12, 12-13, 13-14
|
||||
want := []float64{100 + 50, 100 + 50, 100 + 50, 100}
|
||||
for i, b := range bins {
|
||||
if math.Abs(b.Volume-want[i]) > 1e-9 || b.Low != 10+float64(i) {
|
||||
t.Fatalf("bin %d %+v, want volume %v", i, b, want[i])
|
||||
}
|
||||
}
|
||||
vb := []ProfileBin{{0, 1, 5}, {1, 2, 10}, {2, 3, 40}, {3, 4, 30}, {4, 5, 15}}
|
||||
poc, lo, hi := ValueArea(vb, 0.7)
|
||||
// 40, then 30 above: 70 of 100 is enough.
|
||||
if poc != 2 || lo != 2 || hi != 3 {
|
||||
t.Fatalf("value area %d %d..%d", poc, lo, hi)
|
||||
}
|
||||
if _, _, hi := ValueArea(vb, 1); hi != 4 {
|
||||
t.Fatal("full value area")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("zero bins accepted")
|
||||
}
|
||||
}()
|
||||
VolumeProfileOf(cs, 0)
|
||||
}
|
||||
|
||||
func TestVolumeProfileKeyboard(t *testing.T) {
|
||||
vb := []ProfileBin{{0, 1, 5}, {1, 2, 10}, {2, 3, 40}, {3, 4, 30}, {4, 5, 15}}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = VolumeProfile(c, vb, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile"}})
|
||||
}, 500, 360)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if res.Point != 2 || !tt.HasText("2.00–3.00 · Vol 40 · POC · Value area") {
|
||||
t.Fatalf("POC readout %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.HasText("1.00–2.00 · Vol 10") || tt.HasText("1.00–2.00 · Vol 10 · Value area") {
|
||||
t.Fatalf("outside readout %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("overlapping bins accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
VolumeProfile(c, []ProfileBin{{0, 2, 1}, {1, 3, 1}}, VolumeProfileOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 200, 100)
|
||||
}
|
||||
|
||||
func TestVolumeProfileSnapshot(t *testing.T) {
|
||||
bins := VolumeProfileOf(sampleCandles(200), 18)
|
||||
snapshot(t, "volume_profile", 480, 380, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
VolumeProfile(c, bins, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile", Height: 290}})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user