From 341caf13e16f31fb4b2684863326d27e0864ac7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:50:52 -0400 Subject: [PATCH] WIP mktchart 1 --- candlestick_chart.go | 287 ++++++++++++++++++ candlestick_chart_test.go | 151 +++++++++ candlestick_paint.go | 157 ++++++++++ chart_sync.go | 107 +++++++ drawing_tools.go | 250 +++++++++++++++ drawing_tools_paint.go | 57 ++++ indicator_overlay.go | 169 +++++++++++ indicators.go | 198 ++++++++++++ indicators_test.go | 175 +++++++++++ market_data.go | 117 +++++++ market_input.go | 80 +++++ market_layers.go | 143 +++++++++ messages_mktchart.go | 18 ++ .../darwin/candlestick_chart-dark.png | Bin 0 -> 14785 bytes .../darwin/candlestick_chart-light.png | Bin 0 -> 14262 bytes 15 files changed, 1909 insertions(+) create mode 100644 candlestick_chart.go create mode 100644 candlestick_chart_test.go create mode 100644 candlestick_paint.go create mode 100644 chart_sync.go create mode 100644 drawing_tools.go create mode 100644 drawing_tools_paint.go create mode 100644 indicator_overlay.go create mode 100644 indicators.go create mode 100644 indicators_test.go create mode 100644 market_data.go create mode 100644 market_input.go create mode 100644 market_layers.go create mode 100644 messages_mktchart.go create mode 100644 testdata/snapshots/darwin/candlestick_chart-dark.png create mode 100644 testdata/snapshots/darwin/candlestick_chart-light.png diff --git a/candlestick_chart.go b/candlestick_chart.go new file mode 100644 index 0000000..94180b0 --- /dev/null +++ b/candlestick_chart.go @@ -0,0 +1,287 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartType is how a price chart draws candles. The zero value is ChartCandles. +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 overlays other symbols in percent; the chart turns to percent while any show. + Compare *[]CompareSymbol + // Alerts are draggable price lines; OnAlert runs when the last close crosses one. + Alerts *[]PriceAlertLine + OnAlert func(a PriceAlertLine, last Candle) + // Drawings, when set, takes clicks for the current tool. + Drawings *DrawingTools + // Sync links the view and crosshair to other charts 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 +} + +// 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 draws candles of the view's window, building and painting only those; rises and falls use the window's Trend colors and hollow or filled bodies. +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} }) + 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 is CandlestickChart of Heikin-Ashi candles computed from candles; *raw switches back to them. +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.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi.raw")}}, + ToggleGroupOptions{Label: msg(c, "heikin_ashi.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 the shown candles from the view and last frame's 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 spans the window's lows and highs, overlays and alerts, recomputed only when they change. +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 maps a price to what the axis shows: itself, or percent from base. +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 []chartKey + for _, l := range m.overlays { + keys = append(keys, chartKey{Name: l.name, Color: l.color, Shape: l.shape}) + } + spec := chartSpec{n: n, keys: keys} + if m.to <= m.from { + spec.n = 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) + } + spec.readout = m.readout + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) } + return spec +} + +// readout says the candle's time, prices, change and direction, and the overlays there. +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..505f68f --- /dev/null +++ b/candlestick_chart_test.go @@ -0,0 +1,151 @@ +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) + } + // Zooming out is capped by the width: three DIPs a candle. + 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..5bc89d1 --- /dev/null +++ b/candlestick_paint.go @@ -0,0 +1,157 @@ +package mujica + +import ( + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// paint draws axes, the window's candles, layers and the crosshair at active. +func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) { + g := m.geom(r) + 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 i, l := range m.overlays { + mktPaintLine(p, g, m, l.values, l.color, i) + } + 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 := "Jan 2" + 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 draws the window as the chart's type and returns how many candles it drew. +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 { + closes := make([]float64, len(m.candles)) + for i := m.from; i < m.to; i++ { + closes[i] = m.val(m.candles[i].Close) + } + 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(closes[i])) + 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 and falls filled, so direction reads without color. + 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 values over the window with sparse markers of shape. +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 draws a price label at the plot's right edge, centered on y. +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..a8170ad --- /dev/null +++ b/chart_sync.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartSync links charts by time: the same window, zoom and crosshair. The caller holds it. +type ChartSync struct { + // Count is the linked zoom in candles; zero is the default 120. + Count int + // End is the newest linked time shown; zero follows each chart's newest candle. + End 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 +} + +func (s *ChartSync) linked(id string) bool { return !s.Unlinked[id] } + +// load puts the linked window into view before a chart builds. +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) + } + } +} + +// store writes a linked chart's window and crosshair back after it builds. +func (s *ChartSync) store(id string, candles []Candle, view *MarketView, active int) { + if !s.linked(id) { + return + } + s.Count = view.Count + s.End = time.Time{} + if view.End > 0 && view.End <= len(candles) { + s.End = candles[view.End-1].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 builds a toggle that links or unlinks chart id; Changed reports a switch. +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/drawing_tools.go b/drawing_tools.go new file mode 100644 index 0000000..7f30e8d --- /dev/null +++ b/drawing_tools.go @@ -0,0 +1,250 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "slices" + "time" + + "github.com/egoist/mygo/ui" +) + +// DrawingTool is what clicks on a price chart do. The zero value selects and moves drawings. +type DrawingTool int + +const ( + ToolSelect DrawingTool = iota + ToolTrendLine + ToolHorizontal + ToolFibonacci + ToolRectangle + ToolText +) + +// DrawingPoint anchors a drawing at a candle time and a price, so it survives panning and new data. +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 is the caller-held set of drawings, the current tool and an 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 are the retracement ratios drawn between a Fibonacci's two points. +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 drawings before the last change and reports whether there was one. +func (d *DrawingTools) Undo() bool { + n := len(d.undo) + if 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 turns a plot position into a drawing point on 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 turns an axis value back into a price, rounded to the decimals. +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, drags, nudges and deletes drawings; it reports taking the pointer. +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 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) + switch { + case d.Tool.twoPoint() && d.pending == nil: + d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt} + default: + 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) + } + 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 item i by candles and price, keeping it on the chart's candles. +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 returns the drawing within 6 DIPs of (x, y), newest first, or -1. +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 is the distance from (x, y) to the segment a-b. +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) +} diff --git a/drawing_tools_paint.go b/drawing_tools_paint.go new file mode 100644 index 0000000..7ee541e --- /dev/null +++ b/drawing_tools_paint.go @@ -0,0 +1,57 @@ +package mujica + +import ( + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +// paint draws every drawing, the one being placed, and the selected one's 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: + p.Line(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) + } + p.Line(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) + } + } + } +} diff --git a/indicator_overlay.go b/indicator_overlay.go new file mode 100644 index 0000000..0484e58 --- /dev/null +++ b/indicator_overlay.go @@ -0,0 +1,169 @@ +package mujica + +import ( + "fmt" + "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 is a kind's name, default parameters and where it draws. +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 is an indicator drawn over the price chart: MA, EMA, BOLL or VWAP. +type IndicatorOverlay struct { + Kind IndicatorKind + // Params are the periods (and BOLL's width); nil uses the kind's defaults. + Params []float64 + // Values, when set, are the caller's own series, one per candle, drawn instead of the built-in result. + Values []float64 +} + +// indicatorParams resolves and checks params for kind. +func indicatorParams(k IndicatorKind, params []float64) []float64 { + 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 !(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)) + } + } + 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 kind over candles: named lines, one value per candle. +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 resolves the chart's overlays, computing each only when its candles or settings 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[(len(pal)-2-i+len(pal))%len(pal)] + 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 one indicator's parameters in place; Changed reports a valid new value. +func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) ChoiceResult { + if params == nil { + panic("mujica: IndicatorParamsField needs params") + } + if *params == nil { + *params = kind.DefaultParams() + } + changed := false + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String()) + 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 + } + } + }) + return ChoiceResult{row, changed} +} diff --git a/indicators.go b/indicators.go new file mode 100644 index 0000000..a11e475 --- /dev/null +++ b/indicators.go @@ -0,0 +1,198 @@ +package mujica + +import ( + "fmt" + "math" +) + +// Indicator functions are pure: same input, same output, NaN until enough 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 is the simple moving average of the last n values. +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 is the exponential moving average, k = 2/(n+1), seeded with the SMA of the first n values. +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 the n-period SMA and the bands width population deviations away. +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 is the cumulative volume-weighted average of the typical price (H+L+C)/3. +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 (fast EMA - slow EMA), DEA (signal EMA of DIF) and the histogram DIF - DEA. +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 is Wilder's relative strength index over n periods, 0 to 100. +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 is the stochastic oscillator: RSV over n, K and D smoothed by m1 and m2 from 50, J = 3K - 2D. +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 is on-balance volume: volume added on up closes, subtracted on down closes. +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..d1ad5cd --- /dev/null +++ b/indicators_test.go @@ -0,0 +1,175 @@ +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}) + // k = 0.5: seed 2, then 4*0.5+2*0.5 = 3, 5*0.5+3*0.5 = 4, 6*0.5+4*0.5 = 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) + } + } + // DEA is the EMA(2) of DIF from index 2: seed is the mean of dif[2], dif[3]. + 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") + } + // A straight line: both EMAs trail by a constant, DIF = 0.5, DEA = 0.5, hist = 0. + 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) { + // Gains 1,1 then a loss of 1: first RSI (n=2) at i=2 is all gains = 100; then Wilder smoothing. + r := RSI([]float64{1, 2, 3, 2}, 2) + approx(t, "RSI", r, []float64{nan, nan, 100, 100 - 100/(1+0.5/0.5)}) +} + +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) + // i=1: range 0..4, RSV 100, K = (2*50+100)/3, D = (2*50+K)/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/market_data.go b/market_data.go new file mode 100644 index 0000000..90961a5 --- /dev/null +++ b/market_data.go @@ -0,0 +1,117 @@ +package mujica + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Candle is one period of trading: open, high, low, close and volume. +type Candle struct { + Time time.Time + Open, High, Low, Close float64 + Volume float64 +} + +// marketCheckCandles panics on candles out of time order or with impossible prices. +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)) + } + 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 is the caller-held window of a market chart: zoom and pan. +type MarketView struct { + // Count is how many candles show; zero shows 120. + Count int + // End is one past the last shown candle; zero follows the newest. + End int +} + +// marketWindow resolves the view to [from, to) over n candles, at most room wide. +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") + } + 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 view by delta candles; reaching the newest follows it again. +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 scales the shown count by f, keeping the newest shown candle, within 10 and room. +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, or -1 before the first. +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 candle period: 1m 3m 5m 15m 30m 1h 2h 4h 6h 12h 1D 3D 1W 1M. +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 returns when the candle holding t closes: periods align to the clock in loc, 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..141facb --- /dev/null +++ b/market_input.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// input pans, zooms, drags alerts and drawings, and keeps the focused candle in view. +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 + } + // The keyboard mark may leave the window: the view follows it. + 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 { + 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() + } +} diff --git a/market_layers.go b/market_layers.go new file mode 100644 index 0000000..881121c --- /dev/null +++ b/market_layers.go @@ -0,0 +1,143 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" +) + +// PriceAlertLine is a caller-held price alert drawn as a dashed line that the pointer drags. +type PriceAlertLine struct { + ID string + Price float64 + // Label shows beside the price; empty shows the price only. + Label string +} + +// CompareSymbol is another symbol's closes, aligned to the chart's candles, shown in percent. +type CompareSymbol struct { + Name string + Closes []float64 +} + +// compareLines adds the compare symbols' percent lines and sets percent mode. +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 + pal := internal.Palette(m.k) + 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)) + } + 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), 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: len(m.overlays)}) + } +} + +// CompareLegend lists the compare symbols with their colors and a remove button each; Changed reports a removal. +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 runs OnAlert for each alert the newest close crossed since the last frame. +func (m *mktCore) alertsCheck() { + if m.opts.Alerts == nil || len(m.candles) == 0 { + return + } + last := m.candles[len(m.candles)-1] + if 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 an alert line that the press started on; it reports taking the drag. +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 math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 5 { + m.st.grab = i + } + } + } + if m.st.grab < 0 || m.st.grab >= len(alerts) { + return false + } + p := math.Pow(10, float64(m.dec)) + alerts[m.st.grab].Price = math.Round(g.ys.Invert(y)*p) / p + slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID) + return true +} + +// paintAlerts draws each alert as a dashed line with its price tag. +func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) { + if m.opts.Alerts == nil || m.base != 0 { + return + } + for _, a := range *m.opts.Alerts { + y := internal.Snap(p, g.ys.Map(a.Price)) + 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/messages_mktchart.go b/messages_mktchart.go new file mode 100644 index 0000000..e804b54 --- /dev/null +++ b/messages_mktchart.go @@ -0,0 +1,18 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"}, + "candlestick_chart.volume": {"Vol", "量"}, + "heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"}, + "heikin_ashi.raw": {"Candles", "原 K 线"}, + "heikin_ashi.switch": {"Candle style", "K 线样式"}, + "compare_symbol.label": {"Compared symbols", "对比品种"}, + "compare_symbol.remove": {"Remove %s", "移除 %s"}, + "price_alert_line.tag": {"Alert %s", "预警 %s"}, + "chart_sync.link": {"Sync with other charts", "与其他图表同步"}, + "chart_sync.linked": {"Synced", "已同步"}, + "chart_sync.unlinked": {"Not synced", "未同步"}, + "drawing_tools.note": {"Note", "标注"}, + }) +} diff --git a/testdata/snapshots/darwin/candlestick_chart-dark.png b/testdata/snapshots/darwin/candlestick_chart-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..95acb4220d09adaf6fc5b61c15380c26adc466b3 GIT binary patch literal 14785 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 ZtSLQe9ai|WAwOjT$h}dPs+2GZ{$B|I>rDUv literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/candlestick_chart-light.png b/testdata/snapshots/darwin/candlestick_chart-light.png new file mode 100644 index 0000000000000000000000000000000000000000..09d0938a55a3459c8d5a2892d8c79b6c187a6b65 GIT binary patch literal 14262 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