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 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