diff --git a/candle_countdown.go b/candle_countdown.go new file mode 100644 index 0000000..e4561c5 --- /dev/null +++ b/candle_countdown.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CandleCountdownOptions configure a CandleCountdown. +type CandleCountdownOptions struct { + // Location defaults to UTC. + Location *time.Location + // Now freezes the clock; zero uses the frame time. + Now time.Time +} + +// CandleCountdown displays closing time and schedules visible updates. +func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) *ui.Element { + loc := opts.Location + if loc == nil { + loc = time.UTC + } + now := opts.Now + if now.IsZero() { + now = c.Now() + } + left := interval.Close(now, loc).Sub(now) + k := Tokens(c) + text := candleCountdownText(left) + e := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleText).Label(fmt.Sprintf(msg(c, "candle_countdown.label"), string(interval), text)) + e.Children(func() { + ui.Text(c, string(interval)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Text(c, text).FontFeatures("tnum").FontWeight(500).TextColor(k.Text).SingleLine() + }) + // The next whole second, so the text never skips one. + if opts.Now.IsZero() { + e.DrawOver(func(_ *ui.Painter, _ ui.Rect) { c.After(left - left.Truncate(time.Second) + time.Millisecond) }) + } + return e +} + +// candleCountdownText writes d as 59:58, 3:59:58 or 2d 03:59:58. +func candleCountdownText(d time.Duration) string { + s := int(d.Seconds()) + days, h, m, sec := s/86400, s/3600%24, s/60%60, s%60 + switch { + case days > 0: + return fmt.Sprintf("%dd %02d:%02d:%02d", days, h, m, sec) + case h > 0: + return fmt.Sprintf("%d:%02d:%02d", h, m, sec) + } + return fmt.Sprintf("%02d:%02d", m, sec) +} diff --git a/candle_countdown_test.go b/candle_countdown_test.go new file mode 100644 index 0000000..2b42c7b --- /dev/null +++ b/candle_countdown_test.go @@ -0,0 +1,41 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" + "time" +) + +func TestCandleCountdownIntervalsAndClock(t *testing.T) { + now := time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC) + iv := Interval("1m") + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); CandleCountdown(c, iv, CandleCountdownOptions{Now: now}) }, 300, 100) + if !tt.HasText("00:55") { + t.Fatalf("countdown %q", tt.Texts()) + } + iv = "1h" + tt.Frame() + if !tt.HasText("25:55") { + t.Fatalf("hour %q", tt.Texts()) + } + for _, period := range Intervals { + if !period.Close(now, time.UTC).After(now) { + t.Fatalf("close %s", period) + } + } + for _, tc := range []struct { + d time.Duration + want string + }{{time.Second, "00:01"}, {time.Hour + time.Second, "1:00:01"}, {48*time.Hour + time.Hour, "2d 01:00:00"}} { + if got := candleCountdownText(tc.d); got != tc.want { + t.Fatalf("text %q", got) + } + } +} +func TestCandleCountdownSnapshot(t *testing.T) { + snapshot(t, "candle_countdown", 300, 80, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { + CandleCountdown(c, "1h", CandleCountdownOptions{Now: time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC)}) + }) + }) +} diff --git a/candlestick_chart.go b/candlestick_chart.go new file mode 100644 index 0000000..8dcdf08 --- /dev/null +++ b/candlestick_chart.go @@ -0,0 +1,310 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartType selects the price chart shape. +type ChartType int + +const ( + ChartCandles ChartType = iota + ChartOHLC + ChartLine + ChartArea + ChartHeikinAshi +) + +// CandlestickOptions configure a CandlestickChart and its layers. +type CandlestickOptions struct { + Frame ChartFrame + Type ChartType + // Decimals of prices; zero means 2. + Decimals int + // Overlays draw indicators over the prices. + Overlays []IndicatorOverlay + // Compare switches the chart to normalized percent prices. + Compare *[]CompareSymbol + // Alerts provides draggable lines and price-crossing callbacks. + Alerts *[]PriceAlertLine + OnAlert func(a PriceAlertLine, last Candle) + // Drawings, when set, takes clicks for the current tool. + Drawings *DrawingTools + // Sync links the window and crosshair under SyncID. + Sync *ChartSync + SyncID string +} + +// mktState is a price chart's own frame-to-frame state. +type mktState struct { + drawn int // candles painted last frame + dragRest float32 // pan drag left over, under a candle + lastClose float64 + alertSeen bool + grab int // alert index dragged, -1 none + focus int // keyboard mark last frame, to follow only when it moves + alertFocus int + plot ui.Rect + ys internal.Linear // the price scale as last painted +} + +// mktCore is one price chart being built. +type mktCore struct { + c *ui.Context + k theme.Tokens + ch *chart + candles []Candle + view *MarketView + from, to int + lo, hi float64 + base float64 // percent mode divides by this close; 0 is prices + dec int + opts CandlestickOptions + st *mktState + overlays []mktLine +} + +// mktLine is one drawn indicator or compare line. +type mktLine struct { + name string + values []float64 + color ui.Color + shape int +} + +// CandlestickChart paints visible candles with hollow rises and filled falls. +func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult { + if view == nil { + panic("mujica: CandlestickChart needs a view") + } + if opts.Type < ChartCandles || opts.Type > ChartHeikinAshi { + panic(fmt.Sprintf("mujica: unknown ChartType %d", opts.Type)) + } + if opts.Sync != nil && opts.SyncID == "" { + panic("mujica: CandlestickChart Sync needs a SyncID") + } + ch := newChart(c, opts.Frame) + shown := chartMemo(ch, "candles", [2]any{chartID(candles), opts.Type}, func() []Candle { + marketCheckCandles(candles) + if opts.Type == ChartHeikinAshi { + return HeikinAshi(candles) + } + return candles + }) + m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: shown, view: view, opts: opts, dec: priceDecimals("CandlestickChart", opts.Decimals)} + m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} }) + if ch.res.Element.IsDisabled() { + copy := *view + view = © + m.view = view + m.opts.Sync = nil + opts.Sync = nil + } + if s := opts.Sync; s != nil { + s.load(opts.SyncID, shown, view) + } + m.window() + m.overlays = mktOverlayLines(ch, m, candles) + m.compareLines() + m.yDomain() + m.alertsCheck() + res, plot := ch.build(m.spec()) + if plot != nil { + m.input(plot) + } + if s := opts.Sync; s != nil { + s.store(opts.SyncID, shown, view, res.Point) + } + return res +} + +// OHLCChart is CandlestickChart drawn as open-high-low-close bars. +func OHLCChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult { + opts.Type = ChartOHLC + return CandlestickChart(c, candles, view, opts) +} + +// HeikinAshiChart switches between smoothed candles and raw OHLC. +func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *bool, opts CandlestickOptions) ChartResult { + if raw == nil { + panic("mujica: HeikinAshiChart needs raw") + } + opts.Type = ChartHeikinAshi + if *raw { + opts.Type = ChartCandles + } + var res ChartResult + col := ui.Column(c).Gap(Density(c).Unit() * 2).MinWidth(0) + col.Children(func() { + ui.Row(c).Children(func() { + v := []bool{*raw} + if ToggleGroup(c, &v, []ToggleItem[bool]{{Value: false, Label: msg(c, "heikin_ashi_chart.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi_chart.raw")}}, + ToggleGroupOptions{Label: msg(c, "heikin_ashi_chart.switch")}).Changed() { + *raw = v[0] + slog.Debug("mujica: heikin_ashi switch", "raw", *raw) + } + }) + res = CandlestickChart(c, candles, view, opts) + }) + res.Element = col + return res +} + +// window resolves visible candles using the last measured width. +func (m *mktCore) window() { + m.from, m.to = marketWindow(*m.view, len(m.candles), m.room()) +} + +// room is how many candles fit, three DIPs each. +func (m *mktCore) room() int { + w := m.ch.res.Element.Bounds().W - 64 + if w <= 0 { + return 200 + } + return max(10, int(w/3)) +} + +// yDomain caches visible prices, indicators, and alerts. +func (m *mktCore) yDomain() { + if m.to <= m.from { + return + } + var alerts []PriceAlertLine + if m.opts.Alerts != nil { + alerts = *m.opts.Alerts + } + ids := fmt.Sprintf("%s|%d|%d|%v|%d|%v", chartID(m.candles), m.from, m.to, m.base, len(m.overlays), alerts) + for _, l := range m.overlays { + ids += chartID(l.values) + } + d := chartMemo(m.ch, "ydomain", ids, func() [2]float64 { + lo, hi := math.Inf(1), math.Inf(-1) + for i := m.from; i < m.to; i++ { + lo, hi = min(lo, m.val(m.candles[i].Low)), max(hi, m.val(m.candles[i].High)) + for _, l := range m.overlays { + if v := l.values[i]; !math.IsNaN(v) { + lo, hi = min(lo, v), max(hi, v) + } + } + } + if m.base == 0 { + for _, a := range alerts { + lo, hi = min(lo, a.Price), max(hi, a.Price) + } + } + return [2]float64{lo, hi} + }) + m.lo, m.hi = d[0], d[1] +} + +// val converts price to the current axis unit. +func (m *mktCore) val(p float64) float64 { + if m.base == 0 { + return p + } + return (p/m.base - 1) * 100 +} + +// mktGeom places candles and prices in a plot. +type mktGeom struct { + plot ui.Rect + band internal.Band + ys internal.Linear + ticks []float64 + step float64 +} + +func (m *mktCore) geom(r ui.Rect) mktGeom { + plot := chartInset(r, 60, 26) + lo, hi, step := internal.Nice(m.lo, m.hi, 4) + 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, + } +} + +func (g mktGeom) x(m *mktCore, i int) float32 { return g.band.Center(i - m.from) } + +// index is the candle under x, clamped to the window. +func (g mktGeom) index(m *mktCore, x float32) int { + i := m.from + int((x-g.band.R0)/g.band.Step()) + return max(m.from, min(m.to-1, i)) +} + +func (m *mktCore) spec() ChartSpec { + n := len(m.candles) + var keys []ChartLegendItem + for _, l := range m.overlays { + keys = append(keys, ChartLegendItem{Name: l.name, Color: l.color, Shape: l.shape}) + } + if n == 0 && m.opts.Compare != nil { + pal := internal.Palette(m.k) + for i, s := range *m.opts.Compare { + keys = append(keys, ChartLegendItem{Name: s.Name, Color: pal[(i+1)%len(pal)], Shape: i + 1}) + } + } + spec := ChartSpec{Marks: n, Legend: keys} + if m.to <= m.from { + spec.Marks = 0 + return spec + } + spec.Pick = func(r ui.Rect, x, y float32) int { + g := m.geom(r) + if !g.plot.Contains(x, y) { + return -1 + } + return g.index(m, x) + } + spec.Move = func(i int, key ui.Key) int { + if key == ui.KeyLeft || key == ui.KeyUp { + return max(i-1, 0) + } + return min(i+1, n-1) + } + if d := m.opts.Drawings; d != nil { + spec.Activate = func(i int) { + if !d.Locked && d.Tool != ToolSelect { + d.place(m, DrawingPoint{m.candles[i].Time, m.candles[i].Close}) + } + } + } + spec.Readout = m.readout + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) } + return spec +} + +// readout describes time, OHLC, volume, direction, and indicators. +func (m *mktCore) readout(i int) string { + c := m.candles[i] + f := func(v float64) string { return priceUngrouped(v, m.dec) } + prev := c.Open + if i > 0 { + prev = m.candles[i-1].Close + } + sign := trendSign(c.Close - prev) + pct := 0.0 + if prev != 0 { + pct = (c.Close/prev - 1) * 100 + } + parts := []string{ + c.Time.Format("2006-01-02 15:04"), + fmt.Sprintf(msg(m.c, "candlestick_chart.ohlc"), f(c.Open), f(c.High), f(c.Low), f(c.Close)), + fmt.Sprintf("%+.2f%% %s", pct, trendWord(m.c, sign)), + msg(m.c, "candlestick_chart.volume") + " " + statisticFormat(c.Volume, 0), + } + for _, l := range m.overlays { + parts = append(parts, l.name+" "+chartValue(m.c, l.values[i], f)) + } + return strings.Join(parts, " · ") +} diff --git a/candlestick_chart_test.go b/candlestick_chart_test.go new file mode 100644 index 0000000..6b4c554 --- /dev/null +++ b/candlestick_chart_test.go @@ -0,0 +1,150 @@ +package mujica + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// sampleCandles is a deterministic random walk, hourly from 2026-01-01. +func sampleCandles(n int) []Candle { + out := make([]Candle, n) + price := 100.0 + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := range out { + o := price + c := o + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8 + hi, lo := max(o, c)+0.6+math.Abs(math.Sin(float64(i)))*0.5, min(o, c)-0.5-math.Abs(math.Cos(float64(i)))*0.4 + out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: o, High: hi, Low: lo, Close: c, Volume: 1000 + float64(i%17)*130} + price = c + } + return out +} + +func mktState0(r ChartResult) *mktState { + return *ui.Local(r.Element, "mujica.mkt", func() *mktState { return nil }) +} + +func TestCandlestickChartDrawsOnlyVisible(t *testing.T) { + candles := sampleCandles(100000) + var view MarketView + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}) + }, 900, 400) + tt.Frame() + st := mktState0(res) + if st.drawn != 120 { + t.Fatalf("drew %d candles of 100000", st.drawn) + } + tt.Key(0, ui.KeyTab) + for range 40 { + tt.Key(0, ui.KeyMinus) + } + if st.drawn > 900/3 || view.Count > 900/3 { + t.Fatalf("zoomed out: drew %d, view %+v", st.drawn, view) + } +} + +func TestCandlestickChartKeyboardPanZoomReadout(t *testing.T) { + candles := sampleCandles(500) + view := MarketView{Count: 50} + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}) + }, 900, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 499 || !tt.HasText("2026-01-21 19:00") { + t.Fatalf("End: point %d texts %q", res.Point, tt.Texts()) + } + tt.Key(ui.Shift, ui.KeyLeft) + if view.End != 495 || view.Count != 50 { + t.Fatalf("Shift+Left: %+v", view) + } + tt.Key(0, ui.KeyEqual) + if view.Count != 40 { + t.Fatalf("zoom in: %+v", view) + } + tt.Key(0, ui.KeyHome) + tt.Frame() + if f, _ := marketWindow(view, 500, 300); f != 0 { + t.Fatalf("Home did not bring the first candle into view: %+v", view) + } + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, "O 100.00") && strings.Contains(s, "Vol 1,000") + } + if !found { + t.Fatalf("readout %q", tt.Texts()) + } +} + +func TestCandlestickChartDragPansAndRejects(t *testing.T) { + candles := sampleCandles(300) + view := MarketView{Count: 100} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}).Element.Width(860) + }, 900, 400) + tt.Frame() + tt.Press(400, 150) + tt.Move(560, 150) // about 20 candles of 8 DIPs + tt.Release(560, 150) + if view.End < 275 || view.End > 285 { + t.Fatalf("drag right shows older candles: %+v", view) + } + for name, f := range map[string]func(c *ui.Context){ + "type": func(c *ui.Context) { + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Type: 9}) + }, + "order": func(c *ui.Context) { + bad := []Candle{candles[1], candles[0]} + CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}}) + }, + "prices": func(c *ui.Context) { + bad := []Candle{{Time: candles[0].Time, Open: 5, High: 4, Low: 3, Close: 4}} + CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}}) + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestCandlestickChartTrendColors(t *testing.T) { + candles := sampleCandles(30) + view := MarketView{Count: 30} + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + UseTrend(c, RedUp) + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "沪深300"}}) + }, 600, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if res.Point != 0 || !tt.HasText("开 100.00") { + t.Fatalf("zh readout %q", tt.Texts()) + } +} + +func TestCandlestickChartSnapshot(t *testing.T) { + candles := sampleCandles(80) + view := MarketView{Count: 60} + snapshot(t, "candlestick_chart", 640, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}}) + }) + }) +} diff --git a/candlestick_paint.go b/candlestick_paint.go new file mode 100644 index 0000000..436f8f7 --- /dev/null +++ b/candlestick_paint.go @@ -0,0 +1,154 @@ +package mujica + +import ( + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// paint draws axes, visible candles, layers, and the crosshair. +func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) { + g := m.geom(r) + m.st.plot, m.st.ys = g.plot, g.ys + k := m.k + size := FontSize(m.c, theme.CaptionSize) + label := func(v float64) string { + if m.base != 0 { + return internal.FormatTick(v, g.step) + "%" + } + return internal.FormatTick(v, g.step) + } + internal.AxisY(p, g.plot, g.ys, g.ticks, label, size, k) + m.paintTimeAxis(p, g, size) + p.Clip(g.plot, 0, func() { + m.st.drawn = m.paintCandles(p, g) + for _, l := range m.overlays { + mktPaintLine(p, g, m, l.values, l.color, l.shape) + } + m.paintAlerts(p, g, size) + if d := m.opts.Drawings; d != nil { + d.paint(p, g, m, size) + } + }) + at := active + if at < 0 && m.opts.Sync != nil { + at = m.opts.Sync.cursorIndex(m.opts.SyncID, m.candles) + } + if at >= m.from && at < m.to { + x := internal.Snap(p, g.x(m, at)) + y := internal.Snap(p, g.ys.Map(m.val(m.candles[at].Close))) + p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted) + p.Line(g.plot.X, y, g.plot.X+g.plot.W, y, 1, k.TextMuted.Alpha(0.6)) + mktTag(p, g.plot.X+g.plot.W, y, label(m.val(m.candles[at].Close)), size, k.Text, k.Surface) + } +} + +// paintTimeAxis labels candles about 90 DIPs apart. +func (m *mktCore) paintTimeAxis(p *ui.Painter, g mktGeom, size float32) { + every := max(1, int(math.Ceil(float64(90/g.band.Step())))) + layout := msg(m.c, "candlestick_chart.daily_time") + if m.to-m.from > 1 && m.candles[m.from+1].Time.Sub(m.candles[m.from].Time) < 24*time.Hour { + layout = "15:04" + } + var xs []float32 + var labels []string + for i := m.from; i < m.to; i += every { + xs = append(xs, g.x(m, i)) + labels = append(labels, m.candles[i].Time.Format(layout)) + } + internal.AxisX(p, g.plot, xs, labels, size, m.k) +} + +// paintCandles returns the number of visible candles painted. +func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int { + k := m.k + w := max(1, g.band.Width()) + if m.opts.Type == ChartLine || m.opts.Type == ChartArea { + var line, fill ui.Path + for i := m.from; i < m.to; i++ { + x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(m.val(m.candles[i].Close))) + if i == m.from { + line.MoveTo(x, y) + fill.MoveTo(x, g.plot.Y+g.plot.H).LineTo(x, y) + } else { + line.LineTo(x, y) + fill.LineTo(x, y) + } + } + if m.opts.Type == ChartArea { + fill.LineTo(internal.Snap(p, g.x(m, m.to-1)), g.plot.Y+g.plot.H).Close() + p.FillPath(&fill, k.AccentText.Alpha(0.18)) + } + p.StrokePath(&line, 2, k.AccentText) + return m.to - m.from + } + for i := m.from; i < m.to; i++ { + c := m.candles[i] + up := c.Close >= c.Open + col := trendColor(m.c, 1) + if !up { + col = trendColor(m.c, -1) + } + x := internal.Snap(p, g.x(m, i)) + yo, yc := g.ys.Map(m.val(c.Open)), g.ys.Map(m.val(c.Close)) + yh, yl := g.ys.Map(m.val(c.High)), g.ys.Map(m.val(c.Low)) + if m.opts.Type == ChartOHLC { + p.Line(x, yh, x, yl, 1.5, col) + p.Line(x-w/2, yo, x, yo, 1.5, col) + p.Line(x, yc, x+w/2, yc, 1.5, col) + continue + } + top, bot := min(yo, yc), max(yo, yc) + body := ui.Rect{X: x - w/2, Y: top, W: w, H: max(1, bot-top)} + p.Line(x, yh, x, top, 1, col) + p.Line(x, bot, x, yl, 1, col) + // Rises are hollow; falls are filled. + if up && w >= 3 { + p.Fill(body, k.Surface, 0) + p.Stroke(body, col, 0, 1) + } else { + p.Fill(body, col, 0) + } + } + return m.to - m.from +} + +// mktPaintLine draws visible values with distinct series markers. +func mktPaintLine(p *ui.Painter, g mktGeom, m *mktCore, values []float64, col ui.Color, shape int) { + var path ui.Path + open := false + for i := m.from; i < m.to; i++ { + v := values[i] + if math.IsNaN(v) { + open = false + continue + } + x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(v)) + if open { + path.LineTo(x, y) + } else { + path.MoveTo(x, y) + open = true + } + } + p.StrokePath(&path, 1.5, col) + every := max(1, int(60/max(g.band.Step(), 0.1))) + for i := m.from + shape%every; i < m.to; i += every { + if v := values[i]; !math.IsNaN(v) { + internal.Marker(p, shape, g.x(m, i), g.ys.Map(v), 6, col) + } + } +} + +// mktTag centers a price label on the right edge. +func mktTag(p *ui.Painter, right, y float32, s string, size float32, fg, bg ui.Color) { + w, h := p.MeasureText(0, ui.Span{Text: s, Size: size}) + b := ui.Rect{X: right - w - 8, Y: y - h/2 - 2, W: w + 8, H: h + 4} + p.Fill(b, bg, theme.ControlRadius) + p.Stroke(b, fg, theme.ControlRadius, 1) + p.Text(b.X+4, b.Y+2, s, size, fg) +} diff --git a/chart_sync.go b/chart_sync.go new file mode 100644 index 0000000..82e43ab --- /dev/null +++ b/chart_sync.go @@ -0,0 +1,139 @@ +package mujica + +import ( + "log/slog" + "sort" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartSync shares time windows and crosshairs across linked charts. +type ChartSync struct { + // Count is linked zoom; zero defaults to 120. + Count int + // End is inclusive; zero follows each chart's latest candle. + End time.Time + // Start is the inclusive left edge across candle intervals. + Start time.Time + // Cursor is the crosshair time; zero shows none. + Cursor time.Time + // Unlinked charts keep their own view and crosshair. + Unlinked map[string]bool + owner string + rangeOwner string + loaded map[string]MarketView +} + +func (s *ChartSync) linked(id string) bool { return !s.Unlinked[id] } + +// load applies the shared time window to linked charts. +func (s *ChartSync) load(id string, candles []Candle, view *MarketView) { + if !s.linked(id) { + return + } + view.Count, view.End = s.Count, 0 + if !s.End.IsZero() { + if i := marketIndexAt(candles, s.End); i+1 < len(candles) { + view.End = max(1, i+1) + } + } + if s.rangeOwner == "" { + s.rangeOwner = id + } + if len(candles) > 0 && (s.Start.IsZero() || s.End.IsZero() && s.rangeOwner == id) { + from, _ := marketWindow(*view, len(candles), len(candles)) + s.Start = candles[from].Time + } + if !s.Start.IsZero() && len(candles) > 0 { + from := sort.Search(len(candles), func(i int) bool { return !candles[i].Time.Before(s.Start) }) + to := len(candles) + if view.End > 0 { + to = view.End + } + if from < to { + view.Count = to - from + } + } + if s.loaded == nil { + s.loaded = map[string]MarketView{} + } + s.loaded[id] = *view +} + +// store updates the shared window and active crosshair. +func (s *ChartSync) store(id string, candles []Candle, view *MarketView, active int) { + if !s.linked(id) { + return + } + loaded, ok := s.loaded[id] + if !ok || *view != loaded { + s.Count, s.End, s.rangeOwner = view.Count, time.Time{}, id + if view.End > 0 && view.End <= len(candles) { + s.End = candles[view.End-1].Time + } + if len(candles) > 0 { + from, _ := marketWindow(*view, len(candles), len(candles)) + s.Start = candles[from].Time + } + } + switch { + case active >= 0: + s.Cursor, s.owner = candles[active].Time, id + case s.owner == id: + s.Cursor, s.owner = time.Time{}, "" + } +} + +// cursorIndex is the candle at the linked crosshair, or -1. +func (s *ChartSync) cursorIndex(id string, candles []Candle) int { + if !s.linked(id) || s.Cursor.IsZero() || s.owner == id { + return -1 + } + return marketIndexAt(candles, s.Cursor) +} + +// Link toggles synchronization for a stable chart ID. +func (s *ChartSync) Link(c *ui.Context, id string) ChoiceResult { + if id == "" { + panic("mujica: ChartSync.Link needs an id") + } + k := Tokens(c) + on := s.linked(id) + pressed := on + b := ui.ToggleBase(c, &pressed).MinHeight(theme.MinHitSize).Padding(0, Density(c).Unit()*2).Gap(Density(c).Unit()). + Radius(theme.ControlRadius).Label(msg(c, "chart_sync.link")) + internal.Ring(b, k.Focus, theme.ControlRadius) + changed := b.Changed() && !b.IsDisabled() + if changed { + if s.Unlinked == nil { + s.Unlinked = map[string]bool{} + } + on = pressed + s.Unlinked[id] = !on + if s.owner == id { + s.Cursor, s.owner = time.Time{}, "" + } + slog.Debug("mujica: chart_sync link", "chart", id, "linked", on) + } + fg, bg, word := k.TextMuted, ui.Transparent, msg(c, "chart_sync.unlinked") + if on { + fg, bg, word = k.AccentText, k.Selection, msg(c, "chart_sync.linked") + } + if b.Hovered() && !on { + bg = k.SurfaceHover + } + b.Background(bg).TextColor(fg).Border(theme.BorderWidth, fg).Transition(colorFade) + b.Children(func() { + mark := "circle" + if on { + mark = "check" + } + ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, mark, fg) }) + ui.Text(c, word).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) + return ChoiceResult{b, changed} +} diff --git a/chart_sync_test.go b/chart_sync_test.go new file mode 100644 index 0000000..b1547f4 --- /dev/null +++ b/chart_sync_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestChartSyncRangeCursorAndUnlink(t *testing.T) { + cs := sampleCandles(300) + sync := ChartSync{} + v := MarketView{Count: 40, End: 200} + sync.store("a", cs, &v, 150) + var other MarketView + sync.load("b", cs, &other) + if other != v || sync.cursorIndex("b", cs) != 150 { + t.Fatalf("sync %+v %+v", other, sync) + } + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Disabled(disabled).Children(func() { sync.Link(c, "b") }) + }, 300, 90) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if sync.linked("b") || sync.cursorIndex("b", cs) != -1 { + t.Fatal("unlink failed") + } + other = MarketView{Count: 12} + sync.load("b", cs, &other) + if other.Count != 12 { + t.Fatal("unlinked view overwritten") + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeySpace) + if sync.linked("b") { + t.Fatal("disabled link changed") + } + sync.store("a", cs, &v, -1) + if !sync.Cursor.IsZero() { + t.Fatal("cursor remained after leaving owner") + } +} +func TestChartSyncSnapshot(t *testing.T) { + cs := sampleCandles(50) + sync := ChartSync{Cursor: cs[25].Time, owner: "a"} + v := MarketView{Count: 50} + snapshot(t, "chart_sync", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + sync.Link(c, "b") + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Linked", Height: 230}, Sync: &sync, SyncID: "b"}) + }) + }) +} diff --git a/chart_type_switcher.go b/chart_type_switcher.go new file mode 100644 index 0000000..ee105af --- /dev/null +++ b/chart_type_switcher.go @@ -0,0 +1,37 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// ChartTypeSwitcherOptions configure a ChartTypeSwitcher. +type ChartTypeSwitcherOptions struct { + Disabled bool +} + +// ChartTypeSwitcher chooses candles, OHLC, line, area, or Heikin-Ashi. +func ChartTypeSwitcher(c *ui.Context, value *ChartType, opts ChartTypeSwitcherOptions) ChoiceResult { + if value == nil { + panic("mujica: ChartTypeSwitcher needs a value") + } + if *value < ChartCandles || *value > ChartHeikinAshi { + panic(fmt.Sprintf("mujica: unknown ChartType %d", *value)) + } + var items []ToggleItem[ChartType] + for t := ChartCandles; t <= ChartHeikinAshi; t++ { + items = append(items, ToggleItem[ChartType]{Value: t, Label: msg(c, fmt.Sprintf("chart_type_switcher.type%d", t))}) + } + sel := []ChartType{*value} + var r ChoiceResult + ui.Row(c).Disabled(opts.Disabled).Children(func() { + r = ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "chart_type_switcher.label")}) + }) + if r.Changed() { + *value = sel[0] + slog.Debug("mujica: chart_type_switcher changed", "type", *value) + } + return r +} diff --git a/chart_type_switcher_test.go b/chart_type_switcher_test.go new file mode 100644 index 0000000..f810fa2 --- /dev/null +++ b/chart_type_switcher_test.go @@ -0,0 +1,41 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestChartTypeSwitcherKeyboardAndDisabled(t *testing.T) { + value := ChartCandles + disabled := false + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{Disabled: disabled}).Changed() { + changes++ + } + }, 800, 100) + tt.Key(0, ui.KeyTab) + for _, want := range []ChartType{ChartOHLC, ChartLine, ChartArea, ChartHeikinAshi} { + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if value != want { + t.Fatalf("type %v, want %v", value, want) + } + } + if changes != 4 { + t.Fatalf("changes %d", changes) + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + if value != ChartHeikinAshi || changes != 4 { + t.Fatal("disabled type changed") + } +} +func TestChartTypeSwitcherSnapshot(t *testing.T) { + value := ChartCandles + snapshot(t, "chart_type_switcher", 680, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{}) }) + }) +} diff --git a/compare_symbol_test.go b/compare_symbol_test.go new file mode 100644 index 0000000..138c83f --- /dev/null +++ b/compare_symbol_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCompareSymbolPercentAndRemove(t *testing.T) { + candles := sampleCandles(100) + view := MarketView{Count: 50} + eth := make([]float64, 100) + for i := range eth { + eth[i] = 20 + float64(i)*0.1 + } + cmp := []CompareSymbol{{Name: "ETH", Closes: eth}} + removed := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if CompareLegend(c, &cmp).Changed() { + removed++ + } + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Compare: &cmp}) + }, 800, 420) + tt.Key(0, ui.KeyTab) // remove ETH + tt.Key(0, ui.KeyTab) // the chart + tt.Key(0, ui.KeyEnd) + // Normalized from the first shown candle, index 50: (29.9/25 - 1) = 19.6%. + want := "ETH 19.60" + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, want) + } + if !found { + t.Fatalf("readout lacks %q: %q", want, tt.Texts()) + } + if err := tt.Click("Remove ETH"); err != nil { + t.Fatal(err) + } + if len(cmp) != 0 || removed != 1 { + t.Fatalf("remove: %v %d", cmp, removed) + } + defer func() { + if recover() == nil { + t.Fatal("short closes accepted") + } + }() + bad := []CompareSymbol{{Name: "X", Closes: []float64{1}}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Compare: &bad}) + }, 300, 200) +} + +func TestCompareSymbolSnapshot(t *testing.T) { + candles := sampleCandles(100) + view := MarketView{Count: 80} + eth, sol := make([]float64, 100), make([]float64, 100) + for i := range eth { + eth[i] = 20 + float64(i)*0.08 + float64(i%7)*0.2 + sol[i] = 5 - float64(i)*0.01 + float64(i%5)*0.05 + } + cmp := []CompareSymbol{{Name: "ETH", Closes: eth}, {Name: "SOL", Closes: sol}} + snapshot(t, "compare_symbol", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Gap(8).Children(func() { + CompareLegend(c, &cmp) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}, Type: ChartLine, Compare: &cmp}) + }) + }) +} diff --git a/depth_chart.go b/depth_chart.go new file mode 100644 index 0000000..e455673 --- /dev/null +++ b/depth_chart.go @@ -0,0 +1,183 @@ +package mujica + +import ( + "fmt" + "math" + "sort" + "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 holds cumulative bids and asks around the midpoint. +type depthData struct { + price, total []float64 + nBids int + mid, maxSize float64 +} + +// DepthChart paints cumulative depth and supports keyboard level readouts. +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 := []ChartLegendItem{{Name: msg(c, "depth_chart.bids"), Color: bidCol, Shape: 1}, {Name: msg(c, "depth_chart.asks"), Color: askCol, Shape: 3}} + drawn := ui.Local(ch.res.Element, "mujica.depth.drawn", func() int { return 0 }) + 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{Marks: len(d.price), Legend: keys} + if len(bids) == 0 || len(asks) == 0 { + spec.Marks = 0 + } + // Arrows move by ascending price. + 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 + } + v := xs.Invert(x) + pos := sort.Search(len(d.price), func(i int) bool { return d.price[order(i)] >= v }) + if pos == len(d.price) || pos > 0 && v-d.price[order(pos-1)] < d.price[order(pos)]-v { + pos-- + } + return order(pos) + } + 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 + *drawn = 0 + 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 + indices := chartMemo(ch, fmt.Sprint("depth.indices.", side), [3]any{chartID(d.price), plot.X, plot.W}, func() []int { + return internal.Columns(idx[1]-idx[0], func(i int) float32 { return xs.Map(d.price[idx[0]+i]) }, func(i int) float64 { return d.total[idx[0]+i] }) + }) + *drawn += len(indices) + for _, at := range indices { + i := idx[0] + at + 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 validates price order and computes cumulative volume. +func depthBuild(bids, asks []DepthLevel) depthData { + for i, l := range bids { + if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(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 math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(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 +} diff --git a/depth_chart_test.go b/depth_chart_test.go new file mode 100644 index 0000000..89763bf --- /dev/null +++ b/depth_chart_test.go @@ -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}}) + }) + }) +} diff --git a/docs/components/141-candlestick-chart.md b/docs/components/141-candlestick-chart.md new file mode 100644 index 0000000..4cfdd0e --- /dev/null +++ b/docs/components/141-candlestick-chart.md @@ -0,0 +1,42 @@ +# 141 CandlestickChart + +## 用途 + +K 线;涨跌色可配置(红涨或绿涨);缩放平移;万根级只绘可见区。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +`candles` 按时间递增;`MarketView.Count=0` 显示 120 根,`End=0` 跟随最新。`Frame.Height=0` 为 240 DIP。`Type` 控制形态,`Decimals=0` 为两位。涨跌配色通过 `UseTrend` 设置。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/142-ohlc-chart.md b/docs/components/142-ohlc-chart.md new file mode 100644 index 0000000..733ae36 --- /dev/null +++ b/docs/components/142-ohlc-chart.md @@ -0,0 +1,42 @@ +# 142 OHLCChart + +## 用途 + +美国线;与 K 线共用数据接口。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +与 CandlestickChart 共用 Candle、MarketView 和 CandlestickOptions;函数将 Type 设置为 ChartOHLC。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/143-heikin-ashi-chart.md b/docs/components/143-heikin-ashi-chart.md new file mode 100644 index 0000000..3893d3c --- /dev/null +++ b/docs/components/143-heikin-ashi-chart.md @@ -0,0 +1,43 @@ +# 143 HeikinAshiChart + +## 用途 + +平均 K 线;由调用方 OHLC 计算;与原 K 线可切换。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + raw := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +`raw=false` 显示 Heikin-Ashi;`raw=true` 显示原始数据。辅助函数 `HeikinAshi` 从调用方 OHLC 计算平滑序列。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/144-line-quote-chart.md b/docs/components/144-line-quote-chart.md new file mode 100644 index 0000000..7b610b2 --- /dev/null +++ b/docs/components/144-line-quote-chart.md @@ -0,0 +1,37 @@ +# 144 LineQuoteChart + +## 用途 + +分时图;昨收基线;均价线;盘前盘后分段。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +`PrevClose` 为必填正数;RegularOpen、RegularClose 成对提供,零值展示同一时段。均价使用 Price × Volume 累计。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/145-volume-chart.md b/docs/components/145-volume-chart.md new file mode 100644 index 0000000..9721a7e --- /dev/null +++ b/docs/components/145-volume-chart.md @@ -0,0 +1,42 @@ +# 145 VolumeChart + +## 用途 + +成交量柱;涨跌着色;与主图共享时间轴。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +传入与主图相同的 MarketView 共享时间窗口;Frame.Height 默认 120;Sync 与 SyncID 可关联光标。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/146-depth-chart.md b/docs/components/146-depth-chart.md new file mode 100644 index 0000000..11cfcf1 --- /dev/null +++ b/docs/components/146-depth-chart.md @@ -0,0 +1,37 @@ +# 146 DepthChart + +## 用途 + +买卖深度累计面积;中间价;悬停读数。 + +## 最小示例 + +```go + bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}} + asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +买盘价格递减,卖盘递增,Size 为正数;最优买价小于最优卖价。Decimals 默认两位。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/147-volume-profile.md b/docs/components/147-volume-profile.md new file mode 100644 index 0000000..14cb854 --- /dev/null +++ b/docs/components/147-volume-profile.md @@ -0,0 +1,36 @@ +# 147 VolumeProfile + +## 用途 + +价格分布成交量;价值区与控制点。 + +## 最小示例 + +```go + bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +ProfileBin 按价格递增排列;Volume 非负。ValueArea 默认 0.7;VolumeProfileOf 可按 OHLC 区间生成分布。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/148-multi-chart-layout.md b/docs/components/148-multi-chart-layout.md new file mode 100644 index 0000000..718ae22 --- /dev/null +++ b/docs/components/148-multi-chart-layout.md @@ -0,0 +1,38 @@ +# 148 MultiChartLayout + +## 用途 + +1、2、4、6 宫格多图;独立品种与周期;布局切换不丢状态。 + +## 最小示例 + +```go + state := mujica.MultiChartState{Count: 2} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) { + mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"}) + }) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Count 支持 1、2、4、6,零值为 1;Cells 保存六个独立品种、周期与窗口;Active 指向可见单元。Height 默认 480。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入布局组,方向键移动焦点,Space 切换;Tab 进入各图表独立操作。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/149-chart-sync.md b/docs/components/149-chart-sync.md new file mode 100644 index 0000000..391f64f --- /dev/null +++ b/docs/components/149-chart-sync.md @@ -0,0 +1,44 @@ +# 149 ChartSync + +## 用途 + +多图十字光标与时间范围同步;可逐图解除。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + var sync mujica.ChartSync + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + sync.Link(c, "price") + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Count、Start、End 保存共享时间窗口;不同周期按时间边界映射;Cursor 保存时间光标;Unlinked 按稳定图表 ID 解联。通过 CandlestickOptions 或 VolumeChartOptions 的 Sync 接入。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化;ChartSync.Link 返回 ChoiceResult。 + +## 键盘操作 + +Tab 聚焦 Link 开关,Space 切换关联;图表方向键移动同步光标。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/150-time-range-selector.md b/docs/components/150-time-range-selector.md new file mode 100644 index 0000000..25cfaca --- /dev/null +++ b/docs/components/150-time-range-selector.md @@ -0,0 +1,36 @@ +# 150 TimeRangeSelector + +## 用途 + +1D、5D、1M、3M、YTD、1Y、5Y、自定义;键盘导航。 + +## 最小示例 + +```go + value := mujica.MarketRange{Preset: mujica.Range1M} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +MarketRange.Preset 默认 Range1D;Custom 保存 DateRange。Today 零值跟随系统日期,Bounds 使用调用方时间和时区。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入预设组,左右方向键移动焦点,Space 选择;自定义日期通过 DateRangePicker 键盘操作。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/151-interval-selector.md b/docs/components/151-interval-selector.md new file mode 100644 index 0000000..e64c82a --- /dev/null +++ b/docs/components/151-interval-selector.md @@ -0,0 +1,37 @@ +# 151 IntervalSelector + +## 用途 + +1m 至 1M 周期;分组;收藏周期。 + +## 最小示例 + +```go + value := mujica.Interval("1h") + favorites := []mujica.Interval{"1m", "1h", "1D"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +value 为 Intervals 中的周期;favorites 保存收藏列表;Disabled 禁用选择与收藏。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入收藏组,方向键移动焦点,Space 选择;Tab 遍历弹层周期和收藏按钮,Enter 激活,Escape 关闭。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/152-chart-type-switcher.md b/docs/components/152-chart-type-switcher.md new file mode 100644 index 0000000..29d8291 --- /dev/null +++ b/docs/components/152-chart-type-switcher.md @@ -0,0 +1,36 @@ +# 152 ChartTypeSwitcher + +## 用途 + +K 线、美国线、折线、面积、平均 K 线切换。 + +## 最小示例 + +```go + value := mujica.ChartCandles + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +ChartType 默认 ChartCandles;支持 ChartOHLC、ChartLine、ChartArea、ChartHeikinAshi;Disabled 禁用操作。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入分组,左右方向键移动焦点,Space 选择。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/153-indicator-overlay.md b/docs/components/153-indicator-overlay.md new file mode 100644 index 0000000..00e4897 --- /dev/null +++ b/docs/components/153-indicator-overlay.md @@ -0,0 +1,43 @@ +# 153 IndicatorOverlay + +## 用途 + +主图叠加 MA、EMA、BOLL、VWAP;参数可配;计算由调用方或内置辅助函数。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Kind 支持 MA、EMA、BOLL、VWAP;Params 为 nil 使用默认参数;Values 可覆盖内置计算,长度等于 candles,NaN 表示缺测。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/154-indicator-pane.md b/docs/components/154-indicator-pane.md new file mode 100644 index 0000000..0dbb6fb --- /dev/null +++ b/docs/components/154-indicator-pane.md @@ -0,0 +1,43 @@ +# 154 IndicatorPane + +## 用途 + +副图 MACD、RSI、KDJ、OBV;多副图堆叠与高度调整。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Panes 为 MACD、RSI、KDJ、OBV 配置;Sizes 保存高度比例;Height 默认每副图 140 DIP,最小高度覆盖标题、图例、读数和 40 DIP 绘图区;Label 为无障碍名称前缀;Status、Error、Empty 设置等尺寸占位。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +IndicatorPaneResult.Removed 为移除索引,默认 -1;Resized 表示高度调整完成;Point、Pane 为活跃读数和副图索引,PointChanged 表示读数变化。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。主图与副图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/155-indicator-selector.md b/docs/components/155-indicator-selector.md new file mode 100644 index 0000000..f9fa444 --- /dev/null +++ b/docs/components/155-indicator-selector.md @@ -0,0 +1,37 @@ +# 155 IndicatorSelector + +## 用途 + +指标搜索、添加、移除、参数编辑。 + +## 最小示例 + +```go + var overlays []mujica.IndicatorOverlay + var panes mujica.IndicatorPanes + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +overlays 与 panes 均由调用方持有;添加默认参数,可搜索、移除和编辑。Disabled 关闭面板并阻止编辑。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 聚焦按钮,Enter 打开;Tab 到搜索和参数字段;方向键步进参数;Escape 关闭。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/156-drawing-tools.md b/docs/components/156-drawing-tools.md new file mode 100644 index 0000000..bd3e095 --- /dev/null +++ b/docs/components/156-drawing-tools.md @@ -0,0 +1,43 @@ +# 156 DrawingTools + +## 用途 + +趋势线、水平线、斐波那契、矩形、文字标注;选中、移动、删除、撤销。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Items 保存时间和价格锚点;Tool 默认选择工具;Text 为文字标注内容;Locked 阻止编辑;Selected、Delete、Undo、Clear 管理画线。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +聚焦图表后方向键移动读数;Enter 在当前读数放置锚点,双点工具按两次 Enter;Alt+Enter 循环选择画线;点击放置锚点;Alt+方向键移动选中画线;Delete/Backspace 删除;Command/Ctrl+Z 撤销;Escape 取消。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 图形锚点使用已有 Candle 时间。 diff --git a/docs/components/157-drawing-toolbar.md b/docs/components/157-drawing-toolbar.md new file mode 100644 index 0000000..931b8a5 --- /dev/null +++ b/docs/components/157-drawing-toolbar.md @@ -0,0 +1,36 @@ +# 157 DrawingToolbar + +## 用途 + +画线工具栏;当前工具;锁定与清除;键盘快捷键提示。 + +## 最小示例 + +```go + var drawings mujica.DrawingTools + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +传入 DrawingTools;Horizontal=true 横向,默认纵向;工具名称附快捷键提示。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入工具栏;方向键移动焦点,Enter 激活;Alt+1 至 Alt+6 选择工具。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/158-compare-symbol.md b/docs/components/158-compare-symbol.md new file mode 100644 index 0000000..8774375 --- /dev/null +++ b/docs/components/158-compare-symbol.md @@ -0,0 +1,44 @@ +# 158 CompareSymbol + +## 用途 + +叠加对比品种;百分比归一;序列颜色与移除。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CompareLegend(c, &symbols) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Name 为稳定品种名称;Closes 与主图逐根对齐,正数或 NaN;CompareLegend 提供形状图例和移除。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/159-price-alert-line.md b/docs/components/159-price-alert-line.md new file mode 100644 index 0000000..67175ea --- /dev/null +++ b/docs/components/159-price-alert-line.md @@ -0,0 +1,47 @@ +# 159 PriceAlertLine + +## 用途 + +价格预警线;拖动调整;触发回调;不自行通知。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}} + triggered := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) { + triggered = alert.ID + }}) + ui.Text(c, triggered) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +ID 为唯一非空字符串;Price 为有限价格;Label 可选。CandlestickOptions.Alerts 接入主图;OnAlert 接收预警和最新 Candle。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 OnAlert 在最新收盘价格跨线时触发一次;拖动直接更新 Alerts。 + +## 键盘操作 + +聚焦图表后方向键查看价格;PageUp/PageDown 选择预警线;Alt+上下方向键按价格精度步进;拖动预警线调整价格。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 百分比对比模式展示归一后的品种曲线,预警使用价格模式。 diff --git a/docs/components/160-candle-countdown.md b/docs/components/160-candle-countdown.md new file mode 100644 index 0000000..9023ad9 --- /dev/null +++ b/docs/components/160-candle-countdown.md @@ -0,0 +1,35 @@ +# 160 CandleCountdown + +## 用途 + +当前 K 线收盘倒计时;随周期变化;隐藏时停止计时重绘。 + +## 最小示例 + +```go + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +interval 为 Intervals 中的周期;Location 默认 UTC;Now 零值读取帧时钟,指定值冻结时间。 + +## 状态 + +随周期和时间更新;指定 Now 显示冻结读数;移出可见区域停止安排计时帧。 + +## 事件 + +返回 Element;时间由帧时钟或调用方指定。 + +## 键盘操作 + +展示型组件;周期由调用方选择控件调整。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/drawing_toolbar.go b/drawing_toolbar.go new file mode 100644 index 0000000..6dbaaf0 --- /dev/null +++ b/drawing_toolbar.go @@ -0,0 +1,128 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// DrawingToolbarOptions configure a DrawingToolbar. +type DrawingToolbarOptions struct { + // Horizontal uses a row; the default uses a column. + Horizontal bool +} + +// DrawingToolbar selects tools and manages lock, clear, and undo. +func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) ChoiceResult { + if d == nil { + panic("mujica: DrawingToolbar needs drawings") + } + if d.Tool < ToolSelect || d.Tool > ToolText { + panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool)) + } + k := Tokens(c) + u := Density(c).Unit() + changed := false + bar := ui.Column(c).Gap(u).Padding(u).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Role(ui.RoleToolbar).Label(msg(c, "drawing_toolbar.label")) + if opts.Horizontal { + bar.Row().FocusGroup(ui.Horizontal) + } else { + bar.FocusGroup(ui.Vertical) + } + pick := func(t DrawingTool) { + if !d.Locked && d.Tool != t && !bar.IsDisabled() { + d.Tool, changed = t, true + d.pending, d.moving = nil, false + slog.Debug("mujica: drawing_toolbar tool", "tool", t) + } + } + digits := []ui.Key{ui.Key1, ui.Key2, ui.Key3, ui.Key4, ui.Key5, ui.Key6} + for t := ToolSelect; t <= ToolText; t++ { + if c.Shortcut(ui.Alt, digits[t]) { + pick(t) + } + } + bar.Children(func() { + for t := ToolSelect; t <= ToolText; t++ { + name := fmt.Sprintf("%s (Alt+%d)", msg(c, fmt.Sprintf("drawing_toolbar.tool%d", t)), t+1) + on := d.Tool == t + b := ui.ButtonBase(c).Key(t).Size(theme.MinHitSize+4, theme.MinHitSize+4).Radius(theme.ControlRadius).Label(name).Tooltip(name).Disabled(d.Locked) + internal.Ring(b, k.Focus, theme.ControlRadius) + fg := k.Text + switch { + case on: + b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText) + fg = k.AccentText + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Draw(func(p *ui.Painter, r ui.Rect) { drawingToolGlyph(p, r, t, fg) }) + if b.Clicked() { + pick(t) + } + } + div := ui.Box(c).Background(k.Border).Role(ui.RoleNone) + if opts.Horizontal { + div.Size(theme.BorderWidth, u*6) + } else { + div.Size(u*6, theme.BorderWidth) + } + lockName := msg(c, "drawing_toolbar.lock") + if d.Locked { + lockName = msg(c, "drawing_toolbar.unlock") + } + lock := iconAction(c, icons.Must("lock"), lockName).Tooltip(lockName) + if d.Locked { + lock.Background(k.Selection).TextColor(k.AccentText) + } + if lock.Clicked() && !bar.IsDisabled() { + d.Locked, changed = !d.Locked, true + d.Tool = ToolSelect + slog.Debug("mujica: drawing_toolbar lock", "locked", d.Locked) + } + undo := iconAction(c, icons.Must("rotate-ccw"), msg(c, "drawing_toolbar.undo")).Tooltip(msg(c, "drawing_toolbar.undo")).Disabled(d.Locked || !d.CanUndo()) + if undo.Clicked() && !bar.IsDisabled() && d.Undo() { + changed = true + } + clear := iconAction(c, icons.Must("trash-2"), msg(c, "drawing_toolbar.clear")).Tooltip(msg(c, "drawing_toolbar.clear")).Disabled(d.Locked || len(d.Items) == 0) + if clear.Clicked() && !bar.IsDisabled() && d.Clear() { + changed = true + } + }) + return ChoiceResult{bar, changed} +} + +// drawingToolGlyph draws a small picture of tool t in r. +func drawingToolGlyph(p *ui.Painter, r ui.Rect, t DrawingTool, col ui.Color) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + s := float32(8) + switch t { + case ToolSelect: + var path ui.Path + path.MoveTo(cx-5, cy-7).LineTo(cx+5, cy+1).LineTo(cx, cy+1).LineTo(cx+3, cy+7).LineTo(cx+1, cy+8).LineTo(cx-2, cy+2).LineTo(cx-5, cy+5).Close() + p.FillPath(&path, col) + case ToolTrendLine: + drawingStroke(p, cx-s, cy+s, cx+s, cy-s, 1.5, col) + p.FillPath(internal.Diamond(cx-s, cy+s, 5), col) + p.FillPath(internal.Diamond(cx+s, cy-s, 5), col) + case ToolHorizontal: + p.Line(cx-s, cy, cx+s, cy, 1.5, col) + p.FillPath(internal.Diamond(cx, cy, 5), col) + case ToolFibonacci: + for i, f := range []float32{-1, -0.4, 0.2, 1} { + w := []float32{1.5, 1, 1, 1.5}[i] + p.Line(cx-s, cy+f*s, cx+s, cy+f*s, w, col) + } + case ToolRectangle: + p.Stroke(ui.Rect{X: cx - s, Y: cy - s*0.7, W: 2 * s, H: 1.4 * s}, col, 1, 1.5) + case ToolText: + p.Line(cx-s*0.8, cy-s, cx+s*0.8, cy-s, 2, col) + p.Line(cx, cy-s, cx, cy+s, 2, col) + } +} diff --git a/drawing_toolbar_test.go b/drawing_toolbar_test.go new file mode 100644 index 0000000..142cd0c --- /dev/null +++ b/drawing_toolbar_test.go @@ -0,0 +1,41 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestDrawingToolbarShortcutsLockAndDisabled(t *testing.T) { + d := DrawingTools{} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Disabled(disabled).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) }) + }, 550, 90) + tt.Key(ui.Alt, ui.Key4) + if d.Tool != ToolFibonacci { + t.Fatalf("tool %v", d.Tool) + } + if err := tt.Click("Lock drawings"); err != nil { + t.Fatal(err) + } + tt.Key(ui.Alt, ui.Key3) + if !d.Locked || d.Tool != ToolSelect { + t.Fatal("locked tool changed") + } + if err := tt.Click("Unlock drawings"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Frame() + tt.Key(ui.Alt, ui.Key2) + if d.Tool != ToolSelect { + t.Fatal("disabled tool changed") + } +} +func TestDrawingToolbarSnapshot(t *testing.T) { + d := DrawingTools{Tool: ToolTrendLine} + snapshot(t, "drawing_toolbar", 440, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) }) + }) +} diff --git a/drawing_tools.go b/drawing_tools.go new file mode 100644 index 0000000..1a13889 --- /dev/null +++ b/drawing_tools.go @@ -0,0 +1,262 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "slices" + "time" + + "github.com/egoist/mygo/ui" +) + +// DrawingTool selects annotation creation or selection behavior. +type DrawingTool int + +const ( + ToolSelect DrawingTool = iota + ToolTrendLine + ToolHorizontal + ToolFibonacci + ToolRectangle + ToolText +) + +// DrawingPoint anchors annotations to candle time and price. +type DrawingPoint struct { + Time time.Time + Price float64 +} + +// Drawing is one annotation; one-point tools use A only. +type Drawing struct { + Tool DrawingTool + A, B DrawingPoint + Text string +} + +// DrawingTools holds annotations, the current tool, and undo history. +type DrawingTools struct { + Items []Drawing + Tool DrawingTool + // Text is what the next text annotation says. + Text string + // Locked stops selecting, moving, deleting and drawing. + Locked bool + + sel int // selected item plus one; zero is none + pending *Drawing + undo [][]Drawing + moving bool +} + +// fibLevels lists Fibonacci retracement ratios. +var fibLevels = []float64{0, 0.236, 0.382, 0.5, 0.618, 0.786, 1} + +// Selected returns the selected drawing's index, or -1. +func (d *DrawingTools) Selected() int { return d.sel - 1 } + +// CanUndo reports whether Undo has a step to take back. +func (d *DrawingTools) CanUndo() bool { return len(d.undo) > 0 } + +func (d *DrawingTools) save() { + d.undo = append(d.undo, slices.Clone(d.Items)) +} + +// Undo restores the preceding annotations unless locked. +func (d *DrawingTools) Undo() bool { + n := len(d.undo) + if d.Locked || n == 0 { + return false + } + d.Items, d.undo = d.undo[n-1], d.undo[:n-1] + d.sel, d.pending = 0, nil + slog.Debug("mujica: drawing_tools undo", "items", len(d.Items)) + return true +} + +// Delete removes the selected drawing, undoably. +func (d *DrawingTools) Delete() bool { + if d.Locked || d.sel == 0 || d.sel > len(d.Items) { + return false + } + d.save() + d.Items = slices.Delete(slices.Clone(d.Items), d.sel-1, d.sel) + d.sel = 0 + slog.Debug("mujica: drawing_tools delete") + return true +} + +// Clear removes every drawing, undoably. +func (d *DrawingTools) Clear() bool { + if d.Locked || len(d.Items) == 0 { + return false + } + d.save() + d.Items, d.sel, d.pending = nil, 0, nil + slog.Debug("mujica: drawing_tools clear") + return true +} + +// twoPoint reports whether t needs a second click. +func (t DrawingTool) twoPoint() bool { + return t == ToolTrendLine || t == ToolFibonacci || t == ToolRectangle +} + +// point maps plot coordinates onto the nearest candle. +func (d *DrawingTools) point(m *mktCore, g mktGeom, x, y float32) DrawingPoint { + return DrawingPoint{Time: m.candles[g.index(m, x)].Time, Price: m.priceAt(g.ys.Invert(y))} +} + +// priceAt resolves an axis value into a rounded price. +func (m *mktCore) priceAt(v float64) float64 { + if m.base != 0 { + v = m.base * (1 + v/100) + } + p := math.Pow(10, float64(m.dec)) + return math.Round(v*p) / p +} + +// xy places a drawing point in the plot. +func (d *DrawingTools) xy(m *mktCore, g mktGeom, pt DrawingPoint) (float32, float32) { + i := max(0, marketIndexAt(m.candles, pt.Time)) + return g.x(m, i), g.ys.Map(m.val(pt.Price)) +} + +// input places, selects, moves, and deletes chart annotations. +func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool { + if d.Tool < ToolSelect || d.Tool > ToolText { + panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool)) + } + if d.pending != nil && d.pending.Tool != d.Tool { + d.pending, d.moving = nil, false + } + if plot.Shortcut(ui.Alt, ui.KeyEnter) && !d.Locked && len(d.Items) > 0 { + d.sel = d.sel%len(d.Items) + 1 + m.st.alertFocus = -1 + m.c.Announce(fmt.Sprintf(msg(m.c, "drawing_tools.selected"), d.sel)) + } + if plot.Shortcut(ui.Cmd, ui.KeyZ) { + d.Undo() + } + if d.Locked { + d.pending, d.moving = nil, false + return false + } + if plot.Shortcut(0, ui.KeyDelete) || plot.Shortcut(0, ui.KeyBackspace) { + d.Delete() + } + if plot.Shortcut(0, ui.KeyEscape) { + d.pending, d.sel = nil, 0 + } + if s := d.sel - 1; s >= 0 && s < len(d.Items) { + step := map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, 1}, ui.KeyDown: {0, -1}} + for key, v := range step { + if plot.Shortcut(ui.Alt, key) { + d.save() + d.nudge(m, s, v[0], float64(v[1])*g.step/10) + } + } + } + if !over && !held { + return false + } + if plot.Clicked() && d.Tool != ToolSelect { + pt := d.point(m, g, x, y) + d.place(m, pt) + return true + } + if d.pending != nil { + d.pending.B = d.point(m, g, x, y) + return true + } + if d.Tool != ToolSelect { + return held + } + switch { + case held && !d.moving: + hit := d.hit(m, g, x-dx, y-dy) + if hit < 0 { + d.sel = 0 + return false + } + d.sel, d.moving = hit+1, true + d.save() + case !held: + d.moving = false + if plot.Clicked() { + d.sel = d.hit(m, g, x, y) + 1 + } + return false + } + if dx != 0 || dy != 0 { + steps := int(math.Round(float64(dx / g.band.Step()))) + d.nudge(m, d.sel-1, steps, m.priceAt(g.ys.Invert(y))-m.priceAt(g.ys.Invert(y-dy))) + } + return true +} + +// nudge moves annotations by candles and price. +func (d *DrawingTools) nudge(m *mktCore, i, candles int, price float64) { + it := &d.Items[i] + move := func(pt *DrawingPoint) { + j := max(0, min(len(m.candles)-1, marketIndexAt(m.candles, pt.Time)+candles)) + pt.Time, pt.Price = m.candles[j].Time, pt.Price+price + } + move(&it.A) + move(&it.B) +} + +// hit finds the newest annotation within 6 DIPs. +func (d *DrawingTools) hit(m *mktCore, g mktGeom, x, y float32) int { + for i := len(d.Items) - 1; i >= 0; i-- { + it := d.Items[i] + ax, ay := d.xy(m, g, it.A) + bx, by := d.xy(m, g, it.B) + near := false + switch it.Tool { + case ToolHorizontal: + near = math.Abs(float64(y-ay)) <= 6 + case ToolText: + near = math.Abs(float64(x-ax)) <= 40 && math.Abs(float64(y-ay)) <= 10 + case ToolRectangle, ToolFibonacci: + l, r, t, b := min(ax, bx)-6, max(ax, bx)+6, min(ay, by)-6, max(ay, by)+6 + near = x >= l && x <= r && y >= t && y <= b + default: + near = drawingSegmentDist(x, y, ax, ay, bx, by) <= 6 + } + if near { + return i + } + } + return -1 +} + +// drawingSegmentDist measures distance to a line segment. +func drawingSegmentDist(x, y, ax, ay, bx, by float32) float64 { + vx, vy := float64(bx-ax), float64(by-ay) + t := 0.0 + if l := vx*vx + vy*vy; l > 0 { + t = max(0, min(1, (float64(x-ax)*vx+float64(y-ay)*vy)/l)) + } + return math.Hypot(float64(x-ax)-t*vx, float64(y-ay)-t*vy) +} + +func (d *DrawingTools) place(m *mktCore, pt DrawingPoint) { + if d.Tool.twoPoint() && d.pending == nil { + d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt} + } else { + item := Drawing{Tool: d.Tool, A: pt, B: pt, Text: d.Text} + if d.pending != nil { + item.A = d.pending.A + } + if d.Tool == ToolText && item.Text == "" { + item.Text = msg(m.c, "drawing_tools.note") + } + d.save() + d.Items = append(slices.Clone(d.Items), item) + d.sel, d.pending, d.Tool = len(d.Items), nil, ToolSelect + slog.Debug("mujica: drawing_tools add", "tool", item.Tool) + } + m.c.Invalidate() +} diff --git a/drawing_tools_paint.go b/drawing_tools_paint.go new file mode 100644 index 0000000..fc894b5 --- /dev/null +++ b/drawing_tools_paint.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +// paint draws annotations, previews, and selected handles. +func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) { + k := m.k + items := d.Items + if d.pending != nil { + items = append(append([]Drawing(nil), items...), *d.pending) + } + for i, it := range items { + col := k.Info + width := float32(1.5) + selected := i == d.sel-1 + if selected { + col, width = k.AccentText, 2.5 + } + ax, ay := d.xy(m, g, it.A) + bx, by := d.xy(m, g, it.B) + ax, ay, bx, by = internal.Snap(p, ax), internal.Snap(p, ay), internal.Snap(p, bx), internal.Snap(p, by) + switch it.Tool { + case ToolTrendLine: + drawingStroke(p, ax, ay, bx, by, width, col) + case ToolHorizontal: + p.Line(g.plot.X, ay, g.plot.X+g.plot.W, ay, width, col) + chartText(p, priceUngrouped(it.A.Price, m.dec), g.plot.X+4, ay-2, size, col, 0, 1) + case ToolRectangle: + r := ui.Rect{X: min(ax, bx), Y: min(ay, by), W: max(bx, ax) - min(ax, bx), H: max(by, ay) - min(ay, by)} + p.Fill(r, col.Alpha(0.1), 0) + p.Stroke(r, col, 0, width) + case ToolFibonacci: + for _, lv := range fibLevels { + price := it.B.Price + (it.A.Price-it.B.Price)*lv + y := internal.Snap(p, g.ys.Map(m.val(price))) + p.Line(min(ax, bx), y, max(ax, bx), y, 1, col) + chartText(p, strconv.FormatFloat(lv, 'f', -1, 64)+" · "+priceUngrouped(price, m.dec), max(ax, bx)+4, y, size, col, 0, 0.5) + } + drawingStroke(p, ax, ay, bx, by, 1, col.Alpha(0.5)) + case ToolText: + w, h := chartText(p, it.Text, ax, ay, size, k.Text, 0.5, 0.5) + p.Stroke(ui.Rect{X: ax - w/2 - 4, Y: ay - h/2 - 2, W: w + 8, H: h + 4}, col, 2, width/1.5) + } + if selected { + for _, pt := range [][2]float32{{ax, ay}, {bx, by}} { + p.FillPath(internal.Diamond(pt[0], pt[1], 9), k.Surface) + p.FillPath(internal.Diamond(pt[0], pt[1], 7), col) + } + } + } +} + +func drawingStroke(p *ui.Painter, ax, ay, bx, by, width float32, col ui.Color) { + var path ui.Path + path.MoveTo(internal.Snap(p, ax), internal.Snap(p, ay)).LineTo(internal.Snap(p, bx), internal.Snap(p, by)) + p.StrokePath(&path, width, col) +} diff --git a/drawing_tools_test.go b/drawing_tools_test.go new file mode 100644 index 0000000..13e9902 --- /dev/null +++ b/drawing_tools_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "mujicaui/internal" + "mujicaui/theme" + "testing" +) + +func TestDrawingToolsPlaceMoveDeleteUndo(t *testing.T) { + cs := sampleCandles(60) + v := MarketView{Count: 60} + d := DrawingTools{Tool: ToolHorizontal} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d}) + }) + }, 640, 340) + tt.ClickAt(300, 100) + if len(d.Items) != 1 || d.Items[0].Tool != ToolHorizontal || d.Selected() != 0 { + t.Fatalf("drawings %+v", d) + } + before := d.Items[0].A.Price + tt.Key(ui.Alt, ui.KeyUp) + if d.Items[0].A.Price <= before { + t.Fatal("keyboard move failed") + } + tt.Key(0, ui.KeyDelete) + if len(d.Items) != 0 { + t.Fatal("delete failed") + } + tt.Key(ui.Cmd, ui.KeyZ) + if len(d.Items) != 1 { + t.Fatal("undo failed") + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyDelete) + tt.ClickAt(200, 150) + if len(d.Items) != 1 { + t.Fatal("disabled drawing changed") + } +} +func TestDrawingToolsSnapshot(t *testing.T) { + cs := sampleCandles(60) + v := MarketView{Count: 60} + d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[10].Time, 98}, B: DrawingPoint{cs[45].Time, 105}}, {Tool: ToolHorizontal, A: DrawingPoint{cs[20].Time, 100}}}, sel: 1} + snapshot(t, "drawing_tools", 640, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawings", Height: 260}, Drawings: &d}) + }) + }) +} + +func TestDrawingToolsTrendLinePaint(t *testing.T) { + cs := sampleCandles(2) + img := ui.Render(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + m := &mktCore{c: c, k: Tokens(c), candles: cs, from: 0, to: 2} + g := mktGeom{plot: ui.Rect{W: 200, H: 200}, band: internal.Band{N: 2, R0: 0, R1: 200}, ys: internal.Linear{D0: 0, D1: 10, R0: 180, R1: 20}} + d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[0].Time, 0}, B: DrawingPoint{cs[1].Time, 10}}}} + ui.Box(c).Size(200, 200).Draw(func(p *ui.Painter, _ ui.Rect) { d.paint(p, g, m, 12) }) + }, 200, 200, 1) + bg := theme.Light().Background + pixel := img.RGBAAt(100, 100) + if int(bg.R)-int(pixel.R) < 40 || int(bg.G)-int(pixel.G) < 20 { + t.Fatalf("diagonal midpoint lacks the stroke: %v", pixel) + } +} + +func TestDrawingToolsAllTools(t *testing.T) { + for _, tool := range []DrawingTool{ToolTrendLine, ToolHorizontal, ToolFibonacci, ToolRectangle, ToolText} { + t.Run(string(rune('0'+tool)), func(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + d := DrawingTools{Tool: tool, Text: "Note"} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d}) + }, 640, 340) + tt.ClickAt(200, 120) + if tool.twoPoint() { + if d.pending == nil || len(d.Items) != 0 { + t.Fatal("missing first anchor") + } + tt.ClickAt(400, 160) + } + if len(d.Items) != 1 || d.Items[0].Tool != tool || d.Selected() != 0 { + t.Fatalf("created %+v", d.Items) + } + if tool == ToolText && d.Items[0].Text != "Note" { + t.Fatal("annotation text lost") + } + if !d.Clear() || len(d.Items) != 0 || !d.Undo() || len(d.Items) != 1 { + t.Fatal("clear or undo failed") + } + }) + } +} diff --git a/example_mktchart_test.go b/example_mktchart_test.go new file mode 100644 index 0000000..4ef3be8 --- /dev/null +++ b/example_mktchart_test.go @@ -0,0 +1,265 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + mujica "mujicaui" + "time" +) + +func ExampleCandlestickChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleOHLCChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleHeikinAshiChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + raw := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleLineQuoteChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleVolumeChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleDepthChart() { + bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}} + asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleVolumeProfile() { + bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleMultiChartLayout() { + state := mujica.MultiChartState{Count: 2} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) { + mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"}) + }) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleChartSync() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + var sync mujica.ChartSync + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + sync.Link(c, "price") + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleTimeRangeSelector() { + value := mujica.MarketRange{Preset: mujica.Range1M} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIntervalSelector() { + value := mujica.Interval("1h") + favorites := []mujica.Interval{"1m", "1h", "1D"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleChartTypeSwitcher() { + value := mujica.ChartCandles + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIndicatorOverlay() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIndicatorPane() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIndicatorSelector() { + var overlays []mujica.IndicatorOverlay + var panes mujica.IndicatorPanes + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleDrawingTools() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleDrawingToolbar() { + var drawings mujica.DrawingTools + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleCompareSymbol() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CompareLegend(c, &symbols) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols}) + } + ui.Render(view, 640, 400, 1) +} + +func ExamplePriceAlertLine() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}} + triggered := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) { + triggered = alert.ID + }}) + ui.Text(c, triggered) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleCandleCountdown() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC}) + } + ui.Render(view, 640, 400, 1) +} diff --git a/examples/gallery/pages_mktchart.go b/examples/gallery/pages_mktchart.go index 0f60886..e495179 100644 --- a/examples/gallery/pages_mktchart.go +++ b/examples/gallery/pages_mktchart.go @@ -1,3 +1,28 @@ package main -var mktchartPages = []page{} +import "github.com/egoist/mygo/ui" + +var mktchartPages = []page{ + {ID: "141", Name: "CandlestickChart", ZhName: "K 线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "141") }, Code: mktCode141, Keys: mktKeys}, + {ID: "142", Name: "OHLCChart", ZhName: "美国线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "142") }, Code: mktCode142, Keys: mktKeys}, + {ID: "143", Name: "HeikinAshiChart", ZhName: "平均 K 线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "143") }, Code: mktCode143, Keys: mktKeys}, + {ID: "144", Name: "LineQuoteChart", ZhName: "分时图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "144") }, Code: mktCode144, Keys: mktKeys}, + {ID: "145", Name: "VolumeChart", ZhName: "成交量", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "145") }, Code: mktCode145, Keys: mktKeys}, + {ID: "146", Name: "DepthChart", ZhName: "深度图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "146") }, Code: mktCode146, Keys: mktKeys}, + {ID: "147", Name: "VolumeProfile", ZhName: "成交量分布", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "147") }, Code: mktCode147, Keys: mktKeys}, + {ID: "148", Name: "MultiChartLayout", ZhName: "多图布局", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "148") }, Code: mktCode148, Keys: mktKeys}, + {ID: "149", Name: "ChartSync", ZhName: "图表同步", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "149") }, Code: mktCode149, Keys: mktKeys}, + {ID: "150", Name: "TimeRangeSelector", ZhName: "时间范围", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "150") }, Code: mktCode150, Keys: mktKeys}, + {ID: "151", Name: "IntervalSelector", ZhName: "周期选择", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "151") }, Code: mktCode151, Keys: mktKeys}, + {ID: "152", Name: "ChartTypeSwitcher", ZhName: "图表类型", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "152") }, Code: mktCode152, Keys: mktKeys}, + {ID: "153", Name: "IndicatorOverlay", ZhName: "主图指标", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "153") }, Code: mktCode153, Keys: mktKeys}, + {ID: "154", Name: "IndicatorPane", ZhName: "指标副图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "154") }, Code: mktCode154, Keys: mktKeys}, + {ID: "155", Name: "IndicatorSelector", ZhName: "指标选择", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "155") }, Code: mktCode155, Keys: mktKeys}, + {ID: "156", Name: "DrawingTools", ZhName: "画线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "156") }, Code: mktCode156, Keys: mktKeys}, + {ID: "157", Name: "DrawingToolbar", ZhName: "画线工具栏", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "157") }, Code: mktCode157, Keys: mktKeys}, + {ID: "158", Name: "CompareSymbol", ZhName: "品种对比", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "158") }, Code: mktCode158, Keys: mktKeys}, + {ID: "159", Name: "PriceAlertLine", ZhName: "价格预警", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "159") }, Code: mktCode159, Keys: mktKeys}, + {ID: "160", Name: "CandleCountdown", ZhName: "K 线倒计时", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "160") }, Code: mktCode160, Keys: mktKeys}, +} + +const mktKeys = "Tab focuses controls and charts; arrows move focus or chart readouts; Space selects; Home/End jump; +/- zoom; Shift+arrows pan; Alt+1–6 pick drawing tools; Delete removes; Ctrl/Command+Z undoes; Escape closes panels." diff --git a/examples/gallery/pages_mktchart_code.go b/examples/gallery/pages_mktchart_code.go new file mode 100644 index 0000000..c81078d --- /dev/null +++ b/examples/gallery/pages_mktchart_code.go @@ -0,0 +1,219 @@ +package main + +const mktCode141 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode142 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode143 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + raw := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode144 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode145 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode146 = ` bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}} + asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode147 = ` bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode148 = ` state := mujica.MultiChartState{Count: 2} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) { + mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"}) + }) + } + ui.Render(view, 640, 400, 1)` + +const mktCode149 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + var sync mujica.ChartSync + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + sync.Link(c, "price") + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode150 = ` value := mujica.MarketRange{Preset: mujica.Range1M} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode151 = ` value := mujica.Interval("1h") + favorites := []mujica.Interval{"1m", "1h", "1D"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode152 = ` value := mujica.ChartCandles + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode153 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode154 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode155 = ` var overlays []mujica.IndicatorOverlay + var panes mujica.IndicatorPanes + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode156 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode157 = ` var drawings mujica.DrawingTools + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode158 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CompareLegend(c, &symbols) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode159 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}} + triggered := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) { + triggered = alert.ID + }}) + ui.Text(c, triggered) + } + ui.Render(view, 640, 400, 1)` + +const mktCode160 = ` view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC}) + } + ui.Render(view, 640, 400, 1)` diff --git a/examples/gallery/pages_mktchart_demo.go b/examples/gallery/pages_mktchart_demo.go new file mode 100644 index 0000000..f0fc07b --- /dev/null +++ b/examples/gallery/pages_mktchart_demo.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func mktDemo(c *ui.Context, log func(string), id string) { + s := ui.Local(c.Root(), id, mktInitial) + ui.Column(c).Gap(12).FillWidth().Children(func() { + frame := mktControls(c, s, log) + candles, points, bids, asks, bins := s.candles, s.points, s.bids, s.asks, s.bins + if s.status == "Empty" { + candles = nil + points = nil + bids = nil + asks = nil + bins = nil + } + ui.Column(c).Disabled(s.disabled).Gap(12).FillWidth().Children(func() { + opts := mujica.CandlestickOptions{Frame: frame, Type: s.chartType} + chart := func() { mktLog(log, id, mujica.CandlestickChart(c, candles, &s.window, opts)) } + switch id { + case "141": + if mujica.ChartTypeSwitcher(c, &s.chartType, mujica.ChartTypeSwitcherOptions{}).Changed() { + log(fmt.Sprintf("Chart type: %d", s.chartType)) + } + opts.Type = s.chartType + chart() + case "142": + mktLog(log, id, mujica.OHLCChart(c, candles, &s.window, opts)) + case "143": + before := s.raw + mktLog(log, id, mujica.HeikinAshiChart(c, candles, &s.window, &s.raw, opts)) + if before != s.raw { + log(fmt.Sprintf("Raw: %v", s.raw)) + } + case "144": + if mujica.NumberInput(c, &s.previous, mujica.NumberInputOptions{Min: 80, Max: 120, Step: 0.5, Label: "Previous close"}).Changed() { + log(fmt.Sprintf("Previous close: %.2f", s.previous)) + } + if mujica.Checkbox(c, &s.extended, "Extended sessions", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Extended sessions: %v", s.extended)) + } + o := mujica.LineQuoteChartOptions{Frame: frame, PrevClose: s.previous} + if s.extended { + o.RegularOpen = s.points[0].Time.Add(90 * time.Minute) + o.RegularClose = s.points[0].Time.Add(8 * time.Hour) + } + mktLog(log, id, mujica.LineQuoteChart(c, points, o)) + case "145": + opts.Frame.Height = 150 + chart() + frame.Label = "Volume" + frame.Height = 100 + mktLog(log, id, mujica.VolumeChart(c, candles, &s.window, mujica.VolumeChartOptions{Frame: frame})) + case "146": + mktLog(log, id, mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: frame})) + case "147": + if mujica.NumberInput(c, &s.fraction, mujica.NumberInputOptions{Min: 0.1, Max: 1, Step: 0.1, Label: "Value area fraction"}).Changed() { + log(fmt.Sprintf("Value area: %.1f", s.fraction)) + } + mktLog(log, id, mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: frame, ValueArea: s.fraction})) + case "148": + if mujica.MultiChartLayout(c, &s.layout, mujica.MultiChartLayoutOptions{Height: 420}, func(i int, cell *mujica.ChartCell) { + if mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: fmt.Sprintf("Symbol %d", i+1)}).Changed() { + log(fmt.Sprintf("Cell %d: %s", i+1, cell.Symbol)) + } + items := make([]mujica.SelectOption[mujica.Interval], len(mujica.Intervals)) + for j, iv := range mujica.Intervals { + items[j] = mujica.SelectOption[mujica.Interval]{Value: iv, Label: string(iv)} + } + if mujica.Select(c, &cell.Interval, items, mujica.SelectOptions{Label: fmt.Sprintf("Interval %d", i+1)}).Changed() { + log(fmt.Sprintf("Cell %d: %s", i+1, cell.Interval)) + } + f := frame + f.Label = cell.Symbol + f.Height = 70 + mktLog(log, fmt.Sprint(i), mujica.CandlestickChart(c, candles, &cell.View, mujica.CandlestickOptions{Frame: f})) + }).Changed() { + log(fmt.Sprintf("Layout: %d, active %d", s.layout.Count, s.layout.Active)) + } + case "149": + for _, chartID := range []string{"price", "other"} { + ui.Column(c).Key(chartID).Children(func() { + if s.sync.Link(c, chartID).Changed() { + log("Sync: " + chartID) + } + v := &s.window + if chartID == "other" { + v = &s.second + } + o := opts + o.Frame.Height = 100 + o.Frame.Label = chartID + o.Sync = &s.sync + o.SyncID = chartID + mktLog(log, chartID, mujica.CandlestickChart(c, candles, v, o)) + }) + } + case "150": + if mujica.TimeRangeSelector(c, &s.rangeValue, mujica.TimeRangeSelectorOptions{}).Changed() { + log(fmt.Sprintf("Range: %d", s.rangeValue.Preset)) + } + from, to := s.rangeValue.Bounds(c.Now(), time.UTC) + ui.Text(c, from.Format("2006-01-02")+" — "+to.Format("2006-01-02")) + case "151": + before := fmt.Sprint(s.favorites) + if mujica.IntervalSelector(c, &s.interval, &s.favorites, mujica.IntervalSelectorOptions{}).Changed() { + log("Interval: " + string(s.interval)) + } + if before != fmt.Sprint(s.favorites) { + log("Favorites: " + fmt.Sprint(s.favorites)) + } + mujica.CandleCountdown(c, s.interval, mujica.CandleCountdownOptions{}) + case "152": + if mujica.ChartTypeSwitcher(c, &s.chartType, mujica.ChartTypeSwitcherOptions{}).Changed() { + log(fmt.Sprintf("Chart type: %d", s.chartType)) + } + opts.Type = s.chartType + chart() + case "153": + if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() { + log("Overlays changed") + } + opts.Overlays = s.overlays + chart() + case "154": + if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() { + log("Indicators changed") + } + r := mujica.IndicatorPane(c, candles, &s.window, &s.panes, mujica.IndicatorPaneOptions{Label: "BTC", Height: 320, Status: frame.Status, Error: frame.Error}) + if r.PointChanged { + log(fmt.Sprintf("Pane %d: point %d", r.Pane, r.Point)) + } + if r.Removed >= 0 { + log(fmt.Sprintf("Removed pane %d", r.Removed)) + } + if r.Resized { + log("Panes resized") + } + case "155": + if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() { + log("Indicators changed") + } + opts.Overlays = s.overlays + chart() + case "156", "157": + if mujica.TextInput(c, &s.drawings.Text, mujica.TextInputOptions{Label: "Annotation text"}).Changed() { + log("Annotation text changed") + } + if mujica.DrawingToolbar(c, &s.drawings, mujica.DrawingToolbarOptions{Horizontal: true}).Changed() { + log(fmt.Sprintf("Tool: %d, locked: %v", s.drawings.Tool, s.drawings.Locked)) + } + before := fmt.Sprint(s.drawings.Items) + opts.Drawings = &s.drawings + chart() + if before != fmt.Sprint(s.drawings.Items) { + log(fmt.Sprintf("Drawings: %d", len(s.drawings.Items))) + } + case "158": + if mujica.CompareLegend(c, &s.compare).Changed() { + log("Compared symbol removed") + } + if mujica.Button(c, "Restore ETH", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.compare = mktInitial().compare + log("Restored ETH") + } + if len(candles) > 0 { + opts.Compare = &s.compare + } + chart() + case "159": + before := s.alerts[0].Price + opts.Alerts = &s.alerts + opts.OnAlert = func(a mujica.PriceAlertLine, last mujica.Candle) { + log(fmt.Sprintf("Alert %s crossed %.2f", a.ID, last.Close)) + } + if mujica.Button(c, "Append crossing candle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + last := s.candles[len(s.candles)-1] + next := last + next.Time = last.Time.Add(time.Hour) + next.Open = last.Close + next.Close = s.alerts[0].Price + 2 + if last.Close > s.alerts[0].Price { + next.Close = s.alerts[0].Price - 2 + } + next.High = max(next.Open, next.Close) + 1 + next.Low = min(next.Open, next.Close) - 1 + s.candles = append(s.candles[:len(s.candles):len(s.candles)], next) + candles = s.candles + log("Appended candle") + } + chart() + if before != s.alerts[0].Price { + log(fmt.Sprintf("Alert price: %.2f", s.alerts[0].Price)) + } + case "160": + if mujica.IntervalSelector(c, &s.interval, &s.favorites, mujica.IntervalSelectorOptions{}).Changed() { + log("Interval: " + string(s.interval)) + } + if mujica.Checkbox(c, &s.hidden, "Hide countdown", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Hidden: %v", s.hidden)) + } + if !s.hidden { + mujica.CandleCountdown(c, s.interval, mujica.CandleCountdownOptions{}) + } + } + }) + }) +} diff --git a/examples/gallery/pages_mktchart_state.go b/examples/gallery/pages_mktchart_state.go new file mode 100644 index 0000000..8cd822d --- /dev/null +++ b/examples/gallery/pages_mktchart_state.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +type mktDemoState struct { + candles []mujica.Candle + points []mujica.QuotePoint + bids, asks []mujica.DepthLevel + bins []mujica.ProfileBin + window mujica.MarketView + second mujica.MarketView + sync mujica.ChartSync + layout mujica.MultiChartState + interval mujica.Interval + favorites []mujica.Interval + rangeValue mujica.MarketRange + chartType mujica.ChartType + overlays []mujica.IndicatorOverlay + panes mujica.IndicatorPanes + drawings mujica.DrawingTools + compare []mujica.CompareSymbol + alerts []mujica.PriceAlertLine + status string + disabled, raw, redUp, hidden, extended bool + previous, fraction float64 +} + +func mktInitial() mktDemoState { + s := mktDemoState{status: "Ready", interval: "1h", favorites: []mujica.Interval{"1m", "1h", "1D"}, previous: 100, fraction: 0.7, extended: true} + start := time.Date(2026, 10, 5, 8, 0, 0, 0, time.UTC) + price := 100.0 + compare := make([]float64, 180) + for i := range 180 { + closeAt := price + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8 + s.candles = append(s.candles, mujica.Candle{Time: start.Add(time.Duration(i) * time.Hour), Open: price, High: max(price, closeAt) + 0.8, Low: min(price, closeAt) - 0.7, Close: closeAt, Volume: 1000 + float64(i%17)*130}) + s.points = append(s.points, mujica.QuotePoint{Time: start.Add(time.Duration(i) * 5 * time.Minute), Price: 100 + 2*math.Sin(float64(i)/10), Volume: 100 + float64(i%7)*20}) + compare[i] = 20 + float64(i)*0.08 + price = closeAt + } + for i := range 40 { + s.bids = append(s.bids, mujica.DepthLevel{Price: 99.9 - float64(i)*0.1, Size: float64(10 + i*3)}) + s.asks = append(s.asks, mujica.DepthLevel{Price: 100.1 + float64(i)*0.1, Size: float64(8 + i*4)}) + } + s.bins = mujica.VolumeProfileOf(s.candles, 24) + s.compare = []mujica.CompareSymbol{{Name: "ETH", Closes: compare}} + s.alerts = []mujica.PriceAlertLine{{ID: "target", Price: 104, Label: "Target"}} + s.overlays = []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{10}}} + s.panes = mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorMACD}, {Kind: mujica.IndicatorRSI}}} + s.layout.Count = 2 + for i := range s.layout.Cells { + s.layout.Cells[i] = mujica.ChartCell{Symbol: fmt.Sprintf("Symbol %d", i+1), Interval: "1h"} + } + return s +} + +func mktControls(c *ui.Context, s *mktDemoState, log func(string)) mujica.ChartFrame { + ui.Row(c).Gap(12).Wrap().Children(func() { + if mujica.Select(c, &s.status, []mujica.SelectOption[string]{{Value: "Ready", Label: "Ready"}, {Value: "Loading", Label: "Loading"}, {Value: "Failed", Label: "Failed"}, {Value: "Empty", Label: "Empty"}}, mujica.SelectOptions{Label: "Data state"}).Changed() { + log("Data state: " + s.status) + } + if mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Disabled: %v", s.disabled)) + } + if mujica.Checkbox(c, &s.redUp, "Red up", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Red up: %v", s.redUp)) + } + }) + trend := mujica.GreenUp + if s.redUp { + trend = mujica.RedUp + } + mujica.UseTrend(c, trend) + f := mujica.ChartFrame{Label: "BTC", Height: 220} + switch s.status { + case "Loading": + f.Status = mujica.DataLoading + case "Failed": + f.Status = mujica.DataFailed + f.Error = "Archive unavailable" + } + return f +} + +func mktLog(log func(string), name string, r mujica.ChartResult) { + if r.Changed() { + log(fmt.Sprintf("%s: point %d", name, r.Point)) + } + if r.Retried() { + log(name + ": retry") + } +} diff --git a/examples/gallery/pages_mktchart_test.go b/examples/gallery/pages_mktchart_test.go new file mode 100644 index 0000000..af5ec52 --- /dev/null +++ b/examples/gallery/pages_mktchart_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func mktDemoTester(t *testing.T, id string) (*ui.Tester, *[]string) { + t.Helper() + events := &[]string{} + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Scroll(c).Fill().Padding(12).Children(func() { mktDemo(c, func(s string) { *events = append(*events, s) }, id) }) + }, 900, 800) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + return tt, events +} + +func TestMarketChartPagesHaveInteractiveControls(t *testing.T) { + if len(mktchartPages) != 20 { + t.Fatalf("pages %d", len(mktchartPages)) + } + for _, p := range mktchartPages { + t.Run(p.ID, func(t *testing.T) { + if p.Category != "Market Charts" || p.Code == "" || p.Keys == "" { + t.Fatal("incomplete page") + } + tt, events := mktDemoTester(t, p.ID) + if err := tt.Click("Red up"); err != nil { + t.Fatal(err) + } + if len(*events) == 0 || (*events)[len(*events)-1] != "Red up: true" { + t.Fatalf("control events %q", *events) + } + if err := tt.Click("Disabled"); err != nil { + t.Fatal(err) + } + if (*events)[len(*events)-1] != "Disabled: true" { + t.Fatalf("disabled event %q", *events) + } + }) + } +} + +func TestMarketChartDemosLogKeyboardAndAlerts(t *testing.T) { + for _, id := range []string{"141", "142", "143", "144", "145", "146", "147", "153", "155", "156", "157", "158", "159"} { + t.Run(id, func(t *testing.T) { + tt, events := mktDemoTester(t, id) + tabUntil(t, tt, "BTC") + tt.Key(0, ui.KeyRight) + want := id + ": point 0" + found := false + for _, e := range *events { + found = found || e == want + } + if !found { + t.Fatalf("keyboard event %q, want %s", *events, want) + } + }) + } + tt, events := mktDemoTester(t, "159") + if err := tt.Click("Append crossing candle"); err != nil { + t.Fatal(err) + } + found := false + for _, e := range *events { + found = found || strings.HasPrefix(e, "Alert target crossed ") + } + if !found { + t.Fatalf("alert events %q", *events) + } +} diff --git a/heikin_ashi_chart_test.go b/heikin_ashi_chart_test.go new file mode 100644 index 0000000..40c9f2f --- /dev/null +++ b/heikin_ashi_chart_test.go @@ -0,0 +1,58 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestHeikinAshiChartSwitchesToRaw(t *testing.T) { + candles := sampleCandles(30) + view := MarketView{Count: 30} + raw := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA"}}) + }, 600, 360) + ha := HeikinAshi(candles) + readout := func() string { + for _, s := range tt.Texts() { + if strings.HasPrefix(s, "2026-01-01 00:00") { + return s + } + } + return "" + } + tt.Key(0, ui.KeyTab) // the switch + tt.Key(0, ui.KeyTab) // the plot + tt.Key(0, ui.KeyHome) + if want := "O " + priceUngrouped(ha[0].Open, 2); !strings.Contains(readout(), want) { + t.Fatalf("Heikin-Ashi readout %q, want %q", readout(), want) + } + if err := tt.Click("Candles"); err != nil || !raw { + t.Fatalf("switch: %v %v", err, raw) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyHome) + if want := "O " + priceUngrouped(candles[0].Open, 2); !strings.Contains(readout(), want) { + t.Fatalf("raw readout %q, want %q", readout(), want) + } + defer func() { + if recover() == nil { + t.Fatal("nil raw accepted") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); HeikinAshiChart(c, candles, &view, nil, CandlestickOptions{}) }, 200, 100) +} + +func TestHeikinAshiChartSnapshot(t *testing.T) { + candles := sampleCandles(70) + view := MarketView{Count: 60} + raw := false + snapshot(t, "heikin_ashi_chart", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA", Height: 230}}) + }) + }) +} diff --git a/indicator_overlay.go b/indicator_overlay.go new file mode 100644 index 0000000..8531155 --- /dev/null +++ b/indicator_overlay.go @@ -0,0 +1,183 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +// IndicatorKind names a technical indicator. +type IndicatorKind int + +const ( + IndicatorMA IndicatorKind = iota + IndicatorEMA + IndicatorBOLL + IndicatorVWAP + IndicatorMACD + IndicatorRSI + IndicatorKDJ + IndicatorOBV +) + +// indicatorInfo describes names, default parameters, and plotting locations. +var indicatorInfo = []struct { + name string + params []float64 + overlay bool +}{ + {"MA", []float64{20}, true}, {"EMA", []float64{20}, true}, {"BOLL", []float64{20, 2}, true}, {"VWAP", nil, true}, + {"MACD", []float64{12, 26, 9}, false}, {"RSI", []float64{14}, false}, {"KDJ", []float64{9, 3, 3}, false}, {"OBV", nil, false}, +} + +func (k IndicatorKind) String() string { + if k < IndicatorMA || k > IndicatorOBV { + panic(fmt.Sprintf("mujica: unknown IndicatorKind %d", int(k))) + } + return indicatorInfo[k].name +} + +// DefaultParams returns a fresh copy of the kind's usual parameters. +func (k IndicatorKind) DefaultParams() []float64 { + _ = k.String() + return append([]float64(nil), indicatorInfo[k].params...) +} + +// IndicatorOverlay configures MA, EMA, BOLL, or VWAP. +type IndicatorOverlay struct { + Kind IndicatorKind + // Params nil uses the indicator's defaults. + Params []float64 + // Values overrides calculation; supply one value per candle. + Values []float64 +} + +// indicatorParams resolves and checks params for kind. +func indicatorParams(k IndicatorKind, params []float64) []float64 { + _ = k.String() + want := indicatorInfo[k].params + if params == nil { + return want + } + if len(params) != len(want) { + panic(fmt.Sprintf("mujica: %v takes %d parameters, not %d", k, len(want), len(params))) + } + for i, p := range params { + if math.IsInf(p, 0) || !(p > 0) || (k != IndicatorBOLL || i == 0) && p != float64(int(p)) { + panic(fmt.Sprintf("mujica: %v parameter %v must be a positive whole number", k, p)) + } + } + if k == IndicatorMACD && params[0] >= params[1] { + panic("mujica: MACD fast period must be below slow period") + } + return params +} + +// indicatorName is the kind with its parameters, as "MACD(12,26,9)". +func indicatorName(k IndicatorKind, params []float64) string { + if len(params) == 0 { + return k.String() + } + parts := make([]string, len(params)) + for i, p := range params { + parts[i] = strconv.FormatFloat(p, 'f', -1, 64) + } + return k.String() + "(" + strings.Join(parts, ",") + ")" +} + +// indicatorSeries computes named indicator lines from candles. +func indicatorSeries(k IndicatorKind, params []float64, candles []Candle) []ChartSeries { + p := indicatorParams(k, params) + closes := make([]float64, len(candles)) + for i, c := range candles { + closes[i] = c.Close + } + name := indicatorName(k, p) + switch k { + case IndicatorMA: + return []ChartSeries{{name, SMA(closes, int(p[0]))}} + case IndicatorEMA: + return []ChartSeries{{name, EMA(closes, int(p[0]))}} + case IndicatorBOLL: + mid, up, lo := Bollinger(closes, int(p[0]), p[1]) + return []ChartSeries{{name, mid}, {name + " ↑", up}, {name + " ↓", lo}} + case IndicatorVWAP: + return []ChartSeries{{name, VWAP(candles)}} + case IndicatorMACD: + dif, dea, hist := MACD(closes, int(p[0]), int(p[1]), int(p[2])) + return []ChartSeries{{"DIF", dif}, {"DEA", dea}, {"MACD", hist}} + case IndicatorRSI: + return []ChartSeries{{name, RSI(closes, int(p[0]))}} + case IndicatorKDJ: + kk, d, j := KDJ(candles, int(p[0]), int(p[1]), int(p[2])) + return []ChartSeries{{"K", kk}, {"D", d}, {"J", j}} + } + return []ChartSeries{{name, OBV(candles)}} +} + +// mktOverlayLines caches indicators until data or parameters change. +func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine { + pal := internal.Palette(m.k) + var out []mktLine + for i, o := range m.opts.Overlays { + if _ = o.Kind.String(); !indicatorInfo[o.Kind].overlay { + panic(fmt.Sprintf("mujica: %v is not an overlay indicator", o.Kind)) + } + col := pal[[]int{3, 1, 5, 2, 4}[i%5]] + if o.Values != nil { + if len(o.Values) != len(candles) { + panic("mujica: IndicatorOverlay Values need one value per candle") + } + out = append(out, mktLine{name: indicatorName(o.Kind, indicatorParams(o.Kind, o.Params)), values: o.Values, color: col, shape: len(out)}) + continue + } + key := fmt.Sprintf("%s|%d|%v", chartID(candles), o.Kind, o.Params) + series := chartMemo(ch, fmt.Sprint("overlay.", i), key, func() []ChartSeries { return indicatorSeries(o.Kind, o.Params, candles) }) + for _, s := range series { + out = append(out, mktLine{name: s.Name, values: s.Values, color: col, shape: len(out)}) + } + } + return out +} + +// IndicatorParamsField edits validated indicator parameters in place. +func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) ChoiceResult { + if params == nil { + panic("mujica: IndicatorParamsField needs params") + } + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String()) + if row.IsDisabled() { + copy := append([]float64(nil), (*params)...) + params = © + } + if *params == nil { + *params = kind.DefaultParams() + } + changed := false + indicatorParams(kind, *params) + row.Children(func() { + for i := range *params { + v := (*params)[i] + step, prec := 1.0, 0 + if kind == IndicatorBOLL && i == 1 { + step, prec = 0.1, 1 + } + if NumberInput(c, &v, NumberInputOptions{Min: step, Max: 500, Step: step, Precision: prec, Label: fmt.Sprintf("%v %d", kind, i+1)}).Element.Width(110).Key(i).Changed() || v != (*params)[i] { + if kind == IndicatorMACD && (i == 0 && v >= (*params)[1] || i == 1 && v <= (*params)[0]) { + continue + } + (*params)[i], changed = v, true + } + } + }) + if changed { + slog.Debug("mujica: indicator_overlay params", "kind", kind) + } + return ChoiceResult{row, changed} +} diff --git a/indicator_overlay_test.go b/indicator_overlay_test.go new file mode 100644 index 0000000..06d7c32 --- /dev/null +++ b/indicator_overlay_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestIndicatorOverlayValuesInReadoutAndMemo(t *testing.T) { + candles := sampleCandles(200) + view := MarketView{Count: 60} + overlays := []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{5}}, {Kind: IndicatorBOLL}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Overlays: overlays}) + }, 800, 400) + if !tt.HasText("MA(5)") || !tt.HasText("BOLL(20,2) ↑") { + t.Fatalf("legend %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + ma := SMA(closesOf(candles), 5) + want := "MA(5) " + priceUngrouped(ma[199], 2) + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, want) + } + if !found { + t.Fatalf("readout lacks %q: %q", want, tt.Texts()) + } + // Caller values replace the computation. + own := make([]float64, 200) + for i := range own { + own[i] = 42 + } + overlays = []IndicatorOverlay{{Kind: IndicatorEMA, Values: own}} + tt.Frame() + tt.Frame() + if !tt.HasText("EMA(20) 42.00") { + t.Fatalf("own values %q", tt.Texts()) + } + for name, o := range map[string]IndicatorOverlay{ + "pane kind": {Kind: IndicatorMACD}, + "params": {Kind: IndicatorMA, Params: []float64{0}}, + "values size": {Kind: IndicatorMA, Values: []float64{1}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Overlays: []IndicatorOverlay{o}}) + }, 300, 200) + }() + } +} + +func closesOf(cs []Candle) []float64 { + out := make([]float64, len(cs)) + for i, c := range cs { + out[i] = c.Close + } + return out +} + +func TestIndicatorParamsField(t *testing.T) { + var params []float64 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorParamsField(c, IndicatorMACD, ¶ms) + }, 500, 100) + if len(params) != 3 || params[0] != 12 { + t.Fatalf("defaults %v", params) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if params[0] != 13 { + t.Fatalf("Up on fast %v", params) + } +} + +func TestIndicatorOverlaySnapshot(t *testing.T) { + candles := sampleCandles(120) + view := MarketView{Count: 90} + snapshot(t, "indicator_overlay", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 250}, + Overlays: []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{10}}, {Kind: IndicatorBOLL}}}) + }) + }) +} diff --git a/indicator_pane.go b/indicator_pane.go new file mode 100644 index 0000000..285bffb --- /dev/null +++ b/indicator_pane.go @@ -0,0 +1,252 @@ +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 holds stacked indicators and their height shares. +type IndicatorPanes struct { + Panes []IndicatorPaneSpec + // Sizes reset to equal shares when pane count changes. + Sizes []float32 +} + +// IndicatorPaneOptions configure an IndicatorPane. +type IndicatorPaneOptions struct { + // Height defaults to 140 per pane. + Height float32 + // Label prefixes each pane's accessible name, as the symbol. + Label string + Disabled bool + Status DataStatus + Error string + Empty string +} + +// IndicatorPaneResult reports the stack's events. +type IndicatorPaneResult struct { + Element *ui.Element + // Removed reports the closed pane; -1 means none. + Removed int + // Resized reports finished height changes, for saving. + Resized bool + Point int + Pane int + PointChanged bool +} + +// IndicatorPane stacks resizable technical indicators using the shared window. +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") + } + box := ui.Column(c).FillWidth().Disabled(opts.Disabled) + if box.IsDisabled() { + copy := *panes + copy.Panes = append([]IndicatorPaneSpec(nil), panes.Panes...) + copy.Sizes = append([]float32(nil), panes.Sizes...) + panes = © + } + if opts.Height < 0 { + panic("mujica: IndicatorPane Height must be non-negative") + } + res := IndicatorPaneResult{Removed: -1, Point: -1, Pane: -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 = box + return res + } + if len(panes.Sizes) != n { + panes.Sizes = nil + } + u := Density(c).Unit() + lineH := FontSize(c, theme.CaptionSize) * theme.LineHeight + chrome := 2*max(theme.MinHitSize, lineH) + max(u*5, lineH) + 4*u + minimum := chrome + 40 + 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) + plotH := max(40, b.H-chrome) + col.Children(func() { + closed, point := indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH) + if point.Point >= 0 { + res.Point = point.Point + res.Pane = i + } + res.PointChanged = res.PointChanged || point.Changed() + if closed { + res.Removed = i + } + }) + } + } + box.Height(h).MinHeight(float32(n)*minimum + float32(n-1)*theme.BorderWidth) + 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: minimum, 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 indicator and reports its events. +func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) (bool, ChartResult) { + 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, Status: opts.Status, Error: opts.Error, Empty: opts.Empty}) + 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([]ChartLegendItem, 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] = ChartLegendItem{Name: s.Name, Color: lines[i].color, Shape: i} + } + yr := chartMemo(ch, "y", [5]any{chartID(candles), m.from, m.to, spec.Kind, fmt.Sprint(params)}, 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{Marks: len(candles), Legend: keys} + if m.to <= m.from || math.IsInf(lo, 0) { + spec2.Marks = 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) + } + } + m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} }) + if ch.res.Element.IsDisabled() { + copy := *view + m.view = © + } + result, plot := ch.build(spec2) + if plot != nil { + m.input(plot) + } + return closed && !ch.res.Element.IsDisabled(), result +} + +// 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/indicator_selector.go b/indicator_selector.go new file mode 100644 index 0000000..2e687ba --- /dev/null +++ b/indicator_selector.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// IndicatorSelectorOptions configure an IndicatorSelector. +type IndicatorSelectorOptions struct { + Disabled bool +} + +// IndicatorSelector searches, adds, removes, and configures chart indicators. +func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *IndicatorPanes, opts IndicatorSelectorOptions) ChoiceResult { + if overlays == nil || panes == nil { + panic("mujica: IndicatorSelector needs overlays and panes") + } + k := Tokens(c) + u := Density(c).Unit() + changed := false + box := ui.Row(c).Disabled(opts.Disabled) + box.Children(func() { + open := ui.Local(box, "indicator_selector.open", func() bool { return false }) + query := ui.Local(box, "indicator_selector.query", func() string { return "" }) + if box.IsDisabled() { + *open = false + } + label := fmt.Sprintf(msg(c, "indicator_selector.button"), len(*overlays)+len(panes.Panes)) + trigger := Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}) + if trigger.Clicked() { + *open = !*open + } + Popover(c, trigger, open, PopoverOptions{Title: msg(c, "indicator_selector.title"), Width: 340}, func() { + TextInput(c, query, TextInputOptions{Placeholder: msg(c, "indicator_selector.search"), Label: msg(c, "indicator_selector.search"), Clearable: true}) + ui.Text(c, msg(c, "indicator_selector.available")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, 0) + found := 0 + for kind := IndicatorMA; kind <= IndicatorOBV; kind++ { + name := kind.String() + if q := strings.TrimSpace(*query); q != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(q)) { + continue + } + found++ + ui.Row(c).Key(name).AlignItems(ui.Center).Gap(u * 2).MinHeight(Density(c).ControlHeight()).Children(func() { + ui.Text(c, name).FontWeight(600).Width(56) + where := "indicator_selector.pane" + if indicatorInfo[kind].overlay { + where = "indicator_selector.overlay" + } + ui.Text(c, msg(c, where)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1) + if Button(c, msg(c, "indicator_selector.add"), ButtonOptions{Variant: Ghost, Label: fmt.Sprintf(msg(c, "indicator_selector.add_named"), name)}).Clicked() { + if indicatorInfo[kind].overlay { + *overlays = append(slices.Clone(*overlays), IndicatorOverlay{Kind: kind, Params: kind.DefaultParams()}) + } else { + panes.Panes = append(slices.Clone(panes.Panes), IndicatorPaneSpec{Kind: kind, Params: kind.DefaultParams()}) + } + changed = true + slog.Debug("mujica: indicator_selector add", "kind", name) + } + }) + } + if found == 0 { + ui.Text(c, msg(c, "indicator_selector.none")).TextColor(k.TextMuted) + } + if len(*overlays)+len(panes.Panes) == 0 { + return + } + ui.Text(c, msg(c, "indicator_selector.active")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u*2, 0, 0, 0) + remove := -1 + for i := range *overlays { + if indicatorSelectorActive(c, u, fmt.Sprint("o", i), (*overlays)[i].Kind, &(*overlays)[i].Params, &changed) { + remove = i + } + } + if remove >= 0 { + *overlays = slices.Delete(slices.Clone(*overlays), remove, remove+1) + changed = true + } + remove = -1 + for i := range panes.Panes { + if indicatorSelectorActive(c, u, fmt.Sprint("p", i), panes.Panes[i].Kind, &panes.Panes[i].Params, &changed) { + remove = i + } + } + if remove >= 0 { + panes.Panes = slices.Delete(slices.Clone(panes.Panes), remove, remove+1) + changed = true + } + }) + }) + return ChoiceResult{box, changed} +} + +// indicatorSelectorActive edits parameters and reports removal of an indicator. +func indicatorSelectorActive(c *ui.Context, u float32, key string, kind IndicatorKind, params *[]float64, changed *bool) bool { + removed := false + ui.Row(c).Key(key).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() { + ui.Text(c, kind.String()).FontWeight(600).Width(56) + if len(kind.DefaultParams()) > 0 && IndicatorParamsField(c, kind, params).Changed() { + *changed = true + } + ui.Spacer(c) + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), indicatorName(kind, *params))).Clicked() { + removed = true + slog.Debug("mujica: indicator_selector remove", "kind", kind) + } + }) + return removed +} diff --git a/indicator_selector_test.go b/indicator_selector_test.go new file mode 100644 index 0000000..b826a25 --- /dev/null +++ b/indicator_selector_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestIndicatorSelectorSearchAddRemoveAndDisabled(t *testing.T) { + var overlays []IndicatorOverlay + var panes IndicatorPanes + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{Disabled: disabled}) + }, 640, 740) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if err := tt.Click("Indicators (0)"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Search indicators"); err != nil { + t.Fatal(err) + } + tt.Type("RSI") + if tt.HasText("Add MA") || !tt.HasText("RSI") { + t.Fatalf("search %q", tt.Texts()) + } + if err := tt.Click("Add RSI"); err != nil { + t.Fatal(err) + } + if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI { + t.Fatalf("panes %+v", panes.Panes) + } + if err := tt.Click("Remove RSI(14)"); err != nil { + t.Fatal(err) + } + if len(panes.Panes) != 0 { + t.Fatal("pane not removed") + } + disabled = true + tt.Frame() + if tt.HasText("Search indicators") { + t.Fatal("disabled panel remained open") + } +} +func TestIndicatorSelectorSnapshot(t *testing.T) { + overlays := []IndicatorOverlay{{Kind: IndicatorMA}} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}} + snapshot(t, "indicator_selector", 360, 100, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{}) }) + }) +} + +func TestIndicatorSelectorOverlayParams(t *testing.T) { + var overlays []IndicatorOverlay + var panes IndicatorPanes + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{}) + }, 640, 740) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if err := tt.Click("Indicators (0)"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Add MA"); err != nil { + t.Fatal(err) + } + if len(overlays) != 1 || overlays[0].Kind != IndicatorMA || overlays[0].Params[0] != 20 { + t.Fatalf("overlay %+v", overlays) + } + if err := tt.Click("MA 1"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyUp) + if overlays[0].Params[0] != 21 { + t.Fatalf("params %v", overlays[0].Params) + } +} diff --git a/indicators.go b/indicators.go new file mode 100644 index 0000000..f4321df --- /dev/null +++ b/indicators.go @@ -0,0 +1,198 @@ +package mujica + +import ( + "fmt" + "math" +) + +// Indicator helpers are pure; NaN marks insufficient data. + +// indicatorCheck panics on a period below one. +func indicatorCheck(name string, n int) { + if n < 1 { + panic(fmt.Sprintf("mujica: %s period %d must be at least 1", name, n)) + } +} + +func indicatorNaN(n int) []float64 { + out := make([]float64, n) + for i := range out { + out[i] = math.NaN() + } + return out +} + +// SMA computes the n-period simple moving average. +func SMA(vals []float64, n int) []float64 { + indicatorCheck("SMA", n) + out := indicatorNaN(len(vals)) + var sum float64 + for i, v := range vals { + sum += v + if i >= n { + sum -= vals[i-n] + } + if i >= n-1 { + out[i] = sum / float64(n) + } + } + return out +} + +// EMA uses 2/(n+1), seeded by the first n-period SMA. +func EMA(vals []float64, n int) []float64 { + indicatorCheck("EMA", n) + out := indicatorNaN(len(vals)) + k := 2 / float64(n+1) + var sum, prev float64 + for i, v := range vals { + switch { + case i < n-1: + sum += v + case i == n-1: + prev = (sum + v) / float64(n) + out[i] = prev + default: + prev = v*k + prev*(1-k) + out[i] = prev + } + } + return out +} + +// Bollinger returns SMA and population-deviation bands. +func Bollinger(vals []float64, n int, width float64) (mid, upper, lower []float64) { + indicatorCheck("Bollinger", n) + if !(width > 0) { + panic("mujica: Bollinger width must be positive") + } + mid = SMA(vals, n) + upper, lower = indicatorNaN(len(vals)), indicatorNaN(len(vals)) + for i := n - 1; i < len(vals); i++ { + var ss float64 + for _, v := range vals[i-n+1 : i+1] { + ss += (v - mid[i]) * (v - mid[i]) + } + d := width * math.Sqrt(ss/float64(n)) + upper[i], lower[i] = mid[i]+d, mid[i]-d + } + return mid, upper, lower +} + +// VWAP averages typical prices weighted by cumulative volume. +func VWAP(candles []Candle) []float64 { + out := indicatorNaN(len(candles)) + var pv, vol float64 + for i, c := range candles { + pv += (c.High + c.Low + c.Close) / 3 * c.Volume + vol += c.Volume + if vol > 0 { + out[i] = pv / vol + } + } + return out +} + +// MACD returns DIF, signal EMA, and their difference. +func MACD(vals []float64, fast, slow, signal int) (dif, dea, hist []float64) { + if fast >= slow { + panic("mujica: MACD fast period must be shorter than slow") + } + f, s := EMA(vals, fast), EMA(vals, slow) + dif = indicatorNaN(len(vals)) + for i := range vals { + dif[i] = f[i] - s[i] + } + dea = indicatorNaN(len(vals)) + if len(vals) >= slow { + tail := EMA(dif[slow-1:], signal) + copy(dea[slow-1:], tail) + } + hist = indicatorNaN(len(vals)) + for i := range vals { + hist[i] = dif[i] - dea[i] + } + return dif, dea, hist +} + +// RSI computes Wilder's relative strength index. +func RSI(vals []float64, n int) []float64 { + indicatorCheck("RSI", n) + out := indicatorNaN(len(vals)) + var gain, loss float64 + for i := 1; i < len(vals); i++ { + d := vals[i] - vals[i-1] + g, l := max(d, 0), max(-d, 0) + if i <= n { + gain += g + loss += l + if i < n { + continue + } + gain, loss = gain/float64(n), loss/float64(n) + } else { + gain = (gain*float64(n-1) + g) / float64(n) + loss = (loss*float64(n-1) + l) / float64(n) + } + if loss == 0 { + out[i] = 100 + } else { + out[i] = 100 - 100/(1+gain/loss) + } + } + return out +} + +// KDJ computes stochastic K, D, and J from RSV. +func KDJ(candles []Candle, n, m1, m2 int) (k, d, j []float64) { + indicatorCheck("KDJ", n) + indicatorCheck("KDJ m1", m1) + indicatorCheck("KDJ m2", m2) + k, d, j = indicatorNaN(len(candles)), indicatorNaN(len(candles)), indicatorNaN(len(candles)) + pk, pd := 50.0, 50.0 + for i := n - 1; i < len(candles); i++ { + lo, hi := math.Inf(1), math.Inf(-1) + for _, c := range candles[i-n+1 : i+1] { + lo, hi = min(lo, c.Low), max(hi, c.High) + } + rsv := 50.0 + if hi > lo { + rsv = (candles[i].Close - lo) / (hi - lo) * 100 + } + pk = (float64(m1-1)*pk + rsv) / float64(m1) + pd = (float64(m2-1)*pd + pk) / float64(m2) + k[i], d[i], j[i] = pk, pd, 3*pk-2*pd + } + return k, d, j +} + +// OBV accumulates signed volume according to close direction. +func OBV(candles []Candle) []float64 { + out := make([]float64, len(candles)) + for i := 1; i < len(candles); i++ { + out[i] = out[i-1] + switch { + case candles[i].Close > candles[i-1].Close: + out[i] += candles[i].Volume + case candles[i].Close < candles[i-1].Close: + out[i] -= candles[i].Volume + } + } + return out +} + +// HeikinAshi turns candles into Heikin-Ashi candles, keeping times and volumes. +func HeikinAshi(candles []Candle) []Candle { + out := make([]Candle, len(candles)) + for i, c := range candles { + h := Candle{Time: c.Time, Volume: c.Volume, Close: (c.Open + c.High + c.Low + c.Close) / 4} + if i == 0 { + h.Open = (c.Open + c.Close) / 2 + } else { + h.Open = (out[i-1].Open + out[i-1].Close) / 2 + } + h.High, h.Low = max(c.High, h.Open, h.Close), min(c.Low, h.Open, h.Close) + out[i] = h + } + return out +} diff --git a/indicators_test.go b/indicators_test.go new file mode 100644 index 0000000..c73b94a --- /dev/null +++ b/indicators_test.go @@ -0,0 +1,170 @@ +package mujica + +import ( + "math" + "testing" + "time" +) + +func approx(t *testing.T, name string, got, want []float64) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: len %d, want %d", name, len(got), len(want)) + } + for i := range got { + if math.IsNaN(want[i]) != math.IsNaN(got[i]) || !math.IsNaN(want[i]) && math.Abs(got[i]-want[i]) > 1e-9 { + t.Errorf("%s[%d] = %v, want %v", name, i, got[i], want[i]) + } + } +} + +var nan = math.NaN() + +func TestSMAAndEMA(t *testing.T) { + v := []float64{1, 2, 3, 4, 5, 6} + approx(t, "SMA", SMA(v, 3), []float64{nan, nan, 2, 3, 4, 5}) + approx(t, "EMA", EMA(v, 3), []float64{nan, nan, 2, 3, 4, 5}) + approx(t, "EMA2", EMA([]float64{10, 20, 10}, 1), []float64{10, 20, 10}) + approx(t, "EMA3", EMA([]float64{2, 4, 8}, 2), []float64{nan, 3, 3 + (8-3)*2.0/3}) +} + +func TestBollinger(t *testing.T) { + mid, up, lo := Bollinger([]float64{2, 4, 4, 4, 5, 5, 7, 9}, 8, 2) + // Mean 5, population deviation 2. + approx(t, "mid", mid[7:], []float64{5}) + approx(t, "upper", up[7:], []float64{9}) + approx(t, "lower", lo[7:], []float64{1}) + if !math.IsNaN(up[6]) { + t.Fatal("band before the period") + } +} + +func candlesOf(rows ...[5]float64) []Candle { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + out := make([]Candle, len(rows)) + for i, r := range rows { + out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: r[0], High: r[1], Low: r[2], Close: r[3], Volume: r[4]} + } + return out +} + +func TestVWAPAndOBV(t *testing.T) { + cs := candlesOf([5]float64{10, 12, 9, 12, 100}, [5]float64{12, 15, 12, 12, 300}, [5]float64{12, 12, 6, 9, 100}) + // Typical prices 11, 13, 9. + approx(t, "VWAP", VWAP(cs), []float64{11, (1100 + 3900) / 400.0, (1100 + 3900 + 900) / 500.0}) + approx(t, "OBV", OBV(cs), []float64{0, 0, -100}) +} + +func TestMACD(t *testing.T) { + v := []float64{1, 2, 3, 4, 5, 6, 7} + dif, dea, hist := MACD(v, 2, 3, 2) + f, s := EMA(v, 2), EMA(v, 3) + for i := 2; i < len(v); i++ { + if math.Abs(dif[i]-(f[i]-s[i])) > 1e-12 { + t.Fatalf("dif[%d]", i) + } + } + want := EMA(dif[2:], 2) + approx(t, "dea", dea[2:], want) + if !math.IsNaN(dea[1]) || math.Abs(hist[6]-(dif[6]-dea[6])) > 1e-12 { + t.Fatal("hist or warm-up wrong") + } + approx(t, "dif line", dif[2:], []float64{0.5, 0.5, 0.5, 0.5, 0.5}) + approx(t, "hist line", hist[3:], []float64{0, 0, 0, 0}) +} + +func TestRSI(t *testing.T) { + r := RSI([]float64{1, 2, 3, 2}, 2) + approx(t, "RSI", r, []float64{nan, nan, 100, 50}) +} + +func TestKDJ(t *testing.T) { + cs := candlesOf([5]float64{1, 2, 0, 1, 0}, [5]float64{1, 4, 0, 4, 0}, [5]float64{4, 4, 2, 3, 0}) + k, d, j := KDJ(cs, 2, 3, 3) + k1 := 200.0 / 3 + d1 := (100 + k1) / 3 + // i=2: range 0..4, RSV 75. + k2 := (2*k1 + 75) / 3 + d2 := (2*d1 + k2) / 3 + approx(t, "K", k, []float64{nan, k1, k2}) + approx(t, "D", d, []float64{nan, d1, d2}) + approx(t, "J", j, []float64{nan, 3*k1 - 2*d1, 3*k2 - 2*d2}) +} + +func TestHeikinAshi(t *testing.T) { + cs := candlesOf([5]float64{10, 14, 8, 12, 5}, [5]float64{12, 13, 9, 10, 6}) + h := HeikinAshi(cs) + if h[0].Open != 11 || h[0].Close != 11 || h[0].High != 14 || h[0].Low != 8 { + t.Fatalf("first %+v", h[0]) + } + if h[1].Open != 11 || h[1].Close != 11 || h[1].High != 13 || h[1].Low != 9 || h[1].Volume != 6 { + t.Fatalf("second %+v", h[1]) + } +} + +func TestIndicatorsPanicOnBadPeriods(t *testing.T) { + for name, f := range map[string]func(){ + "SMA": func() { SMA(nil, 0) }, + "MACD": func() { MACD(nil, 26, 12, 9) }, + "BOLL": func() { Bollinger(nil, 20, 0) }, + "KDJ m": func() { KDJ(nil, 9, 0, 3) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + f() + }() + } +} + +func TestMarketWindowPanZoom(t *testing.T) { + v := MarketView{} + if f, to := marketWindow(v, 1000, 400); f != 880 || to != 1000 { + t.Fatalf("default %d..%d", f, to) + } + marketPan(&v, 1000, 400, -100) + if v.End != 900 || v.Count != 120 { + t.Fatalf("pan back %+v", v) + } + marketZoom(&v, 1000, 400, 0.5) + if f, to := marketWindow(v, 1000, 400); f != 840 || to != 900 { + t.Fatalf("zoom in %d..%d", f, to) + } + marketZoom(&v, 1000, 100, 10) + if v.Count != 100 { + t.Fatalf("zoom out capped by room: %+v", v) + } + marketPan(&v, 1000, 100, 5000) + if v.End != 0 { + t.Fatalf("pan to the newest follows: %+v", v) + } + if f, to := marketWindow(MarketView{Count: 50, End: 10}, 1000, 400); f != 0 || to != 50 { + t.Fatalf("start clamp %d..%d", f, to) + } +} + +func TestIntervalClose(t *testing.T) { + loc := time.UTC + at := time.Date(2026, 10, 7, 13, 47, 10, 0, loc) // a Wednesday + for iv, want := range map[Interval]time.Time{ + "1m": time.Date(2026, 10, 7, 13, 48, 0, 0, loc), + "15m": time.Date(2026, 10, 7, 14, 0, 0, 0, loc), + "4h": time.Date(2026, 10, 7, 16, 0, 0, 0, loc), + "1D": time.Date(2026, 10, 8, 0, 0, 0, 0, loc), + "1W": time.Date(2026, 10, 12, 0, 0, 0, 0, loc), + "1M": time.Date(2026, 11, 1, 0, 0, 0, 0, loc), + } { + if got := iv.Close(at, loc); !got.Equal(want) { + t.Errorf("%s: %v, want %v", iv, got, want) + } + } + defer func() { + if recover() == nil { + t.Fatal("unknown interval accepted") + } + }() + Interval("7m").Close(at, loc) +} diff --git a/interval_selector.go b/interval_selector.go new file mode 100644 index 0000000..b7e430c --- /dev/null +++ b/interval_selector.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// intervalGroups sorts Intervals under their headings. +var intervalGroups = []struct { + key string + items []Interval +}{ + {"interval_selector.minutes", []Interval{"1m", "3m", "5m", "15m", "30m"}}, + {"interval_selector.hours", []Interval{"1h", "2h", "4h", "6h", "12h"}}, + {"interval_selector.days", []Interval{"1D", "3D", "1W", "1M"}}, +} + +// IntervalSelectorOptions configure an IntervalSelector. +type IntervalSelectorOptions struct { + Disabled bool +} + +// IntervalSelector groups candle periods and preserves caller-owned favorites. +func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opts IntervalSelectorOptions) ChoiceResult { + if value == nil || favorites == nil { + panic("mujica: IntervalSelector needs a value and favorites") + } + for _, iv := range append([]Interval{*value}, *favorites...) { + if !slices.Contains(Intervals, iv) { + panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv))) + } + } + k := Tokens(c) + u := Density(c).Unit() + changed := false + row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled) + disabled := row.IsDisabled() + row.Children(func() { + quick := slices.Clone(*favorites) + if !slices.Contains(quick, *value) { + quick = append(quick, *value) + } + slices.SortFunc(quick, func(a, b Interval) int { return slices.Index(Intervals, a) - slices.Index(Intervals, b) }) + items := make([]ToggleItem[Interval], len(quick)) + for i, iv := range quick { + items[i] = ToggleItem[Interval]{Value: iv, Label: string(iv)} + } + sel := []Interval{*value} + if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "interval_selector.label")}).Changed() && !disabled { + *value, changed = sel[0], true + } + trigger := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("chevron-down"), Label: msg(c, "interval_selector.all")}) + open := ui.Local(row, "interval_selector.open", func() bool { return false }) + if disabled { + *open = false + } + if trigger.Clicked() && !disabled { + *open = !*open + } + Popover(c, trigger, open, PopoverOptions{Label: msg(c, "interval_selector.all"), Width: 220}, func() { + for _, g := range intervalGroups { + ui.Text(c, msg(c, g.key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, u) + for _, iv := range g.items { + if intervalSelectorRow(c, k, u, iv, iv == *value, slices.Contains(*favorites, iv), favorites) { + *value, changed, *open = iv, true, false + } + } + } + }) + }) + if changed { + slog.Debug("mujica: interval_selector changed", "interval", *value) + } + return ChoiceResult{row, changed} +} + +// intervalSelectorRow shows a selectable period and its favorite toggle. +func intervalSelectorRow(c *ui.Context, k theme.Tokens, u float32, iv Interval, on, fav bool, favorites *[]Interval) bool { + chosen := false + ui.Row(c).Key(string(iv)).AlignItems(ui.Center).Gap(u).Children(func() { + b := ui.ButtonBase(c).Grow(1).MinHeight(Density(c).ControlHeight()).Padding(0, u*2).Justify(ui.Start).Radius(theme.ControlRadius).Gap(u * 2) + internal.Ring(b, k.Focus, theme.ControlRadius) + if on { + b.Background(k.Selection).TextColor(k.AccentText) + } else if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) { + if on { + internal.Mark(p, r, "check", k.AccentText) + } + }) + ui.Text(c, string(iv)).SingleLine() + }) + chosen = b.Clicked() + name := fmt.Sprintf(msg(c, "interval_selector.favorite"), iv) + if fav { + name = fmt.Sprintf(msg(c, "interval_selector.unfavorite"), iv) + } + star := iconAction(c, icons.Must("star"), name) + if fav { + star.TextColor(k.Warning) + } + if star.Clicked() && !star.IsDisabled() { + if fav { + *favorites = slices.DeleteFunc(slices.Clone(*favorites), func(x Interval) bool { return x == iv }) + } else { + *favorites = append(slices.Clone(*favorites), iv) + } + slog.Debug("mujica: interval_selector favorite", "interval", iv, "on", !fav) + } + }) + return chosen +} diff --git a/interval_selector_test.go b/interval_selector_test.go new file mode 100644 index 0000000..910c87a --- /dev/null +++ b/interval_selector_test.go @@ -0,0 +1,61 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestIntervalSelectorGroupsFavoritesAndDisabled(t *testing.T) { + value := Interval("1h") + favorites := []Interval{"1m", "1h"} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{Disabled: disabled}) + }, 500, 700) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if err := tt.Click("All intervals"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Minutes") || !tt.HasText("Hours") || !tt.HasText("Days and longer") { + t.Fatalf("groups %q", tt.Texts()) + } + if err := tt.Click("Add 4h to favorites"); err != nil { + t.Fatal(err) + } + if len(favorites) != 3 || favorites[2] != "4h" { + t.Fatalf("favorites %v", favorites) + } + for range 50 { + if tt.Focused("1M") { + break + } + tt.Key(0, ui.KeyTab) + } + if !tt.Focused("1M") { + t.Fatal("keyboard could not reach 1M") + } + tt.Key(0, ui.KeyEnter) + if value != "1M" { + t.Fatalf("interval %v", value) + } + if err := tt.Click("All intervals"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Frame() + if tt.HasText("Days and longer") { + t.Fatal("disabled popup remained open") + } + tt.Key(ui.Alt, ui.KeyDown) + if value != "1M" || len(favorites) != 3 { + t.Fatal("disabled interval changed") + } +} +func TestIntervalSelectorSnapshot(t *testing.T) { + value := Interval("1h") + favorites := []Interval{"1m", "5m", "1h", "1D"} + snapshot(t, "interval_selector", 460, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{}) }) + }) +} diff --git a/line_quote_chart.go b/line_quote_chart.go new file mode 100644 index 0000000..bd7f274 --- /dev/null +++ b/line_quote_chart.go @@ -0,0 +1,199 @@ +package mujica + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// QuotePoint holds time, price, and incremental volume. +type QuotePoint struct { + Time time.Time + Price float64 + Volume float64 +} + +// LineQuoteChartOptions configure a LineQuoteChart. +type LineQuoteChartOptions struct { + Frame ChartFrame + // PrevClose is the required positive baseline price. + PrevClose float64 + // RegularOpen and RegularClose optionally delimit extended trading sessions. + RegularOpen, RegularClose time.Time + Decimals int +} + +// lineQuoteSession returns pre-market, regular, or after-market. +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 draws price, weighted average, and extended sessions. +func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptions) ChartResult { + if math.IsInf(opts.PrevClose, 0) || !(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 || math.IsNaN(p.Volume) || math.IsInf(p.Volume, 0) || math.IsInf(p.Price, 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", [2]any{chartID(points), opts.PrevClose}, 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]) + } + // Center the scale on the previous close. + 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{Marks: len(points), Legend: []ChartLegendItem{ + {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 { + if layout == "Jan 2" { + layout = msg(c, "candlestick_chart.daily_time") + } + 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 := chartMemo(ch, "prices", chartID(points), func() []float64 { + out := make([]float64, len(points)) + for i, pt := range points { + out[i] = pt.Price + } + return out + }) + at := func(i int) float32 { return x(xs, i) } + for li, vals := range [][]float64{price, avg} { + col := []ui.Color{k.AccentText, k.Warning}[li] + runs := chartMemo(ch, fmt.Sprint("quote.runs.", li), [3]any{chartID(points), plot.X, plot.W}, func() [][]int { + var out [][]int + chartRuns(vals, at, func(idx []int) { out = append(out, idx) }) + return out + }) + for _, idx := range runs { + 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) + for j := 0; j < len(idx); j += max(1, len(idx)/12) { + i := idx[j] + internal.Marker(p, []int{0, 3}[li], at(i), ys.Map(vals[i]), 5, 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) + } +} diff --git a/line_quote_chart_test.go b/line_quote_chart_test.go new file mode 100644 index 0000000..404d7a4 --- /dev/null +++ b/line_quote_chart_test.go @@ -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}) + }) + }) +} diff --git a/market_boundary_test.go b/market_boundary_test.go new file mode 100644 index 0000000..91c9a79 --- /dev/null +++ b/market_boundary_test.go @@ -0,0 +1,68 @@ +package mujica + +import ( + "bytes" + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMarketIndicatorDomainChangesWithParams(t *testing.T) { + cs := sampleCandles(300) + v := MarketView{Count: 80} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}} + build := func(c *ui.Context) { + Use(c, Settings{}) + IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC"}) + } + tt := ui.NewTester(build, 640, 400) + panes.Panes[0].Kind = IndicatorOBV + tt.Frame() + fresh := ui.NewTester(build, 640, 400) + if !bytes.Equal(encode(t, tt.Image()), encode(t, fresh.Image())) { + t.Fatal("changed indicator retained its previous scale") + } + +} + +func TestMarketRejectsInvalidLayers(t *testing.T) { + for _, kind := range []string{"compare zero", "compare infinity", "alert infinity", "alert duplicate", "profile NaN", "depth NaN", "indicator infinity"} { + t.Run(kind, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("invalid data accepted") + } + }() + cs := sampleCandles(2) + v := MarketView{} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := CandlestickOptions{Frame: ChartFrame{Label: "Price"}} + switch kind { + case "compare zero": + x := []CompareSymbol{{Name: "X", Closes: []float64{0, 1}}} + o.Compare = &x + case "compare infinity": + x := []CompareSymbol{{Name: "X", Closes: []float64{1, math.Inf(1)}}} + o.Compare = &x + case "alert infinity": + x := []PriceAlertLine{{ID: "a", Price: math.Inf(1)}} + o.Alerts = &x + case "alert duplicate": + x := []PriceAlertLine{{ID: "a", Price: 1}, {ID: "a", Price: 2}} + o.Alerts = &x + case "profile NaN": + VolumeProfile(c, []ProfileBin{{1, 2, math.NaN()}}, VolumeProfileOptions{Frame: o.Frame}) + return + case "depth NaN": + DepthChart(c, []DepthLevel{{math.NaN(), 1}}, []DepthLevel{{2, 1}}, DepthChartOptions{Frame: o.Frame}) + return + case "indicator infinity": + o.Overlays = []IndicatorOverlay{{Kind: IndicatorBOLL, Params: []float64{2, math.Inf(1)}}} + } + CandlestickChart(c, cs, &v, o) + }, 500, 320) + }) + } +} diff --git a/market_data.go b/market_data.go new file mode 100644 index 0000000..87293a9 --- /dev/null +++ b/market_data.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Candle holds OHLC prices, time, and volume. +type Candle struct { + Time time.Time + Open, High, Low, Close float64 + Volume float64 +} + +// marketCheckCandles validates chronological candles and finite OHLC values. +func marketCheckCandles(candles []Candle) { + for i, c := range candles { + if c.Time.IsZero() || i > 0 && !c.Time.After(candles[i-1].Time) { + panic(fmt.Sprintf("mujica: candle %d time is zero or not after the one before", i)) + } + for _, v := range []float64{c.Open, c.High, c.Low, c.Close, c.Volume} { + if math.IsNaN(v) || math.IsInf(v, 0) { + panic("mujica: candle values must be finite") + } + } + if !(c.Low <= min(c.Open, c.Close)) || !(c.High >= max(c.Open, c.Close)) || c.Volume < 0 || math.IsNaN(c.Volume) { + panic(fmt.Sprintf("mujica: candle %d has impossible prices or volume", i)) + } + } +} + +// MarketView holds the visible candle window. +type MarketView struct { + // Count is how many candles show; zero shows 120. + Count int + // End is exclusive; zero follows the newest candle. + End int +} + +// marketWindow resolves the visible range, bounded by room. +func marketWindow(v MarketView, n, room int) (from, to int) { + if v.Count < 0 || v.End < 0 { + panic("mujica: MarketView Count and End must not be negative") + } + if n == 0 { + return 0, 0 + } + count := v.Count + if count == 0 { + count = 120 + } + count = max(1, min(count, room, n)) + to = n + if v.End > 0 && v.End < n { + to = max(v.End, count) + } + return to - count, to +} + +// marketPan moves the window; the newest edge enables following. +func marketPan(v *MarketView, n, room, delta int) { + from, to := marketWindow(*v, n, room) + v.Count = to - from + to = max(v.Count, min(n, to+delta)) + if to >= n { + v.End = 0 + } else { + v.End = to + } +} + +// marketZoom changes visible count while preserving the right edge. +func marketZoom(v *MarketView, n, room int, f float64) { + from, to := marketWindow(*v, n, room) + count := int(math.Round(float64(to-from) * f)) + if f > 1 && count == to-from { + count++ + } + v.Count = max(min(10, n), min(count, room, n)) + if to < n { + v.End = max(to, v.Count) + } +} + +// marketIndexAt finds the candle at or before t. +func marketIndexAt(candles []Candle, t time.Time) int { + return sort.Search(len(candles), func(i int) bool { return candles[i].Time.After(t) }) - 1 +} + +// Interval names a supported candle period. +type Interval string + +// Intervals lists every Interval, shortest first. +var Intervals = []Interval{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1D", "3D", "1W", "1M"} + +// Close aligns periods locally; weeks start Monday. +func (iv Interval) Close(t time.Time, loc *time.Location) time.Time { + t = t.In(loc) + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) + step := func(d time.Duration) time.Time { + since := t.Sub(day) + return day.Add((since/d + 1) * d) + } + switch iv { + case "1m", "3m", "5m", "15m", "30m": + m, _ := time.ParseDuration(string(iv)) + return step(m) + case "1h", "2h", "4h", "6h", "12h": + h, _ := time.ParseDuration(string(iv)) + return step(h) + case "1D": + return day.AddDate(0, 0, 1) + case "3D": + days := int(day.Sub(time.Date(1970, 1, 1, 0, 0, 0, 0, loc)).Hours()/24 + 0.5) + return day.AddDate(0, 0, 3-days%3) + case "1W": + back := (int(day.Weekday()) + 6) % 7 + return day.AddDate(0, 0, 7-back) + case "1M": + return time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc) + } + panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv))) +} diff --git a/market_input.go b/market_input.go new file mode 100644 index 0000000..8fb7428 --- /dev/null +++ b/market_input.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" +) + +// input handles pan, zoom, alerts, drawings, and keyboard following. +func (m *mktCore) input(plot *ui.Element) { + if plot.IsDisabled() { + return + } + n := len(m.candles) + room := m.room() + changed := false + plot.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind != ui.InputScroll || plot.IsDisabled() || ev.DY == 0 { + return false + } + f := 0.9 + if ev.DY > 0 { + f = 1 / 0.9 + } + marketZoom(m.view, n, m.room(), f) + return true + }) + switch { + case plot.Shortcut(ui.Shift, ui.KeyLeft): + marketPan(m.view, n, room, -max(1, (m.to-m.from)/10)) + changed = true + case plot.Shortcut(ui.Shift, ui.KeyRight): + marketPan(m.view, n, room, max(1, (m.to-m.from)/10)) + changed = true + case plot.Shortcut(0, ui.KeyEqual): + marketZoom(m.view, n, room, 0.8) + changed = true + case plot.Shortcut(0, ui.KeyMinus): + marketZoom(m.view, n, room, 1.25) + changed = true + } + // Keep the keyboard mark visible. + f := m.ch.st.focus + if f >= 0 && f != m.st.focus && !changed && (f < m.from || f >= m.to) { + if f < m.from { + marketPan(m.view, n, room, f-m.from) + } else { + marketPan(m.view, n, room, f-m.to+1) + } + changed = true + } + m.st.focus = f + b := plot.Bounds() + g := m.geom(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: b.H}) + px, py, over := plot.PointerPosition() + x, y := b.X+px, b.Y+py + dx, dy, held := plot.Dragged() + used := false + if d := m.opts.Drawings; d != nil { + used = d.input(m, plot, g, x, y, over, held, dx, dy) + } + if !used { + m.alertsKeyboard(plot) + used = m.alertsInput(g, x, y, held, dy) + } + if !used && held && dx != 0 && g.band.Step() > 0 { + m.st.dragRest += dx + if steps := int(m.st.dragRest / g.band.Step()); steps != 0 { + m.st.dragRest -= float32(steps) * g.band.Step() + marketPan(m.view, n, room, -steps) + changed = true + } + } + if !held { + m.st.dragRest = 0 + } + if changed { + slog.Debug("mujica: candlestick_chart view", "count", m.view.Count, "end", m.view.End) + m.c.Invalidate() + } +} + +func (m *mktCore) alertsKeyboard(plot *ui.Element) { + if m.opts.Alerts == nil || len(*m.opts.Alerts) == 0 || m.base != 0 { + return + } + a := *m.opts.Alerts + m.st.alertFocus = min(m.st.alertFocus, len(a)-1) + selected := false + if plot.Shortcut(0, ui.KeyPageDown) { + m.st.alertFocus = (m.st.alertFocus + 1) % len(a) + selected = true + } + if plot.Shortcut(0, ui.KeyPageUp) { + m.st.alertFocus = (max(m.st.alertFocus, 0) - 1 + len(a)) % len(a) + selected = true + } + if selected && m.opts.Drawings != nil { + d := m.opts.Drawings + d.sel, d.pending, d.Tool = 0, nil, ToolSelect + } + delta := 0 + if m.opts.Drawings == nil || m.opts.Drawings.Selected() < 0 { + if plot.Shortcut(ui.Alt, ui.KeyUp) { + delta = 1 + } + if plot.Shortcut(ui.Alt, ui.KeyDown) { + delta = -1 + } + } + if i := m.st.alertFocus; i >= 0 && (selected || delta != 0) { + spec := numberInputResolve(NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec}) + if delta != 0 { + a[i].Price = spec.stepBy(a[i].Price, delta) + slog.Debug("mujica: price_alert_line moved", "id", a[i].ID) + } + m.c.Announce(fmt.Sprintf(msg(m.c, "price_alert_line.tag"), priceUngrouped(a[i].Price, m.dec))) + m.c.Invalidate() + } +} diff --git a/market_keyboard_test.go b/market_keyboard_test.go new file mode 100644 index 0000000..a4f99b3 --- /dev/null +++ b/market_keyboard_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestDrawingToolsKeyboardCreateAndSelect(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + d := DrawingTools{Tool: ToolTrendLine} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d}) + }, 640, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if len(d.Items) != 1 || d.Items[0].A.Time != cs[0].Time || d.Items[0].B.Time != cs[1].Time { + t.Fatalf("keyboard drawing %+v", d) + } + tt.Key(0, ui.KeyEscape) + tt.Key(ui.Alt, ui.KeyEnter) + if d.Selected() != 0 { + t.Fatalf("selection %d", d.Selected()) + } + tt.Key(0, ui.KeyDelete) + if len(d.Items) != 0 { + t.Fatal("keyboard deletion failed") + } +} + +func TestPriceAlertLineKeyboard(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + alerts := []PriceAlertLine{{ID: "a", Price: 100}, {ID: "b", Price: 105}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Alerts"}, Alerts: &alerts}) + }, 640, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyUp) + if alerts[0].Price != 100.01 || alerts[1].Price != 105 { + t.Fatalf("first alert %+v", alerts) + } + tt.Key(0, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyDown) + if alerts[1].Price != 104.99 { + t.Fatalf("second alert %+v", alerts) + } +} diff --git a/market_large_test.go b/market_large_test.go new file mode 100644 index 0000000..826edd1 --- /dev/null +++ b/market_large_test.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestDepthChartLargeDataBounded(t *testing.T) { + bids, asks := make([]DepthLevel, 100000), make([]DepthLevel, 100000) + for i := range bids { + bids[i] = DepthLevel{100 - float64(i)*0.0001, 1} + asks[i] = DepthLevel{101 + float64(i)*0.0001, 2} + } + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}}) + }, 640, 340) + drawn := *ui.Local(res.Element, "mujica.depth.drawn", func() int { return 0 }) + if drawn <= 0 || drawn > 4*640+8 { + t.Fatalf("painted %d depth vertices", drawn) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 199999 { + t.Fatalf("last point %d", res.Point) + } +} + +func TestVolumeProfileLargeDataBounded(t *testing.T) { + bins := make([]ProfileBin, 100000) + for i := range bins { + bins[i] = ProfileBin{float64(i), float64(i + 1), float64(i%19 + 1)} + } + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = VolumeProfile(c, bins, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile"}}) + }, 640, 340) + drawn := *ui.Local(res.Element, "mujica.profile.drawn", func() int { return 0 }) + if drawn <= 0 || drawn > 4*340+4 { + t.Fatalf("painted %d profile bins", drawn) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 99999 { + t.Fatalf("last bin %d", res.Point) + } +} diff --git a/market_layers.go b/market_layers.go new file mode 100644 index 0000000..c98aefb --- /dev/null +++ b/market_layers.go @@ -0,0 +1,178 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" +) + +// PriceAlertLine holds a draggable, caller-owned price threshold. +type PriceAlertLine struct { + ID string + Price float64 + // Label shows beside the price; empty shows the price only. + Label string +} + +// CompareSymbol holds aligned closes for percent comparison. +type CompareSymbol struct { + Name string + Closes []float64 +} + +// compareLines normalizes prices and compared symbols into percentages. +func (m *mktCore) compareLines() { + if m.opts.Compare == nil || len(*m.opts.Compare) == 0 || m.to <= m.from { + return + } + m.base = m.candles[m.from].Close + if m.base == 0 { + panic("mujica: percent comparison needs a nonzero base close") + } + pal := internal.Palette(m.k) + for i := range m.overlays { + line := &m.overlays[i] + values := line.values + base := m.base + line.values = chartMemo(m.ch, fmt.Sprint("compare.overlay.", i), [2]any{chartID(values), base}, func() []float64 { + out := make([]float64, len(values)) + for j, v := range values { + out[j] = (v/base - 1) * 100 + } + return out + }) + } + for i, s := range *m.opts.Compare { + if s.Name == "" || len(s.Closes) != len(m.candles) { + panic(fmt.Sprintf("mujica: CompareSymbol %q needs a Name and one close per candle", s.Name)) + } + chartMemo(m.ch, "compare.check."+s.Name, chartID(s.Closes), func() bool { + for _, v := range s.Closes { + if math.IsInf(v, 0) || (!math.IsNaN(v) && v <= 0) { + panic("mujica: compared closes must be positive or NaN") + } + } + return true + }) + base := math.NaN() + for j := m.from; j < m.to && math.IsNaN(base); j++ { + base = s.Closes[j] + } + closes := s.Closes + vals := chartMemo(m.ch, "compare."+s.Name, [2]any{chartID(closes), math.Float64bits(base)}, func() []float64 { + out := make([]float64, len(closes)) + for j, v := range closes { + out[j] = (v/base - 1) * 100 + } + return out + }) + m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: i + 1}) + } +} + +// CompareLegend shows series markers and removal actions. +func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) ChoiceResult { + if symbols == nil { + panic("mujica: CompareLegend needs symbols") + } + k := Tokens(c) + pal := internal.Palette(k) + u := Density(c).Unit() + removed := -1 + row := ui.Row(c).Wrap().Gap(u * 2).Role(ui.RoleList).Label(msg(c, "compare_symbol.label")) + row.Children(func() { + for i, s := range *symbols { + ui.Row(c).Key(s.Name).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() { + col := pal[(i+1)%len(pal)] + ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, i+1, r.X+5, r.Y+5, 9, col) }) + ui.Text(c, s.Name).SingleLine() + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "compare_symbol.remove"), s.Name)).Clicked() && !row.IsDisabled() { + removed = i + } + }) + } + }) + if removed >= 0 { + slog.Debug("mujica: compare_symbol removed", "name", (*symbols)[removed].Name) + *symbols = append((*symbols)[:removed:removed], (*symbols)[removed+1:]...) + } + return ChoiceResult{row, removed >= 0} +} + +// alertsCheck fires callbacks when the latest close crosses thresholds. +func (m *mktCore) alertsCheck() { + if m.opts.Alerts != nil { + seen := map[string]bool{} + for _, a := range *m.opts.Alerts { + if a.ID == "" || seen[a.ID] || math.IsNaN(a.Price) || math.IsInf(a.Price, 0) { + panic("mujica: alerts need unique IDs and finite prices") + } + seen[a.ID] = true + } + } + if m.opts.Alerts == nil || len(m.candles) == 0 { + return + } + last := m.candles[len(m.candles)-1] + if !m.ch.res.Element.IsDisabled() && m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil { + for _, a := range *m.opts.Alerts { + if (m.st.lastClose-a.Price)*(last.Close-a.Price) <= 0 && m.st.lastClose != a.Price { + slog.Debug("mujica: price_alert_line crossed", "id", a.ID) + m.opts.OnAlert(a, last) + } + } + } + m.st.lastClose, m.st.alertSeen = last.Close, true +} + +// alertsInput drags the threshold captured by the initial press. +func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bool { + if m.opts.Alerts == nil || m.base != 0 { + return false + } + alerts := *m.opts.Alerts + if !held { + m.st.grab = -1 + return false + } + if m.st.grab < 0 { + for i, a := range alerts { + if g.plot.Contains(x, y-dy) && math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 14 { + m.st.grab = i + } + } + } + if m.st.grab < 0 || m.st.grab >= len(alerts) { + return false + } + spec := numberInputResolve(NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec}) + alerts[m.st.grab].Price = spec.round(g.ys.Invert(y)) + slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID) + return true +} + +// paintAlerts draws dashed thresholds and price labels. +func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) { + if m.opts.Alerts == nil || m.base != 0 { + return + } + for i, a := range *m.opts.Alerts { + y := internal.Snap(p, g.ys.Map(a.Price)) + if i == m.st.alertFocus { + p.FillPath(internal.Diamond(internal.Snap(p, g.plot.X+8), y, 9), m.k.Warning) + } + for x := g.plot.X; x < g.plot.X+g.plot.W; x += 8 { + p.Line(x, y, min(x+4, g.plot.X+g.plot.W), y, 1, m.k.Warning) + } + text := priceUngrouped(a.Price, m.dec) + if a.Label != "" { + text = a.Label + " " + text + } + mktTag(p, g.plot.X+g.plot.W, y, fmt.Sprintf(msg(m.c, "price_alert_line.tag"), text), size, m.k.Warning, m.k.Surface) + } +} diff --git a/market_regression_test.go b/market_regression_test.go new file mode 100644 index 0000000..db4267d --- /dev/null +++ b/market_regression_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMarketEmptyWindow(t *testing.T) { + from, to := marketWindow(MarketView{}, 0, 200) + if from != 0 || to != 0 { + t.Fatalf("empty window %d..%d", from, to) + } +} + +func TestMarketDisabledAlert(t *testing.T) { + cs := sampleCandles(2) + v := MarketView{} + alerts := []PriceAlertLine{{ID: "a", Price: cs[1].Close + 1}} + disabled, fired := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Price"}, Alerts: &alerts, + OnAlert: func(PriceAlertLine, Candle) { fired++ }}) + }) + }, 500, 320) + disabled = true + next := cs[1] + next.Time = next.Time.Add(3600e9) + next.Close += 2 + next.High = max(next.High, next.Close) + cs = append(cs, next) + tt.Frame() + if fired != 0 { + t.Fatalf("disabled alert fired %d times", fired) + } +} + +func TestMarketDisabledLayoutCopy(t *testing.T) { + state := MultiChartState{Count: 2} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + MultiChartLayout(c, &state, MultiChartLayoutOptions{}, func(_ int, cell *ChartCell) { + cell.Symbol = "changed" + }) + }) + }, 600, 600) + if state.Cells[0].Symbol != "" || state.Cells[1].Symbol != "" { + t.Fatalf("disabled layout wrote cells: %+v", state.Cells) + } +} + +func TestMarketDisabledParamsCopy(t *testing.T) { + var params []float64 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { IndicatorParamsField(c, IndicatorMA, ¶ms) }) + }, 500, 100) + if params != nil { + t.Fatalf("disabled field initialized caller parameters: %v", params) + } +} + +func TestMarketInvalidFiniteData(t *testing.T) { + for _, v := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} { + func() { + defer func() { + if recover() == nil { + t.Errorf("nonfinite candle accepted: %v", v) + } + }() + cs := sampleCandles(1) + cs[0].High = v + marketCheckCandles(cs) + }() + } +} + +func TestMarketLockedUndo(t *testing.T) { + d := DrawingTools{Items: []Drawing{{Tool: ToolHorizontal}}} + d.Clear() + d.Locked = true + if d.Undo() || len(d.Items) != 0 { + t.Fatal("locked drawings accepted undo") + } +} diff --git a/market_review_regression_test.go b/market_review_regression_test.go new file mode 100644 index 0000000..04ddf07 --- /dev/null +++ b/market_review_regression_test.go @@ -0,0 +1,192 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "image" + "math" + "testing" + "time" +) + +func TestMarketSyncRange(t *testing.T) { + cs := sampleCandles(300) + short := cs[:200] + s := ChartSync{Count: 40, End: cs[249].Time} + var a, b MarketView + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"}) + CandlestickChart(c, short, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"}) + }) + }, 640, 400) + t.Logf("A=%+v B=%+v sync.End=%v", a, b, s.End) + if a.End != 250 || s.End != cs[249].Time { + t.Error("B erased A's explicit historical range") + } +} +func TestMarketPaneKeyboardGeometry(t *testing.T) { + cs := sampleCandles(300) + v := MarketView{Count: 40} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorOBV}}} + var r IndicatorPaneResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r = IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC", Height: 220}) + }, 640, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyHome) + tt.Frame() + from, to := marketWindow(v, 300, 192) + t.Logf("Point=%d visible=%d..%d view=%+v", r.Point, from, to, v) + if r.Point < from || r.Point >= to { + t.Error("focused readout has no visible crosshair") + } +} +func TestMarketAlertsWithDrawingTools(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{} + a := []PriceAlertLine{{ID: "a", Price: 100}} + d := DrawingTools{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Alerts: &a, Drawings: &d}) + }, 640, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyUp) + t.Logf("alert=%v", a[0].Price) + if a[0].Price != 100.01 { + t.Error("alert keyboard adjustment inaccessible with idle drawing tools") + } +} +func TestMarketDrawingSwitchCancelsPending(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{} + d := DrawingTools{Tool: ToolTrendLine} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + DrawingToolbar(c, &d, DrawingToolbarOptions{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Drawings: &d}).Element.Grow(1) + }) + }, 640, 450) + tt.ClickAt(250, 100) + if d.pending == nil { + t.Fatal("setup missing pending point") + } + tt.Key(ui.Alt, ui.Key1) + t.Logf("Tool=%d pending=%+v", d.Tool, d.pending) + before := v + tt.Press(300, 150) + tt.Move(400, 150) + tt.Release(400, 150) + t.Logf("before=%+v after=%+v pending=%+v", before, v, d.pending) + if d.Tool == ToolSelect && d.pending != nil { + t.Error("Select retains previous tool preview and captures pan input") + } +} +func TestMarketCountdownDST(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 3, 8, 23, 30, 0, 0, loc) + close := Interval("1h").Close(now, loc) + t.Logf("now=%s closes=%s", now, close) + if close.Hour() != 0 { + t.Error("hourly candle closes off the wall-clock hour") + } +} +func TestMarketPaneResizeGeometry(t *testing.T) { + cs := sampleCandles(300) + v := MarketView{Count: 40} + p := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI}}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorPane(c, cs, &v, &p, IndicatorPaneOptions{Label: "BTC", Height: 320}) + }, 640, 400) + for i := 0; i < 12; i++ { + tt.Key(0, ui.KeyTab) + if tt.Focused("Resize MACD(12,26,9) and RSI(14)") { + break + } + } + t.Logf("texts=%q", tt.Texts()) + tt.Key(0, ui.KeyHome) + tt.Frame() + top, _ := tt.Find("MACD(12,26,9)") + plot, _ := tt.Find("BTC MACD(12,26,9)") + t.Logf("sizes=%v top=%+v plot=%+v", p.Sizes, top, plot) + if plot.H == 0 { + t.Error("resizing to advertised minimum completely hides the chart") + } +} +func TestMarketMissingComparisonCache(t *testing.T) { + cs := sampleCandles(100000) + v := MarketView{} + s := []CompareSymbol{{Name: "missing", Closes: make([]float64, len(cs))}} + for i := range s[0].Closes { + s[0].Closes[i] = math.NaN() + } + var r ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r = CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Compare: &s}) + }, 640, 320) + memo := ui.Local(r.Element, "mujica.memo.compare.missing", func() chartMemoEntry { return chartMemoEntry{} }) + before := chartID(memo.val.([]float64)) + tt.Frame() + memo = ui.Local(r.Element, "mujica.memo.compare.missing", func() chartMemoEntry { return chartMemoEntry{} }) + after := chartID(memo.val.([]float64)) + t.Logf("before=%s after=%s", before, after) + if before != after { + t.Error("unchanged missing comparison allocated and recomputed 100000 values") + } +} + +func TestMarketDailyAxisLocale(t *testing.T) { + candles := sampleCandles(30) + for i := range candles { + candles[i].Time = candles[0].Time.AddDate(0, 0, i) + } + render := func(locale Locale) *image.RGBA { + view := MarketView{} + return ui.Render(func(c *ui.Context) { + Use(c, Settings{Locale: locale}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}}) + }, 640, 320, 1) + } + en, zh := render(EnUS), render(ZhCN) + for y := 214; y < 240; y++ { + for x := 60; x < 620; x++ { + if en.RGBAAt(x, y) != zh.RGBAAt(x, y) { + return + } + } + } + t.Fatal("Chinese daily time axis retained English labels") +} + +func TestMarketSyncDifferentIntervals(t *testing.T) { + cs := sampleCandles(300) + var slower []Candle + for i := 0; i < len(cs); i += 2 { + slower = append(slower, cs[i]) + } + s := ChartSync{Count: 40, End: cs[199].Time} + var a, b MarketView + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"}) + CandlestickChart(c, slower, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"}) + }) + }, 640, 400) + tt.Frame() + from, to := marketWindow(b, len(slower), 200) + if b.Count != 20 || slower[from].Time != cs[160].Time || slower[to-1].Time != cs[198].Time || s.End != cs[199].Time { + t.Fatalf("intervals drifted: a=%+v b=%+v sync=%+v", a, b, s) + } +} diff --git a/market_states_test.go b/market_states_test.go new file mode 100644 index 0000000..90f3d53 --- /dev/null +++ b/market_states_test.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "mujicaui/theme" + "testing" +) + +func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) { + for _, name := range []string{"Candlestick", "OHLC", "HeikinAshi", "Quote", "Volume", "Depth", "Profile", "Overlay", "Compare", "Alerts", "Drawings"} { + t.Run(name, func(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + raw := false + bids, asks := sampleBook() + bins := VolumeProfileOf(cs, 12) + quotes, _, _ := samplePoints() + symbols := []CompareSymbol{{Name: "Other", Closes: closesOf(cs)}} + alerts := []PriceAlertLine{{ID: "a", Price: 100}} + drawings := DrawingTools{} + status := DataReady + disabled, empty := false, false + var r ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ui.Column(c).Disabled(disabled).Width(600).Children(func() { + candles := cs + if empty { + candles = nil + } + frame := ChartFrame{Label: "Market", Height: 180, Status: status, Error: "Archive unavailable"} + opts := CandlestickOptions{Frame: frame} + switch name { + case "OHLC": + r = OHLCChart(c, candles, &v, opts) + case "HeikinAshi": + r = HeikinAshiChart(c, candles, &v, &raw, opts) + case "Quote": + q := quotes + if empty { + q = nil + } + r = LineQuoteChart(c, q, LineQuoteChartOptions{Frame: frame, PrevClose: 100}) + case "Volume": + r = VolumeChart(c, candles, &v, VolumeChartOptions{Frame: frame}) + case "Depth": + b, a := bids, asks + if empty { + b = nil + a = nil + } + r = DepthChart(c, b, a, DepthChartOptions{Frame: frame}) + case "Profile": + b := bins + if empty { + b = nil + } + r = VolumeProfile(c, b, VolumeProfileOptions{Frame: frame}) + default: + if name == "Overlay" { + opts.Overlays = []IndicatorOverlay{{Kind: IndicatorMA}} + } + if name == "Compare" { + opts.Compare = &symbols + } + if name == "Alerts" { + opts.Alerts = &alerts + } + if name == "Drawings" { + opts.Drawings = &drawings + } + r = CandlestickChart(c, candles, &v, opts) + } + }) + }, 620, 350) + for range 10 { + if tt.Focused("Market") { + break + } + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyRight) + if r.Point < 0 || len(tt.Announcements()) == 0 { + t.Fatalf("keyboard path point %d", r.Point) + } + tt.Key(0, ui.KeyHome) + first := r.Point + tt.Key(0, ui.KeyLeft) + if name != "Depth" && name != "Profile" && r.Point != first { + t.Fatalf("left boundary %d", r.Point) + } + disabled = true + before := v + tt.Frame() + tt.Key(0, ui.KeyEqual) + tt.Scroll(200, 120, 0, 20) + tt.ClickAt(200, 120) + if v != before { + t.Fatalf("disabled window changed: %+v", v) + } + disabled = false + tt.Frame() + height := r.Element.Bounds().H + for _, st := range []DataStatus{DataLoading, DataFailed, DataReady} { + status = st + empty = st == DataReady + tt.Frame() + if r.Point != -1 || r.Element.Bounds().H != height { + t.Fatalf("state %v: point %d height %v want %v", st, r.Point, r.Element.Bounds().H, height) + } + } + }) + } +} diff --git a/messages_mktchart.go b/messages_mktchart.go new file mode 100644 index 0000000..61c3fef --- /dev/null +++ b/messages_mktchart.go @@ -0,0 +1,76 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "candlestick_chart.daily_time": {"Jan 2", "1月2日"}, + "candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"}, + "candlestick_chart.volume": {"Vol", "量"}, + "heikin_ashi_chart.smoothed": {"Heikin-Ashi", "平均 K 线"}, + "heikin_ashi_chart.raw": {"Candles", "原 K 线"}, + "heikin_ashi_chart.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", "价值区下沿"}, + "time_range_selector.custom": {"Custom", "自定义"}, + "time_range_selector.label": {"Time range", "时间范围"}, + "interval_selector.label": {"Interval", "周期"}, + "interval_selector.all": {"All intervals", "全部周期"}, + "interval_selector.minutes": {"Minutes", "分钟"}, + "interval_selector.hours": {"Hours", "小时"}, + "interval_selector.days": {"Days and longer", "日及以上"}, + "interval_selector.favorite": {"Add %s to favorites", "收藏 %s"}, + "interval_selector.unfavorite": {"Remove %s from favorites", "取消收藏 %s"}, + "chart_type_switcher.label": {"Chart type", "图表类型"}, + "chart_type_switcher.type0": {"Candles", "K 线"}, + "chart_type_switcher.type1": {"OHLC", "美国线"}, + "chart_type_switcher.type2": {"Line", "折线"}, + "chart_type_switcher.type3": {"Area", "面积"}, + "chart_type_switcher.type4": {"Heikin-Ashi", "平均 K 线"}, + "candle_countdown.label": {"%s candle closes in %s", "%s K 线 %s 后收盘"}, + "multi_chart_layout.label": {"Chart layout", "图表布局"}, + "multi_chart_layout.count": {"%d charts", "%d 宫格"}, + "multi_chart_layout.cell": {"Chart %d %s", "图表 %d %s"}, + "drawing_toolbar.label": {"Drawing tools", "画线工具"}, + "drawing_toolbar.tool0": {"Select", "选择"}, + "drawing_toolbar.tool1": {"Trend line", "趋势线"}, + "drawing_toolbar.tool2": {"Horizontal line", "水平线"}, + "drawing_toolbar.tool3": {"Fibonacci", "斐波那契"}, + "drawing_toolbar.tool4": {"Rectangle", "矩形"}, + "drawing_toolbar.tool5": {"Text", "文字"}, + "drawing_toolbar.lock": {"Lock drawings", "锁定画线"}, + "drawing_toolbar.unlock": {"Unlock drawings", "解锁画线"}, + "drawing_toolbar.undo": {"Undo", "撤销"}, + "drawing_toolbar.clear": {"Clear drawings", "清除画线"}, + "indicator_selector.button": {"Indicators (%d)", "指标(%d)"}, + "indicator_selector.title": {"Indicators", "指标"}, + "indicator_selector.search": {"Search indicators", "搜索指标"}, + "indicator_selector.available": {"Available", "可添加"}, + "indicator_selector.active": {"On the chart", "已添加"}, + "indicator_selector.overlay": {"Over prices", "主图叠加"}, + "indicator_selector.pane": {"Own pane", "副图"}, + "indicator_selector.add": {"Add", "添加"}, + "indicator_selector.add_named": {"Add %s", "添加 %s"}, + "indicator_selector.none": {"No indicator matches", "没有匹配的指标"}, + "drawing_tools.selected": {"Drawing %d selected", "已选择画线 %d"}, + "drawing_tools.note": {"Note", "标注"}, + }) +} diff --git a/multi_chart_layout.go b/multi_chart_layout.go new file mode 100644 index 0000000..977b422 --- /dev/null +++ b/multi_chart_layout.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartCell holds one chart's symbol, interval, and window. +type ChartCell struct { + Symbol string + Interval Interval + View MarketView +} + +// MultiChartState preserves six cells across layout changes. +type MultiChartState struct { + // Count is 1, 2, 4 or 6; zero is 1. + Count int + // Cells preserve their settings while hidden. + Cells [6]ChartCell + Active int +} + +// MultiChartLayoutOptions configure a MultiChartLayout. +type MultiChartLayoutOptions struct { + // Height of the grid; zero is 480. + Height float32 +} + +// MultiChartLayout builds 1, 2, 4, or 6 independent charts. +func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayoutOptions, cell func(i int, cell *ChartCell)) ChoiceResult { + if state == nil || cell == nil { + panic("mujica: MultiChartLayout needs a state and a cell builder") + } + root := ui.Column(c).Gap(Density(c).Unit() * 2).FillWidth() + if root.IsDisabled() { + copy := *state + state = © + } + if opts.Height < 0 { + panic("mujica: MultiChartLayout Height must be non-negative") + } + if state.Count == 0 { + state.Count = 1 + } + cols := map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count] + if cols == 0 { + panic(fmt.Sprintf("mujica: MultiChartLayout Count %d must be 1, 2, 4 or 6", state.Count)) + } + if state.Active < 0 || state.Active >= state.Count { + panic(fmt.Sprintf("mujica: MultiChartLayout Active %d outside %d cells", state.Active, state.Count)) + } + k := Tokens(c) + u := Density(c).Unit() + h := opts.Height + if h == 0 { + h = 480 + } + changed := false + root.Children(func() { + sel := []int{state.Count} + var items []ToggleItem[int] + for _, n := range []int{1, 2, 4, 6} { + items = append(items, ToggleItem[int]{Value: n, Label: fmt.Sprint(n), Name: fmt.Sprintf(msg(c, "multi_chart_layout.count"), n)}) + } + ui.Row(c).Children(func() { + if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "multi_chart_layout.label")}).Changed() { + state.Count, changed = sel[0], true + state.Active = min(state.Active, state.Count-1) + slog.Debug("mujica: multi_chart_layout count", "count", state.Count) + } + }) + cols = map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count] + rows := (state.Count + cols - 1) / cols + grid := ui.Box(c).Grid().Columns(cols).Gap(u * 2).Height(h).FillWidth() + grid.Children(func() { + for i := range state.Count { + box := ui.Column(c).Key(i).MinWidth(0).MinHeight(0).Height((h - u*2*float32(rows-1)) / float32(rows)).Padding(u * 2). + Background(k.Surface).Radius(theme.CardRadius).Clip().Role(ui.RoleGroup). + Label(fmt.Sprintf(msg(c, "multi_chart_layout.cell"), i+1, state.Cells[i].Symbol)) + if i == state.Active && state.Count > 1 { + box.Border(2, k.AccentText) + box.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Frame(p, r, 3, 5, k.Ornament) }) + } else { + box.Border(theme.BorderWidth, k.Border) + } + if !box.IsDisabled() && (box.FocusWithin() || box.Clicked()) { + if state.Active != i { + state.Active, changed = i, true + } + } + box.Children(func() { cell(i, &state.Cells[i]) }) + } + }) + }) + return ChoiceResult{root, changed} +} diff --git a/multi_chart_layout_test.go b/multi_chart_layout_test.go new file mode 100644 index 0000000..9a85df6 --- /dev/null +++ b/multi_chart_layout_test.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestMultiChartLayoutKeepsCellsAndDisabled(t *testing.T) { + state := MultiChartState{Count: 6, Active: 5} + for i := range state.Cells { + state.Cells[i] = ChartCell{Symbol: "BTC", Interval: "1h", View: MarketView{Count: 20 + i}} + } + disabled := false + built := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + built = 0 + ui.Column(c).Disabled(disabled).Children(func() { + MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 200}, func(_ int, cell *ChartCell) { built++; ui.Text(c, cell.Symbol) }) + }) + }, 700, 300) + if built != 6 { + t.Fatalf("built %d", built) + } + if err := tt.Click("6 charts"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + if state.Count != 4 || state.Active != 3 || built != 4 { + t.Fatalf("layout %+v built %d", state, built) + } + if state.Cells[5].View.Count != 25 { + t.Fatal("hidden state lost") + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if state.Count != 6 || state.Cells[5].View.Count != 25 { + t.Fatal("restored state lost") + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + if state.Count != 6 { + t.Fatal("disabled layout changed") + } +} +func TestMultiChartLayoutSnapshot(t *testing.T) { + state := MultiChartState{Count: 4} + snapshot(t, "multi_chart_layout", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 280}, func(i int, cell *ChartCell) { ui.Text(c, "BTC · 1h") }) + }) + }) +} diff --git a/ohlc_chart_test.go b/ohlc_chart_test.go new file mode 100644 index 0000000..8bd948f --- /dev/null +++ b/ohlc_chart_test.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "bytes" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestOHLCChartSharesDataAndDiffersInLook(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 40} + ohlc := true + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ohlc { + res = OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}}) + } else { + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}}) + } + }, 600, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 59 || mktState0(res).drawn != 40 { + t.Fatalf("point %d drawn %d", res.Point, mktState0(res).drawn) + } + a := encode(t, tt.Image()) + ohlc = false + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) { + t.Fatal("OHLC bars look the same as candles") + } +} + +func TestOHLCChartSnapshot(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 50} + snapshot(t, "ohlc_chart", 640, 300, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars", Height: 240}}) + }) + }) +} diff --git a/overlay_layers.go b/overlay_layers.go index 6b9c419..381fc35 100644 --- a/overlay_layers.go +++ b/overlay_layers.go @@ -53,7 +53,19 @@ func overlayEscape(c *ui.Context, key any) bool { // The root is around every focus, so it takes Escape before MyGo's overlays. byFocus := c.Root().Shortcut(0, ui.KeyEscape) byWindow := c.Shortcut(0, ui.KeyEscape) - return byFocus || byWindow + if byFocus || byWindow { + s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} }) + for i, k := range s.keys { + if k == key { + s.keys = append(s.keys[:i], s.keys[i+1:]...) + s.seen = append(s.seen[:i], s.seen[i+1:]...) + break + } + } + c.Invalidate() + return true + } + return false } // overlaySiteState follows the focus of a layer after it closes. diff --git a/price_alert_line_test.go b/price_alert_line_test.go new file mode 100644 index 0000000..d138d26 --- /dev/null +++ b/price_alert_line_test.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPriceAlertLineDragAndTrigger(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 60} + alerts := []PriceAlertLine{{ID: "a", Price: 100, Label: "Buy"}} + var fired []string + var res ChartResult + _ = res + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Alerts: &alerts, + OnAlert: func(a PriceAlertLine, last Candle) { fired = append(fired, a.ID) }}) + }, 800, 400) + if !tt.HasText("BTC") { + t.Fatal("not built") + } + // Drag the line from its own y, as painted. + st := mktState0(res) + plot := st.plot + y := st.ys.Map(100) + tt.Press(plot.X+100, y) + tt.Move(plot.X+100, y-40) + tt.Release(plot.X+100, y-40) + if alerts[0].Price <= 100 || view.End != 0 { + t.Fatalf("drag did not move the alert up, or panned: %+v %+v", alerts, view) + } + // A new close crossing the alert fires it once. + alerts[0].Price = candles[59].Close + 0.5 + next := candles[59] + next.Time = next.Time.Add(3600e9) + next.Open, next.Close, next.High, next.Low = next.Close, next.Close+1, next.Close+1.2, next.Close-0.1 + candles = append(candles, next) + tt.Frame() + tt.Frame() + if len(fired) != 1 || fired[0] != "a" { + t.Fatalf("fired %v", fired) + } +} + +func TestPriceAlertLineSnapshot(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 60} + alerts := []PriceAlertLine{{ID: "a", Price: 104, Label: "Take profit"}, {ID: "b", Price: 96}} + snapshot(t, "price_alert_line", 640, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}, Alerts: &alerts}) + }) + }) +} diff --git a/testdata/snapshots/darwin/candle_countdown-dark.png b/testdata/snapshots/darwin/candle_countdown-dark.png new file mode 100644 index 0000000..0670f01 Binary files /dev/null and b/testdata/snapshots/darwin/candle_countdown-dark.png differ diff --git a/testdata/snapshots/darwin/candle_countdown-light.png b/testdata/snapshots/darwin/candle_countdown-light.png new file mode 100644 index 0000000..661c931 Binary files /dev/null and b/testdata/snapshots/darwin/candle_countdown-light.png differ diff --git a/testdata/snapshots/darwin/candlestick_chart-dark.png b/testdata/snapshots/darwin/candlestick_chart-dark.png new file mode 100644 index 0000000..95acb42 Binary files /dev/null and b/testdata/snapshots/darwin/candlestick_chart-dark.png differ diff --git a/testdata/snapshots/darwin/candlestick_chart-light.png b/testdata/snapshots/darwin/candlestick_chart-light.png new file mode 100644 index 0000000..09d0938 Binary files /dev/null and b/testdata/snapshots/darwin/candlestick_chart-light.png differ diff --git a/testdata/snapshots/darwin/chart_sync-dark.png b/testdata/snapshots/darwin/chart_sync-dark.png new file mode 100644 index 0000000..9f518b5 Binary files /dev/null and b/testdata/snapshots/darwin/chart_sync-dark.png differ diff --git a/testdata/snapshots/darwin/chart_sync-light.png b/testdata/snapshots/darwin/chart_sync-light.png new file mode 100644 index 0000000..d996b23 Binary files /dev/null and b/testdata/snapshots/darwin/chart_sync-light.png differ diff --git a/testdata/snapshots/darwin/chart_type_switcher-dark.png b/testdata/snapshots/darwin/chart_type_switcher-dark.png new file mode 100644 index 0000000..08bb4a8 Binary files /dev/null and b/testdata/snapshots/darwin/chart_type_switcher-dark.png differ diff --git a/testdata/snapshots/darwin/chart_type_switcher-light.png b/testdata/snapshots/darwin/chart_type_switcher-light.png new file mode 100644 index 0000000..6543cec Binary files /dev/null and b/testdata/snapshots/darwin/chart_type_switcher-light.png differ diff --git a/testdata/snapshots/darwin/compare_symbol-dark.png b/testdata/snapshots/darwin/compare_symbol-dark.png new file mode 100644 index 0000000..676d1c5 Binary files /dev/null and b/testdata/snapshots/darwin/compare_symbol-dark.png differ diff --git a/testdata/snapshots/darwin/compare_symbol-light.png b/testdata/snapshots/darwin/compare_symbol-light.png new file mode 100644 index 0000000..d196513 Binary files /dev/null and b/testdata/snapshots/darwin/compare_symbol-light.png differ diff --git a/testdata/snapshots/darwin/depth_chart-dark.png b/testdata/snapshots/darwin/depth_chart-dark.png new file mode 100644 index 0000000..761e3dc Binary files /dev/null and b/testdata/snapshots/darwin/depth_chart-dark.png differ diff --git a/testdata/snapshots/darwin/depth_chart-light.png b/testdata/snapshots/darwin/depth_chart-light.png new file mode 100644 index 0000000..539579c Binary files /dev/null and b/testdata/snapshots/darwin/depth_chart-light.png differ diff --git a/testdata/snapshots/darwin/drawing_toolbar-dark.png b/testdata/snapshots/darwin/drawing_toolbar-dark.png new file mode 100644 index 0000000..6ace567 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_toolbar-dark.png differ diff --git a/testdata/snapshots/darwin/drawing_toolbar-light.png b/testdata/snapshots/darwin/drawing_toolbar-light.png new file mode 100644 index 0000000..02168e9 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_toolbar-light.png differ diff --git a/testdata/snapshots/darwin/drawing_tools-dark.png b/testdata/snapshots/darwin/drawing_tools-dark.png new file mode 100644 index 0000000..3651947 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_tools-dark.png differ diff --git a/testdata/snapshots/darwin/drawing_tools-light.png b/testdata/snapshots/darwin/drawing_tools-light.png new file mode 100644 index 0000000..9d7aad2 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_tools-light.png differ diff --git a/testdata/snapshots/darwin/heikin_ashi_chart-dark.png b/testdata/snapshots/darwin/heikin_ashi_chart-dark.png new file mode 100644 index 0000000..d8d8a54 Binary files /dev/null and b/testdata/snapshots/darwin/heikin_ashi_chart-dark.png differ diff --git a/testdata/snapshots/darwin/heikin_ashi_chart-light.png b/testdata/snapshots/darwin/heikin_ashi_chart-light.png new file mode 100644 index 0000000..6087dec Binary files /dev/null and b/testdata/snapshots/darwin/heikin_ashi_chart-light.png differ diff --git a/testdata/snapshots/darwin/indicator_overlay-dark.png b/testdata/snapshots/darwin/indicator_overlay-dark.png new file mode 100644 index 0000000..00070ce Binary files /dev/null and b/testdata/snapshots/darwin/indicator_overlay-dark.png differ diff --git a/testdata/snapshots/darwin/indicator_overlay-light.png b/testdata/snapshots/darwin/indicator_overlay-light.png new file mode 100644 index 0000000..98f7c6a Binary files /dev/null and b/testdata/snapshots/darwin/indicator_overlay-light.png differ diff --git a/testdata/snapshots/darwin/indicator_pane-dark.png b/testdata/snapshots/darwin/indicator_pane-dark.png new file mode 100644 index 0000000..2600ec0 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..1e0c7a5 Binary files /dev/null and b/testdata/snapshots/darwin/indicator_pane-light.png differ diff --git a/testdata/snapshots/darwin/indicator_selector-dark.png b/testdata/snapshots/darwin/indicator_selector-dark.png new file mode 100644 index 0000000..b7bd63b Binary files /dev/null and b/testdata/snapshots/darwin/indicator_selector-dark.png differ diff --git a/testdata/snapshots/darwin/indicator_selector-light.png b/testdata/snapshots/darwin/indicator_selector-light.png new file mode 100644 index 0000000..dcdf642 Binary files /dev/null and b/testdata/snapshots/darwin/indicator_selector-light.png differ diff --git a/testdata/snapshots/darwin/interval_selector-dark.png b/testdata/snapshots/darwin/interval_selector-dark.png new file mode 100644 index 0000000..1c18b0d Binary files /dev/null and b/testdata/snapshots/darwin/interval_selector-dark.png differ diff --git a/testdata/snapshots/darwin/interval_selector-light.png b/testdata/snapshots/darwin/interval_selector-light.png new file mode 100644 index 0000000..16be480 Binary files /dev/null and b/testdata/snapshots/darwin/interval_selector-light.png differ diff --git a/testdata/snapshots/darwin/line_quote_chart-dark.png b/testdata/snapshots/darwin/line_quote_chart-dark.png new file mode 100644 index 0000000..093d1e0 Binary files /dev/null and b/testdata/snapshots/darwin/line_quote_chart-dark.png differ diff --git a/testdata/snapshots/darwin/line_quote_chart-light.png b/testdata/snapshots/darwin/line_quote_chart-light.png new file mode 100644 index 0000000..ecd78a3 Binary files /dev/null and b/testdata/snapshots/darwin/line_quote_chart-light.png differ diff --git a/testdata/snapshots/darwin/multi_chart_layout-dark.png b/testdata/snapshots/darwin/multi_chart_layout-dark.png new file mode 100644 index 0000000..0c574e0 Binary files /dev/null and b/testdata/snapshots/darwin/multi_chart_layout-dark.png differ diff --git a/testdata/snapshots/darwin/multi_chart_layout-light.png b/testdata/snapshots/darwin/multi_chart_layout-light.png new file mode 100644 index 0000000..6325835 Binary files /dev/null and b/testdata/snapshots/darwin/multi_chart_layout-light.png differ diff --git a/testdata/snapshots/darwin/ohlc_chart-dark.png b/testdata/snapshots/darwin/ohlc_chart-dark.png new file mode 100644 index 0000000..472d4d0 Binary files /dev/null and b/testdata/snapshots/darwin/ohlc_chart-dark.png differ diff --git a/testdata/snapshots/darwin/ohlc_chart-light.png b/testdata/snapshots/darwin/ohlc_chart-light.png new file mode 100644 index 0000000..7ffe790 Binary files /dev/null and b/testdata/snapshots/darwin/ohlc_chart-light.png differ diff --git a/testdata/snapshots/darwin/price_alert_line-dark.png b/testdata/snapshots/darwin/price_alert_line-dark.png new file mode 100644 index 0000000..a356e5e Binary files /dev/null and b/testdata/snapshots/darwin/price_alert_line-dark.png differ diff --git a/testdata/snapshots/darwin/price_alert_line-light.png b/testdata/snapshots/darwin/price_alert_line-light.png new file mode 100644 index 0000000..57adfad Binary files /dev/null and b/testdata/snapshots/darwin/price_alert_line-light.png differ diff --git a/testdata/snapshots/darwin/time_range_selector-dark.png b/testdata/snapshots/darwin/time_range_selector-dark.png new file mode 100644 index 0000000..05ff743 Binary files /dev/null and b/testdata/snapshots/darwin/time_range_selector-dark.png differ diff --git a/testdata/snapshots/darwin/time_range_selector-light.png b/testdata/snapshots/darwin/time_range_selector-light.png new file mode 100644 index 0000000..05275da Binary files /dev/null and b/testdata/snapshots/darwin/time_range_selector-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/testdata/snapshots/darwin/volume_profile-dark.png b/testdata/snapshots/darwin/volume_profile-dark.png new file mode 100644 index 0000000..46e1626 Binary files /dev/null and b/testdata/snapshots/darwin/volume_profile-dark.png differ diff --git a/testdata/snapshots/darwin/volume_profile-light.png b/testdata/snapshots/darwin/volume_profile-light.png new file mode 100644 index 0000000..b99de04 Binary files /dev/null and b/testdata/snapshots/darwin/volume_profile-light.png differ diff --git a/time_range_selector.go b/time_range_selector.go new file mode 100644 index 0000000..127a6e0 --- /dev/null +++ b/time_range_selector.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" +) + +// RangePreset names a time range; zero selects Range1D. +type RangePreset int + +const ( + Range1D RangePreset = iota + Range5D + Range1M + Range3M + RangeYTD + Range1Y + Range5Y + RangeCustom +) + +var rangePresetNames = []string{"1D", "5D", "1M", "3M", "YTD", "1Y", "5Y"} + +// MarketRange stores a preset or custom date range. +type MarketRange struct { + Preset RangePreset + // Custom holds the dates when Preset is RangeCustom. + Custom DateRange +} + +// Bounds returns local boundaries, including the custom end date. +func (r MarketRange) Bounds(now time.Time, loc *time.Location) (from, to time.Time) { + now = now.In(loc) + switch r.Preset { + case Range1D: + return now.AddDate(0, 0, -1), now + case Range5D: + return now.AddDate(0, 0, -5), now + case Range1M: + return now.AddDate(0, -1, 0), now + case Range3M: + return now.AddDate(0, -3, 0), now + case RangeYTD: + return time.Date(now.Year(), 1, 1, 0, 0, 0, 0, loc), now + case Range1Y: + return now.AddDate(-1, 0, 0), now + case Range5Y: + return now.AddDate(-5, 0, 0), now + case RangeCustom: + if r.Custom.IsZero() { + panic("mujica: custom MarketRange without dates") + } + s, e := r.Custom.Start, r.Custom.End + return time.Date(s.Year, s.Month, s.Day, 0, 0, 0, 0, loc), time.Date(e.Year, e.Month, e.Day, 0, 0, 0, 0, loc).AddDate(0, 0, 1) + } + panic(fmt.Sprintf("mujica: unknown RangePreset %d", r.Preset)) +} + +// TimeRangeSelectorOptions configure a TimeRangeSelector. +type TimeRangeSelectorOptions struct { + Disabled bool + // Today marks the calendar's today; zero uses the clock. + Today Date +} + +// TimeRangeSelector chooses presets and custom dates through keyboard-accessible controls. +func TimeRangeSelector(c *ui.Context, value *MarketRange, opts TimeRangeSelectorOptions) ChoiceResult { + if value == nil { + panic("mujica: TimeRangeSelector needs a value") + } + if value.Preset < Range1D || value.Preset > RangeCustom { + panic(fmt.Sprintf("mujica: unknown RangePreset %d", value.Preset)) + } + if value.Preset == RangeCustom && value.Custom.IsZero() { + panic("mujica: custom MarketRange without dates") + } + items := make([]ToggleItem[RangePreset], len(rangePresetNames)+1) + for i, n := range rangePresetNames { + items[i] = ToggleItem[RangePreset]{Value: RangePreset(i), Label: n} + } + items[len(items)-1] = ToggleItem[RangePreset]{Value: RangeCustom, Label: msg(c, "time_range_selector.custom")} + changed := false + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Wrap().Disabled(opts.Disabled) + row.Children(func() { + sel := []RangePreset{value.Preset} + if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "time_range_selector.label")}).Changed() && !row.IsDisabled() { + if sel[0] == RangeCustom && value.Custom.IsZero() { + today := datetimeToday(c, opts.Today) + value.Custom = DateRange{Start: today.AddMonths(-1), End: today} + } + value.Preset, changed = sel[0], true + } + if value.Preset == RangeCustom { + if DateRangePicker(c, &value.Custom, DateRangePickerOptions{Today: opts.Today, Max: datetimeToday(c, opts.Today)}).Changed() { + changed = true + } + } + }) + if changed { + slog.Debug("mujica: time_range_selector changed", "preset", value.Preset, "custom", value.Custom) + } + return ChoiceResult{row, changed} +} diff --git a/time_range_selector_test.go b/time_range_selector_test.go new file mode 100644 index 0000000..714dd27 --- /dev/null +++ b/time_range_selector_test.go @@ -0,0 +1,48 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" + "time" +) + +func TestTimeRangeSelectorPresetsCustomAndDisabled(t *testing.T) { + value := MarketRange{} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TimeRangeSelector(c, &value, TimeRangeSelectorOptions{Disabled: disabled, Today: Date{2026, time.October, 5}}) + }, 850, 160) + tt.Key(0, ui.KeyTab) + for _, want := range []RangePreset{Range5D, Range1M, Range3M, RangeYTD, Range1Y, Range5Y, RangeCustom} { + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if value.Preset != want { + t.Fatalf("preset %v", value.Preset) + } + } + if value.Custom.Start != (Date{2026, time.September, 5}) || value.Custom.End != (Date{2026, time.October, 5}) { + t.Fatalf("custom %+v", value.Custom) + } + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + for p := Range1D; p <= RangeCustom; p++ { + r := value + r.Preset = p + from, to := r.Bounds(now, time.UTC) + if !from.Before(to) { + t.Fatalf("range %v: %v %v", p, from, to) + } + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + if value.Preset != RangeCustom { + t.Fatal("disabled preset changed") + } +} +func TestTimeRangeSelectorSnapshot(t *testing.T) { + value := MarketRange{Preset: Range1M} + snapshot(t, "time_range_selector", 680, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { TimeRangeSelector(c, &value, TimeRangeSelectorOptions{}) }) + }) +} diff --git a/volume_chart.go b/volume_chart.go new file mode 100644 index 0000000..bd3bb6a --- /dev/null +++ b/volume_chart.go @@ -0,0 +1,144 @@ +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 paints visible volume bars using the shared MarketView. +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 }) + if ch.res.Element.IsDisabled() { + copy := *view + view = © + opts.Sync = nil + } + 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{Marks: len(candles)} + if m.to <= m.from { + spec.Marks = 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"}}) + }) + }) +} diff --git a/volume_profile.go b/volume_profile.go new file mode 100644 index 0000000..34f774a --- /dev/null +++ b/volume_profile.go @@ -0,0 +1,214 @@ +package mujica + +import ( + "fmt" + "math" + "sort" + "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 candle volumes across 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 + } + marketCheckCandles(candles) + 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 finds POC and its contiguous volume fraction. +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 { + if b.Volume < 0 || math.IsNaN(b.Volume) || math.IsInf(b.Volume, 0) { + panic("mujica: profile volumes must be finite and non-negative") + } + 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 defaults to 0.7. + ValueArea float64 + Decimals int +} + +// VolumeProfile draws volume distribution, value area, and POC. +func VolumeProfile(c *ui.Context, bins []ProfileBin, opts VolumeProfileOptions) ChartResult { + frac := opts.ValueArea + if frac == 0 { + frac = 0.7 + } + if !(frac > 0 && frac <= 1) { + panic("mujica: VolumeProfile ValueArea must be in (0, 1]") + } + 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 math.IsNaN(b.Volume) || math.IsInf(b.Volume, 0) || math.IsInf(b.High, 0) || math.IsInf(b.Low, 0) || !(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 := []ChartLegendItem{{Name: msg(c, "volume_profile.value_area"), Color: inArea, Shape: 1}, {Name: msg(c, "volume_profile.other"), Color: outArea, Shape: 4}} + drawn := ui.Local(ch.res.Element, "mujica.profile.drawn", func() int { return 0 }) + 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{Marks: len(bins), Legend: 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) + i := sort.Search(len(bins), func(i int) bool { return bins[i].High >= v }) + if i < len(bins) && v >= bins[i].Low { + 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) + indices := chartMemo(ch, "profile.indices", [3]any{chartID(bins), plot.Y, plot.H}, func() []int { + return internal.Columns(len(bins), func(i int) float32 { return ys.Map(bins[i].Low) }, func(i int) float64 { return bins[i].Volume }) + }) + *drawn = len(indices) + for _, i := range indices { + b := bins[i] + 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, 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 +} diff --git a/volume_profile_test.go b/volume_profile_test.go new file mode 100644 index 0000000..5444b82 --- /dev/null +++ b/volume_profile_test.go @@ -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}}) + }) + }) +}