WIP mktchart 2

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:53:33 -04:00
1 parent 341caf13e1
commit ab59d51628
9 files changed
+481

No files matched your search

+220
View File
@@ -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)
}
}
}
+65
View File
@@ -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})
})
})
}
+1
View File
@@ -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", "标注"},
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+139
View File
@@ -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)
}
+56
View File
@@ -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"}})
})
})
}