package mujica import ( "fmt" "log/slog" "math" "strings" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // ChartType selects the price chart shape. type ChartType int const ( ChartCandles ChartType = iota ChartOHLC ChartLine ChartArea ChartHeikinAshi ) // CandlestickOptions configure a CandlestickChart and its layers. type CandlestickOptions struct { Frame ChartFrame Type ChartType // Decimals of prices; zero means 2. Decimals int // Overlays draw indicators over the prices. Overlays []IndicatorOverlay // Compare switches the chart to normalized percent prices. Compare *[]CompareSymbol // Alerts provides draggable lines and price-crossing callbacks. Alerts *[]PriceAlertLine OnAlert func(a PriceAlertLine, last Candle) // Drawings, when set, takes clicks for the current tool. Drawings *DrawingTools // Sync links the window and crosshair under SyncID. Sync *ChartSync SyncID string } // mktState is a price chart's own frame-to-frame state. type mktState struct { drawn int // candles painted last frame dragRest float32 // pan drag left over, under a candle lastClose float64 alertSeen bool grab int // alert index dragged, -1 none focus int // keyboard mark last frame, to follow only when it moves alertFocus int plot ui.Rect ys internal.Linear // the price scale as last painted } // mktCore is one price chart being built. type mktCore struct { c *ui.Context k theme.Tokens ch *chart candles []Candle view *MarketView from, to int lo, hi float64 base float64 // percent mode divides by this close; 0 is prices dec int opts CandlestickOptions st *mktState overlays []mktLine } // mktLine is one drawn indicator or compare line. type mktLine struct { name string values []float64 color ui.Color shape int } // CandlestickChart paints visible candles with hollow rises and filled falls. func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult { if view == nil { panic("mujica: CandlestickChart needs a view") } if opts.Type < ChartCandles || opts.Type > ChartHeikinAshi { panic(fmt.Sprintf("mujica: unknown ChartType %d", opts.Type)) } if opts.Sync != nil && opts.SyncID == "" { panic("mujica: CandlestickChart Sync needs a SyncID") } ch := newChart(c, opts.Frame) shown := chartMemo(ch, "candles", [2]any{chartID(candles), opts.Type}, func() []Candle { marketCheckCandles(candles) if opts.Type == ChartHeikinAshi { return HeikinAshi(candles) } return candles }) m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: shown, view: view, opts: opts, dec: priceDecimals("CandlestickChart", opts.Decimals)} m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} }) if ch.res.Element.IsDisabled() { copy := *view view = © m.view = view m.opts.Sync = nil opts.Sync = nil } if s := opts.Sync; s != nil { s.load(opts.SyncID, shown, view) } m.window() m.overlays = mktOverlayLines(ch, m, candles) m.compareLines() m.yDomain() m.alertsCheck() res, plot := ch.build(m.spec()) if plot != nil { m.input(plot) } if s := opts.Sync; s != nil { s.store(opts.SyncID, shown, view, res.Point) } return res } // OHLCChart is CandlestickChart drawn as open-high-low-close bars. func OHLCChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult { opts.Type = ChartOHLC return CandlestickChart(c, candles, view, opts) } // HeikinAshiChart switches between smoothed candles and raw OHLC. func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *bool, opts CandlestickOptions) ChartResult { if raw == nil { panic("mujica: HeikinAshiChart needs raw") } opts.Type = ChartHeikinAshi if *raw { opts.Type = ChartCandles } var res ChartResult col := ui.Column(c).Gap(Density(c).Unit() * 2).MinWidth(0) col.Children(func() { ui.Row(c).Children(func() { v := []bool{*raw} if ToggleGroup(c, &v, []ToggleItem[bool]{{Value: false, Label: msg(c, "heikin_ashi_chart.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi_chart.raw")}}, ToggleGroupOptions{Label: msg(c, "heikin_ashi_chart.switch")}).Changed() { *raw = v[0] slog.Debug("mujica: heikin_ashi switch", "raw", *raw) } }) res = CandlestickChart(c, candles, view, opts) }) res.Element = col return res } // window resolves visible candles using the last measured width. func (m *mktCore) window() { m.from, m.to = marketWindow(*m.view, len(m.candles), m.room()) } // room is how many candles fit, three DIPs each. func (m *mktCore) room() int { w := m.ch.res.Element.Bounds().W - 64 if w <= 0 { return 200 } return max(10, int(w/3)) } // yDomain caches visible prices, indicators, and alerts. func (m *mktCore) yDomain() { if m.to <= m.from { return } var alerts []PriceAlertLine if m.opts.Alerts != nil { alerts = *m.opts.Alerts } ids := fmt.Sprintf("%s|%d|%d|%v|%d|%v", chartID(m.candles), m.from, m.to, m.base, len(m.overlays), alerts) for _, l := range m.overlays { ids += chartID(l.values) } d := chartMemo(m.ch, "ydomain", ids, func() [2]float64 { lo, hi := math.Inf(1), math.Inf(-1) for i := m.from; i < m.to; i++ { lo, hi = min(lo, m.val(m.candles[i].Low)), max(hi, m.val(m.candles[i].High)) for _, l := range m.overlays { if v := l.values[i]; !math.IsNaN(v) { lo, hi = min(lo, v), max(hi, v) } } } if m.base == 0 { for _, a := range alerts { lo, hi = min(lo, a.Price), max(hi, a.Price) } } return [2]float64{lo, hi} }) m.lo, m.hi = d[0], d[1] } // val converts price to the current axis unit. func (m *mktCore) val(p float64) float64 { if m.base == 0 { return p } return (p/m.base - 1) * 100 } // mktGeom places candles and prices in a plot. type mktGeom struct { plot ui.Rect band internal.Band ys internal.Linear ticks []float64 step float64 } func (m *mktCore) geom(r ui.Rect) mktGeom { plot := chartInset(r, 60, 26) lo, hi, step := internal.Nice(m.lo, m.hi, 4) return mktGeom{ plot: plot, band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3}, ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, ticks: internal.Ticks(lo, hi, step), step: step, } } func (g mktGeom) x(m *mktCore, i int) float32 { return g.band.Center(i - m.from) } // index is the candle under x, clamped to the window. func (g mktGeom) index(m *mktCore, x float32) int { i := m.from + int((x-g.band.R0)/g.band.Step()) return max(m.from, min(m.to-1, i)) } func (m *mktCore) spec() chartSpec { n := len(m.candles) var keys []chartKey for _, l := range m.overlays { keys = append(keys, chartKey{Name: l.name, Color: l.color, Shape: l.shape}) } if n == 0 && m.opts.Compare != nil { pal := internal.Palette(m.k) for i, s := range *m.opts.Compare { keys = append(keys, chartKey{Name: s.Name, Color: pal[(i+1)%len(pal)], Shape: i + 1}) } } 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) } if d := m.opts.Drawings; d != nil { spec.keyActivate = func(i int) { if !d.Locked && d.Tool != ToolSelect { d.place(m, DrawingPoint{m.candles[i].Time, m.candles[i].Close}) } } } spec.readout = m.readout spec.draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) } return spec } // readout describes time, OHLC, volume, direction, and indicators. func (m *mktCore) readout(i int) string { c := m.candles[i] f := func(v float64) string { return priceUngrouped(v, m.dec) } prev := c.Open if i > 0 { prev = m.candles[i-1].Close } sign := trendSign(c.Close - prev) pct := 0.0 if prev != 0 { pct = (c.Close/prev - 1) * 100 } parts := []string{ c.Time.Format("2006-01-02 15:04"), fmt.Sprintf(msg(m.c, "candlestick_chart.ohlc"), f(c.Open), f(c.High), f(c.Low), f(c.Close)), fmt.Sprintf("%+.2f%% %s", pct, trendWord(m.c, sign)), msg(m.c, "candlestick_chart.volume") + " " + statisticFormat(c.Volume, 0), } for _, l := range m.overlays { parts = append(parts, l.name+" "+chartValue(m.c, l.values[i], f)) } return strings.Join(parts, " ยท ") }