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