diff --git a/indicator_pane.go b/indicator_pane.go new file mode 100644 index 0000000..547d864 --- /dev/null +++ b/indicator_pane.go @@ -0,0 +1,220 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// IndicatorPaneSpec is one lower pane: MACD, RSI, KDJ or OBV. +type IndicatorPaneSpec struct { + Kind IndicatorKind + // Params nil uses the kind's defaults. + Params []float64 +} + +// IndicatorPanes is the caller-held stack of panes and their height shares. +type IndicatorPanes struct { + Panes []IndicatorPaneSpec + // Sizes are the panes' height shares; they reset to equal when the pane count changes. + Sizes []float32 +} + +// IndicatorPaneOptions configure an IndicatorPane. +type IndicatorPaneOptions struct { + // Height is the whole stack's height; zero is 140 per pane. + Height float32 + // Label prefixes each pane's accessible name, as the symbol. + Label string + Disabled bool +} + +// IndicatorPaneResult reports the stack's events. +type IndicatorPaneResult struct { + Element *ui.Element + // Removed is the index of the pane closed this frame, -1 for none. + Removed int + // Resized reports finished height changes, for saving. + Resized bool +} + +// IndicatorPane stacks indicator panes under a price chart, sharing its view; dividers between them resize. +func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *IndicatorPanes, opts IndicatorPaneOptions) IndicatorPaneResult { + if view == nil || panes == nil { + panic("mujica: IndicatorPane needs a view and panes") + } + res := IndicatorPaneResult{Removed: -1} + n := len(panes.Panes) + for _, p := range panes.Panes { + if _ = p.Kind.String(); indicatorInfo[p.Kind].overlay { + panic(fmt.Sprintf("mujica: %v is an overlay, not a pane indicator", p.Kind)) + } + indicatorParams(p.Kind, p.Params) + } + if n == 0 { + res.Element = ui.Column(c) + return res + } + if len(panes.Sizes) != n { + panes.Sizes = nil + } + h := opts.Height + if h == 0 { + h = 140 * float32(n) + } + build := func(i int) func() { + return func() { + col := ui.Column(c).Fill().MinHeight(0) + b := col.Bounds() + internal.Remeasure(c, col, b.W, b.H) + u := Density(c).Unit() + // The plot takes what the title, legend and readout rows leave. + plotH := max(40, b.H-2*theme.MinHitSize-u*12) + col.Children(func() { + if indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH) { + res.Removed = i + } + }) + } + } + box := ui.Column(c).Height(h).FillWidth().Disabled(opts.Disabled) + box.Children(func() { + if n == 1 { + build(0)() + return + } + list := make([]ResizablePanel, n) + for i, p := range panes.Panes { + list[i] = ResizablePanel{Label: indicatorName(p.Kind, indicatorParams(p.Kind, p.Params)), Min: 60, Build: build(i)} + } + r := ResizablePanelGroup(c, &panes.Sizes, list, ResizablePanelGroupOptions{Vertical: true}) + r.Element.Fill() + res.Resized = r.Committed + }) + if res.Removed >= 0 { + slog.Debug("mujica: indicator_pane removed", "kind", panes.Panes[res.Removed].Kind) + panes.Panes = append(panes.Panes[:res.Removed:res.Removed], panes.Panes[res.Removed+1:]...) + panes.Sizes = nil + c.Invalidate() + } + res.Element = box + return res +} + +// indicatorPaneOne draws one pane's lines over the view's window; it reports the close button. +func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) bool { + k := Tokens(c) + params := indicatorParams(spec.Kind, spec.Params) + name := indicatorName(spec.Kind, params) + closed := false + ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() { + ui.Text(c, name).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.Text).Grow(1) + closed = iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), name)).Clicked() + }) + label := strings.TrimSpace(opts.Label + " " + name) + ch := newChart(c, ChartFrame{Label: label, Height: height}) + series := chartMemo(ch, "series", fmt.Sprintf("%s|%v|%v", chartID(candles), spec.Kind, params), func() []ChartSeries { + marketCheckCandles(candles) + return indicatorSeries(spec.Kind, params, candles) + }) + m := &mktCore{c: c, k: k, ch: ch, candles: candles, view: view} + m.window() + pal := internal.Palette(k) + lines := make([]mktLine, len(series)) + keys := make([]chartKey, len(series)) + for i, s := range series { + lines[i] = mktLine{name: s.Name, values: s.Values, color: pal[(i+1)%len(pal)], shape: i} + keys[i] = chartKey{Name: s.Name, Color: lines[i].color, Shape: i} + } + yr := chartMemo(ch, "y", [3]any{chartID(candles), m.from, m.to}, func() [2]float64 { + lo, hi := math.Inf(1), math.Inf(-1) + for _, l := range lines { + for i := m.from; i < m.to; i++ { + if v := l.values[i]; !math.IsNaN(v) { + lo, hi = min(lo, v), max(hi, v) + } + } + } + if spec.Kind == IndicatorRSI { + lo, hi = 0, 100 + } + return [2]float64{lo, hi} + }) + lo, hi := yr[0], yr[1] + spec2 := chartSpec{n: len(candles), keys: keys} + if m.to <= m.from || math.IsInf(lo, 0) { + spec2.n = 0 + } + m.lo, m.hi = lo, hi + spec2.pick = func(r ui.Rect, x, y float32) int { + g := m.paneGeom(r) + if !g.plot.Contains(x, y) { + return -1 + } + return g.index(m, x) + } + spec2.readout = func(i int) string { + parts := []string{candles[i].Time.Format("2006-01-02 15:04")} + for _, l := range lines { + parts = append(parts, l.name+" "+chartValue(c, l.values[i], func(v float64) string { return fmt.Sprintf("%.2f", v) })) + } + return strings.Join(parts, " · ") + } + spec2.draw = func(p *ui.Painter, r ui.Rect, active int) { + g := m.paneGeom(r) + size := FontSize(c, theme.CaptionSize) + internal.AxisY(p, g.plot, g.ys, g.ticks, func(v float64) string { return volumeShort(v, g.step) }, size, k) + p.Clip(g.plot, 0, func() { + for i, l := range lines { + if spec.Kind == IndicatorMACD && i == 2 { + indicatorPaneBars(p, g, m, l.values, c) + continue + } + mktPaintLine(p, g, m, l.values, l.color, i) + } + }) + if active >= m.from && active < m.to { + x := internal.Snap(p, g.x(m, active)) + p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted) + } + } + ch.build(spec2) + return closed && !ch.res.Element.IsDisabled() +} + +// paneGeom is geom without the time axis, for lower panes. +func (m *mktCore) paneGeom(r ui.Rect) mktGeom { + plot := chartInset(r, 60, 4) + lo, hi, step := internal.Nice(m.lo, m.hi, 2) + return mktGeom{plot: plot, band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3}, + ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, ticks: internal.Ticks(lo, hi, step), step: step} +} + +// indicatorPaneBars draws MACD's histogram: hollow bars above zero, filled below. +func indicatorPaneBars(p *ui.Painter, g mktGeom, m *mktCore, vals []float64, c *ui.Context) { + k := Tokens(c) + zero := g.ys.Map(0) + w := max(1, g.band.Width()) + for i := m.from; i < m.to; i++ { + v := vals[i] + if math.IsNaN(v) { + continue + } + y := g.ys.Map(v) + x := internal.Snap(p, g.x(m, i)) + bar := ui.Rect{X: x - w/2, Y: min(y, zero), W: w, H: max(1, float32(math.Abs(float64(y-zero))))} + if v >= 0 && w >= 3 { + p.Fill(bar, k.Surface, 0) + p.Stroke(bar, trendColor(c, 1), 0, 1) + } else { + p.Fill(bar, trendColor(c, trendSign(v)), 0) + } + } +} diff --git a/indicator_pane_test.go b/indicator_pane_test.go new file mode 100644 index 0000000..332e2ac --- /dev/null +++ b/indicator_pane_test.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestIndicatorPaneStackReadAndRemove(t *testing.T) { + candles := sampleCandles(300) + view := MarketView{Count: 80} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI, Params: []float64{6}}}} + removed := -1 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if r := IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Label: "BTC"}); r.Removed >= 0 { + removed = r.Removed + } + }, 800, 500) + tt.Frame() + if !tt.HasText("MACD(12,26,9)") || !tt.HasText("RSI(6)") || len(panes.Sizes) != 2 { + t.Fatalf("texts %q sizes %v", tt.Texts(), panes.Sizes) + } + tt.Key(0, ui.KeyTab) // remove MACD + tt.Key(0, ui.KeyTab) // MACD plot + tt.Key(0, ui.KeyEnd) + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, "DIF ") && strings.Contains(s, "DEA ") + } + if !found { + t.Fatalf("MACD readout missing: %q", tt.Texts()) + } + if err := tt.Click("Remove MACD(12,26,9)"); err != nil { + t.Fatal(err) + } + if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI || removed != 0 { + t.Fatalf("after remove %+v (%d)", panes.Panes, removed) + } + for name, p := range map[string]IndicatorPanes{ + "overlay kind": {Panes: []IndicatorPaneSpec{{Kind: IndicatorMA}}}, + "bad params": {Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI, Params: []float64{1, 2}}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); IndicatorPane(c, candles, &view, &p, IndicatorPaneOptions{}) }, 300, 200) + }() + } +} + +func TestIndicatorPaneSnapshot(t *testing.T) { + candles := sampleCandles(120) + view := MarketView{Count: 80} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorKDJ}}} + snapshot(t, "indicator_pane", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Height: 316}) + }) + }) +} diff --git a/messages_mktchart.go b/messages_mktchart.go index e804b54..97e1603 100644 --- a/messages_mktchart.go +++ b/messages_mktchart.go @@ -13,6 +13,7 @@ func init() { "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", "标注"}, }) } diff --git a/testdata/snapshots/darwin/indicator_pane-dark.png b/testdata/snapshots/darwin/indicator_pane-dark.png new file mode 100644 index 0000000..5e3cda3 Binary files /dev/null and b/testdata/snapshots/darwin/indicator_pane-dark.png differ diff --git a/testdata/snapshots/darwin/indicator_pane-light.png b/testdata/snapshots/darwin/indicator_pane-light.png new file mode 100644 index 0000000..f4df8bd Binary files /dev/null and b/testdata/snapshots/darwin/indicator_pane-light.png differ diff --git a/testdata/snapshots/darwin/volume_chart-dark.png b/testdata/snapshots/darwin/volume_chart-dark.png new file mode 100644 index 0000000..cbaf592 Binary files /dev/null and b/testdata/snapshots/darwin/volume_chart-dark.png differ diff --git a/testdata/snapshots/darwin/volume_chart-light.png b/testdata/snapshots/darwin/volume_chart-light.png new file mode 100644 index 0000000..ec4a431 Binary files /dev/null and b/testdata/snapshots/darwin/volume_chart-light.png differ diff --git a/volume_chart.go b/volume_chart.go new file mode 100644 index 0000000..6885fb2 --- /dev/null +++ b/volume_chart.go @@ -0,0 +1,139 @@ +package mujica + +import ( + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// VolumeChartOptions configure a VolumeChart. +type VolumeChartOptions struct { + Frame ChartFrame + // Sync links the crosshair to other charts under SyncID. + Sync *ChartSync + SyncID string +} + +// VolumeChart draws the volume of the view's window as bars, hollow on rises and filled on falls; give it the price chart's view to share the time axis. +func VolumeChart(c *ui.Context, candles []Candle, view *MarketView, opts VolumeChartOptions) ChartResult { + if view == nil { + panic("mujica: VolumeChart needs a view") + } + if opts.Sync != nil && opts.SyncID == "" { + panic("mujica: VolumeChart Sync needs a SyncID") + } + if opts.Frame.Height == 0 { + opts.Frame.Height = 120 + } + ch := newChart(c, opts.Frame) + chartMemo(ch, "check", chartID(candles), func() bool { marketCheckCandles(candles); return true }) + m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: candles, view: view} + if s := opts.Sync; s != nil { + s.load(opts.SyncID, candles, view) + } + m.window() + hi := chartMemo(ch, "vmax", [3]any{chartID(candles), m.from, m.to}, func() float64 { + v := 0.0 + for i := m.from; i < m.to; i++ { + v = max(v, candles[i].Volume) + } + return v + }) + k := m.k + size := FontSize(c, theme.CaptionSize) + geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear, []float64, float64) { + plot := chartInset(r, 60, 4) + lo, top, step := internal.Nice(0, max(hi, 1), 2) + return plot, internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3}, + internal.Linear{D0: lo, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, internal.Ticks(lo, top, step), step + } + spec := chartSpec{n: len(candles)} + if m.to <= m.from { + spec.n = 0 + } + spec.pick = func(r ui.Rect, x, y float32) int { + plot, band, _, _, _ := geom(r) + if !plot.Contains(x, y) { + return -1 + } + return max(m.from, min(m.to-1, m.from+int((x-band.R0)/band.Step()))) + } + spec.readout = func(i int) string { + cd := candles[i] + return strings.Join([]string{cd.Time.Format("2006-01-02 15:04"), msg(c, "candlestick_chart.volume") + " " + statisticFormat(cd.Volume, 0), + trendWord(c, trendSign(cd.Close-cd.Open))}, " · ") + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, band, ys, ticks, step := geom(r) + internal.AxisY(p, plot, ys, ticks, func(v float64) string { return volumeShort(v, step) }, size, k) + w := max(1, band.Width()) + at := active + if at < 0 && opts.Sync != nil { + at = opts.Sync.cursorIndex(opts.SyncID, candles) + } + for i := m.from; i < m.to; i++ { + cd := candles[i] + x := internal.Snap(p, band.Center(i-m.from)) + y := ys.Map(cd.Volume) + bar := ui.Rect{X: x - w/2, Y: y, W: w, H: plot.Y + plot.H - y} + up := cd.Close >= cd.Open + col := trendColor(c, 1) + if !up { + col = trendColor(c, -1) + } + if up && w >= 3 { + p.Fill(bar, k.Surface, 0) + p.Stroke(bar, col, 0, 1) + } else { + p.Fill(bar, col, 0) + } + } + m.st0().drawn = m.to - m.from + if at >= m.from && at < m.to { + x := internal.Snap(p, band.Center(at-m.from)) + p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.TextMuted) + } + } + res, plot := ch.build(spec) + if plot != nil && !plot.IsDisabled() { + room := m.room() + switch { + case plot.Shortcut(ui.Shift, ui.KeyLeft): + marketPan(view, len(candles), room, -max(1, (m.to-m.from)/10)) + case plot.Shortcut(ui.Shift, ui.KeyRight): + marketPan(view, len(candles), room, max(1, (m.to-m.from)/10)) + } + if f := ch.st.focus; f >= 0 && (f < m.from || f >= m.to) { + ch.st.focus = max(m.from, min(m.to-1, f)) + } + } + if s := opts.Sync; s != nil { + s.store(opts.SyncID, candles, view, res.Point) + } + return res +} + +// st0 is the chart's market state, made on first use. +func (m *mktCore) st0() *mktState { + if m.st == nil { + m.st = *ui.Local(m.ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1} }) + } + return m.st +} + +// volumeShort writes a volume tick as 1.2K, 3M or 4B. +func volumeShort(v, step float64) string { + for _, u := range []struct { + div float64 + unit string + }{{1e9, "B"}, {1e6, "M"}, {1e3, "K"}} { + if math.Abs(v) >= u.div { + return internal.FormatTick(v/u.div, step/u.div) + u.unit + } + } + return internal.FormatTick(v, step) +} diff --git a/volume_chart_test.go b/volume_chart_test.go new file mode 100644 index 0000000..ef563d6 --- /dev/null +++ b/volume_chart_test.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestVolumeChartSharesViewAndReads(t *testing.T) { + candles := sampleCandles(100000) + view := MarketView{Count: 60} + var price, vol ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(800).Children(func() { + price = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Price"}}) + vol = VolumeChart(c, candles, &view, VolumeChartOptions{Frame: ChartFrame{Label: "Volume"}}) + }) + }, 820, 600) + tt.Frame() + if d := mktState0(vol).drawn; d != 60 { + t.Fatalf("volume drew %d bars", d) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + found := false + for _, s := range tt.Texts() { + found = found || strings.HasPrefix(s, candles[99999].Time.Format("2006-01-02 15:04")+" · Vol ") + } + if !found || vol.Point != 99999 { + t.Fatalf("readout %q point %d", tt.Texts(), vol.Point) + } + tt.Key(ui.Shift, ui.KeyLeft) + if view.End == 0 { + t.Fatal("Shift+Left on volume did not pan the shared view") + } + _ = price + defer func() { + if recover() == nil { + t.Fatal("nil view accepted") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); VolumeChart(c, candles, nil, VolumeChartOptions{}) }, 200, 100) +} + +func TestVolumeChartSnapshot(t *testing.T) { + candles := sampleCandles(80) + view := MarketView{Count: 60} + snapshot(t, "volume_chart", 640, 180, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + VolumeChart(c, candles, &view, VolumeChartOptions{Frame: ChartFrame{Label: "Volume"}}) + }) + }) +}