Merge Market Charts; port to public ChartSpec
No files matched your search
@@ -0,0 +1,56 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CandleCountdownOptions configure a CandleCountdown.
|
||||
type CandleCountdownOptions struct {
|
||||
// Location defaults to UTC.
|
||||
Location *time.Location
|
||||
// Now freezes the clock; zero uses the frame time.
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// CandleCountdown displays closing time and schedules visible updates.
|
||||
func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) *ui.Element {
|
||||
loc := opts.Location
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
}
|
||||
left := interval.Close(now, loc).Sub(now)
|
||||
k := Tokens(c)
|
||||
text := candleCountdownText(left)
|
||||
e := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleText).Label(fmt.Sprintf(msg(c, "candle_countdown.label"), string(interval), text))
|
||||
e.Children(func() {
|
||||
ui.Text(c, string(interval)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Text(c, text).FontFeatures("tnum").FontWeight(500).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
// The next whole second, so the text never skips one.
|
||||
if opts.Now.IsZero() {
|
||||
e.DrawOver(func(_ *ui.Painter, _ ui.Rect) { c.After(left - left.Truncate(time.Second) + time.Millisecond) })
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// candleCountdownText writes d as 59:58, 3:59:58 or 2d 03:59:58.
|
||||
func candleCountdownText(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
days, h, m, sec := s/86400, s/3600%24, s/60%60, s%60
|
||||
switch {
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd %02d:%02d:%02d", days, h, m, sec)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", m, sec)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCandleCountdownIntervalsAndClock(t *testing.T) {
|
||||
now := time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC)
|
||||
iv := Interval("1m")
|
||||
tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); CandleCountdown(c, iv, CandleCountdownOptions{Now: now}) }, 300, 100)
|
||||
if !tt.HasText("00:55") {
|
||||
t.Fatalf("countdown %q", tt.Texts())
|
||||
}
|
||||
iv = "1h"
|
||||
tt.Frame()
|
||||
if !tt.HasText("25:55") {
|
||||
t.Fatalf("hour %q", tt.Texts())
|
||||
}
|
||||
for _, period := range Intervals {
|
||||
if !period.Close(now, time.UTC).After(now) {
|
||||
t.Fatalf("close %s", period)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{{time.Second, "00:01"}, {time.Hour + time.Second, "1:00:01"}, {48*time.Hour + time.Hour, "2d 01:00:00"}} {
|
||||
if got := candleCountdownText(tc.d); got != tc.want {
|
||||
t.Fatalf("text %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCandleCountdownSnapshot(t *testing.T) {
|
||||
snapshot(t, "candle_countdown", 300, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() {
|
||||
CandleCountdown(c, "1h", CandleCountdownOptions{Now: time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartType selects the price chart shape.
|
||||
type ChartType int
|
||||
|
||||
const (
|
||||
ChartCandles ChartType = iota
|
||||
ChartOHLC
|
||||
ChartLine
|
||||
ChartArea
|
||||
ChartHeikinAshi
|
||||
)
|
||||
|
||||
// CandlestickOptions configure a CandlestickChart and its layers.
|
||||
type CandlestickOptions struct {
|
||||
Frame ChartFrame
|
||||
Type ChartType
|
||||
// Decimals of prices; zero means 2.
|
||||
Decimals int
|
||||
// Overlays draw indicators over the prices.
|
||||
Overlays []IndicatorOverlay
|
||||
// Compare switches the chart to normalized percent prices.
|
||||
Compare *[]CompareSymbol
|
||||
// Alerts provides draggable lines and price-crossing callbacks.
|
||||
Alerts *[]PriceAlertLine
|
||||
OnAlert func(a PriceAlertLine, last Candle)
|
||||
// Drawings, when set, takes clicks for the current tool.
|
||||
Drawings *DrawingTools
|
||||
// Sync links the window and crosshair under SyncID.
|
||||
Sync *ChartSync
|
||||
SyncID string
|
||||
}
|
||||
|
||||
// mktState is a price chart's own frame-to-frame state.
|
||||
type mktState struct {
|
||||
drawn int // candles painted last frame
|
||||
dragRest float32 // pan drag left over, under a candle
|
||||
lastClose float64
|
||||
alertSeen bool
|
||||
grab int // alert index dragged, -1 none
|
||||
focus int // keyboard mark last frame, to follow only when it moves
|
||||
alertFocus int
|
||||
plot ui.Rect
|
||||
ys internal.Linear // the price scale as last painted
|
||||
}
|
||||
|
||||
// mktCore is one price chart being built.
|
||||
type mktCore struct {
|
||||
c *ui.Context
|
||||
k theme.Tokens
|
||||
ch *chart
|
||||
candles []Candle
|
||||
view *MarketView
|
||||
from, to int
|
||||
lo, hi float64
|
||||
base float64 // percent mode divides by this close; 0 is prices
|
||||
dec int
|
||||
opts CandlestickOptions
|
||||
st *mktState
|
||||
overlays []mktLine
|
||||
}
|
||||
|
||||
// mktLine is one drawn indicator or compare line.
|
||||
type mktLine struct {
|
||||
name string
|
||||
values []float64
|
||||
color ui.Color
|
||||
shape int
|
||||
}
|
||||
|
||||
// CandlestickChart paints visible candles with hollow rises and filled falls.
|
||||
func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult {
|
||||
if view == nil {
|
||||
panic("mujica: CandlestickChart needs a view")
|
||||
}
|
||||
if opts.Type < ChartCandles || opts.Type > ChartHeikinAshi {
|
||||
panic(fmt.Sprintf("mujica: unknown ChartType %d", opts.Type))
|
||||
}
|
||||
if opts.Sync != nil && opts.SyncID == "" {
|
||||
panic("mujica: CandlestickChart Sync needs a SyncID")
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
shown := chartMemo(ch, "candles", [2]any{chartID(candles), opts.Type}, func() []Candle {
|
||||
marketCheckCandles(candles)
|
||||
if opts.Type == ChartHeikinAshi {
|
||||
return HeikinAshi(candles)
|
||||
}
|
||||
return candles
|
||||
})
|
||||
m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: shown, view: view, opts: opts, dec: priceDecimals("CandlestickChart", opts.Decimals)}
|
||||
m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} })
|
||||
if ch.res.Element.IsDisabled() {
|
||||
copy := *view
|
||||
view = ©
|
||||
m.view = view
|
||||
m.opts.Sync = nil
|
||||
opts.Sync = nil
|
||||
}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.load(opts.SyncID, shown, view)
|
||||
}
|
||||
m.window()
|
||||
m.overlays = mktOverlayLines(ch, m, candles)
|
||||
m.compareLines()
|
||||
m.yDomain()
|
||||
m.alertsCheck()
|
||||
res, plot := ch.build(m.spec())
|
||||
if plot != nil {
|
||||
m.input(plot)
|
||||
}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.store(opts.SyncID, shown, view, res.Point)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// OHLCChart is CandlestickChart drawn as open-high-low-close bars.
|
||||
func OHLCChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult {
|
||||
opts.Type = ChartOHLC
|
||||
return CandlestickChart(c, candles, view, opts)
|
||||
}
|
||||
|
||||
// HeikinAshiChart switches between smoothed candles and raw OHLC.
|
||||
func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *bool, opts CandlestickOptions) ChartResult {
|
||||
if raw == nil {
|
||||
panic("mujica: HeikinAshiChart needs raw")
|
||||
}
|
||||
opts.Type = ChartHeikinAshi
|
||||
if *raw {
|
||||
opts.Type = ChartCandles
|
||||
}
|
||||
var res ChartResult
|
||||
col := ui.Column(c).Gap(Density(c).Unit() * 2).MinWidth(0)
|
||||
col.Children(func() {
|
||||
ui.Row(c).Children(func() {
|
||||
v := []bool{*raw}
|
||||
if ToggleGroup(c, &v, []ToggleItem[bool]{{Value: false, Label: msg(c, "heikin_ashi_chart.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi_chart.raw")}},
|
||||
ToggleGroupOptions{Label: msg(c, "heikin_ashi_chart.switch")}).Changed() {
|
||||
*raw = v[0]
|
||||
slog.Debug("mujica: heikin_ashi switch", "raw", *raw)
|
||||
}
|
||||
})
|
||||
res = CandlestickChart(c, candles, view, opts)
|
||||
})
|
||||
res.Element = col
|
||||
return res
|
||||
}
|
||||
|
||||
// window resolves visible candles using the last measured width.
|
||||
func (m *mktCore) window() {
|
||||
m.from, m.to = marketWindow(*m.view, len(m.candles), m.room())
|
||||
}
|
||||
|
||||
// room is how many candles fit, three DIPs each.
|
||||
func (m *mktCore) room() int {
|
||||
w := m.ch.res.Element.Bounds().W - 64
|
||||
if w <= 0 {
|
||||
return 200
|
||||
}
|
||||
return max(10, int(w/3))
|
||||
}
|
||||
|
||||
// yDomain caches visible prices, indicators, and alerts.
|
||||
func (m *mktCore) yDomain() {
|
||||
if m.to <= m.from {
|
||||
return
|
||||
}
|
||||
var alerts []PriceAlertLine
|
||||
if m.opts.Alerts != nil {
|
||||
alerts = *m.opts.Alerts
|
||||
}
|
||||
ids := fmt.Sprintf("%s|%d|%d|%v|%d|%v", chartID(m.candles), m.from, m.to, m.base, len(m.overlays), alerts)
|
||||
for _, l := range m.overlays {
|
||||
ids += chartID(l.values)
|
||||
}
|
||||
d := chartMemo(m.ch, "ydomain", ids, func() [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for i := m.from; i < m.to; i++ {
|
||||
lo, hi = min(lo, m.val(m.candles[i].Low)), max(hi, m.val(m.candles[i].High))
|
||||
for _, l := range m.overlays {
|
||||
if v := l.values[i]; !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.base == 0 {
|
||||
for _, a := range alerts {
|
||||
lo, hi = min(lo, a.Price), max(hi, a.Price)
|
||||
}
|
||||
}
|
||||
return [2]float64{lo, hi}
|
||||
})
|
||||
m.lo, m.hi = d[0], d[1]
|
||||
}
|
||||
|
||||
// val converts price to the current axis unit.
|
||||
func (m *mktCore) val(p float64) float64 {
|
||||
if m.base == 0 {
|
||||
return p
|
||||
}
|
||||
return (p/m.base - 1) * 100
|
||||
}
|
||||
|
||||
// mktGeom places candles and prices in a plot.
|
||||
type mktGeom struct {
|
||||
plot ui.Rect
|
||||
band internal.Band
|
||||
ys internal.Linear
|
||||
ticks []float64
|
||||
step float64
|
||||
}
|
||||
|
||||
func (m *mktCore) geom(r ui.Rect) mktGeom {
|
||||
plot := chartInset(r, 60, 26)
|
||||
lo, hi, step := internal.Nice(m.lo, m.hi, 4)
|
||||
return mktGeom{
|
||||
plot: plot,
|
||||
band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)},
|
||||
ticks: internal.Ticks(lo, hi, step),
|
||||
step: step,
|
||||
}
|
||||
}
|
||||
|
||||
func (g mktGeom) x(m *mktCore, i int) float32 { return g.band.Center(i - m.from) }
|
||||
|
||||
// index is the candle under x, clamped to the window.
|
||||
func (g mktGeom) index(m *mktCore, x float32) int {
|
||||
i := m.from + int((x-g.band.R0)/g.band.Step())
|
||||
return max(m.from, min(m.to-1, i))
|
||||
}
|
||||
|
||||
func (m *mktCore) spec() ChartSpec {
|
||||
n := len(m.candles)
|
||||
var keys []ChartLegendItem
|
||||
for _, l := range m.overlays {
|
||||
keys = append(keys, ChartLegendItem{Name: l.name, Color: l.color, Shape: l.shape})
|
||||
}
|
||||
if n == 0 && m.opts.Compare != nil {
|
||||
pal := internal.Palette(m.k)
|
||||
for i, s := range *m.opts.Compare {
|
||||
keys = append(keys, ChartLegendItem{Name: s.Name, Color: pal[(i+1)%len(pal)], Shape: i + 1})
|
||||
}
|
||||
}
|
||||
spec := ChartSpec{Marks: n, Legend: keys}
|
||||
if m.to <= m.from {
|
||||
spec.Marks = 0
|
||||
return spec
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
g := m.geom(r)
|
||||
if !g.plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return g.index(m, x)
|
||||
}
|
||||
spec.Move = func(i int, key ui.Key) int {
|
||||
if key == ui.KeyLeft || key == ui.KeyUp {
|
||||
return max(i-1, 0)
|
||||
}
|
||||
return min(i+1, n-1)
|
||||
}
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
spec.Activate = func(i int) {
|
||||
if !d.Locked && d.Tool != ToolSelect {
|
||||
d.place(m, DrawingPoint{m.candles[i].Time, m.candles[i].Close})
|
||||
}
|
||||
}
|
||||
}
|
||||
spec.Readout = m.readout
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) }
|
||||
return spec
|
||||
}
|
||||
|
||||
// readout describes time, OHLC, volume, direction, and indicators.
|
||||
func (m *mktCore) readout(i int) string {
|
||||
c := m.candles[i]
|
||||
f := func(v float64) string { return priceUngrouped(v, m.dec) }
|
||||
prev := c.Open
|
||||
if i > 0 {
|
||||
prev = m.candles[i-1].Close
|
||||
}
|
||||
sign := trendSign(c.Close - prev)
|
||||
pct := 0.0
|
||||
if prev != 0 {
|
||||
pct = (c.Close/prev - 1) * 100
|
||||
}
|
||||
parts := []string{
|
||||
c.Time.Format("2006-01-02 15:04"),
|
||||
fmt.Sprintf(msg(m.c, "candlestick_chart.ohlc"), f(c.Open), f(c.High), f(c.Low), f(c.Close)),
|
||||
fmt.Sprintf("%+.2f%% %s", pct, trendWord(m.c, sign)),
|
||||
msg(m.c, "candlestick_chart.volume") + " " + statisticFormat(c.Volume, 0),
|
||||
}
|
||||
for _, l := range m.overlays {
|
||||
parts = append(parts, l.name+" "+chartValue(m.c, l.values[i], f))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// sampleCandles is a deterministic random walk, hourly from 2026-01-01.
|
||||
func sampleCandles(n int) []Candle {
|
||||
out := make([]Candle, n)
|
||||
price := 100.0
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range out {
|
||||
o := price
|
||||
c := o + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8
|
||||
hi, lo := max(o, c)+0.6+math.Abs(math.Sin(float64(i)))*0.5, min(o, c)-0.5-math.Abs(math.Cos(float64(i)))*0.4
|
||||
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: o, High: hi, Low: lo, Close: c, Volume: 1000 + float64(i%17)*130}
|
||||
price = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mktState0(r ChartResult) *mktState {
|
||||
return *ui.Local(r.Element, "mujica.mkt", func() *mktState { return nil })
|
||||
}
|
||||
|
||||
func TestCandlestickChartDrawsOnlyVisible(t *testing.T) {
|
||||
candles := sampleCandles(100000)
|
||||
var view MarketView
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})
|
||||
}, 900, 400)
|
||||
tt.Frame()
|
||||
st := mktState0(res)
|
||||
if st.drawn != 120 {
|
||||
t.Fatalf("drew %d candles of 100000", st.drawn)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
for range 40 {
|
||||
tt.Key(0, ui.KeyMinus)
|
||||
}
|
||||
if st.drawn > 900/3 || view.Count > 900/3 {
|
||||
t.Fatalf("zoomed out: drew %d, view %+v", st.drawn, view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartKeyboardPanZoomReadout(t *testing.T) {
|
||||
candles := sampleCandles(500)
|
||||
view := MarketView{Count: 50}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})
|
||||
}, 900, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 499 || !tt.HasText("2026-01-21 19:00") {
|
||||
t.Fatalf("End: point %d texts %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyLeft)
|
||||
if view.End != 495 || view.Count != 50 {
|
||||
t.Fatalf("Shift+Left: %+v", view)
|
||||
}
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
if view.Count != 40 {
|
||||
t.Fatalf("zoom in: %+v", view)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
if f, _ := marketWindow(view, 500, 300); f != 0 {
|
||||
t.Fatalf("Home did not bring the first candle into view: %+v", view)
|
||||
}
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "O 100.00") && strings.Contains(s, "Vol 1,000")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartDragPansAndRejects(t *testing.T) {
|
||||
candles := sampleCandles(300)
|
||||
view := MarketView{Count: 100}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}).Element.Width(860)
|
||||
}, 900, 400)
|
||||
tt.Frame()
|
||||
tt.Press(400, 150)
|
||||
tt.Move(560, 150) // about 20 candles of 8 DIPs
|
||||
tt.Release(560, 150)
|
||||
if view.End < 275 || view.End > 285 {
|
||||
t.Fatalf("drag right shows older candles: %+v", view)
|
||||
}
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"type": func(c *ui.Context) {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Type: 9})
|
||||
},
|
||||
"order": func(c *ui.Context) {
|
||||
bad := []Candle{candles[1], candles[0]}
|
||||
CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
"prices": func(c *ui.Context) {
|
||||
bad := []Candle{{Time: candles[0].Time, Open: 5, High: 4, Low: 3, Close: 4}}
|
||||
CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartTrendColors(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
view := MarketView{Count: 30}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
UseTrend(c, RedUp)
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "沪深300"}})
|
||||
}, 600, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if res.Point != 0 || !tt.HasText("开 100.00") {
|
||||
t.Fatalf("zh readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(80)
|
||||
view := MarketView{Count: 60}
|
||||
snapshot(t, "candlestick_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// paint draws axes, visible candles, layers, and the crosshair.
|
||||
func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) {
|
||||
g := m.geom(r)
|
||||
m.st.plot, m.st.ys = g.plot, g.ys
|
||||
k := m.k
|
||||
size := FontSize(m.c, theme.CaptionSize)
|
||||
label := func(v float64) string {
|
||||
if m.base != 0 {
|
||||
return internal.FormatTick(v, g.step) + "%"
|
||||
}
|
||||
return internal.FormatTick(v, g.step)
|
||||
}
|
||||
internal.AxisY(p, g.plot, g.ys, g.ticks, label, size, k)
|
||||
m.paintTimeAxis(p, g, size)
|
||||
p.Clip(g.plot, 0, func() {
|
||||
m.st.drawn = m.paintCandles(p, g)
|
||||
for _, l := range m.overlays {
|
||||
mktPaintLine(p, g, m, l.values, l.color, l.shape)
|
||||
}
|
||||
m.paintAlerts(p, g, size)
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
d.paint(p, g, m, size)
|
||||
}
|
||||
})
|
||||
at := active
|
||||
if at < 0 && m.opts.Sync != nil {
|
||||
at = m.opts.Sync.cursorIndex(m.opts.SyncID, m.candles)
|
||||
}
|
||||
if at >= m.from && at < m.to {
|
||||
x := internal.Snap(p, g.x(m, at))
|
||||
y := internal.Snap(p, g.ys.Map(m.val(m.candles[at].Close)))
|
||||
p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted)
|
||||
p.Line(g.plot.X, y, g.plot.X+g.plot.W, y, 1, k.TextMuted.Alpha(0.6))
|
||||
mktTag(p, g.plot.X+g.plot.W, y, label(m.val(m.candles[at].Close)), size, k.Text, k.Surface)
|
||||
}
|
||||
}
|
||||
|
||||
// paintTimeAxis labels candles about 90 DIPs apart.
|
||||
func (m *mktCore) paintTimeAxis(p *ui.Painter, g mktGeom, size float32) {
|
||||
every := max(1, int(math.Ceil(float64(90/g.band.Step()))))
|
||||
layout := msg(m.c, "candlestick_chart.daily_time")
|
||||
if m.to-m.from > 1 && m.candles[m.from+1].Time.Sub(m.candles[m.from].Time) < 24*time.Hour {
|
||||
layout = "15:04"
|
||||
}
|
||||
var xs []float32
|
||||
var labels []string
|
||||
for i := m.from; i < m.to; i += every {
|
||||
xs = append(xs, g.x(m, i))
|
||||
labels = append(labels, m.candles[i].Time.Format(layout))
|
||||
}
|
||||
internal.AxisX(p, g.plot, xs, labels, size, m.k)
|
||||
}
|
||||
|
||||
// paintCandles returns the number of visible candles painted.
|
||||
func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int {
|
||||
k := m.k
|
||||
w := max(1, g.band.Width())
|
||||
if m.opts.Type == ChartLine || m.opts.Type == ChartArea {
|
||||
var line, fill ui.Path
|
||||
for i := m.from; i < m.to; i++ {
|
||||
x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(m.val(m.candles[i].Close)))
|
||||
if i == m.from {
|
||||
line.MoveTo(x, y)
|
||||
fill.MoveTo(x, g.plot.Y+g.plot.H).LineTo(x, y)
|
||||
} else {
|
||||
line.LineTo(x, y)
|
||||
fill.LineTo(x, y)
|
||||
}
|
||||
}
|
||||
if m.opts.Type == ChartArea {
|
||||
fill.LineTo(internal.Snap(p, g.x(m, m.to-1)), g.plot.Y+g.plot.H).Close()
|
||||
p.FillPath(&fill, k.AccentText.Alpha(0.18))
|
||||
}
|
||||
p.StrokePath(&line, 2, k.AccentText)
|
||||
return m.to - m.from
|
||||
}
|
||||
for i := m.from; i < m.to; i++ {
|
||||
c := m.candles[i]
|
||||
up := c.Close >= c.Open
|
||||
col := trendColor(m.c, 1)
|
||||
if !up {
|
||||
col = trendColor(m.c, -1)
|
||||
}
|
||||
x := internal.Snap(p, g.x(m, i))
|
||||
yo, yc := g.ys.Map(m.val(c.Open)), g.ys.Map(m.val(c.Close))
|
||||
yh, yl := g.ys.Map(m.val(c.High)), g.ys.Map(m.val(c.Low))
|
||||
if m.opts.Type == ChartOHLC {
|
||||
p.Line(x, yh, x, yl, 1.5, col)
|
||||
p.Line(x-w/2, yo, x, yo, 1.5, col)
|
||||
p.Line(x, yc, x+w/2, yc, 1.5, col)
|
||||
continue
|
||||
}
|
||||
top, bot := min(yo, yc), max(yo, yc)
|
||||
body := ui.Rect{X: x - w/2, Y: top, W: w, H: max(1, bot-top)}
|
||||
p.Line(x, yh, x, top, 1, col)
|
||||
p.Line(x, bot, x, yl, 1, col)
|
||||
// Rises are hollow; falls are filled.
|
||||
if up && w >= 3 {
|
||||
p.Fill(body, k.Surface, 0)
|
||||
p.Stroke(body, col, 0, 1)
|
||||
} else {
|
||||
p.Fill(body, col, 0)
|
||||
}
|
||||
}
|
||||
return m.to - m.from
|
||||
}
|
||||
|
||||
// mktPaintLine draws visible values with distinct series markers.
|
||||
func mktPaintLine(p *ui.Painter, g mktGeom, m *mktCore, values []float64, col ui.Color, shape int) {
|
||||
var path ui.Path
|
||||
open := false
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v := values[i]
|
||||
if math.IsNaN(v) {
|
||||
open = false
|
||||
continue
|
||||
}
|
||||
x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(v))
|
||||
if open {
|
||||
path.LineTo(x, y)
|
||||
} else {
|
||||
path.MoveTo(x, y)
|
||||
open = true
|
||||
}
|
||||
}
|
||||
p.StrokePath(&path, 1.5, col)
|
||||
every := max(1, int(60/max(g.band.Step(), 0.1)))
|
||||
for i := m.from + shape%every; i < m.to; i += every {
|
||||
if v := values[i]; !math.IsNaN(v) {
|
||||
internal.Marker(p, shape, g.x(m, i), g.ys.Map(v), 6, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mktTag centers a price label on the right edge.
|
||||
func mktTag(p *ui.Painter, right, y float32, s string, size float32, fg, bg ui.Color) {
|
||||
w, h := p.MeasureText(0, ui.Span{Text: s, Size: size})
|
||||
b := ui.Rect{X: right - w - 8, Y: y - h/2 - 2, W: w + 8, H: h + 4}
|
||||
p.Fill(b, bg, theme.ControlRadius)
|
||||
p.Stroke(b, fg, theme.ControlRadius, 1)
|
||||
p.Text(b.X+4, b.Y+2, s, size, fg)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartSync shares time windows and crosshairs across linked charts.
|
||||
type ChartSync struct {
|
||||
// Count is linked zoom; zero defaults to 120.
|
||||
Count int
|
||||
// End is inclusive; zero follows each chart's latest candle.
|
||||
End time.Time
|
||||
// Start is the inclusive left edge across candle intervals.
|
||||
Start time.Time
|
||||
// Cursor is the crosshair time; zero shows none.
|
||||
Cursor time.Time
|
||||
// Unlinked charts keep their own view and crosshair.
|
||||
Unlinked map[string]bool
|
||||
owner string
|
||||
rangeOwner string
|
||||
loaded map[string]MarketView
|
||||
}
|
||||
|
||||
func (s *ChartSync) linked(id string) bool { return !s.Unlinked[id] }
|
||||
|
||||
// load applies the shared time window to linked charts.
|
||||
func (s *ChartSync) load(id string, candles []Candle, view *MarketView) {
|
||||
if !s.linked(id) {
|
||||
return
|
||||
}
|
||||
view.Count, view.End = s.Count, 0
|
||||
if !s.End.IsZero() {
|
||||
if i := marketIndexAt(candles, s.End); i+1 < len(candles) {
|
||||
view.End = max(1, i+1)
|
||||
}
|
||||
}
|
||||
if s.rangeOwner == "" {
|
||||
s.rangeOwner = id
|
||||
}
|
||||
if len(candles) > 0 && (s.Start.IsZero() || s.End.IsZero() && s.rangeOwner == id) {
|
||||
from, _ := marketWindow(*view, len(candles), len(candles))
|
||||
s.Start = candles[from].Time
|
||||
}
|
||||
if !s.Start.IsZero() && len(candles) > 0 {
|
||||
from := sort.Search(len(candles), func(i int) bool { return !candles[i].Time.Before(s.Start) })
|
||||
to := len(candles)
|
||||
if view.End > 0 {
|
||||
to = view.End
|
||||
}
|
||||
if from < to {
|
||||
view.Count = to - from
|
||||
}
|
||||
}
|
||||
if s.loaded == nil {
|
||||
s.loaded = map[string]MarketView{}
|
||||
}
|
||||
s.loaded[id] = *view
|
||||
}
|
||||
|
||||
// store updates the shared window and active crosshair.
|
||||
func (s *ChartSync) store(id string, candles []Candle, view *MarketView, active int) {
|
||||
if !s.linked(id) {
|
||||
return
|
||||
}
|
||||
loaded, ok := s.loaded[id]
|
||||
if !ok || *view != loaded {
|
||||
s.Count, s.End, s.rangeOwner = view.Count, time.Time{}, id
|
||||
if view.End > 0 && view.End <= len(candles) {
|
||||
s.End = candles[view.End-1].Time
|
||||
}
|
||||
if len(candles) > 0 {
|
||||
from, _ := marketWindow(*view, len(candles), len(candles))
|
||||
s.Start = candles[from].Time
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case active >= 0:
|
||||
s.Cursor, s.owner = candles[active].Time, id
|
||||
case s.owner == id:
|
||||
s.Cursor, s.owner = time.Time{}, ""
|
||||
}
|
||||
}
|
||||
|
||||
// cursorIndex is the candle at the linked crosshair, or -1.
|
||||
func (s *ChartSync) cursorIndex(id string, candles []Candle) int {
|
||||
if !s.linked(id) || s.Cursor.IsZero() || s.owner == id {
|
||||
return -1
|
||||
}
|
||||
return marketIndexAt(candles, s.Cursor)
|
||||
}
|
||||
|
||||
// Link toggles synchronization for a stable chart ID.
|
||||
func (s *ChartSync) Link(c *ui.Context, id string) ChoiceResult {
|
||||
if id == "" {
|
||||
panic("mujica: ChartSync.Link needs an id")
|
||||
}
|
||||
k := Tokens(c)
|
||||
on := s.linked(id)
|
||||
pressed := on
|
||||
b := ui.ToggleBase(c, &pressed).MinHeight(theme.MinHitSize).Padding(0, Density(c).Unit()*2).Gap(Density(c).Unit()).
|
||||
Radius(theme.ControlRadius).Label(msg(c, "chart_sync.link"))
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
changed := b.Changed() && !b.IsDisabled()
|
||||
if changed {
|
||||
if s.Unlinked == nil {
|
||||
s.Unlinked = map[string]bool{}
|
||||
}
|
||||
on = pressed
|
||||
s.Unlinked[id] = !on
|
||||
if s.owner == id {
|
||||
s.Cursor, s.owner = time.Time{}, ""
|
||||
}
|
||||
slog.Debug("mujica: chart_sync link", "chart", id, "linked", on)
|
||||
}
|
||||
fg, bg, word := k.TextMuted, ui.Transparent, msg(c, "chart_sync.unlinked")
|
||||
if on {
|
||||
fg, bg, word = k.AccentText, k.Selection, msg(c, "chart_sync.linked")
|
||||
}
|
||||
if b.Hovered() && !on {
|
||||
bg = k.SurfaceHover
|
||||
}
|
||||
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, fg).Transition(colorFade)
|
||||
b.Children(func() {
|
||||
mark := "circle"
|
||||
if on {
|
||||
mark = "check"
|
||||
}
|
||||
ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, mark, fg) })
|
||||
ui.Text(c, word).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
return ChoiceResult{b, changed}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChartSyncRangeCursorAndUnlink(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
sync := ChartSync{}
|
||||
v := MarketView{Count: 40, End: 200}
|
||||
sync.store("a", cs, &v, 150)
|
||||
var other MarketView
|
||||
sync.load("b", cs, &other)
|
||||
if other != v || sync.cursorIndex("b", cs) != 150 {
|
||||
t.Fatalf("sync %+v %+v", other, sync)
|
||||
}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Disabled(disabled).Children(func() { sync.Link(c, "b") })
|
||||
}, 300, 90)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if sync.linked("b") || sync.cursorIndex("b", cs) != -1 {
|
||||
t.Fatal("unlink failed")
|
||||
}
|
||||
other = MarketView{Count: 12}
|
||||
sync.load("b", cs, &other)
|
||||
if other.Count != 12 {
|
||||
t.Fatal("unlinked view overwritten")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if sync.linked("b") {
|
||||
t.Fatal("disabled link changed")
|
||||
}
|
||||
sync.store("a", cs, &v, -1)
|
||||
if !sync.Cursor.IsZero() {
|
||||
t.Fatal("cursor remained after leaving owner")
|
||||
}
|
||||
}
|
||||
func TestChartSyncSnapshot(t *testing.T) {
|
||||
cs := sampleCandles(50)
|
||||
sync := ChartSync{Cursor: cs[25].Time, owner: "a"}
|
||||
v := MarketView{Count: 50}
|
||||
snapshot(t, "chart_sync", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
sync.Link(c, "b")
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Linked", Height: 230}, Sync: &sync, SyncID: "b"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ChartTypeSwitcherOptions configure a ChartTypeSwitcher.
|
||||
type ChartTypeSwitcherOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ChartTypeSwitcher chooses candles, OHLC, line, area, or Heikin-Ashi.
|
||||
func ChartTypeSwitcher(c *ui.Context, value *ChartType, opts ChartTypeSwitcherOptions) ChoiceResult {
|
||||
if value == nil {
|
||||
panic("mujica: ChartTypeSwitcher needs a value")
|
||||
}
|
||||
if *value < ChartCandles || *value > ChartHeikinAshi {
|
||||
panic(fmt.Sprintf("mujica: unknown ChartType %d", *value))
|
||||
}
|
||||
var items []ToggleItem[ChartType]
|
||||
for t := ChartCandles; t <= ChartHeikinAshi; t++ {
|
||||
items = append(items, ToggleItem[ChartType]{Value: t, Label: msg(c, fmt.Sprintf("chart_type_switcher.type%d", t))})
|
||||
}
|
||||
sel := []ChartType{*value}
|
||||
var r ChoiceResult
|
||||
ui.Row(c).Disabled(opts.Disabled).Children(func() {
|
||||
r = ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "chart_type_switcher.label")})
|
||||
})
|
||||
if r.Changed() {
|
||||
*value = sel[0]
|
||||
slog.Debug("mujica: chart_type_switcher changed", "type", *value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChartTypeSwitcherKeyboardAndDisabled(t *testing.T) {
|
||||
value := ChartCandles
|
||||
disabled := false
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{Disabled: disabled}).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 800, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
for _, want := range []ChartType{ChartOHLC, ChartLine, ChartArea, ChartHeikinAshi} {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if value != want {
|
||||
t.Fatalf("type %v, want %v", value, want)
|
||||
}
|
||||
}
|
||||
if changes != 4 {
|
||||
t.Fatalf("changes %d", changes)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if value != ChartHeikinAshi || changes != 4 {
|
||||
t.Fatal("disabled type changed")
|
||||
}
|
||||
}
|
||||
func TestChartTypeSwitcherSnapshot(t *testing.T) {
|
||||
value := ChartCandles
|
||||
snapshot(t, "chart_type_switcher", 680, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestCompareSymbolPercentAndRemove(t *testing.T) {
|
||||
candles := sampleCandles(100)
|
||||
view := MarketView{Count: 50}
|
||||
eth := make([]float64, 100)
|
||||
for i := range eth {
|
||||
eth[i] = 20 + float64(i)*0.1
|
||||
}
|
||||
cmp := []CompareSymbol{{Name: "ETH", Closes: eth}}
|
||||
removed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if CompareLegend(c, &cmp).Changed() {
|
||||
removed++
|
||||
}
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Compare: &cmp})
|
||||
}, 800, 420)
|
||||
tt.Key(0, ui.KeyTab) // remove ETH
|
||||
tt.Key(0, ui.KeyTab) // the chart
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
// Normalized from the first shown candle, index 50: (29.9/25 - 1) = 19.6%.
|
||||
want := "ETH 19.60"
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, want)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout lacks %q: %q", want, tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Remove ETH"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmp) != 0 || removed != 1 {
|
||||
t.Fatalf("remove: %v %d", cmp, removed)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("short closes accepted")
|
||||
}
|
||||
}()
|
||||
bad := []CompareSymbol{{Name: "X", Closes: []float64{1}}}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Compare: &bad})
|
||||
}, 300, 200)
|
||||
}
|
||||
|
||||
func TestCompareSymbolSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(100)
|
||||
view := MarketView{Count: 80}
|
||||
eth, sol := make([]float64, 100), make([]float64, 100)
|
||||
for i := range eth {
|
||||
eth[i] = 20 + float64(i)*0.08 + float64(i%7)*0.2
|
||||
sol[i] = 5 - float64(i)*0.01 + float64(i%5)*0.05
|
||||
}
|
||||
cmp := []CompareSymbol{{Name: "ETH", Closes: eth}, {Name: "SOL", Closes: sol}}
|
||||
snapshot(t, "compare_symbol", 640, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Gap(8).Children(func() {
|
||||
CompareLegend(c, &cmp)
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}, Type: ChartLine, Compare: &cmp})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DepthLevel is one price level of an order book.
|
||||
type DepthLevel struct {
|
||||
Price, Size float64
|
||||
}
|
||||
|
||||
// DepthChartOptions configure a DepthChart.
|
||||
type DepthChartOptions struct {
|
||||
Frame ChartFrame
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// depthData holds cumulative bids and asks around the midpoint.
|
||||
type depthData struct {
|
||||
price, total []float64
|
||||
nBids int
|
||||
mid, maxSize float64
|
||||
}
|
||||
|
||||
// DepthChart paints cumulative depth and supports keyboard level readouts.
|
||||
func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) ChartResult {
|
||||
dec := priceDecimals("DepthChart", opts.Decimals)
|
||||
ch := newChart(c, opts.Frame)
|
||||
k := Tokens(c)
|
||||
d := chartMemo(ch, "depth", [2]string{chartID(bids), chartID(asks)}, func() depthData { return depthBuild(bids, asks) })
|
||||
bidCol, askCol := trendColor(c, 1), trendColor(c, -1)
|
||||
keys := []ChartLegendItem{{Name: msg(c, "depth_chart.bids"), Color: bidCol, Shape: 1}, {Name: msg(c, "depth_chart.asks"), Color: askCol, Shape: 3}}
|
||||
drawn := ui.Local(ch.res.Element, "mujica.depth.drawn", func() int { return 0 })
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := chartInset(r, 56, 26)
|
||||
lo, hi := d.price[d.nBids-1], d.price[len(d.price)-1]
|
||||
return plot, internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: 0, D1: d.maxSize * 1.05, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
spec := ChartSpec{Marks: len(d.price), Legend: keys}
|
||||
if len(bids) == 0 || len(asks) == 0 {
|
||||
spec.Marks = 0
|
||||
}
|
||||
// Arrows move by ascending price.
|
||||
order := func(i int) int {
|
||||
if i < d.nBids {
|
||||
return d.nBids - 1 - i
|
||||
}
|
||||
return i
|
||||
}
|
||||
spec.Move = func(i int, key ui.Key) int {
|
||||
pos := order(i)
|
||||
if key == ui.KeyLeft || key == ui.KeyUp {
|
||||
pos = max(pos-1, 0)
|
||||
} else {
|
||||
pos = min(pos+1, len(d.price)-1)
|
||||
}
|
||||
return order(pos)
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
v := xs.Invert(x)
|
||||
pos := sort.Search(len(d.price), func(i int) bool { return d.price[order(i)] >= v })
|
||||
if pos == len(d.price) || pos > 0 && v-d.price[order(pos-1)] < d.price[order(pos)]-v {
|
||||
pos--
|
||||
}
|
||||
return order(pos)
|
||||
}
|
||||
spec.Readout = func(i int) string {
|
||||
side := msg(c, "depth_chart.bid")
|
||||
if i >= d.nBids {
|
||||
side = msg(c, "depth_chart.ask")
|
||||
}
|
||||
return strings.Join([]string{side + " " + priceUngrouped(d.price[i], dec), fmt.Sprintf(msg(c, "depth_chart.total"), statisticFormat(d.total[i], 0)),
|
||||
msg(c, "depth_chart.mid") + " " + priceUngrouped(d.mid, dec)}, " · ")
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
_, _, ystep := internal.Nice(0, d.maxSize*1.05, 4)
|
||||
internal.AxisY(p, plot, ys, internal.Ticks(0, d.maxSize*1.05, ystep), func(v float64) string { return volumeShort(v, ystep) }, size, k)
|
||||
_, _, xstep := internal.Nice(xs.D0, xs.D1, max(2, int(plot.W/90)))
|
||||
var tx []float32
|
||||
var tl []string
|
||||
for _, t := range internal.Ticks(xs.D0, xs.D1, xstep) {
|
||||
tx, tl = append(tx, xs.Map(t)), append(tl, internal.FormatTick(t, xstep))
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
base := plot.Y + plot.H
|
||||
*drawn = 0
|
||||
for side, idx := range [][2]int{{0, d.nBids}, {d.nBids, len(d.price)}} {
|
||||
col := []ui.Color{bidCol, askCol}[side]
|
||||
var area, edge ui.Path
|
||||
mx := internal.Snap(p, xs.Map(d.mid))
|
||||
area.MoveTo(mx, base)
|
||||
edge.MoveTo(mx, base)
|
||||
prevY := base
|
||||
indices := chartMemo(ch, fmt.Sprint("depth.indices.", side), [3]any{chartID(d.price), plot.X, plot.W}, func() []int {
|
||||
return internal.Columns(idx[1]-idx[0], func(i int) float32 { return xs.Map(d.price[idx[0]+i]) }, func(i int) float64 { return d.total[idx[0]+i] })
|
||||
})
|
||||
*drawn += len(indices)
|
||||
for _, at := range indices {
|
||||
i := idx[0] + at
|
||||
x, y := internal.Snap(p, xs.Map(d.price[i])), internal.Snap(p, ys.Map(d.total[i]))
|
||||
area.LineTo(x, prevY).LineTo(x, y)
|
||||
edge.LineTo(x, prevY).LineTo(x, y)
|
||||
prevY = y
|
||||
}
|
||||
end := plot.X
|
||||
if side == 1 {
|
||||
end = plot.X + plot.W
|
||||
}
|
||||
area.LineTo(end, prevY).LineTo(end, base).Close()
|
||||
edge.LineTo(end, prevY)
|
||||
p.FillPath(&area, col.Alpha(0.2))
|
||||
p.StrokePath(&edge, 2, col)
|
||||
label := []string{msg(c, "depth_chart.bids"), msg(c, "depth_chart.asks")}[side]
|
||||
chartText(p, label, []float32{plot.X + 6, plot.X + plot.W - 6}[side], plot.Y+4, size, col, []float32{0, 1}[side], 0)
|
||||
}
|
||||
mx := internal.Snap(p, xs.Map(d.mid))
|
||||
for y := plot.Y; y < base; y += 6 {
|
||||
p.Line(mx, y, mx, min(y+3, base), 1, k.TextMuted)
|
||||
}
|
||||
chartText(p, priceUngrouped(d.mid, dec), mx, plot.Y+4, size, k.Text, 0.5, 0)
|
||||
if active >= 0 {
|
||||
x, y := xs.Map(d.price[active]), ys.Map(d.total[active])
|
||||
p.Line(internal.Snap(p, x), plot.Y, internal.Snap(p, x), base, 1, k.TextMuted)
|
||||
shape := 1
|
||||
if active >= d.nBids {
|
||||
shape = 3
|
||||
}
|
||||
internal.Marker(p, shape, x, y, 10, keys[shape/3].Color)
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// depthBuild validates price order and computes cumulative volume.
|
||||
func depthBuild(bids, asks []DepthLevel) depthData {
|
||||
for i, l := range bids {
|
||||
if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(l.Size > 0) || i > 0 && !(l.Price < bids[i-1].Price) {
|
||||
panic(fmt.Sprintf("mujica: DepthChart bid %d needs a positive size and a price below the one before", i))
|
||||
}
|
||||
}
|
||||
for i, l := range asks {
|
||||
if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(l.Size > 0) || i > 0 && !(l.Price > asks[i-1].Price) {
|
||||
panic(fmt.Sprintf("mujica: DepthChart ask %d needs a positive size and a price above the one before", i))
|
||||
}
|
||||
}
|
||||
if len(bids) > 0 && len(asks) > 0 && !(bids[0].Price < asks[0].Price) {
|
||||
panic("mujica: DepthChart best bid must be below best ask")
|
||||
}
|
||||
var d depthData
|
||||
d.nBids = len(bids)
|
||||
var sum float64
|
||||
for _, l := range bids {
|
||||
sum += l.Size
|
||||
d.price, d.total = append(d.price, l.Price), append(d.total, sum)
|
||||
}
|
||||
d.maxSize = sum
|
||||
sum = 0
|
||||
for _, l := range asks {
|
||||
sum += l.Size
|
||||
d.price, d.total = append(d.price, l.Price), append(d.total, sum)
|
||||
}
|
||||
d.maxSize = max(d.maxSize, sum)
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
d.mid = (bids[0].Price + asks[0].Price) / 2
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func sampleBook() ([]DepthLevel, []DepthLevel) {
|
||||
var bids, asks []DepthLevel
|
||||
for i := range 12 {
|
||||
bids = append(bids, DepthLevel{Price: 99.9 - float64(i)*0.1, Size: float64(10 + i*3)})
|
||||
asks = append(asks, DepthLevel{Price: 100.1 + float64(i)*0.1, Size: float64(8 + i*4)})
|
||||
}
|
||||
return bids, asks
|
||||
}
|
||||
|
||||
func TestDepthChartCumulativeAndKeyboard(t *testing.T) {
|
||||
bids, asks := sampleBook()
|
||||
d := depthBuild(bids, asks)
|
||||
if d.mid != 100 || d.total[0] != 10 || d.total[1] != 23 || d.total[12] != 8 || d.total[13] != 20 {
|
||||
t.Fatalf("cumulative %+v", d.total[:14])
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}})
|
||||
}, 700, 360)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight) // the best bid first
|
||||
if res.Point != 0 || !tt.HasText("Bid 99.90 · cumulative 10 · Mid 100.00") {
|
||||
t.Fatalf("first level %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft) // outward to lower bids
|
||||
if res.Point != 1 || !tt.HasText("Bid 99.80 · cumulative 23") {
|
||||
t.Fatalf("lower bid %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if res.Point != 12 || !tt.HasText("Ask 100.10 · cumulative 8") {
|
||||
t.Fatalf("crossing to asks %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
for name, book := range map[string][2][]DepthLevel{
|
||||
"bid order": {{{Price: 1, Size: 1}, {Price: 2, Size: 1}}, nil},
|
||||
"crossed": {{{Price: 5, Size: 1}}, {{Price: 4, Size: 1}}},
|
||||
"size": {nil, {{Price: 4, Size: 0}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
depthBuild(book[0], book[1])
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepthChartSnapshot(t *testing.T) {
|
||||
bids, asks := sampleBook()
|
||||
snapshot(t, "depth_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth", Height: 240}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# 141 CandlestickChart
|
||||
|
||||
## 用途
|
||||
|
||||
K 线;涨跌色可配置(红涨或绿涨);缩放平移;万根级只绘可见区。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`candles` 按时间递增;`MarketView.Count=0` 显示 120 根,`End=0` 跟随最新。`Frame.Height=0` 为 240 DIP。`Type` 控制形态,`Decimals=0` 为两位。涨跌配色通过 `UseTrend` 设置。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 142 OHLCChart
|
||||
|
||||
## 用途
|
||||
|
||||
美国线;与 K 线共用数据接口。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
与 CandlestickChart 共用 Candle、MarketView 和 CandlestickOptions;函数将 Type 设置为 ChartOHLC。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 143 HeikinAshiChart
|
||||
|
||||
## 用途
|
||||
|
||||
平均 K 线;由调用方 OHLC 计算;与原 K 线可切换。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
raw := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`raw=false` 显示 Heikin-Ashi;`raw=true` 显示原始数据。辅助函数 `HeikinAshi` 从调用方 OHLC 计算平滑序列。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 144 LineQuoteChart
|
||||
|
||||
## 用途
|
||||
|
||||
分时图;昨收基线;均价线;盘前盘后分段。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`PrevClose` 为必填正数;RegularOpen、RegularClose 成对提供,零值展示同一时段。均价使用 Price × Volume 累计。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 145 VolumeChart
|
||||
|
||||
## 用途
|
||||
|
||||
成交量柱;涨跌着色;与主图共享时间轴。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
传入与主图相同的 MarketView 共享时间窗口;Frame.Height 默认 120;Sync 与 SyncID 可关联光标。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 146 DepthChart
|
||||
|
||||
## 用途
|
||||
|
||||
买卖深度累计面积;中间价;悬停读数。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}}
|
||||
asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
买盘价格递减,卖盘递增,Size 为正数;最优买价小于最优卖价。Decimals 默认两位。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 147 VolumeProfile
|
||||
|
||||
## 用途
|
||||
|
||||
价格分布成交量;价值区与控制点。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
ProfileBin 按价格递增排列;Volume 非负。ValueArea 默认 0.7;VolumeProfileOf 可按 OHLC 区间生成分布。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 148 MultiChartLayout
|
||||
|
||||
## 用途
|
||||
|
||||
1、2、4、6 宫格多图;独立品种与周期;布局切换不丢状态。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
state := mujica.MultiChartState{Count: 2}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) {
|
||||
mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
Count 支持 1、2、4、6,零值为 1;Cells 保存六个独立品种、周期与窗口;Active 指向可见单元。Height 默认 480。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入布局组,方向键移动焦点,Space 切换;Tab 进入各图表独立操作。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 149 ChartSync
|
||||
|
||||
## 用途
|
||||
|
||||
多图十字光标与时间范围同步;可逐图解除。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
var sync mujica.ChartSync
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
sync.Link(c, "price")
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
Count、Start、End 保存共享时间窗口;不同周期按时间边界映射;Cursor 保存时间光标;Unlinked 按稳定图表 ID 解联。通过 CandlestickOptions 或 VolumeChartOptions 的 Sync 接入。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化;ChartSync.Link 返回 ChoiceResult。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦 Link 开关,Space 切换关联;图表方向键移动同步光标。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 150 TimeRangeSelector
|
||||
|
||||
## 用途
|
||||
|
||||
1D、5D、1M、3M、YTD、1Y、5Y、自定义;键盘导航。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
value := mujica.MarketRange{Preset: mujica.Range1M}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
MarketRange.Preset 默认 Range1D;Custom 保存 DateRange。Today 零值跟随系统日期,Bounds 使用调用方时间和时区。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入预设组,左右方向键移动焦点,Space 选择;自定义日期通过 DateRangePicker 键盘操作。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 151 IntervalSelector
|
||||
|
||||
## 用途
|
||||
|
||||
1m 至 1M 周期;分组;收藏周期。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
value := mujica.Interval("1h")
|
||||
favorites := []mujica.Interval{"1m", "1h", "1D"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
value 为 Intervals 中的周期;favorites 保存收藏列表;Disabled 禁用选择与收藏。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入收藏组,方向键移动焦点,Space 选择;Tab 遍历弹层周期和收藏按钮,Enter 激活,Escape 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 152 ChartTypeSwitcher
|
||||
|
||||
## 用途
|
||||
|
||||
K 线、美国线、折线、面积、平均 K 线切换。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
value := mujica.ChartCandles
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
ChartType 默认 ChartCandles;支持 ChartOHLC、ChartLine、ChartArea、ChartHeikinAshi;Disabled 禁用操作。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入分组,左右方向键移动焦点,Space 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 153 IndicatorOverlay
|
||||
|
||||
## 用途
|
||||
|
||||
主图叠加 MA、EMA、BOLL、VWAP;参数可配;计算由调用方或内置辅助函数。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
Kind 支持 MA、EMA、BOLL、VWAP;Params 为 nil 使用默认参数;Values 可覆盖内置计算,长度等于 candles,NaN 表示缺测。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 154 IndicatorPane
|
||||
|
||||
## 用途
|
||||
|
||||
副图 MACD、RSI、KDJ、OBV;多副图堆叠与高度调整。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
Panes 为 MACD、RSI、KDJ、OBV 配置;Sizes 保存高度比例;Height 默认每副图 140 DIP,最小高度覆盖标题、图例、读数和 40 DIP 绘图区;Label 为无障碍名称前缀;Status、Error、Empty 设置等尺寸占位。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
IndicatorPaneResult.Removed 为移除索引,默认 -1;Resized 表示高度调整完成;Point、Pane 为活跃读数和副图索引,PointChanged 表示读数变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。主图与副图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 155 IndicatorSelector
|
||||
|
||||
## 用途
|
||||
|
||||
指标搜索、添加、移除、参数编辑。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var overlays []mujica.IndicatorOverlay
|
||||
var panes mujica.IndicatorPanes
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
overlays 与 panes 均由调用方持有;添加默认参数,可搜索、移除和编辑。Disabled 关闭面板并阻止编辑。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦按钮,Enter 打开;Tab 到搜索和参数字段;方向键步进参数;Escape 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 156 DrawingTools
|
||||
|
||||
## 用途
|
||||
|
||||
趋势线、水平线、斐波那契、矩形、文字标注;选中、移动、删除、撤销。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
Items 保存时间和价格锚点;Tool 默认选择工具;Text 为文字标注内容;Locked 阻止编辑;Selected、Delete、Undo、Clear 管理画线。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
聚焦图表后方向键移动读数;Enter 在当前读数放置锚点,双点工具按两次 Enter;Alt+Enter 循环选择画线;点击放置锚点;Alt+方向键移动选中画线;Delete/Backspace 删除;Command/Ctrl+Z 撤销;Escape 取消。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 图形锚点使用已有 Candle 时间。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 157 DrawingToolbar
|
||||
|
||||
## 用途
|
||||
|
||||
画线工具栏;当前工具;锁定与清除;键盘快捷键提示。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var drawings mujica.DrawingTools
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
传入 DrawingTools;Horizontal=true 横向,默认纵向;工具名称附快捷键提示。
|
||||
|
||||
## 状态
|
||||
|
||||
浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。
|
||||
|
||||
## 事件
|
||||
|
||||
ChoiceResult.Changed() 表示选择变化。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入工具栏;方向键移动焦点,Enter 激活;Alt+1 至 Alt+6 选择工具。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 158 CompareSymbol
|
||||
|
||||
## 用途
|
||||
|
||||
叠加对比品种;百分比归一;序列颜色与移除。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CompareLegend(c, &symbols)
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
Name 为稳定品种名称;Closes 与主图逐根对齐,正数或 NaN;CompareLegend 提供形状图例和移除。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 159 PriceAlertLine
|
||||
|
||||
## 用途
|
||||
|
||||
价格预警线;拖动调整;触发回调;不自行通知。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}}
|
||||
triggered := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) {
|
||||
triggered = alert.ID
|
||||
}})
|
||||
ui.Text(c, triggered)
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
ID 为唯一非空字符串;Price 为有限价格;Label 可选。CandlestickOptions.Alerts 接入主图;OnAlert 接收预警和最新 Candle。
|
||||
|
||||
## 状态
|
||||
|
||||
图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。
|
||||
|
||||
## 事件
|
||||
|
||||
ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 OnAlert 在最新收盘价格跨线时触发一次;拖动直接更新 Alerts。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
聚焦图表后方向键查看价格;PageUp/PageDown 选择预警线;Alt+上下方向键按价格精度步进;拖动预警线调整价格。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 百分比对比模式展示归一后的品种曲线,预警使用价格模式。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 160 CandleCountdown
|
||||
|
||||
## 用途
|
||||
|
||||
当前 K 线收盘倒计时;随周期变化;隐藏时停止计时重绘。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
interval 为 Intervals 中的周期;Location 默认 UTC;Now 零值读取帧时钟,指定值冻结时间。
|
||||
|
||||
## 状态
|
||||
|
||||
随周期和时间更新;指定 Now 显示冻结读数;移出可见区域停止安排计时帧。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 Element;时间由帧时钟或调用方指定。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示型组件;周期由调用方选择控件调整。
|
||||
|
||||
## 限制
|
||||
|
||||
数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。
|
||||
@@ -0,0 +1,128 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DrawingToolbarOptions configure a DrawingToolbar.
|
||||
type DrawingToolbarOptions struct {
|
||||
// Horizontal uses a row; the default uses a column.
|
||||
Horizontal bool
|
||||
}
|
||||
|
||||
// DrawingToolbar selects tools and manages lock, clear, and undo.
|
||||
func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) ChoiceResult {
|
||||
if d == nil {
|
||||
panic("mujica: DrawingToolbar needs drawings")
|
||||
}
|
||||
if d.Tool < ToolSelect || d.Tool > ToolText {
|
||||
panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool))
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
changed := false
|
||||
bar := ui.Column(c).Gap(u).Padding(u).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Role(ui.RoleToolbar).Label(msg(c, "drawing_toolbar.label"))
|
||||
if opts.Horizontal {
|
||||
bar.Row().FocusGroup(ui.Horizontal)
|
||||
} else {
|
||||
bar.FocusGroup(ui.Vertical)
|
||||
}
|
||||
pick := func(t DrawingTool) {
|
||||
if !d.Locked && d.Tool != t && !bar.IsDisabled() {
|
||||
d.Tool, changed = t, true
|
||||
d.pending, d.moving = nil, false
|
||||
slog.Debug("mujica: drawing_toolbar tool", "tool", t)
|
||||
}
|
||||
}
|
||||
digits := []ui.Key{ui.Key1, ui.Key2, ui.Key3, ui.Key4, ui.Key5, ui.Key6}
|
||||
for t := ToolSelect; t <= ToolText; t++ {
|
||||
if c.Shortcut(ui.Alt, digits[t]) {
|
||||
pick(t)
|
||||
}
|
||||
}
|
||||
bar.Children(func() {
|
||||
for t := ToolSelect; t <= ToolText; t++ {
|
||||
name := fmt.Sprintf("%s (Alt+%d)", msg(c, fmt.Sprintf("drawing_toolbar.tool%d", t)), t+1)
|
||||
on := d.Tool == t
|
||||
b := ui.ButtonBase(c).Key(t).Size(theme.MinHitSize+4, theme.MinHitSize+4).Radius(theme.ControlRadius).Label(name).Tooltip(name).Disabled(d.Locked)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
fg := k.Text
|
||||
switch {
|
||||
case on:
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
|
||||
fg = k.AccentText
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Draw(func(p *ui.Painter, r ui.Rect) { drawingToolGlyph(p, r, t, fg) })
|
||||
if b.Clicked() {
|
||||
pick(t)
|
||||
}
|
||||
}
|
||||
div := ui.Box(c).Background(k.Border).Role(ui.RoleNone)
|
||||
if opts.Horizontal {
|
||||
div.Size(theme.BorderWidth, u*6)
|
||||
} else {
|
||||
div.Size(u*6, theme.BorderWidth)
|
||||
}
|
||||
lockName := msg(c, "drawing_toolbar.lock")
|
||||
if d.Locked {
|
||||
lockName = msg(c, "drawing_toolbar.unlock")
|
||||
}
|
||||
lock := iconAction(c, icons.Must("lock"), lockName).Tooltip(lockName)
|
||||
if d.Locked {
|
||||
lock.Background(k.Selection).TextColor(k.AccentText)
|
||||
}
|
||||
if lock.Clicked() && !bar.IsDisabled() {
|
||||
d.Locked, changed = !d.Locked, true
|
||||
d.Tool = ToolSelect
|
||||
slog.Debug("mujica: drawing_toolbar lock", "locked", d.Locked)
|
||||
}
|
||||
undo := iconAction(c, icons.Must("rotate-ccw"), msg(c, "drawing_toolbar.undo")).Tooltip(msg(c, "drawing_toolbar.undo")).Disabled(d.Locked || !d.CanUndo())
|
||||
if undo.Clicked() && !bar.IsDisabled() && d.Undo() {
|
||||
changed = true
|
||||
}
|
||||
clear := iconAction(c, icons.Must("trash-2"), msg(c, "drawing_toolbar.clear")).Tooltip(msg(c, "drawing_toolbar.clear")).Disabled(d.Locked || len(d.Items) == 0)
|
||||
if clear.Clicked() && !bar.IsDisabled() && d.Clear() {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
return ChoiceResult{bar, changed}
|
||||
}
|
||||
|
||||
// drawingToolGlyph draws a small picture of tool t in r.
|
||||
func drawingToolGlyph(p *ui.Painter, r ui.Rect, t DrawingTool, col ui.Color) {
|
||||
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
|
||||
s := float32(8)
|
||||
switch t {
|
||||
case ToolSelect:
|
||||
var path ui.Path
|
||||
path.MoveTo(cx-5, cy-7).LineTo(cx+5, cy+1).LineTo(cx, cy+1).LineTo(cx+3, cy+7).LineTo(cx+1, cy+8).LineTo(cx-2, cy+2).LineTo(cx-5, cy+5).Close()
|
||||
p.FillPath(&path, col)
|
||||
case ToolTrendLine:
|
||||
drawingStroke(p, cx-s, cy+s, cx+s, cy-s, 1.5, col)
|
||||
p.FillPath(internal.Diamond(cx-s, cy+s, 5), col)
|
||||
p.FillPath(internal.Diamond(cx+s, cy-s, 5), col)
|
||||
case ToolHorizontal:
|
||||
p.Line(cx-s, cy, cx+s, cy, 1.5, col)
|
||||
p.FillPath(internal.Diamond(cx, cy, 5), col)
|
||||
case ToolFibonacci:
|
||||
for i, f := range []float32{-1, -0.4, 0.2, 1} {
|
||||
w := []float32{1.5, 1, 1, 1.5}[i]
|
||||
p.Line(cx-s, cy+f*s, cx+s, cy+f*s, w, col)
|
||||
}
|
||||
case ToolRectangle:
|
||||
p.Stroke(ui.Rect{X: cx - s, Y: cy - s*0.7, W: 2 * s, H: 1.4 * s}, col, 1, 1.5)
|
||||
case ToolText:
|
||||
p.Line(cx-s*0.8, cy-s, cx+s*0.8, cy-s, 2, col)
|
||||
p.Line(cx, cy-s, cx, cy+s, 2, col)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDrawingToolbarShortcutsLockAndDisabled(t *testing.T) {
|
||||
d := DrawingTools{}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Disabled(disabled).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) })
|
||||
}, 550, 90)
|
||||
tt.Key(ui.Alt, ui.Key4)
|
||||
if d.Tool != ToolFibonacci {
|
||||
t.Fatalf("tool %v", d.Tool)
|
||||
}
|
||||
if err := tt.Click("Lock drawings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.Key3)
|
||||
if !d.Locked || d.Tool != ToolSelect {
|
||||
t.Fatal("locked tool changed")
|
||||
}
|
||||
if err := tt.Click("Unlock drawings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(ui.Alt, ui.Key2)
|
||||
if d.Tool != ToolSelect {
|
||||
t.Fatal("disabled tool changed")
|
||||
}
|
||||
}
|
||||
func TestDrawingToolbarSnapshot(t *testing.T) {
|
||||
d := DrawingTools{Tool: ToolTrendLine}
|
||||
snapshot(t, "drawing_toolbar", 440, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// DrawingTool selects annotation creation or selection behavior.
|
||||
type DrawingTool int
|
||||
|
||||
const (
|
||||
ToolSelect DrawingTool = iota
|
||||
ToolTrendLine
|
||||
ToolHorizontal
|
||||
ToolFibonacci
|
||||
ToolRectangle
|
||||
ToolText
|
||||
)
|
||||
|
||||
// DrawingPoint anchors annotations to candle time and price.
|
||||
type DrawingPoint struct {
|
||||
Time time.Time
|
||||
Price float64
|
||||
}
|
||||
|
||||
// Drawing is one annotation; one-point tools use A only.
|
||||
type Drawing struct {
|
||||
Tool DrawingTool
|
||||
A, B DrawingPoint
|
||||
Text string
|
||||
}
|
||||
|
||||
// DrawingTools holds annotations, the current tool, and undo history.
|
||||
type DrawingTools struct {
|
||||
Items []Drawing
|
||||
Tool DrawingTool
|
||||
// Text is what the next text annotation says.
|
||||
Text string
|
||||
// Locked stops selecting, moving, deleting and drawing.
|
||||
Locked bool
|
||||
|
||||
sel int // selected item plus one; zero is none
|
||||
pending *Drawing
|
||||
undo [][]Drawing
|
||||
moving bool
|
||||
}
|
||||
|
||||
// fibLevels lists Fibonacci retracement ratios.
|
||||
var fibLevels = []float64{0, 0.236, 0.382, 0.5, 0.618, 0.786, 1}
|
||||
|
||||
// Selected returns the selected drawing's index, or -1.
|
||||
func (d *DrawingTools) Selected() int { return d.sel - 1 }
|
||||
|
||||
// CanUndo reports whether Undo has a step to take back.
|
||||
func (d *DrawingTools) CanUndo() bool { return len(d.undo) > 0 }
|
||||
|
||||
func (d *DrawingTools) save() {
|
||||
d.undo = append(d.undo, slices.Clone(d.Items))
|
||||
}
|
||||
|
||||
// Undo restores the preceding annotations unless locked.
|
||||
func (d *DrawingTools) Undo() bool {
|
||||
n := len(d.undo)
|
||||
if d.Locked || n == 0 {
|
||||
return false
|
||||
}
|
||||
d.Items, d.undo = d.undo[n-1], d.undo[:n-1]
|
||||
d.sel, d.pending = 0, nil
|
||||
slog.Debug("mujica: drawing_tools undo", "items", len(d.Items))
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete removes the selected drawing, undoably.
|
||||
func (d *DrawingTools) Delete() bool {
|
||||
if d.Locked || d.sel == 0 || d.sel > len(d.Items) {
|
||||
return false
|
||||
}
|
||||
d.save()
|
||||
d.Items = slices.Delete(slices.Clone(d.Items), d.sel-1, d.sel)
|
||||
d.sel = 0
|
||||
slog.Debug("mujica: drawing_tools delete")
|
||||
return true
|
||||
}
|
||||
|
||||
// Clear removes every drawing, undoably.
|
||||
func (d *DrawingTools) Clear() bool {
|
||||
if d.Locked || len(d.Items) == 0 {
|
||||
return false
|
||||
}
|
||||
d.save()
|
||||
d.Items, d.sel, d.pending = nil, 0, nil
|
||||
slog.Debug("mujica: drawing_tools clear")
|
||||
return true
|
||||
}
|
||||
|
||||
// twoPoint reports whether t needs a second click.
|
||||
func (t DrawingTool) twoPoint() bool {
|
||||
return t == ToolTrendLine || t == ToolFibonacci || t == ToolRectangle
|
||||
}
|
||||
|
||||
// point maps plot coordinates onto the nearest candle.
|
||||
func (d *DrawingTools) point(m *mktCore, g mktGeom, x, y float32) DrawingPoint {
|
||||
return DrawingPoint{Time: m.candles[g.index(m, x)].Time, Price: m.priceAt(g.ys.Invert(y))}
|
||||
}
|
||||
|
||||
// priceAt resolves an axis value into a rounded price.
|
||||
func (m *mktCore) priceAt(v float64) float64 {
|
||||
if m.base != 0 {
|
||||
v = m.base * (1 + v/100)
|
||||
}
|
||||
p := math.Pow(10, float64(m.dec))
|
||||
return math.Round(v*p) / p
|
||||
}
|
||||
|
||||
// xy places a drawing point in the plot.
|
||||
func (d *DrawingTools) xy(m *mktCore, g mktGeom, pt DrawingPoint) (float32, float32) {
|
||||
i := max(0, marketIndexAt(m.candles, pt.Time))
|
||||
return g.x(m, i), g.ys.Map(m.val(pt.Price))
|
||||
}
|
||||
|
||||
// input places, selects, moves, and deletes chart annotations.
|
||||
func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool {
|
||||
if d.Tool < ToolSelect || d.Tool > ToolText {
|
||||
panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool))
|
||||
}
|
||||
if d.pending != nil && d.pending.Tool != d.Tool {
|
||||
d.pending, d.moving = nil, false
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyEnter) && !d.Locked && len(d.Items) > 0 {
|
||||
d.sel = d.sel%len(d.Items) + 1
|
||||
m.st.alertFocus = -1
|
||||
m.c.Announce(fmt.Sprintf(msg(m.c, "drawing_tools.selected"), d.sel))
|
||||
}
|
||||
if plot.Shortcut(ui.Cmd, ui.KeyZ) {
|
||||
d.Undo()
|
||||
}
|
||||
if d.Locked {
|
||||
d.pending, d.moving = nil, false
|
||||
return false
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyDelete) || plot.Shortcut(0, ui.KeyBackspace) {
|
||||
d.Delete()
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEscape) {
|
||||
d.pending, d.sel = nil, 0
|
||||
}
|
||||
if s := d.sel - 1; s >= 0 && s < len(d.Items) {
|
||||
step := map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, 1}, ui.KeyDown: {0, -1}}
|
||||
for key, v := range step {
|
||||
if plot.Shortcut(ui.Alt, key) {
|
||||
d.save()
|
||||
d.nudge(m, s, v[0], float64(v[1])*g.step/10)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !over && !held {
|
||||
return false
|
||||
}
|
||||
if plot.Clicked() && d.Tool != ToolSelect {
|
||||
pt := d.point(m, g, x, y)
|
||||
d.place(m, pt)
|
||||
return true
|
||||
}
|
||||
if d.pending != nil {
|
||||
d.pending.B = d.point(m, g, x, y)
|
||||
return true
|
||||
}
|
||||
if d.Tool != ToolSelect {
|
||||
return held
|
||||
}
|
||||
switch {
|
||||
case held && !d.moving:
|
||||
hit := d.hit(m, g, x-dx, y-dy)
|
||||
if hit < 0 {
|
||||
d.sel = 0
|
||||
return false
|
||||
}
|
||||
d.sel, d.moving = hit+1, true
|
||||
d.save()
|
||||
case !held:
|
||||
d.moving = false
|
||||
if plot.Clicked() {
|
||||
d.sel = d.hit(m, g, x, y) + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
if dx != 0 || dy != 0 {
|
||||
steps := int(math.Round(float64(dx / g.band.Step())))
|
||||
d.nudge(m, d.sel-1, steps, m.priceAt(g.ys.Invert(y))-m.priceAt(g.ys.Invert(y-dy)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nudge moves annotations by candles and price.
|
||||
func (d *DrawingTools) nudge(m *mktCore, i, candles int, price float64) {
|
||||
it := &d.Items[i]
|
||||
move := func(pt *DrawingPoint) {
|
||||
j := max(0, min(len(m.candles)-1, marketIndexAt(m.candles, pt.Time)+candles))
|
||||
pt.Time, pt.Price = m.candles[j].Time, pt.Price+price
|
||||
}
|
||||
move(&it.A)
|
||||
move(&it.B)
|
||||
}
|
||||
|
||||
// hit finds the newest annotation within 6 DIPs.
|
||||
func (d *DrawingTools) hit(m *mktCore, g mktGeom, x, y float32) int {
|
||||
for i := len(d.Items) - 1; i >= 0; i-- {
|
||||
it := d.Items[i]
|
||||
ax, ay := d.xy(m, g, it.A)
|
||||
bx, by := d.xy(m, g, it.B)
|
||||
near := false
|
||||
switch it.Tool {
|
||||
case ToolHorizontal:
|
||||
near = math.Abs(float64(y-ay)) <= 6
|
||||
case ToolText:
|
||||
near = math.Abs(float64(x-ax)) <= 40 && math.Abs(float64(y-ay)) <= 10
|
||||
case ToolRectangle, ToolFibonacci:
|
||||
l, r, t, b := min(ax, bx)-6, max(ax, bx)+6, min(ay, by)-6, max(ay, by)+6
|
||||
near = x >= l && x <= r && y >= t && y <= b
|
||||
default:
|
||||
near = drawingSegmentDist(x, y, ax, ay, bx, by) <= 6
|
||||
}
|
||||
if near {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// drawingSegmentDist measures distance to a line segment.
|
||||
func drawingSegmentDist(x, y, ax, ay, bx, by float32) float64 {
|
||||
vx, vy := float64(bx-ax), float64(by-ay)
|
||||
t := 0.0
|
||||
if l := vx*vx + vy*vy; l > 0 {
|
||||
t = max(0, min(1, (float64(x-ax)*vx+float64(y-ay)*vy)/l))
|
||||
}
|
||||
return math.Hypot(float64(x-ax)-t*vx, float64(y-ay)-t*vy)
|
||||
}
|
||||
|
||||
func (d *DrawingTools) place(m *mktCore, pt DrawingPoint) {
|
||||
if d.Tool.twoPoint() && d.pending == nil {
|
||||
d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt}
|
||||
} else {
|
||||
item := Drawing{Tool: d.Tool, A: pt, B: pt, Text: d.Text}
|
||||
if d.pending != nil {
|
||||
item.A = d.pending.A
|
||||
}
|
||||
if d.Tool == ToolText && item.Text == "" {
|
||||
item.Text = msg(m.c, "drawing_tools.note")
|
||||
}
|
||||
d.save()
|
||||
d.Items = append(slices.Clone(d.Items), item)
|
||||
d.sel, d.pending, d.Tool = len(d.Items), nil, ToolSelect
|
||||
slog.Debug("mujica: drawing_tools add", "tool", item.Tool)
|
||||
}
|
||||
m.c.Invalidate()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// paint draws annotations, previews, and selected handles.
|
||||
func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) {
|
||||
k := m.k
|
||||
items := d.Items
|
||||
if d.pending != nil {
|
||||
items = append(append([]Drawing(nil), items...), *d.pending)
|
||||
}
|
||||
for i, it := range items {
|
||||
col := k.Info
|
||||
width := float32(1.5)
|
||||
selected := i == d.sel-1
|
||||
if selected {
|
||||
col, width = k.AccentText, 2.5
|
||||
}
|
||||
ax, ay := d.xy(m, g, it.A)
|
||||
bx, by := d.xy(m, g, it.B)
|
||||
ax, ay, bx, by = internal.Snap(p, ax), internal.Snap(p, ay), internal.Snap(p, bx), internal.Snap(p, by)
|
||||
switch it.Tool {
|
||||
case ToolTrendLine:
|
||||
drawingStroke(p, ax, ay, bx, by, width, col)
|
||||
case ToolHorizontal:
|
||||
p.Line(g.plot.X, ay, g.plot.X+g.plot.W, ay, width, col)
|
||||
chartText(p, priceUngrouped(it.A.Price, m.dec), g.plot.X+4, ay-2, size, col, 0, 1)
|
||||
case ToolRectangle:
|
||||
r := ui.Rect{X: min(ax, bx), Y: min(ay, by), W: max(bx, ax) - min(ax, bx), H: max(by, ay) - min(ay, by)}
|
||||
p.Fill(r, col.Alpha(0.1), 0)
|
||||
p.Stroke(r, col, 0, width)
|
||||
case ToolFibonacci:
|
||||
for _, lv := range fibLevels {
|
||||
price := it.B.Price + (it.A.Price-it.B.Price)*lv
|
||||
y := internal.Snap(p, g.ys.Map(m.val(price)))
|
||||
p.Line(min(ax, bx), y, max(ax, bx), y, 1, col)
|
||||
chartText(p, strconv.FormatFloat(lv, 'f', -1, 64)+" · "+priceUngrouped(price, m.dec), max(ax, bx)+4, y, size, col, 0, 0.5)
|
||||
}
|
||||
drawingStroke(p, ax, ay, bx, by, 1, col.Alpha(0.5))
|
||||
case ToolText:
|
||||
w, h := chartText(p, it.Text, ax, ay, size, k.Text, 0.5, 0.5)
|
||||
p.Stroke(ui.Rect{X: ax - w/2 - 4, Y: ay - h/2 - 2, W: w + 8, H: h + 4}, col, 2, width/1.5)
|
||||
}
|
||||
if selected {
|
||||
for _, pt := range [][2]float32{{ax, ay}, {bx, by}} {
|
||||
p.FillPath(internal.Diamond(pt[0], pt[1], 9), k.Surface)
|
||||
p.FillPath(internal.Diamond(pt[0], pt[1], 7), col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawingStroke(p *ui.Painter, ax, ay, bx, by, width float32, col ui.Color) {
|
||||
var path ui.Path
|
||||
path.MoveTo(internal.Snap(p, ax), internal.Snap(p, ay)).LineTo(internal.Snap(p, bx), internal.Snap(p, by))
|
||||
p.StrokePath(&path, width, col)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDrawingToolsPlaceMoveDeleteUndo(t *testing.T) {
|
||||
cs := sampleCandles(60)
|
||||
v := MarketView{Count: 60}
|
||||
d := DrawingTools{Tool: ToolHorizontal}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d})
|
||||
})
|
||||
}, 640, 340)
|
||||
tt.ClickAt(300, 100)
|
||||
if len(d.Items) != 1 || d.Items[0].Tool != ToolHorizontal || d.Selected() != 0 {
|
||||
t.Fatalf("drawings %+v", d)
|
||||
}
|
||||
before := d.Items[0].A.Price
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if d.Items[0].A.Price <= before {
|
||||
t.Fatal("keyboard move failed")
|
||||
}
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if len(d.Items) != 0 {
|
||||
t.Fatal("delete failed")
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyZ)
|
||||
if len(d.Items) != 1 {
|
||||
t.Fatal("undo failed")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
tt.ClickAt(200, 150)
|
||||
if len(d.Items) != 1 {
|
||||
t.Fatal("disabled drawing changed")
|
||||
}
|
||||
}
|
||||
func TestDrawingToolsSnapshot(t *testing.T) {
|
||||
cs := sampleCandles(60)
|
||||
v := MarketView{Count: 60}
|
||||
d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[10].Time, 98}, B: DrawingPoint{cs[45].Time, 105}}, {Tool: ToolHorizontal, A: DrawingPoint{cs[20].Time, 100}}}, sel: 1}
|
||||
snapshot(t, "drawing_tools", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawings", Height: 260}, Drawings: &d})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrawingToolsTrendLinePaint(t *testing.T) {
|
||||
cs := sampleCandles(2)
|
||||
img := ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
m := &mktCore{c: c, k: Tokens(c), candles: cs, from: 0, to: 2}
|
||||
g := mktGeom{plot: ui.Rect{W: 200, H: 200}, band: internal.Band{N: 2, R0: 0, R1: 200}, ys: internal.Linear{D0: 0, D1: 10, R0: 180, R1: 20}}
|
||||
d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[0].Time, 0}, B: DrawingPoint{cs[1].Time, 10}}}}
|
||||
ui.Box(c).Size(200, 200).Draw(func(p *ui.Painter, _ ui.Rect) { d.paint(p, g, m, 12) })
|
||||
}, 200, 200, 1)
|
||||
bg := theme.Light().Background
|
||||
pixel := img.RGBAAt(100, 100)
|
||||
if int(bg.R)-int(pixel.R) < 40 || int(bg.G)-int(pixel.G) < 20 {
|
||||
t.Fatalf("diagonal midpoint lacks the stroke: %v", pixel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawingToolsAllTools(t *testing.T) {
|
||||
for _, tool := range []DrawingTool{ToolTrendLine, ToolHorizontal, ToolFibonacci, ToolRectangle, ToolText} {
|
||||
t.Run(string(rune('0'+tool)), func(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
d := DrawingTools{Tool: tool, Text: "Note"}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d})
|
||||
}, 640, 340)
|
||||
tt.ClickAt(200, 120)
|
||||
if tool.twoPoint() {
|
||||
if d.pending == nil || len(d.Items) != 0 {
|
||||
t.Fatal("missing first anchor")
|
||||
}
|
||||
tt.ClickAt(400, 160)
|
||||
}
|
||||
if len(d.Items) != 1 || d.Items[0].Tool != tool || d.Selected() != 0 {
|
||||
t.Fatalf("created %+v", d.Items)
|
||||
}
|
||||
if tool == ToolText && d.Items[0].Text != "Note" {
|
||||
t.Fatal("annotation text lost")
|
||||
}
|
||||
if !d.Clear() || len(d.Items) != 0 || !d.Undo() || len(d.Items) != 1 {
|
||||
t.Fatal("clear or undo failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
mujica "mujicaui"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ExampleCandlestickChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleOHLCChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleHeikinAshiChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
raw := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleLineQuoteChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleVolumeChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleDepthChart() {
|
||||
bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}}
|
||||
asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleVolumeProfile() {
|
||||
bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleMultiChartLayout() {
|
||||
state := mujica.MultiChartState{Count: 2}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) {
|
||||
mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleChartSync() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
var sync mujica.ChartSync
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
sync.Link(c, "price")
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleTimeRangeSelector() {
|
||||
value := mujica.MarketRange{Preset: mujica.Range1M}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIntervalSelector() {
|
||||
value := mujica.Interval("1h")
|
||||
favorites := []mujica.Interval{"1m", "1h", "1D"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleChartTypeSwitcher() {
|
||||
value := mujica.ChartCandles
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIndicatorOverlay() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIndicatorPane() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIndicatorSelector() {
|
||||
var overlays []mujica.IndicatorOverlay
|
||||
var panes mujica.IndicatorPanes
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleDrawingTools() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleDrawingToolbar() {
|
||||
var drawings mujica.DrawingTools
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleCompareSymbol() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CompareLegend(c, &symbols)
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExamplePriceAlertLine() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}}
|
||||
triggered := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) {
|
||||
triggered = alert.ID
|
||||
}})
|
||||
ui.Text(c, triggered)
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleCandleCountdown() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
@@ -1,3 +1,28 @@
|
||||
package main
|
||||
|
||||
var mktchartPages = []page{}
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
var mktchartPages = []page{
|
||||
{ID: "141", Name: "CandlestickChart", ZhName: "K 线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "141") }, Code: mktCode141, Keys: mktKeys},
|
||||
{ID: "142", Name: "OHLCChart", ZhName: "美国线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "142") }, Code: mktCode142, Keys: mktKeys},
|
||||
{ID: "143", Name: "HeikinAshiChart", ZhName: "平均 K 线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "143") }, Code: mktCode143, Keys: mktKeys},
|
||||
{ID: "144", Name: "LineQuoteChart", ZhName: "分时图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "144") }, Code: mktCode144, Keys: mktKeys},
|
||||
{ID: "145", Name: "VolumeChart", ZhName: "成交量", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "145") }, Code: mktCode145, Keys: mktKeys},
|
||||
{ID: "146", Name: "DepthChart", ZhName: "深度图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "146") }, Code: mktCode146, Keys: mktKeys},
|
||||
{ID: "147", Name: "VolumeProfile", ZhName: "成交量分布", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "147") }, Code: mktCode147, Keys: mktKeys},
|
||||
{ID: "148", Name: "MultiChartLayout", ZhName: "多图布局", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "148") }, Code: mktCode148, Keys: mktKeys},
|
||||
{ID: "149", Name: "ChartSync", ZhName: "图表同步", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "149") }, Code: mktCode149, Keys: mktKeys},
|
||||
{ID: "150", Name: "TimeRangeSelector", ZhName: "时间范围", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "150") }, Code: mktCode150, Keys: mktKeys},
|
||||
{ID: "151", Name: "IntervalSelector", ZhName: "周期选择", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "151") }, Code: mktCode151, Keys: mktKeys},
|
||||
{ID: "152", Name: "ChartTypeSwitcher", ZhName: "图表类型", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "152") }, Code: mktCode152, Keys: mktKeys},
|
||||
{ID: "153", Name: "IndicatorOverlay", ZhName: "主图指标", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "153") }, Code: mktCode153, Keys: mktKeys},
|
||||
{ID: "154", Name: "IndicatorPane", ZhName: "指标副图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "154") }, Code: mktCode154, Keys: mktKeys},
|
||||
{ID: "155", Name: "IndicatorSelector", ZhName: "指标选择", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "155") }, Code: mktCode155, Keys: mktKeys},
|
||||
{ID: "156", Name: "DrawingTools", ZhName: "画线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "156") }, Code: mktCode156, Keys: mktKeys},
|
||||
{ID: "157", Name: "DrawingToolbar", ZhName: "画线工具栏", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "157") }, Code: mktCode157, Keys: mktKeys},
|
||||
{ID: "158", Name: "CompareSymbol", ZhName: "品种对比", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "158") }, Code: mktCode158, Keys: mktKeys},
|
||||
{ID: "159", Name: "PriceAlertLine", ZhName: "价格预警", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "159") }, Code: mktCode159, Keys: mktKeys},
|
||||
{ID: "160", Name: "CandleCountdown", ZhName: "K 线倒计时", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "160") }, Code: mktCode160, Keys: mktKeys},
|
||||
}
|
||||
|
||||
const mktKeys = "Tab focuses controls and charts; arrows move focus or chart readouts; Space selects; Home/End jump; +/- zoom; Shift+arrows pan; Alt+1–6 pick drawing tools; Delete removes; Ctrl/Command+Z undoes; Escape closes panels."
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
const mktCode141 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode142 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode143 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
raw := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode144 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode145 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode146 = ` bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}}
|
||||
asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode147 = ` bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode148 = ` state := mujica.MultiChartState{Count: 2}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) {
|
||||
mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode149 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
var sync mujica.ChartSync
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
sync.Link(c, "price")
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode150 = ` value := mujica.MarketRange{Preset: mujica.Range1M}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode151 = ` value := mujica.Interval("1h")
|
||||
favorites := []mujica.Interval{"1m", "1h", "1D"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode152 = ` value := mujica.ChartCandles
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode153 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode154 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode155 = ` var overlays []mujica.IndicatorOverlay
|
||||
var panes mujica.IndicatorPanes
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode156 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode157 = ` var drawings mujica.DrawingTools
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode158 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CompareLegend(c, &symbols)
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode159 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []mujica.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window mujica.MarketView
|
||||
alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}}
|
||||
triggered := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) {
|
||||
triggered = alert.ID
|
||||
}})
|
||||
ui.Text(c, triggered)
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
|
||||
const mktCode160 = ` view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)`
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func mktDemo(c *ui.Context, log func(string), id string) {
|
||||
s := ui.Local(c.Root(), id, mktInitial)
|
||||
ui.Column(c).Gap(12).FillWidth().Children(func() {
|
||||
frame := mktControls(c, s, log)
|
||||
candles, points, bids, asks, bins := s.candles, s.points, s.bids, s.asks, s.bins
|
||||
if s.status == "Empty" {
|
||||
candles = nil
|
||||
points = nil
|
||||
bids = nil
|
||||
asks = nil
|
||||
bins = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Gap(12).FillWidth().Children(func() {
|
||||
opts := mujica.CandlestickOptions{Frame: frame, Type: s.chartType}
|
||||
chart := func() { mktLog(log, id, mujica.CandlestickChart(c, candles, &s.window, opts)) }
|
||||
switch id {
|
||||
case "141":
|
||||
if mujica.ChartTypeSwitcher(c, &s.chartType, mujica.ChartTypeSwitcherOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Chart type: %d", s.chartType))
|
||||
}
|
||||
opts.Type = s.chartType
|
||||
chart()
|
||||
case "142":
|
||||
mktLog(log, id, mujica.OHLCChart(c, candles, &s.window, opts))
|
||||
case "143":
|
||||
before := s.raw
|
||||
mktLog(log, id, mujica.HeikinAshiChart(c, candles, &s.window, &s.raw, opts))
|
||||
if before != s.raw {
|
||||
log(fmt.Sprintf("Raw: %v", s.raw))
|
||||
}
|
||||
case "144":
|
||||
if mujica.NumberInput(c, &s.previous, mujica.NumberInputOptions{Min: 80, Max: 120, Step: 0.5, Label: "Previous close"}).Changed() {
|
||||
log(fmt.Sprintf("Previous close: %.2f", s.previous))
|
||||
}
|
||||
if mujica.Checkbox(c, &s.extended, "Extended sessions", mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Extended sessions: %v", s.extended))
|
||||
}
|
||||
o := mujica.LineQuoteChartOptions{Frame: frame, PrevClose: s.previous}
|
||||
if s.extended {
|
||||
o.RegularOpen = s.points[0].Time.Add(90 * time.Minute)
|
||||
o.RegularClose = s.points[0].Time.Add(8 * time.Hour)
|
||||
}
|
||||
mktLog(log, id, mujica.LineQuoteChart(c, points, o))
|
||||
case "145":
|
||||
opts.Frame.Height = 150
|
||||
chart()
|
||||
frame.Label = "Volume"
|
||||
frame.Height = 100
|
||||
mktLog(log, id, mujica.VolumeChart(c, candles, &s.window, mujica.VolumeChartOptions{Frame: frame}))
|
||||
case "146":
|
||||
mktLog(log, id, mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: frame}))
|
||||
case "147":
|
||||
if mujica.NumberInput(c, &s.fraction, mujica.NumberInputOptions{Min: 0.1, Max: 1, Step: 0.1, Label: "Value area fraction"}).Changed() {
|
||||
log(fmt.Sprintf("Value area: %.1f", s.fraction))
|
||||
}
|
||||
mktLog(log, id, mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: frame, ValueArea: s.fraction}))
|
||||
case "148":
|
||||
if mujica.MultiChartLayout(c, &s.layout, mujica.MultiChartLayoutOptions{Height: 420}, func(i int, cell *mujica.ChartCell) {
|
||||
if mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: fmt.Sprintf("Symbol %d", i+1)}).Changed() {
|
||||
log(fmt.Sprintf("Cell %d: %s", i+1, cell.Symbol))
|
||||
}
|
||||
items := make([]mujica.SelectOption[mujica.Interval], len(mujica.Intervals))
|
||||
for j, iv := range mujica.Intervals {
|
||||
items[j] = mujica.SelectOption[mujica.Interval]{Value: iv, Label: string(iv)}
|
||||
}
|
||||
if mujica.Select(c, &cell.Interval, items, mujica.SelectOptions{Label: fmt.Sprintf("Interval %d", i+1)}).Changed() {
|
||||
log(fmt.Sprintf("Cell %d: %s", i+1, cell.Interval))
|
||||
}
|
||||
f := frame
|
||||
f.Label = cell.Symbol
|
||||
f.Height = 70
|
||||
mktLog(log, fmt.Sprint(i), mujica.CandlestickChart(c, candles, &cell.View, mujica.CandlestickOptions{Frame: f}))
|
||||
}).Changed() {
|
||||
log(fmt.Sprintf("Layout: %d, active %d", s.layout.Count, s.layout.Active))
|
||||
}
|
||||
case "149":
|
||||
for _, chartID := range []string{"price", "other"} {
|
||||
ui.Column(c).Key(chartID).Children(func() {
|
||||
if s.sync.Link(c, chartID).Changed() {
|
||||
log("Sync: " + chartID)
|
||||
}
|
||||
v := &s.window
|
||||
if chartID == "other" {
|
||||
v = &s.second
|
||||
}
|
||||
o := opts
|
||||
o.Frame.Height = 100
|
||||
o.Frame.Label = chartID
|
||||
o.Sync = &s.sync
|
||||
o.SyncID = chartID
|
||||
mktLog(log, chartID, mujica.CandlestickChart(c, candles, v, o))
|
||||
})
|
||||
}
|
||||
case "150":
|
||||
if mujica.TimeRangeSelector(c, &s.rangeValue, mujica.TimeRangeSelectorOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Range: %d", s.rangeValue.Preset))
|
||||
}
|
||||
from, to := s.rangeValue.Bounds(c.Now(), time.UTC)
|
||||
ui.Text(c, from.Format("2006-01-02")+" — "+to.Format("2006-01-02"))
|
||||
case "151":
|
||||
before := fmt.Sprint(s.favorites)
|
||||
if mujica.IntervalSelector(c, &s.interval, &s.favorites, mujica.IntervalSelectorOptions{}).Changed() {
|
||||
log("Interval: " + string(s.interval))
|
||||
}
|
||||
if before != fmt.Sprint(s.favorites) {
|
||||
log("Favorites: " + fmt.Sprint(s.favorites))
|
||||
}
|
||||
mujica.CandleCountdown(c, s.interval, mujica.CandleCountdownOptions{})
|
||||
case "152":
|
||||
if mujica.ChartTypeSwitcher(c, &s.chartType, mujica.ChartTypeSwitcherOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Chart type: %d", s.chartType))
|
||||
}
|
||||
opts.Type = s.chartType
|
||||
chart()
|
||||
case "153":
|
||||
if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() {
|
||||
log("Overlays changed")
|
||||
}
|
||||
opts.Overlays = s.overlays
|
||||
chart()
|
||||
case "154":
|
||||
if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() {
|
||||
log("Indicators changed")
|
||||
}
|
||||
r := mujica.IndicatorPane(c, candles, &s.window, &s.panes, mujica.IndicatorPaneOptions{Label: "BTC", Height: 320, Status: frame.Status, Error: frame.Error})
|
||||
if r.PointChanged {
|
||||
log(fmt.Sprintf("Pane %d: point %d", r.Pane, r.Point))
|
||||
}
|
||||
if r.Removed >= 0 {
|
||||
log(fmt.Sprintf("Removed pane %d", r.Removed))
|
||||
}
|
||||
if r.Resized {
|
||||
log("Panes resized")
|
||||
}
|
||||
case "155":
|
||||
if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() {
|
||||
log("Indicators changed")
|
||||
}
|
||||
opts.Overlays = s.overlays
|
||||
chart()
|
||||
case "156", "157":
|
||||
if mujica.TextInput(c, &s.drawings.Text, mujica.TextInputOptions{Label: "Annotation text"}).Changed() {
|
||||
log("Annotation text changed")
|
||||
}
|
||||
if mujica.DrawingToolbar(c, &s.drawings, mujica.DrawingToolbarOptions{Horizontal: true}).Changed() {
|
||||
log(fmt.Sprintf("Tool: %d, locked: %v", s.drawings.Tool, s.drawings.Locked))
|
||||
}
|
||||
before := fmt.Sprint(s.drawings.Items)
|
||||
opts.Drawings = &s.drawings
|
||||
chart()
|
||||
if before != fmt.Sprint(s.drawings.Items) {
|
||||
log(fmt.Sprintf("Drawings: %d", len(s.drawings.Items)))
|
||||
}
|
||||
case "158":
|
||||
if mujica.CompareLegend(c, &s.compare).Changed() {
|
||||
log("Compared symbol removed")
|
||||
}
|
||||
if mujica.Button(c, "Restore ETH", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.compare = mktInitial().compare
|
||||
log("Restored ETH")
|
||||
}
|
||||
if len(candles) > 0 {
|
||||
opts.Compare = &s.compare
|
||||
}
|
||||
chart()
|
||||
case "159":
|
||||
before := s.alerts[0].Price
|
||||
opts.Alerts = &s.alerts
|
||||
opts.OnAlert = func(a mujica.PriceAlertLine, last mujica.Candle) {
|
||||
log(fmt.Sprintf("Alert %s crossed %.2f", a.ID, last.Close))
|
||||
}
|
||||
if mujica.Button(c, "Append crossing candle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
last := s.candles[len(s.candles)-1]
|
||||
next := last
|
||||
next.Time = last.Time.Add(time.Hour)
|
||||
next.Open = last.Close
|
||||
next.Close = s.alerts[0].Price + 2
|
||||
if last.Close > s.alerts[0].Price {
|
||||
next.Close = s.alerts[0].Price - 2
|
||||
}
|
||||
next.High = max(next.Open, next.Close) + 1
|
||||
next.Low = min(next.Open, next.Close) - 1
|
||||
s.candles = append(s.candles[:len(s.candles):len(s.candles)], next)
|
||||
candles = s.candles
|
||||
log("Appended candle")
|
||||
}
|
||||
chart()
|
||||
if before != s.alerts[0].Price {
|
||||
log(fmt.Sprintf("Alert price: %.2f", s.alerts[0].Price))
|
||||
}
|
||||
case "160":
|
||||
if mujica.IntervalSelector(c, &s.interval, &s.favorites, mujica.IntervalSelectorOptions{}).Changed() {
|
||||
log("Interval: " + string(s.interval))
|
||||
}
|
||||
if mujica.Checkbox(c, &s.hidden, "Hide countdown", mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Hidden: %v", s.hidden))
|
||||
}
|
||||
if !s.hidden {
|
||||
mujica.CandleCountdown(c, s.interval, mujica.CandleCountdownOptions{})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
type mktDemoState struct {
|
||||
candles []mujica.Candle
|
||||
points []mujica.QuotePoint
|
||||
bids, asks []mujica.DepthLevel
|
||||
bins []mujica.ProfileBin
|
||||
window mujica.MarketView
|
||||
second mujica.MarketView
|
||||
sync mujica.ChartSync
|
||||
layout mujica.MultiChartState
|
||||
interval mujica.Interval
|
||||
favorites []mujica.Interval
|
||||
rangeValue mujica.MarketRange
|
||||
chartType mujica.ChartType
|
||||
overlays []mujica.IndicatorOverlay
|
||||
panes mujica.IndicatorPanes
|
||||
drawings mujica.DrawingTools
|
||||
compare []mujica.CompareSymbol
|
||||
alerts []mujica.PriceAlertLine
|
||||
status string
|
||||
disabled, raw, redUp, hidden, extended bool
|
||||
previous, fraction float64
|
||||
}
|
||||
|
||||
func mktInitial() mktDemoState {
|
||||
s := mktDemoState{status: "Ready", interval: "1h", favorites: []mujica.Interval{"1m", "1h", "1D"}, previous: 100, fraction: 0.7, extended: true}
|
||||
start := time.Date(2026, 10, 5, 8, 0, 0, 0, time.UTC)
|
||||
price := 100.0
|
||||
compare := make([]float64, 180)
|
||||
for i := range 180 {
|
||||
closeAt := price + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8
|
||||
s.candles = append(s.candles, mujica.Candle{Time: start.Add(time.Duration(i) * time.Hour), Open: price, High: max(price, closeAt) + 0.8, Low: min(price, closeAt) - 0.7, Close: closeAt, Volume: 1000 + float64(i%17)*130})
|
||||
s.points = append(s.points, mujica.QuotePoint{Time: start.Add(time.Duration(i) * 5 * time.Minute), Price: 100 + 2*math.Sin(float64(i)/10), Volume: 100 + float64(i%7)*20})
|
||||
compare[i] = 20 + float64(i)*0.08
|
||||
price = closeAt
|
||||
}
|
||||
for i := range 40 {
|
||||
s.bids = append(s.bids, mujica.DepthLevel{Price: 99.9 - float64(i)*0.1, Size: float64(10 + i*3)})
|
||||
s.asks = append(s.asks, mujica.DepthLevel{Price: 100.1 + float64(i)*0.1, Size: float64(8 + i*4)})
|
||||
}
|
||||
s.bins = mujica.VolumeProfileOf(s.candles, 24)
|
||||
s.compare = []mujica.CompareSymbol{{Name: "ETH", Closes: compare}}
|
||||
s.alerts = []mujica.PriceAlertLine{{ID: "target", Price: 104, Label: "Target"}}
|
||||
s.overlays = []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{10}}}
|
||||
s.panes = mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorMACD}, {Kind: mujica.IndicatorRSI}}}
|
||||
s.layout.Count = 2
|
||||
for i := range s.layout.Cells {
|
||||
s.layout.Cells[i] = mujica.ChartCell{Symbol: fmt.Sprintf("Symbol %d", i+1), Interval: "1h"}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func mktControls(c *ui.Context, s *mktDemoState, log func(string)) mujica.ChartFrame {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
if mujica.Select(c, &s.status, []mujica.SelectOption[string]{{Value: "Ready", Label: "Ready"}, {Value: "Loading", Label: "Loading"}, {Value: "Failed", Label: "Failed"}, {Value: "Empty", Label: "Empty"}}, mujica.SelectOptions{Label: "Data state"}).Changed() {
|
||||
log("Data state: " + s.status)
|
||||
}
|
||||
if mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Disabled: %v", s.disabled))
|
||||
}
|
||||
if mujica.Checkbox(c, &s.redUp, "Red up", mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("Red up: %v", s.redUp))
|
||||
}
|
||||
})
|
||||
trend := mujica.GreenUp
|
||||
if s.redUp {
|
||||
trend = mujica.RedUp
|
||||
}
|
||||
mujica.UseTrend(c, trend)
|
||||
f := mujica.ChartFrame{Label: "BTC", Height: 220}
|
||||
switch s.status {
|
||||
case "Loading":
|
||||
f.Status = mujica.DataLoading
|
||||
case "Failed":
|
||||
f.Status = mujica.DataFailed
|
||||
f.Error = "Archive unavailable"
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func mktLog(log func(string), name string, r mujica.ChartResult) {
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("%s: point %d", name, r.Point))
|
||||
}
|
||||
if r.Retried() {
|
||||
log(name + ": retry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func mktDemoTester(t *testing.T, id string) (*ui.Tester, *[]string) {
|
||||
t.Helper()
|
||||
events := &[]string{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Scroll(c).Fill().Padding(12).Children(func() { mktDemo(c, func(s string) { *events = append(*events, s) }, id) })
|
||||
}, 900, 800)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
return tt, events
|
||||
}
|
||||
|
||||
func TestMarketChartPagesHaveInteractiveControls(t *testing.T) {
|
||||
if len(mktchartPages) != 20 {
|
||||
t.Fatalf("pages %d", len(mktchartPages))
|
||||
}
|
||||
for _, p := range mktchartPages {
|
||||
t.Run(p.ID, func(t *testing.T) {
|
||||
if p.Category != "Market Charts" || p.Code == "" || p.Keys == "" {
|
||||
t.Fatal("incomplete page")
|
||||
}
|
||||
tt, events := mktDemoTester(t, p.ID)
|
||||
if err := tt.Click("Red up"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) == 0 || (*events)[len(*events)-1] != "Red up: true" {
|
||||
t.Fatalf("control events %q", *events)
|
||||
}
|
||||
if err := tt.Click("Disabled"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (*events)[len(*events)-1] != "Disabled: true" {
|
||||
t.Fatalf("disabled event %q", *events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketChartDemosLogKeyboardAndAlerts(t *testing.T) {
|
||||
for _, id := range []string{"141", "142", "143", "144", "145", "146", "147", "153", "155", "156", "157", "158", "159"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
tt, events := mktDemoTester(t, id)
|
||||
tabUntil(t, tt, "BTC")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
want := id + ": point 0"
|
||||
found := false
|
||||
for _, e := range *events {
|
||||
found = found || e == want
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("keyboard event %q, want %s", *events, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
tt, events := mktDemoTester(t, "159")
|
||||
if err := tt.Click("Append crossing candle"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range *events {
|
||||
found = found || strings.HasPrefix(e, "Alert target crossed ")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("alert events %q", *events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestHeikinAshiChartSwitchesToRaw(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
view := MarketView{Count: 30}
|
||||
raw := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA"}})
|
||||
}, 600, 360)
|
||||
ha := HeikinAshi(candles)
|
||||
readout := func() string {
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, "2026-01-01 00:00") {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // the switch
|
||||
tt.Key(0, ui.KeyTab) // the plot
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if want := "O " + priceUngrouped(ha[0].Open, 2); !strings.Contains(readout(), want) {
|
||||
t.Fatalf("Heikin-Ashi readout %q, want %q", readout(), want)
|
||||
}
|
||||
if err := tt.Click("Candles"); err != nil || !raw {
|
||||
t.Fatalf("switch: %v %v", err, raw)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if want := "O " + priceUngrouped(candles[0].Open, 2); !strings.Contains(readout(), want) {
|
||||
t.Fatalf("raw readout %q, want %q", readout(), want)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("nil raw accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); HeikinAshiChart(c, candles, &view, nil, CandlestickOptions{}) }, 200, 100)
|
||||
}
|
||||
|
||||
func TestHeikinAshiChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(70)
|
||||
view := MarketView{Count: 60}
|
||||
raw := false
|
||||
snapshot(t, "heikin_ashi_chart", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA", Height: 230}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// IndicatorKind names a technical indicator.
|
||||
type IndicatorKind int
|
||||
|
||||
const (
|
||||
IndicatorMA IndicatorKind = iota
|
||||
IndicatorEMA
|
||||
IndicatorBOLL
|
||||
IndicatorVWAP
|
||||
IndicatorMACD
|
||||
IndicatorRSI
|
||||
IndicatorKDJ
|
||||
IndicatorOBV
|
||||
)
|
||||
|
||||
// indicatorInfo describes names, default parameters, and plotting locations.
|
||||
var indicatorInfo = []struct {
|
||||
name string
|
||||
params []float64
|
||||
overlay bool
|
||||
}{
|
||||
{"MA", []float64{20}, true}, {"EMA", []float64{20}, true}, {"BOLL", []float64{20, 2}, true}, {"VWAP", nil, true},
|
||||
{"MACD", []float64{12, 26, 9}, false}, {"RSI", []float64{14}, false}, {"KDJ", []float64{9, 3, 3}, false}, {"OBV", nil, false},
|
||||
}
|
||||
|
||||
func (k IndicatorKind) String() string {
|
||||
if k < IndicatorMA || k > IndicatorOBV {
|
||||
panic(fmt.Sprintf("mujica: unknown IndicatorKind %d", int(k)))
|
||||
}
|
||||
return indicatorInfo[k].name
|
||||
}
|
||||
|
||||
// DefaultParams returns a fresh copy of the kind's usual parameters.
|
||||
func (k IndicatorKind) DefaultParams() []float64 {
|
||||
_ = k.String()
|
||||
return append([]float64(nil), indicatorInfo[k].params...)
|
||||
}
|
||||
|
||||
// IndicatorOverlay configures MA, EMA, BOLL, or VWAP.
|
||||
type IndicatorOverlay struct {
|
||||
Kind IndicatorKind
|
||||
// Params nil uses the indicator's defaults.
|
||||
Params []float64
|
||||
// Values overrides calculation; supply one value per candle.
|
||||
Values []float64
|
||||
}
|
||||
|
||||
// indicatorParams resolves and checks params for kind.
|
||||
func indicatorParams(k IndicatorKind, params []float64) []float64 {
|
||||
_ = k.String()
|
||||
want := indicatorInfo[k].params
|
||||
if params == nil {
|
||||
return want
|
||||
}
|
||||
if len(params) != len(want) {
|
||||
panic(fmt.Sprintf("mujica: %v takes %d parameters, not %d", k, len(want), len(params)))
|
||||
}
|
||||
for i, p := range params {
|
||||
if math.IsInf(p, 0) || !(p > 0) || (k != IndicatorBOLL || i == 0) && p != float64(int(p)) {
|
||||
panic(fmt.Sprintf("mujica: %v parameter %v must be a positive whole number", k, p))
|
||||
}
|
||||
}
|
||||
if k == IndicatorMACD && params[0] >= params[1] {
|
||||
panic("mujica: MACD fast period must be below slow period")
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// indicatorName is the kind with its parameters, as "MACD(12,26,9)".
|
||||
func indicatorName(k IndicatorKind, params []float64) string {
|
||||
if len(params) == 0 {
|
||||
return k.String()
|
||||
}
|
||||
parts := make([]string, len(params))
|
||||
for i, p := range params {
|
||||
parts[i] = strconv.FormatFloat(p, 'f', -1, 64)
|
||||
}
|
||||
return k.String() + "(" + strings.Join(parts, ",") + ")"
|
||||
}
|
||||
|
||||
// indicatorSeries computes named indicator lines from candles.
|
||||
func indicatorSeries(k IndicatorKind, params []float64, candles []Candle) []ChartSeries {
|
||||
p := indicatorParams(k, params)
|
||||
closes := make([]float64, len(candles))
|
||||
for i, c := range candles {
|
||||
closes[i] = c.Close
|
||||
}
|
||||
name := indicatorName(k, p)
|
||||
switch k {
|
||||
case IndicatorMA:
|
||||
return []ChartSeries{{name, SMA(closes, int(p[0]))}}
|
||||
case IndicatorEMA:
|
||||
return []ChartSeries{{name, EMA(closes, int(p[0]))}}
|
||||
case IndicatorBOLL:
|
||||
mid, up, lo := Bollinger(closes, int(p[0]), p[1])
|
||||
return []ChartSeries{{name, mid}, {name + " ↑", up}, {name + " ↓", lo}}
|
||||
case IndicatorVWAP:
|
||||
return []ChartSeries{{name, VWAP(candles)}}
|
||||
case IndicatorMACD:
|
||||
dif, dea, hist := MACD(closes, int(p[0]), int(p[1]), int(p[2]))
|
||||
return []ChartSeries{{"DIF", dif}, {"DEA", dea}, {"MACD", hist}}
|
||||
case IndicatorRSI:
|
||||
return []ChartSeries{{name, RSI(closes, int(p[0]))}}
|
||||
case IndicatorKDJ:
|
||||
kk, d, j := KDJ(candles, int(p[0]), int(p[1]), int(p[2]))
|
||||
return []ChartSeries{{"K", kk}, {"D", d}, {"J", j}}
|
||||
}
|
||||
return []ChartSeries{{name, OBV(candles)}}
|
||||
}
|
||||
|
||||
// mktOverlayLines caches indicators until data or parameters change.
|
||||
func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine {
|
||||
pal := internal.Palette(m.k)
|
||||
var out []mktLine
|
||||
for i, o := range m.opts.Overlays {
|
||||
if _ = o.Kind.String(); !indicatorInfo[o.Kind].overlay {
|
||||
panic(fmt.Sprintf("mujica: %v is not an overlay indicator", o.Kind))
|
||||
}
|
||||
col := pal[[]int{3, 1, 5, 2, 4}[i%5]]
|
||||
if o.Values != nil {
|
||||
if len(o.Values) != len(candles) {
|
||||
panic("mujica: IndicatorOverlay Values need one value per candle")
|
||||
}
|
||||
out = append(out, mktLine{name: indicatorName(o.Kind, indicatorParams(o.Kind, o.Params)), values: o.Values, color: col, shape: len(out)})
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d|%v", chartID(candles), o.Kind, o.Params)
|
||||
series := chartMemo(ch, fmt.Sprint("overlay.", i), key, func() []ChartSeries { return indicatorSeries(o.Kind, o.Params, candles) })
|
||||
for _, s := range series {
|
||||
out = append(out, mktLine{name: s.Name, values: s.Values, color: col, shape: len(out)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IndicatorParamsField edits validated indicator parameters in place.
|
||||
func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) ChoiceResult {
|
||||
if params == nil {
|
||||
panic("mujica: IndicatorParamsField needs params")
|
||||
}
|
||||
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String())
|
||||
if row.IsDisabled() {
|
||||
copy := append([]float64(nil), (*params)...)
|
||||
params = ©
|
||||
}
|
||||
if *params == nil {
|
||||
*params = kind.DefaultParams()
|
||||
}
|
||||
changed := false
|
||||
indicatorParams(kind, *params)
|
||||
row.Children(func() {
|
||||
for i := range *params {
|
||||
v := (*params)[i]
|
||||
step, prec := 1.0, 0
|
||||
if kind == IndicatorBOLL && i == 1 {
|
||||
step, prec = 0.1, 1
|
||||
}
|
||||
if NumberInput(c, &v, NumberInputOptions{Min: step, Max: 500, Step: step, Precision: prec, Label: fmt.Sprintf("%v %d", kind, i+1)}).Element.Width(110).Key(i).Changed() || v != (*params)[i] {
|
||||
if kind == IndicatorMACD && (i == 0 && v >= (*params)[1] || i == 1 && v <= (*params)[0]) {
|
||||
continue
|
||||
}
|
||||
(*params)[i], changed = v, true
|
||||
}
|
||||
}
|
||||
})
|
||||
if changed {
|
||||
slog.Debug("mujica: indicator_overlay params", "kind", kind)
|
||||
}
|
||||
return ChoiceResult{row, changed}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestIndicatorOverlayValuesInReadoutAndMemo(t *testing.T) {
|
||||
candles := sampleCandles(200)
|
||||
view := MarketView{Count: 60}
|
||||
overlays := []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{5}}, {Kind: IndicatorBOLL}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Overlays: overlays})
|
||||
}, 800, 400)
|
||||
if !tt.HasText("MA(5)") || !tt.HasText("BOLL(20,2) ↑") {
|
||||
t.Fatalf("legend %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
ma := SMA(closesOf(candles), 5)
|
||||
want := "MA(5) " + priceUngrouped(ma[199], 2)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, want)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout lacks %q: %q", want, tt.Texts())
|
||||
}
|
||||
// Caller values replace the computation.
|
||||
own := make([]float64, 200)
|
||||
for i := range own {
|
||||
own[i] = 42
|
||||
}
|
||||
overlays = []IndicatorOverlay{{Kind: IndicatorEMA, Values: own}}
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("EMA(20) 42.00") {
|
||||
t.Fatalf("own values %q", tt.Texts())
|
||||
}
|
||||
for name, o := range map[string]IndicatorOverlay{
|
||||
"pane kind": {Kind: IndicatorMACD},
|
||||
"params": {Kind: IndicatorMA, Params: []float64{0}},
|
||||
"values size": {Kind: IndicatorMA, Values: []float64{1}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Overlays: []IndicatorOverlay{o}})
|
||||
}, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func closesOf(cs []Candle) []float64 {
|
||||
out := make([]float64, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Close
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestIndicatorParamsField(t *testing.T) {
|
||||
var params []float64
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IndicatorParamsField(c, IndicatorMACD, ¶ms)
|
||||
}, 500, 100)
|
||||
if len(params) != 3 || params[0] != 12 {
|
||||
t.Fatalf("defaults %v", params)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if params[0] != 13 {
|
||||
t.Fatalf("Up on fast %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorOverlaySnapshot(t *testing.T) {
|
||||
candles := sampleCandles(120)
|
||||
view := MarketView{Count: 90}
|
||||
snapshot(t, "indicator_overlay", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 250},
|
||||
Overlays: []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{10}}, {Kind: IndicatorBOLL}}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// IndicatorPaneSpec is one lower pane: MACD, RSI, KDJ or OBV.
|
||||
type IndicatorPaneSpec struct {
|
||||
Kind IndicatorKind
|
||||
// Params nil uses the kind's defaults.
|
||||
Params []float64
|
||||
}
|
||||
|
||||
// IndicatorPanes holds stacked indicators and their height shares.
|
||||
type IndicatorPanes struct {
|
||||
Panes []IndicatorPaneSpec
|
||||
// Sizes reset to equal shares when pane count changes.
|
||||
Sizes []float32
|
||||
}
|
||||
|
||||
// IndicatorPaneOptions configure an IndicatorPane.
|
||||
type IndicatorPaneOptions struct {
|
||||
// Height defaults to 140 per pane.
|
||||
Height float32
|
||||
// Label prefixes each pane's accessible name, as the symbol.
|
||||
Label string
|
||||
Disabled bool
|
||||
Status DataStatus
|
||||
Error string
|
||||
Empty string
|
||||
}
|
||||
|
||||
// IndicatorPaneResult reports the stack's events.
|
||||
type IndicatorPaneResult struct {
|
||||
Element *ui.Element
|
||||
// Removed reports the closed pane; -1 means none.
|
||||
Removed int
|
||||
// Resized reports finished height changes, for saving.
|
||||
Resized bool
|
||||
Point int
|
||||
Pane int
|
||||
PointChanged bool
|
||||
}
|
||||
|
||||
// IndicatorPane stacks resizable technical indicators using the shared window.
|
||||
func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *IndicatorPanes, opts IndicatorPaneOptions) IndicatorPaneResult {
|
||||
if view == nil || panes == nil {
|
||||
panic("mujica: IndicatorPane needs a view and panes")
|
||||
}
|
||||
box := ui.Column(c).FillWidth().Disabled(opts.Disabled)
|
||||
if box.IsDisabled() {
|
||||
copy := *panes
|
||||
copy.Panes = append([]IndicatorPaneSpec(nil), panes.Panes...)
|
||||
copy.Sizes = append([]float32(nil), panes.Sizes...)
|
||||
panes = ©
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: IndicatorPane Height must be non-negative")
|
||||
}
|
||||
res := IndicatorPaneResult{Removed: -1, Point: -1, Pane: -1}
|
||||
n := len(panes.Panes)
|
||||
for _, p := range panes.Panes {
|
||||
if _ = p.Kind.String(); indicatorInfo[p.Kind].overlay {
|
||||
panic(fmt.Sprintf("mujica: %v is an overlay, not a pane indicator", p.Kind))
|
||||
}
|
||||
indicatorParams(p.Kind, p.Params)
|
||||
}
|
||||
if n == 0 {
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
if len(panes.Sizes) != n {
|
||||
panes.Sizes = nil
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
lineH := FontSize(c, theme.CaptionSize) * theme.LineHeight
|
||||
chrome := 2*max(theme.MinHitSize, lineH) + max(u*5, lineH) + 4*u
|
||||
minimum := chrome + 40
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 140 * float32(n)
|
||||
}
|
||||
build := func(i int) func() {
|
||||
return func() {
|
||||
col := ui.Column(c).Fill().MinHeight(0)
|
||||
b := col.Bounds()
|
||||
internal.Remeasure(c, col, b.W, b.H)
|
||||
plotH := max(40, b.H-chrome)
|
||||
col.Children(func() {
|
||||
closed, point := indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH)
|
||||
if point.Point >= 0 {
|
||||
res.Point = point.Point
|
||||
res.Pane = i
|
||||
}
|
||||
res.PointChanged = res.PointChanged || point.Changed()
|
||||
if closed {
|
||||
res.Removed = i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
box.Height(h).MinHeight(float32(n)*minimum + float32(n-1)*theme.BorderWidth)
|
||||
box.Children(func() {
|
||||
if n == 1 {
|
||||
build(0)()
|
||||
return
|
||||
}
|
||||
list := make([]ResizablePanel, n)
|
||||
for i, p := range panes.Panes {
|
||||
list[i] = ResizablePanel{Label: indicatorName(p.Kind, indicatorParams(p.Kind, p.Params)), Min: minimum, Build: build(i)}
|
||||
}
|
||||
r := ResizablePanelGroup(c, &panes.Sizes, list, ResizablePanelGroupOptions{Vertical: true})
|
||||
r.Element.Fill()
|
||||
res.Resized = r.Committed
|
||||
})
|
||||
if res.Removed >= 0 {
|
||||
slog.Debug("mujica: indicator_pane removed", "kind", panes.Panes[res.Removed].Kind)
|
||||
panes.Panes = append(panes.Panes[:res.Removed:res.Removed], panes.Panes[res.Removed+1:]...)
|
||||
panes.Sizes = nil
|
||||
c.Invalidate()
|
||||
}
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
|
||||
// indicatorPaneOne draws one indicator and reports its events.
|
||||
func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) (bool, ChartResult) {
|
||||
k := Tokens(c)
|
||||
params := indicatorParams(spec.Kind, spec.Params)
|
||||
name := indicatorName(spec.Kind, params)
|
||||
closed := false
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() {
|
||||
ui.Text(c, name).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.Text).Grow(1)
|
||||
closed = iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), name)).Clicked()
|
||||
})
|
||||
label := strings.TrimSpace(opts.Label + " " + name)
|
||||
ch := newChart(c, ChartFrame{Label: label, Height: height, Status: opts.Status, Error: opts.Error, Empty: opts.Empty})
|
||||
series := chartMemo(ch, "series", fmt.Sprintf("%s|%v|%v", chartID(candles), spec.Kind, params), func() []ChartSeries {
|
||||
marketCheckCandles(candles)
|
||||
return indicatorSeries(spec.Kind, params, candles)
|
||||
})
|
||||
m := &mktCore{c: c, k: k, ch: ch, candles: candles, view: view}
|
||||
m.window()
|
||||
pal := internal.Palette(k)
|
||||
lines := make([]mktLine, len(series))
|
||||
keys := make([]ChartLegendItem, len(series))
|
||||
for i, s := range series {
|
||||
lines[i] = mktLine{name: s.Name, values: s.Values, color: pal[(i+1)%len(pal)], shape: i}
|
||||
keys[i] = ChartLegendItem{Name: s.Name, Color: lines[i].color, Shape: i}
|
||||
}
|
||||
yr := chartMemo(ch, "y", [5]any{chartID(candles), m.from, m.to, spec.Kind, fmt.Sprint(params)}, func() [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, l := range lines {
|
||||
for i := m.from; i < m.to; i++ {
|
||||
if v := l.values[i]; !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.Kind == IndicatorRSI {
|
||||
lo, hi = 0, 100
|
||||
}
|
||||
return [2]float64{lo, hi}
|
||||
})
|
||||
lo, hi := yr[0], yr[1]
|
||||
spec2 := ChartSpec{Marks: len(candles), Legend: keys}
|
||||
if m.to <= m.from || math.IsInf(lo, 0) {
|
||||
spec2.Marks = 0
|
||||
}
|
||||
m.lo, m.hi = lo, hi
|
||||
spec2.Pick = func(r ui.Rect, x, y float32) int {
|
||||
g := m.paneGeom(r)
|
||||
if !g.plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return g.index(m, x)
|
||||
}
|
||||
spec2.Readout = func(i int) string {
|
||||
parts := []string{candles[i].Time.Format("2006-01-02 15:04")}
|
||||
for _, l := range lines {
|
||||
parts = append(parts, l.name+" "+chartValue(c, l.values[i], func(v float64) string { return fmt.Sprintf("%.2f", v) }))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec2.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
g := m.paneGeom(r)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
internal.AxisY(p, g.plot, g.ys, g.ticks, func(v float64) string { return volumeShort(v, g.step) }, size, k)
|
||||
p.Clip(g.plot, 0, func() {
|
||||
for i, l := range lines {
|
||||
if spec.Kind == IndicatorMACD && i == 2 {
|
||||
indicatorPaneBars(p, g, m, l.values, c)
|
||||
continue
|
||||
}
|
||||
mktPaintLine(p, g, m, l.values, l.color, i)
|
||||
}
|
||||
})
|
||||
if active >= m.from && active < m.to {
|
||||
x := internal.Snap(p, g.x(m, active))
|
||||
p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted)
|
||||
}
|
||||
}
|
||||
m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} })
|
||||
if ch.res.Element.IsDisabled() {
|
||||
copy := *view
|
||||
m.view = ©
|
||||
}
|
||||
result, plot := ch.build(spec2)
|
||||
if plot != nil {
|
||||
m.input(plot)
|
||||
}
|
||||
return closed && !ch.res.Element.IsDisabled(), result
|
||||
}
|
||||
|
||||
// paneGeom is geom without the time axis, for lower panes.
|
||||
func (m *mktCore) paneGeom(r ui.Rect) mktGeom {
|
||||
plot := chartInset(r, 60, 4)
|
||||
lo, hi, step := internal.Nice(m.lo, m.hi, 2)
|
||||
return mktGeom{plot: plot, band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, ticks: internal.Ticks(lo, hi, step), step: step}
|
||||
}
|
||||
|
||||
// indicatorPaneBars draws MACD's histogram: hollow bars above zero, filled below.
|
||||
func indicatorPaneBars(p *ui.Painter, g mktGeom, m *mktCore, vals []float64, c *ui.Context) {
|
||||
k := Tokens(c)
|
||||
zero := g.ys.Map(0)
|
||||
w := max(1, g.band.Width())
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v := vals[i]
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
y := g.ys.Map(v)
|
||||
x := internal.Snap(p, g.x(m, i))
|
||||
bar := ui.Rect{X: x - w/2, Y: min(y, zero), W: w, H: max(1, float32(math.Abs(float64(y-zero))))}
|
||||
if v >= 0 && w >= 3 {
|
||||
p.Fill(bar, k.Surface, 0)
|
||||
p.Stroke(bar, trendColor(c, 1), 0, 1)
|
||||
} else {
|
||||
p.Fill(bar, trendColor(c, trendSign(v)), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestIndicatorPaneStackReadAndRemove(t *testing.T) {
|
||||
candles := sampleCandles(300)
|
||||
view := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI, Params: []float64{6}}}}
|
||||
removed := -1
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if r := IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Label: "BTC"}); r.Removed >= 0 {
|
||||
removed = r.Removed
|
||||
}
|
||||
}, 800, 500)
|
||||
tt.Frame()
|
||||
if !tt.HasText("MACD(12,26,9)") || !tt.HasText("RSI(6)") || len(panes.Sizes) != 2 {
|
||||
t.Fatalf("texts %q sizes %v", tt.Texts(), panes.Sizes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // remove MACD
|
||||
tt.Key(0, ui.KeyTab) // MACD plot
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "DIF ") && strings.Contains(s, "DEA ")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("MACD readout missing: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Remove MACD(12,26,9)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI || removed != 0 {
|
||||
t.Fatalf("after remove %+v (%d)", panes.Panes, removed)
|
||||
}
|
||||
for name, p := range map[string]IndicatorPanes{
|
||||
"overlay kind": {Panes: []IndicatorPaneSpec{{Kind: IndicatorMA}}},
|
||||
"bad params": {Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI, Params: []float64{1, 2}}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); IndicatorPane(c, candles, &view, &p, IndicatorPaneOptions{}) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorPaneSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(120)
|
||||
view := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorKDJ}}}
|
||||
snapshot(t, "indicator_pane", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Height: 316})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// IndicatorSelectorOptions configure an IndicatorSelector.
|
||||
type IndicatorSelectorOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IndicatorSelector searches, adds, removes, and configures chart indicators.
|
||||
func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *IndicatorPanes, opts IndicatorSelectorOptions) ChoiceResult {
|
||||
if overlays == nil || panes == nil {
|
||||
panic("mujica: IndicatorSelector needs overlays and panes")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
changed := false
|
||||
box := ui.Row(c).Disabled(opts.Disabled)
|
||||
box.Children(func() {
|
||||
open := ui.Local(box, "indicator_selector.open", func() bool { return false })
|
||||
query := ui.Local(box, "indicator_selector.query", func() string { return "" })
|
||||
if box.IsDisabled() {
|
||||
*open = false
|
||||
}
|
||||
label := fmt.Sprintf(msg(c, "indicator_selector.button"), len(*overlays)+len(panes.Panes))
|
||||
trigger := Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")})
|
||||
if trigger.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
Popover(c, trigger, open, PopoverOptions{Title: msg(c, "indicator_selector.title"), Width: 340}, func() {
|
||||
TextInput(c, query, TextInputOptions{Placeholder: msg(c, "indicator_selector.search"), Label: msg(c, "indicator_selector.search"), Clearable: true})
|
||||
ui.Text(c, msg(c, "indicator_selector.available")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, 0)
|
||||
found := 0
|
||||
for kind := IndicatorMA; kind <= IndicatorOBV; kind++ {
|
||||
name := kind.String()
|
||||
if q := strings.TrimSpace(*query); q != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(q)) {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
ui.Row(c).Key(name).AlignItems(ui.Center).Gap(u * 2).MinHeight(Density(c).ControlHeight()).Children(func() {
|
||||
ui.Text(c, name).FontWeight(600).Width(56)
|
||||
where := "indicator_selector.pane"
|
||||
if indicatorInfo[kind].overlay {
|
||||
where = "indicator_selector.overlay"
|
||||
}
|
||||
ui.Text(c, msg(c, where)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1)
|
||||
if Button(c, msg(c, "indicator_selector.add"), ButtonOptions{Variant: Ghost, Label: fmt.Sprintf(msg(c, "indicator_selector.add_named"), name)}).Clicked() {
|
||||
if indicatorInfo[kind].overlay {
|
||||
*overlays = append(slices.Clone(*overlays), IndicatorOverlay{Kind: kind, Params: kind.DefaultParams()})
|
||||
} else {
|
||||
panes.Panes = append(slices.Clone(panes.Panes), IndicatorPaneSpec{Kind: kind, Params: kind.DefaultParams()})
|
||||
}
|
||||
changed = true
|
||||
slog.Debug("mujica: indicator_selector add", "kind", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if found == 0 {
|
||||
ui.Text(c, msg(c, "indicator_selector.none")).TextColor(k.TextMuted)
|
||||
}
|
||||
if len(*overlays)+len(panes.Panes) == 0 {
|
||||
return
|
||||
}
|
||||
ui.Text(c, msg(c, "indicator_selector.active")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u*2, 0, 0, 0)
|
||||
remove := -1
|
||||
for i := range *overlays {
|
||||
if indicatorSelectorActive(c, u, fmt.Sprint("o", i), (*overlays)[i].Kind, &(*overlays)[i].Params, &changed) {
|
||||
remove = i
|
||||
}
|
||||
}
|
||||
if remove >= 0 {
|
||||
*overlays = slices.Delete(slices.Clone(*overlays), remove, remove+1)
|
||||
changed = true
|
||||
}
|
||||
remove = -1
|
||||
for i := range panes.Panes {
|
||||
if indicatorSelectorActive(c, u, fmt.Sprint("p", i), panes.Panes[i].Kind, &panes.Panes[i].Params, &changed) {
|
||||
remove = i
|
||||
}
|
||||
}
|
||||
if remove >= 0 {
|
||||
panes.Panes = slices.Delete(slices.Clone(panes.Panes), remove, remove+1)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
return ChoiceResult{box, changed}
|
||||
}
|
||||
|
||||
// indicatorSelectorActive edits parameters and reports removal of an indicator.
|
||||
func indicatorSelectorActive(c *ui.Context, u float32, key string, kind IndicatorKind, params *[]float64, changed *bool) bool {
|
||||
removed := false
|
||||
ui.Row(c).Key(key).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() {
|
||||
ui.Text(c, kind.String()).FontWeight(600).Width(56)
|
||||
if len(kind.DefaultParams()) > 0 && IndicatorParamsField(c, kind, params).Changed() {
|
||||
*changed = true
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), indicatorName(kind, *params))).Clicked() {
|
||||
removed = true
|
||||
slog.Debug("mujica: indicator_selector remove", "kind", kind)
|
||||
}
|
||||
})
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndicatorSelectorSearchAddRemoveAndDisabled(t *testing.T) {
|
||||
var overlays []IndicatorOverlay
|
||||
var panes IndicatorPanes
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{Disabled: disabled})
|
||||
}, 640, 740)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if err := tt.Click("Indicators (0)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Search indicators"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type("RSI")
|
||||
if tt.HasText("Add MA") || !tt.HasText("RSI") {
|
||||
t.Fatalf("search %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Add RSI"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI {
|
||||
t.Fatalf("panes %+v", panes.Panes)
|
||||
}
|
||||
if err := tt.Click("Remove RSI(14)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 0 {
|
||||
t.Fatal("pane not removed")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Search indicators") {
|
||||
t.Fatal("disabled panel remained open")
|
||||
}
|
||||
}
|
||||
func TestIndicatorSelectorSnapshot(t *testing.T) {
|
||||
overlays := []IndicatorOverlay{{Kind: IndicatorMA}}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}}
|
||||
snapshot(t, "indicator_selector", 360, 100, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestIndicatorSelectorOverlayParams(t *testing.T) {
|
||||
var overlays []IndicatorOverlay
|
||||
var panes IndicatorPanes
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{})
|
||||
}, 640, 740)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if err := tt.Click("Indicators (0)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Add MA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(overlays) != 1 || overlays[0].Kind != IndicatorMA || overlays[0].Params[0] != 20 {
|
||||
t.Fatalf("overlay %+v", overlays)
|
||||
}
|
||||
if err := tt.Click("MA 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if overlays[0].Params[0] != 21 {
|
||||
t.Fatalf("params %v", overlays[0].Params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Indicator helpers are pure; NaN marks insufficient data.
|
||||
|
||||
// indicatorCheck panics on a period below one.
|
||||
func indicatorCheck(name string, n int) {
|
||||
if n < 1 {
|
||||
panic(fmt.Sprintf("mujica: %s period %d must be at least 1", name, n))
|
||||
}
|
||||
}
|
||||
|
||||
func indicatorNaN(n int) []float64 {
|
||||
out := make([]float64, n)
|
||||
for i := range out {
|
||||
out[i] = math.NaN()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SMA computes the n-period simple moving average.
|
||||
func SMA(vals []float64, n int) []float64 {
|
||||
indicatorCheck("SMA", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
var sum float64
|
||||
for i, v := range vals {
|
||||
sum += v
|
||||
if i >= n {
|
||||
sum -= vals[i-n]
|
||||
}
|
||||
if i >= n-1 {
|
||||
out[i] = sum / float64(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EMA uses 2/(n+1), seeded by the first n-period SMA.
|
||||
func EMA(vals []float64, n int) []float64 {
|
||||
indicatorCheck("EMA", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
k := 2 / float64(n+1)
|
||||
var sum, prev float64
|
||||
for i, v := range vals {
|
||||
switch {
|
||||
case i < n-1:
|
||||
sum += v
|
||||
case i == n-1:
|
||||
prev = (sum + v) / float64(n)
|
||||
out[i] = prev
|
||||
default:
|
||||
prev = v*k + prev*(1-k)
|
||||
out[i] = prev
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bollinger returns SMA and population-deviation bands.
|
||||
func Bollinger(vals []float64, n int, width float64) (mid, upper, lower []float64) {
|
||||
indicatorCheck("Bollinger", n)
|
||||
if !(width > 0) {
|
||||
panic("mujica: Bollinger width must be positive")
|
||||
}
|
||||
mid = SMA(vals, n)
|
||||
upper, lower = indicatorNaN(len(vals)), indicatorNaN(len(vals))
|
||||
for i := n - 1; i < len(vals); i++ {
|
||||
var ss float64
|
||||
for _, v := range vals[i-n+1 : i+1] {
|
||||
ss += (v - mid[i]) * (v - mid[i])
|
||||
}
|
||||
d := width * math.Sqrt(ss/float64(n))
|
||||
upper[i], lower[i] = mid[i]+d, mid[i]-d
|
||||
}
|
||||
return mid, upper, lower
|
||||
}
|
||||
|
||||
// VWAP averages typical prices weighted by cumulative volume.
|
||||
func VWAP(candles []Candle) []float64 {
|
||||
out := indicatorNaN(len(candles))
|
||||
var pv, vol float64
|
||||
for i, c := range candles {
|
||||
pv += (c.High + c.Low + c.Close) / 3 * c.Volume
|
||||
vol += c.Volume
|
||||
if vol > 0 {
|
||||
out[i] = pv / vol
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MACD returns DIF, signal EMA, and their difference.
|
||||
func MACD(vals []float64, fast, slow, signal int) (dif, dea, hist []float64) {
|
||||
if fast >= slow {
|
||||
panic("mujica: MACD fast period must be shorter than slow")
|
||||
}
|
||||
f, s := EMA(vals, fast), EMA(vals, slow)
|
||||
dif = indicatorNaN(len(vals))
|
||||
for i := range vals {
|
||||
dif[i] = f[i] - s[i]
|
||||
}
|
||||
dea = indicatorNaN(len(vals))
|
||||
if len(vals) >= slow {
|
||||
tail := EMA(dif[slow-1:], signal)
|
||||
copy(dea[slow-1:], tail)
|
||||
}
|
||||
hist = indicatorNaN(len(vals))
|
||||
for i := range vals {
|
||||
hist[i] = dif[i] - dea[i]
|
||||
}
|
||||
return dif, dea, hist
|
||||
}
|
||||
|
||||
// RSI computes Wilder's relative strength index.
|
||||
func RSI(vals []float64, n int) []float64 {
|
||||
indicatorCheck("RSI", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
var gain, loss float64
|
||||
for i := 1; i < len(vals); i++ {
|
||||
d := vals[i] - vals[i-1]
|
||||
g, l := max(d, 0), max(-d, 0)
|
||||
if i <= n {
|
||||
gain += g
|
||||
loss += l
|
||||
if i < n {
|
||||
continue
|
||||
}
|
||||
gain, loss = gain/float64(n), loss/float64(n)
|
||||
} else {
|
||||
gain = (gain*float64(n-1) + g) / float64(n)
|
||||
loss = (loss*float64(n-1) + l) / float64(n)
|
||||
}
|
||||
if loss == 0 {
|
||||
out[i] = 100
|
||||
} else {
|
||||
out[i] = 100 - 100/(1+gain/loss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KDJ computes stochastic K, D, and J from RSV.
|
||||
func KDJ(candles []Candle, n, m1, m2 int) (k, d, j []float64) {
|
||||
indicatorCheck("KDJ", n)
|
||||
indicatorCheck("KDJ m1", m1)
|
||||
indicatorCheck("KDJ m2", m2)
|
||||
k, d, j = indicatorNaN(len(candles)), indicatorNaN(len(candles)), indicatorNaN(len(candles))
|
||||
pk, pd := 50.0, 50.0
|
||||
for i := n - 1; i < len(candles); i++ {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, c := range candles[i-n+1 : i+1] {
|
||||
lo, hi = min(lo, c.Low), max(hi, c.High)
|
||||
}
|
||||
rsv := 50.0
|
||||
if hi > lo {
|
||||
rsv = (candles[i].Close - lo) / (hi - lo) * 100
|
||||
}
|
||||
pk = (float64(m1-1)*pk + rsv) / float64(m1)
|
||||
pd = (float64(m2-1)*pd + pk) / float64(m2)
|
||||
k[i], d[i], j[i] = pk, pd, 3*pk-2*pd
|
||||
}
|
||||
return k, d, j
|
||||
}
|
||||
|
||||
// OBV accumulates signed volume according to close direction.
|
||||
func OBV(candles []Candle) []float64 {
|
||||
out := make([]float64, len(candles))
|
||||
for i := 1; i < len(candles); i++ {
|
||||
out[i] = out[i-1]
|
||||
switch {
|
||||
case candles[i].Close > candles[i-1].Close:
|
||||
out[i] += candles[i].Volume
|
||||
case candles[i].Close < candles[i-1].Close:
|
||||
out[i] -= candles[i].Volume
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeikinAshi turns candles into Heikin-Ashi candles, keeping times and volumes.
|
||||
func HeikinAshi(candles []Candle) []Candle {
|
||||
out := make([]Candle, len(candles))
|
||||
for i, c := range candles {
|
||||
h := Candle{Time: c.Time, Volume: c.Volume, Close: (c.Open + c.High + c.Low + c.Close) / 4}
|
||||
if i == 0 {
|
||||
h.Open = (c.Open + c.Close) / 2
|
||||
} else {
|
||||
h.Open = (out[i-1].Open + out[i-1].Close) / 2
|
||||
}
|
||||
h.High, h.Low = max(c.High, h.Open, h.Close), min(c.Low, h.Open, h.Close)
|
||||
out[i] = h
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func approx(t *testing.T, name string, got, want []float64) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: len %d, want %d", name, len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if math.IsNaN(want[i]) != math.IsNaN(got[i]) || !math.IsNaN(want[i]) && math.Abs(got[i]-want[i]) > 1e-9 {
|
||||
t.Errorf("%s[%d] = %v, want %v", name, i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nan = math.NaN()
|
||||
|
||||
func TestSMAAndEMA(t *testing.T) {
|
||||
v := []float64{1, 2, 3, 4, 5, 6}
|
||||
approx(t, "SMA", SMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
||||
approx(t, "EMA", EMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
||||
approx(t, "EMA2", EMA([]float64{10, 20, 10}, 1), []float64{10, 20, 10})
|
||||
approx(t, "EMA3", EMA([]float64{2, 4, 8}, 2), []float64{nan, 3, 3 + (8-3)*2.0/3})
|
||||
}
|
||||
|
||||
func TestBollinger(t *testing.T) {
|
||||
mid, up, lo := Bollinger([]float64{2, 4, 4, 4, 5, 5, 7, 9}, 8, 2)
|
||||
// Mean 5, population deviation 2.
|
||||
approx(t, "mid", mid[7:], []float64{5})
|
||||
approx(t, "upper", up[7:], []float64{9})
|
||||
approx(t, "lower", lo[7:], []float64{1})
|
||||
if !math.IsNaN(up[6]) {
|
||||
t.Fatal("band before the period")
|
||||
}
|
||||
}
|
||||
|
||||
func candlesOf(rows ...[5]float64) []Candle {
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
out := make([]Candle, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: r[0], High: r[1], Low: r[2], Close: r[3], Volume: r[4]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestVWAPAndOBV(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 12, 9, 12, 100}, [5]float64{12, 15, 12, 12, 300}, [5]float64{12, 12, 6, 9, 100})
|
||||
// Typical prices 11, 13, 9.
|
||||
approx(t, "VWAP", VWAP(cs), []float64{11, (1100 + 3900) / 400.0, (1100 + 3900 + 900) / 500.0})
|
||||
approx(t, "OBV", OBV(cs), []float64{0, 0, -100})
|
||||
}
|
||||
|
||||
func TestMACD(t *testing.T) {
|
||||
v := []float64{1, 2, 3, 4, 5, 6, 7}
|
||||
dif, dea, hist := MACD(v, 2, 3, 2)
|
||||
f, s := EMA(v, 2), EMA(v, 3)
|
||||
for i := 2; i < len(v); i++ {
|
||||
if math.Abs(dif[i]-(f[i]-s[i])) > 1e-12 {
|
||||
t.Fatalf("dif[%d]", i)
|
||||
}
|
||||
}
|
||||
want := EMA(dif[2:], 2)
|
||||
approx(t, "dea", dea[2:], want)
|
||||
if !math.IsNaN(dea[1]) || math.Abs(hist[6]-(dif[6]-dea[6])) > 1e-12 {
|
||||
t.Fatal("hist or warm-up wrong")
|
||||
}
|
||||
approx(t, "dif line", dif[2:], []float64{0.5, 0.5, 0.5, 0.5, 0.5})
|
||||
approx(t, "hist line", hist[3:], []float64{0, 0, 0, 0})
|
||||
}
|
||||
|
||||
func TestRSI(t *testing.T) {
|
||||
r := RSI([]float64{1, 2, 3, 2}, 2)
|
||||
approx(t, "RSI", r, []float64{nan, nan, 100, 50})
|
||||
}
|
||||
|
||||
func TestKDJ(t *testing.T) {
|
||||
cs := candlesOf([5]float64{1, 2, 0, 1, 0}, [5]float64{1, 4, 0, 4, 0}, [5]float64{4, 4, 2, 3, 0})
|
||||
k, d, j := KDJ(cs, 2, 3, 3)
|
||||
k1 := 200.0 / 3
|
||||
d1 := (100 + k1) / 3
|
||||
// i=2: range 0..4, RSV 75.
|
||||
k2 := (2*k1 + 75) / 3
|
||||
d2 := (2*d1 + k2) / 3
|
||||
approx(t, "K", k, []float64{nan, k1, k2})
|
||||
approx(t, "D", d, []float64{nan, d1, d2})
|
||||
approx(t, "J", j, []float64{nan, 3*k1 - 2*d1, 3*k2 - 2*d2})
|
||||
}
|
||||
|
||||
func TestHeikinAshi(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 14, 8, 12, 5}, [5]float64{12, 13, 9, 10, 6})
|
||||
h := HeikinAshi(cs)
|
||||
if h[0].Open != 11 || h[0].Close != 11 || h[0].High != 14 || h[0].Low != 8 {
|
||||
t.Fatalf("first %+v", h[0])
|
||||
}
|
||||
if h[1].Open != 11 || h[1].Close != 11 || h[1].High != 13 || h[1].Low != 9 || h[1].Volume != 6 {
|
||||
t.Fatalf("second %+v", h[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorsPanicOnBadPeriods(t *testing.T) {
|
||||
for name, f := range map[string]func(){
|
||||
"SMA": func() { SMA(nil, 0) },
|
||||
"MACD": func() { MACD(nil, 26, 12, 9) },
|
||||
"BOLL": func() { Bollinger(nil, 20, 0) },
|
||||
"KDJ m": func() { KDJ(nil, 9, 0, 3) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketWindowPanZoom(t *testing.T) {
|
||||
v := MarketView{}
|
||||
if f, to := marketWindow(v, 1000, 400); f != 880 || to != 1000 {
|
||||
t.Fatalf("default %d..%d", f, to)
|
||||
}
|
||||
marketPan(&v, 1000, 400, -100)
|
||||
if v.End != 900 || v.Count != 120 {
|
||||
t.Fatalf("pan back %+v", v)
|
||||
}
|
||||
marketZoom(&v, 1000, 400, 0.5)
|
||||
if f, to := marketWindow(v, 1000, 400); f != 840 || to != 900 {
|
||||
t.Fatalf("zoom in %d..%d", f, to)
|
||||
}
|
||||
marketZoom(&v, 1000, 100, 10)
|
||||
if v.Count != 100 {
|
||||
t.Fatalf("zoom out capped by room: %+v", v)
|
||||
}
|
||||
marketPan(&v, 1000, 100, 5000)
|
||||
if v.End != 0 {
|
||||
t.Fatalf("pan to the newest follows: %+v", v)
|
||||
}
|
||||
if f, to := marketWindow(MarketView{Count: 50, End: 10}, 1000, 400); f != 0 || to != 50 {
|
||||
t.Fatalf("start clamp %d..%d", f, to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntervalClose(t *testing.T) {
|
||||
loc := time.UTC
|
||||
at := time.Date(2026, 10, 7, 13, 47, 10, 0, loc) // a Wednesday
|
||||
for iv, want := range map[Interval]time.Time{
|
||||
"1m": time.Date(2026, 10, 7, 13, 48, 0, 0, loc),
|
||||
"15m": time.Date(2026, 10, 7, 14, 0, 0, 0, loc),
|
||||
"4h": time.Date(2026, 10, 7, 16, 0, 0, 0, loc),
|
||||
"1D": time.Date(2026, 10, 8, 0, 0, 0, 0, loc),
|
||||
"1W": time.Date(2026, 10, 12, 0, 0, 0, 0, loc),
|
||||
"1M": time.Date(2026, 11, 1, 0, 0, 0, 0, loc),
|
||||
} {
|
||||
if got := iv.Close(at, loc); !got.Equal(want) {
|
||||
t.Errorf("%s: %v, want %v", iv, got, want)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown interval accepted")
|
||||
}
|
||||
}()
|
||||
Interval("7m").Close(at, loc)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// intervalGroups sorts Intervals under their headings.
|
||||
var intervalGroups = []struct {
|
||||
key string
|
||||
items []Interval
|
||||
}{
|
||||
{"interval_selector.minutes", []Interval{"1m", "3m", "5m", "15m", "30m"}},
|
||||
{"interval_selector.hours", []Interval{"1h", "2h", "4h", "6h", "12h"}},
|
||||
{"interval_selector.days", []Interval{"1D", "3D", "1W", "1M"}},
|
||||
}
|
||||
|
||||
// IntervalSelectorOptions configure an IntervalSelector.
|
||||
type IntervalSelectorOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IntervalSelector groups candle periods and preserves caller-owned favorites.
|
||||
func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opts IntervalSelectorOptions) ChoiceResult {
|
||||
if value == nil || favorites == nil {
|
||||
panic("mujica: IntervalSelector needs a value and favorites")
|
||||
}
|
||||
for _, iv := range append([]Interval{*value}, *favorites...) {
|
||||
if !slices.Contains(Intervals, iv) {
|
||||
panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv)))
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
changed := false
|
||||
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
|
||||
disabled := row.IsDisabled()
|
||||
row.Children(func() {
|
||||
quick := slices.Clone(*favorites)
|
||||
if !slices.Contains(quick, *value) {
|
||||
quick = append(quick, *value)
|
||||
}
|
||||
slices.SortFunc(quick, func(a, b Interval) int { return slices.Index(Intervals, a) - slices.Index(Intervals, b) })
|
||||
items := make([]ToggleItem[Interval], len(quick))
|
||||
for i, iv := range quick {
|
||||
items[i] = ToggleItem[Interval]{Value: iv, Label: string(iv)}
|
||||
}
|
||||
sel := []Interval{*value}
|
||||
if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "interval_selector.label")}).Changed() && !disabled {
|
||||
*value, changed = sel[0], true
|
||||
}
|
||||
trigger := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("chevron-down"), Label: msg(c, "interval_selector.all")})
|
||||
open := ui.Local(row, "interval_selector.open", func() bool { return false })
|
||||
if disabled {
|
||||
*open = false
|
||||
}
|
||||
if trigger.Clicked() && !disabled {
|
||||
*open = !*open
|
||||
}
|
||||
Popover(c, trigger, open, PopoverOptions{Label: msg(c, "interval_selector.all"), Width: 220}, func() {
|
||||
for _, g := range intervalGroups {
|
||||
ui.Text(c, msg(c, g.key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, u)
|
||||
for _, iv := range g.items {
|
||||
if intervalSelectorRow(c, k, u, iv, iv == *value, slices.Contains(*favorites, iv), favorites) {
|
||||
*value, changed, *open = iv, true, false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
if changed {
|
||||
slog.Debug("mujica: interval_selector changed", "interval", *value)
|
||||
}
|
||||
return ChoiceResult{row, changed}
|
||||
}
|
||||
|
||||
// intervalSelectorRow shows a selectable period and its favorite toggle.
|
||||
func intervalSelectorRow(c *ui.Context, k theme.Tokens, u float32, iv Interval, on, fav bool, favorites *[]Interval) bool {
|
||||
chosen := false
|
||||
ui.Row(c).Key(string(iv)).AlignItems(ui.Center).Gap(u).Children(func() {
|
||||
b := ui.ButtonBase(c).Grow(1).MinHeight(Density(c).ControlHeight()).Padding(0, u*2).Justify(ui.Start).Radius(theme.ControlRadius).Gap(u * 2)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if on {
|
||||
b.Background(k.Selection).TextColor(k.AccentText)
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if on {
|
||||
internal.Mark(p, r, "check", k.AccentText)
|
||||
}
|
||||
})
|
||||
ui.Text(c, string(iv)).SingleLine()
|
||||
})
|
||||
chosen = b.Clicked()
|
||||
name := fmt.Sprintf(msg(c, "interval_selector.favorite"), iv)
|
||||
if fav {
|
||||
name = fmt.Sprintf(msg(c, "interval_selector.unfavorite"), iv)
|
||||
}
|
||||
star := iconAction(c, icons.Must("star"), name)
|
||||
if fav {
|
||||
star.TextColor(k.Warning)
|
||||
}
|
||||
if star.Clicked() && !star.IsDisabled() {
|
||||
if fav {
|
||||
*favorites = slices.DeleteFunc(slices.Clone(*favorites), func(x Interval) bool { return x == iv })
|
||||
} else {
|
||||
*favorites = append(slices.Clone(*favorites), iv)
|
||||
}
|
||||
slog.Debug("mujica: interval_selector favorite", "interval", iv, "on", !fav)
|
||||
}
|
||||
})
|
||||
return chosen
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntervalSelectorGroupsFavoritesAndDisabled(t *testing.T) {
|
||||
value := Interval("1h")
|
||||
favorites := []Interval{"1m", "1h"}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{Disabled: disabled})
|
||||
}, 500, 700)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if err := tt.Click("All intervals"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Minutes") || !tt.HasText("Hours") || !tt.HasText("Days and longer") {
|
||||
t.Fatalf("groups %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Add 4h to favorites"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(favorites) != 3 || favorites[2] != "4h" {
|
||||
t.Fatalf("favorites %v", favorites)
|
||||
}
|
||||
for range 50 {
|
||||
if tt.Focused("1M") {
|
||||
break
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if !tt.Focused("1M") {
|
||||
t.Fatal("keyboard could not reach 1M")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if value != "1M" {
|
||||
t.Fatalf("interval %v", value)
|
||||
}
|
||||
if err := tt.Click("All intervals"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Days and longer") {
|
||||
t.Fatal("disabled popup remained open")
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if value != "1M" || len(favorites) != 3 {
|
||||
t.Fatal("disabled interval changed")
|
||||
}
|
||||
}
|
||||
func TestIntervalSelectorSnapshot(t *testing.T) {
|
||||
value := Interval("1h")
|
||||
favorites := []Interval{"1m", "5m", "1h", "1D"}
|
||||
snapshot(t, "interval_selector", 460, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// QuotePoint holds time, price, and incremental volume.
|
||||
type QuotePoint struct {
|
||||
Time time.Time
|
||||
Price float64
|
||||
Volume float64
|
||||
}
|
||||
|
||||
// LineQuoteChartOptions configure a LineQuoteChart.
|
||||
type LineQuoteChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// PrevClose is the required positive baseline price.
|
||||
PrevClose float64
|
||||
// RegularOpen and RegularClose optionally delimit extended trading sessions.
|
||||
RegularOpen, RegularClose time.Time
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// lineQuoteSession returns pre-market, regular, or after-market.
|
||||
func lineQuoteSession(t time.Time, opts LineQuoteChartOptions) int {
|
||||
switch {
|
||||
case opts.RegularOpen.IsZero():
|
||||
return 0
|
||||
case t.Before(opts.RegularOpen):
|
||||
return -1
|
||||
case !t.Before(opts.RegularClose):
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LineQuoteChart draws price, weighted average, and extended sessions.
|
||||
func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptions) ChartResult {
|
||||
if math.IsInf(opts.PrevClose, 0) || !(opts.PrevClose > 0) {
|
||||
panic("mujica: LineQuoteChart needs a positive PrevClose")
|
||||
}
|
||||
if opts.RegularOpen.IsZero() != opts.RegularClose.IsZero() || opts.RegularClose.Before(opts.RegularOpen) {
|
||||
panic("mujica: LineQuoteChart needs both session bounds, in order")
|
||||
}
|
||||
dec := priceDecimals("LineQuoteChart", opts.Decimals)
|
||||
ch := newChart(c, opts.Frame)
|
||||
k := Tokens(c)
|
||||
avg := chartMemo(ch, "avg", chartID(points), func() []float64 {
|
||||
out := make([]float64, len(points))
|
||||
var pv, v float64
|
||||
for i, p := range points {
|
||||
if p.Time.IsZero() || i > 0 && !p.Time.After(points[i-1].Time) || !(p.Price > 0) || p.Volume < 0 || math.IsNaN(p.Volume) || math.IsInf(p.Volume, 0) || math.IsInf(p.Price, 0) {
|
||||
panic(fmt.Sprintf("mujica: LineQuoteChart point %d is out of order or invalid", i))
|
||||
}
|
||||
pv += p.Price * p.Volume
|
||||
v += p.Volume
|
||||
out[i] = p.Price
|
||||
if v > 0 {
|
||||
out[i] = pv / v
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
dom := chartMemo(ch, "y", [2]any{chartID(points), opts.PrevClose}, func() [2]float64 {
|
||||
lo, hi := opts.PrevClose, opts.PrevClose
|
||||
for i, p := range points {
|
||||
lo, hi = min(lo, p.Price, avg[i]), max(hi, p.Price, avg[i])
|
||||
}
|
||||
// Center the scale on the previous close.
|
||||
d := max(hi-opts.PrevClose, opts.PrevClose-lo)
|
||||
return [2]float64{opts.PrevClose - d, opts.PrevClose + d}
|
||||
})
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := chartInset(r, 60, 26)
|
||||
plot.W -= 44 // percent axis on the right
|
||||
var t0, t1 float64
|
||||
if len(points) > 0 {
|
||||
t0, t1 = float64(points[0].Time.Unix()), float64(points[len(points)-1].Time.Unix())
|
||||
}
|
||||
return plot, internal.Linear{D0: t0, D1: t1, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: dom[0], D1: dom[1], R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
x := func(xs internal.Linear, i int) float32 { return xs.Map(float64(points[i].Time.Unix())) }
|
||||
spec := ChartSpec{Marks: len(points), Legend: []ChartLegendItem{
|
||||
{Name: msg(c, "line_quote_chart.price"), Color: k.AccentText, Shape: 0},
|
||||
{Name: msg(c, "line_quote_chart.average"), Color: k.Warning, Shape: 3},
|
||||
}}
|
||||
spec.Pick = func(r ui.Rect, px, py float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !plot.Contains(px, py) {
|
||||
return -1
|
||||
}
|
||||
return internal.Nearest(len(points), func(i int) (float32, float32) { return x(xs, i), 0 }, px, 0, plot.W, true)
|
||||
}
|
||||
spec.Readout = func(i int) string {
|
||||
p := points[i]
|
||||
chg := (p.Price/opts.PrevClose - 1) * 100
|
||||
parts := []string{p.Time.Format("15:04"), priceUngrouped(p.Price, dec), fmt.Sprintf("%+.2f%% %s", chg, trendWord(c, trendSign(chg))),
|
||||
msg(c, "line_quote_chart.average") + " " + priceUngrouped(avg[i], dec)}
|
||||
if s := lineQuoteSession(p.Time, opts); s != 0 {
|
||||
parts = append(parts, msg(c, map[int]string{-1: "line_quote_chart.pre", 1: "line_quote_chart.after"}[s]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
_, _, step := internal.Nice(dom[0], dom[1], 6)
|
||||
ticks := internal.Ticks(dom[0], dom[1], step)
|
||||
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
for _, t := range ticks {
|
||||
chartText(p, fmt.Sprintf("%+.2f%%", (t/opts.PrevClose-1)*100), plot.X+plot.W+6, ys.Map(t), size, trendColor(c, trendSign(t-opts.PrevClose)), 0, 0.5)
|
||||
}
|
||||
lineQuoteSessions(p, plot, xs, opts, size, c)
|
||||
var tx []float32
|
||||
var tl []string
|
||||
if len(points) > 1 {
|
||||
ts, layout := internal.TimeTicks(points[0].Time, points[len(points)-1].Time, max(2, int(plot.W/90)))
|
||||
for _, t := range ts {
|
||||
if layout == "Jan 2" {
|
||||
layout = msg(c, "candlestick_chart.daily_time")
|
||||
}
|
||||
tx, tl = append(tx, xs.Map(float64(t.Unix()))), append(tl, t.Format(layout))
|
||||
}
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
by := internal.Snap(p, ys.Map(opts.PrevClose))
|
||||
for xx := plot.X; xx < plot.X+plot.W; xx += 6 {
|
||||
p.Line(xx, by, min(xx+3, plot.X+plot.W), by, 1, k.TextMuted)
|
||||
}
|
||||
price := chartMemo(ch, "prices", chartID(points), func() []float64 {
|
||||
out := make([]float64, len(points))
|
||||
for i, pt := range points {
|
||||
out[i] = pt.Price
|
||||
}
|
||||
return out
|
||||
})
|
||||
at := func(i int) float32 { return x(xs, i) }
|
||||
for li, vals := range [][]float64{price, avg} {
|
||||
col := []ui.Color{k.AccentText, k.Warning}[li]
|
||||
runs := chartMemo(ch, fmt.Sprint("quote.runs.", li), [3]any{chartID(points), plot.X, plot.W}, func() [][]int {
|
||||
var out [][]int
|
||||
chartRuns(vals, at, func(idx []int) { out = append(out, idx) })
|
||||
return out
|
||||
})
|
||||
for _, idx := range runs {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
px, py := internal.Snap(p, at(i)), internal.Snap(p, ys.Map(vals[i]))
|
||||
if j == 0 {
|
||||
line.MoveTo(px, py)
|
||||
} else {
|
||||
line.LineTo(px, py)
|
||||
}
|
||||
}
|
||||
p.StrokePath(&line, []float32{1.75, 1.25}[li], col)
|
||||
for j := 0; j < len(idx); j += max(1, len(idx)/12) {
|
||||
i := idx[j]
|
||||
internal.Marker(p, []int{0, 3}[li], at(i), ys.Map(vals[i]), 5, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
if active >= 0 {
|
||||
ax := internal.Snap(p, at(active))
|
||||
p.Line(ax, plot.Y, ax, plot.Y+plot.H, 1, k.TextMuted)
|
||||
internal.Marker(p, 0, ax, ys.Map(price[active]), 9, k.AccentText)
|
||||
internal.Marker(p, 3, ax, ys.Map(avg[active]), 9, k.Warning)
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// lineQuoteSessions shades and labels the pre- and after-market spans.
|
||||
func lineQuoteSessions(p *ui.Painter, plot ui.Rect, xs internal.Linear, opts LineQuoteChartOptions, size float32, c *ui.Context) {
|
||||
if opts.RegularOpen.IsZero() {
|
||||
return
|
||||
}
|
||||
k := Tokens(c)
|
||||
x0, x1 := max(plot.X, xs.Map(float64(opts.RegularOpen.Unix()))), min(plot.X+plot.W, xs.Map(float64(opts.RegularClose.Unix())))
|
||||
for _, span := range []struct {
|
||||
a, b float32
|
||||
key string
|
||||
}{{plot.X, x0, "line_quote_chart.pre"}, {x1, plot.X + plot.W, "line_quote_chart.after"}} {
|
||||
if span.b-span.a < 1 {
|
||||
continue
|
||||
}
|
||||
p.Fill(ui.Rect{X: span.a, Y: plot.Y, W: span.b - span.a, H: plot.H}, k.SurfaceHover.Alpha(0.6), 0)
|
||||
chartText(p, msg(c, span.key), span.a+4, plot.Y+2, size, k.TextMuted, 0, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func samplePoints() ([]QuotePoint, time.Time, time.Time) {
|
||||
day := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
var pts []QuotePoint
|
||||
for m := 8 * 60; m < 18*60; m += 5 {
|
||||
price := 100 + 2*math.Sin(float64(m)/50) + float64(m-480)/200
|
||||
pts = append(pts, QuotePoint{Time: day.Add(time.Duration(m) * time.Minute), Price: math.Round(price*100) / 100, Volume: 100 + float64(m%7)*10})
|
||||
}
|
||||
return pts, day.Add(9*time.Hour + 30*time.Minute), day.Add(16 * time.Hour)
|
||||
}
|
||||
|
||||
func TestLineQuoteChartReadoutAverageSessions(t *testing.T) {
|
||||
pts, open, closeAt := samplePoints()
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = LineQuoteChart(c, pts, LineQuoteChartOptions{Frame: ChartFrame{Label: "AAPL"}, PrevClose: 100, RegularOpen: open, RegularClose: closeAt})
|
||||
}, 800, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
var pv, v float64
|
||||
pv, v = pts[0].Price*pts[0].Volume, pts[0].Volume
|
||||
want := "08:00 · " + priceUngrouped(pts[0].Price, 2)
|
||||
found := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, want) {
|
||||
found = s
|
||||
}
|
||||
}
|
||||
if found == "" || !strings.Contains(found, "Average "+priceUngrouped(pv/v, 2)) || !strings.HasSuffix(found, "Pre-market") {
|
||||
t.Fatalf("first readout %q in %q", found, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
last := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, "17:55") {
|
||||
last = s
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, "After hours") || res.Point != len(pts)-1 {
|
||||
t.Fatalf("last readout %q", last)
|
||||
}
|
||||
for name, o := range map[string]LineQuoteChartOptions{
|
||||
"prev close": {Frame: ChartFrame{Label: "x"}},
|
||||
"half bound": {Frame: ChartFrame{Label: "x"}, PrevClose: 1, RegularOpen: open},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); LineQuoteChart(c, pts, o) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineQuoteChartSnapshot(t *testing.T) {
|
||||
pts, open, closeAt := samplePoints()
|
||||
snapshot(t, "line_quote_chart", 680, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
LineQuoteChart(c, pts, LineQuoteChartOptions{Frame: ChartFrame{Label: "AAPL", Height: 260}, PrevClose: 100, RegularOpen: open, RegularClose: closeAt})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMarketIndicatorDomainChangesWithParams(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
v := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}}
|
||||
build := func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC"})
|
||||
}
|
||||
tt := ui.NewTester(build, 640, 400)
|
||||
panes.Panes[0].Kind = IndicatorOBV
|
||||
tt.Frame()
|
||||
fresh := ui.NewTester(build, 640, 400)
|
||||
if !bytes.Equal(encode(t, tt.Image()), encode(t, fresh.Image())) {
|
||||
t.Fatal("changed indicator retained its previous scale")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMarketRejectsInvalidLayers(t *testing.T) {
|
||||
for _, kind := range []string{"compare zero", "compare infinity", "alert infinity", "alert duplicate", "profile NaN", "depth NaN", "indicator infinity"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("invalid data accepted")
|
||||
}
|
||||
}()
|
||||
cs := sampleCandles(2)
|
||||
v := MarketView{}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
o := CandlestickOptions{Frame: ChartFrame{Label: "Price"}}
|
||||
switch kind {
|
||||
case "compare zero":
|
||||
x := []CompareSymbol{{Name: "X", Closes: []float64{0, 1}}}
|
||||
o.Compare = &x
|
||||
case "compare infinity":
|
||||
x := []CompareSymbol{{Name: "X", Closes: []float64{1, math.Inf(1)}}}
|
||||
o.Compare = &x
|
||||
case "alert infinity":
|
||||
x := []PriceAlertLine{{ID: "a", Price: math.Inf(1)}}
|
||||
o.Alerts = &x
|
||||
case "alert duplicate":
|
||||
x := []PriceAlertLine{{ID: "a", Price: 1}, {ID: "a", Price: 2}}
|
||||
o.Alerts = &x
|
||||
case "profile NaN":
|
||||
VolumeProfile(c, []ProfileBin{{1, 2, math.NaN()}}, VolumeProfileOptions{Frame: o.Frame})
|
||||
return
|
||||
case "depth NaN":
|
||||
DepthChart(c, []DepthLevel{{math.NaN(), 1}}, []DepthLevel{{2, 1}}, DepthChartOptions{Frame: o.Frame})
|
||||
return
|
||||
case "indicator infinity":
|
||||
o.Overlays = []IndicatorOverlay{{Kind: IndicatorBOLL, Params: []float64{2, math.Inf(1)}}}
|
||||
}
|
||||
CandlestickChart(c, cs, &v, o)
|
||||
}, 500, 320)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candle holds OHLC prices, time, and volume.
|
||||
type Candle struct {
|
||||
Time time.Time
|
||||
Open, High, Low, Close float64
|
||||
Volume float64
|
||||
}
|
||||
|
||||
// marketCheckCandles validates chronological candles and finite OHLC values.
|
||||
func marketCheckCandles(candles []Candle) {
|
||||
for i, c := range candles {
|
||||
if c.Time.IsZero() || i > 0 && !c.Time.After(candles[i-1].Time) {
|
||||
panic(fmt.Sprintf("mujica: candle %d time is zero or not after the one before", i))
|
||||
}
|
||||
for _, v := range []float64{c.Open, c.High, c.Low, c.Close, c.Volume} {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
panic("mujica: candle values must be finite")
|
||||
}
|
||||
}
|
||||
if !(c.Low <= min(c.Open, c.Close)) || !(c.High >= max(c.Open, c.Close)) || c.Volume < 0 || math.IsNaN(c.Volume) {
|
||||
panic(fmt.Sprintf("mujica: candle %d has impossible prices or volume", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarketView holds the visible candle window.
|
||||
type MarketView struct {
|
||||
// Count is how many candles show; zero shows 120.
|
||||
Count int
|
||||
// End is exclusive; zero follows the newest candle.
|
||||
End int
|
||||
}
|
||||
|
||||
// marketWindow resolves the visible range, bounded by room.
|
||||
func marketWindow(v MarketView, n, room int) (from, to int) {
|
||||
if v.Count < 0 || v.End < 0 {
|
||||
panic("mujica: MarketView Count and End must not be negative")
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
count := v.Count
|
||||
if count == 0 {
|
||||
count = 120
|
||||
}
|
||||
count = max(1, min(count, room, n))
|
||||
to = n
|
||||
if v.End > 0 && v.End < n {
|
||||
to = max(v.End, count)
|
||||
}
|
||||
return to - count, to
|
||||
}
|
||||
|
||||
// marketPan moves the window; the newest edge enables following.
|
||||
func marketPan(v *MarketView, n, room, delta int) {
|
||||
from, to := marketWindow(*v, n, room)
|
||||
v.Count = to - from
|
||||
to = max(v.Count, min(n, to+delta))
|
||||
if to >= n {
|
||||
v.End = 0
|
||||
} else {
|
||||
v.End = to
|
||||
}
|
||||
}
|
||||
|
||||
// marketZoom changes visible count while preserving the right edge.
|
||||
func marketZoom(v *MarketView, n, room int, f float64) {
|
||||
from, to := marketWindow(*v, n, room)
|
||||
count := int(math.Round(float64(to-from) * f))
|
||||
if f > 1 && count == to-from {
|
||||
count++
|
||||
}
|
||||
v.Count = max(min(10, n), min(count, room, n))
|
||||
if to < n {
|
||||
v.End = max(to, v.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// marketIndexAt finds the candle at or before t.
|
||||
func marketIndexAt(candles []Candle, t time.Time) int {
|
||||
return sort.Search(len(candles), func(i int) bool { return candles[i].Time.After(t) }) - 1
|
||||
}
|
||||
|
||||
// Interval names a supported candle period.
|
||||
type Interval string
|
||||
|
||||
// Intervals lists every Interval, shortest first.
|
||||
var Intervals = []Interval{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1D", "3D", "1W", "1M"}
|
||||
|
||||
// Close aligns periods locally; weeks start Monday.
|
||||
func (iv Interval) Close(t time.Time, loc *time.Location) time.Time {
|
||||
t = t.In(loc)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
step := func(d time.Duration) time.Time {
|
||||
since := t.Sub(day)
|
||||
return day.Add((since/d + 1) * d)
|
||||
}
|
||||
switch iv {
|
||||
case "1m", "3m", "5m", "15m", "30m":
|
||||
m, _ := time.ParseDuration(string(iv))
|
||||
return step(m)
|
||||
case "1h", "2h", "4h", "6h", "12h":
|
||||
h, _ := time.ParseDuration(string(iv))
|
||||
return step(h)
|
||||
case "1D":
|
||||
return day.AddDate(0, 0, 1)
|
||||
case "3D":
|
||||
days := int(day.Sub(time.Date(1970, 1, 1, 0, 0, 0, 0, loc)).Hours()/24 + 0.5)
|
||||
return day.AddDate(0, 0, 3-days%3)
|
||||
case "1W":
|
||||
back := (int(day.Weekday()) + 6) % 7
|
||||
return day.AddDate(0, 0, 7-back)
|
||||
case "1M":
|
||||
return time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv)))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// input handles pan, zoom, alerts, drawings, and keyboard following.
|
||||
func (m *mktCore) input(plot *ui.Element) {
|
||||
if plot.IsDisabled() {
|
||||
return
|
||||
}
|
||||
n := len(m.candles)
|
||||
room := m.room()
|
||||
changed := false
|
||||
plot.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputScroll || plot.IsDisabled() || ev.DY == 0 {
|
||||
return false
|
||||
}
|
||||
f := 0.9
|
||||
if ev.DY > 0 {
|
||||
f = 1 / 0.9
|
||||
}
|
||||
marketZoom(m.view, n, m.room(), f)
|
||||
return true
|
||||
})
|
||||
switch {
|
||||
case plot.Shortcut(ui.Shift, ui.KeyLeft):
|
||||
marketPan(m.view, n, room, -max(1, (m.to-m.from)/10))
|
||||
changed = true
|
||||
case plot.Shortcut(ui.Shift, ui.KeyRight):
|
||||
marketPan(m.view, n, room, max(1, (m.to-m.from)/10))
|
||||
changed = true
|
||||
case plot.Shortcut(0, ui.KeyEqual):
|
||||
marketZoom(m.view, n, room, 0.8)
|
||||
changed = true
|
||||
case plot.Shortcut(0, ui.KeyMinus):
|
||||
marketZoom(m.view, n, room, 1.25)
|
||||
changed = true
|
||||
}
|
||||
// Keep the keyboard mark visible.
|
||||
f := m.ch.st.focus
|
||||
if f >= 0 && f != m.st.focus && !changed && (f < m.from || f >= m.to) {
|
||||
if f < m.from {
|
||||
marketPan(m.view, n, room, f-m.from)
|
||||
} else {
|
||||
marketPan(m.view, n, room, f-m.to+1)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
m.st.focus = f
|
||||
b := plot.Bounds()
|
||||
g := m.geom(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: b.H})
|
||||
px, py, over := plot.PointerPosition()
|
||||
x, y := b.X+px, b.Y+py
|
||||
dx, dy, held := plot.Dragged()
|
||||
used := false
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
used = d.input(m, plot, g, x, y, over, held, dx, dy)
|
||||
}
|
||||
if !used {
|
||||
m.alertsKeyboard(plot)
|
||||
used = m.alertsInput(g, x, y, held, dy)
|
||||
}
|
||||
if !used && held && dx != 0 && g.band.Step() > 0 {
|
||||
m.st.dragRest += dx
|
||||
if steps := int(m.st.dragRest / g.band.Step()); steps != 0 {
|
||||
m.st.dragRest -= float32(steps) * g.band.Step()
|
||||
marketPan(m.view, n, room, -steps)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
m.st.dragRest = 0
|
||||
}
|
||||
if changed {
|
||||
slog.Debug("mujica: candlestick_chart view", "count", m.view.Count, "end", m.view.End)
|
||||
m.c.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mktCore) alertsKeyboard(plot *ui.Element) {
|
||||
if m.opts.Alerts == nil || len(*m.opts.Alerts) == 0 || m.base != 0 {
|
||||
return
|
||||
}
|
||||
a := *m.opts.Alerts
|
||||
m.st.alertFocus = min(m.st.alertFocus, len(a)-1)
|
||||
selected := false
|
||||
if plot.Shortcut(0, ui.KeyPageDown) {
|
||||
m.st.alertFocus = (m.st.alertFocus + 1) % len(a)
|
||||
selected = true
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyPageUp) {
|
||||
m.st.alertFocus = (max(m.st.alertFocus, 0) - 1 + len(a)) % len(a)
|
||||
selected = true
|
||||
}
|
||||
if selected && m.opts.Drawings != nil {
|
||||
d := m.opts.Drawings
|
||||
d.sel, d.pending, d.Tool = 0, nil, ToolSelect
|
||||
}
|
||||
delta := 0
|
||||
if m.opts.Drawings == nil || m.opts.Drawings.Selected() < 0 {
|
||||
if plot.Shortcut(ui.Alt, ui.KeyUp) {
|
||||
delta = 1
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyDown) {
|
||||
delta = -1
|
||||
}
|
||||
}
|
||||
if i := m.st.alertFocus; i >= 0 && (selected || delta != 0) {
|
||||
spec := numberInputResolve(NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec})
|
||||
if delta != 0 {
|
||||
a[i].Price = spec.stepBy(a[i].Price, delta)
|
||||
slog.Debug("mujica: price_alert_line moved", "id", a[i].ID)
|
||||
}
|
||||
m.c.Announce(fmt.Sprintf(msg(m.c, "price_alert_line.tag"), priceUngrouped(a[i].Price, m.dec)))
|
||||
m.c.Invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDrawingToolsKeyboardCreateAndSelect(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
d := DrawingTools{Tool: ToolTrendLine}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d})
|
||||
}, 640, 340)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(d.Items) != 1 || d.Items[0].A.Time != cs[0].Time || d.Items[0].B.Time != cs[1].Time {
|
||||
t.Fatalf("keyboard drawing %+v", d)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.Key(ui.Alt, ui.KeyEnter)
|
||||
if d.Selected() != 0 {
|
||||
t.Fatalf("selection %d", d.Selected())
|
||||
}
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if len(d.Items) != 0 {
|
||||
t.Fatal("keyboard deletion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceAlertLineKeyboard(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: 100}, {ID: "b", Price: 105}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Alerts"}, Alerts: &alerts})
|
||||
}, 640, 340)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if alerts[0].Price != 100.01 || alerts[1].Price != 105 {
|
||||
t.Fatalf("first alert %+v", alerts)
|
||||
}
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if alerts[1].Price != 104.99 {
|
||||
t.Fatalf("second alert %+v", alerts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDepthChartLargeDataBounded(t *testing.T) {
|
||||
bids, asks := make([]DepthLevel, 100000), make([]DepthLevel, 100000)
|
||||
for i := range bids {
|
||||
bids[i] = DepthLevel{100 - float64(i)*0.0001, 1}
|
||||
asks[i] = DepthLevel{101 + float64(i)*0.0001, 2}
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}})
|
||||
}, 640, 340)
|
||||
drawn := *ui.Local(res.Element, "mujica.depth.drawn", func() int { return 0 })
|
||||
if drawn <= 0 || drawn > 4*640+8 {
|
||||
t.Fatalf("painted %d depth vertices", drawn)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 199999 {
|
||||
t.Fatalf("last point %d", res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeProfileLargeDataBounded(t *testing.T) {
|
||||
bins := make([]ProfileBin, 100000)
|
||||
for i := range bins {
|
||||
bins[i] = ProfileBin{float64(i), float64(i + 1), float64(i%19 + 1)}
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = VolumeProfile(c, bins, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile"}})
|
||||
}, 640, 340)
|
||||
drawn := *ui.Local(res.Element, "mujica.profile.drawn", func() int { return 0 })
|
||||
if drawn <= 0 || drawn > 4*340+4 {
|
||||
t.Fatalf("painted %d profile bins", drawn)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 99999 {
|
||||
t.Fatalf("last bin %d", res.Point)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// PriceAlertLine holds a draggable, caller-owned price threshold.
|
||||
type PriceAlertLine struct {
|
||||
ID string
|
||||
Price float64
|
||||
// Label shows beside the price; empty shows the price only.
|
||||
Label string
|
||||
}
|
||||
|
||||
// CompareSymbol holds aligned closes for percent comparison.
|
||||
type CompareSymbol struct {
|
||||
Name string
|
||||
Closes []float64
|
||||
}
|
||||
|
||||
// compareLines normalizes prices and compared symbols into percentages.
|
||||
func (m *mktCore) compareLines() {
|
||||
if m.opts.Compare == nil || len(*m.opts.Compare) == 0 || m.to <= m.from {
|
||||
return
|
||||
}
|
||||
m.base = m.candles[m.from].Close
|
||||
if m.base == 0 {
|
||||
panic("mujica: percent comparison needs a nonzero base close")
|
||||
}
|
||||
pal := internal.Palette(m.k)
|
||||
for i := range m.overlays {
|
||||
line := &m.overlays[i]
|
||||
values := line.values
|
||||
base := m.base
|
||||
line.values = chartMemo(m.ch, fmt.Sprint("compare.overlay.", i), [2]any{chartID(values), base}, func() []float64 {
|
||||
out := make([]float64, len(values))
|
||||
for j, v := range values {
|
||||
out[j] = (v/base - 1) * 100
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
for i, s := range *m.opts.Compare {
|
||||
if s.Name == "" || len(s.Closes) != len(m.candles) {
|
||||
panic(fmt.Sprintf("mujica: CompareSymbol %q needs a Name and one close per candle", s.Name))
|
||||
}
|
||||
chartMemo(m.ch, "compare.check."+s.Name, chartID(s.Closes), func() bool {
|
||||
for _, v := range s.Closes {
|
||||
if math.IsInf(v, 0) || (!math.IsNaN(v) && v <= 0) {
|
||||
panic("mujica: compared closes must be positive or NaN")
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
base := math.NaN()
|
||||
for j := m.from; j < m.to && math.IsNaN(base); j++ {
|
||||
base = s.Closes[j]
|
||||
}
|
||||
closes := s.Closes
|
||||
vals := chartMemo(m.ch, "compare."+s.Name, [2]any{chartID(closes), math.Float64bits(base)}, func() []float64 {
|
||||
out := make([]float64, len(closes))
|
||||
for j, v := range closes {
|
||||
out[j] = (v/base - 1) * 100
|
||||
}
|
||||
return out
|
||||
})
|
||||
m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: i + 1})
|
||||
}
|
||||
}
|
||||
|
||||
// CompareLegend shows series markers and removal actions.
|
||||
func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) ChoiceResult {
|
||||
if symbols == nil {
|
||||
panic("mujica: CompareLegend needs symbols")
|
||||
}
|
||||
k := Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
u := Density(c).Unit()
|
||||
removed := -1
|
||||
row := ui.Row(c).Wrap().Gap(u * 2).Role(ui.RoleList).Label(msg(c, "compare_symbol.label"))
|
||||
row.Children(func() {
|
||||
for i, s := range *symbols {
|
||||
ui.Row(c).Key(s.Name).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() {
|
||||
col := pal[(i+1)%len(pal)]
|
||||
ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, i+1, r.X+5, r.Y+5, 9, col) })
|
||||
ui.Text(c, s.Name).SingleLine()
|
||||
if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "compare_symbol.remove"), s.Name)).Clicked() && !row.IsDisabled() {
|
||||
removed = i
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if removed >= 0 {
|
||||
slog.Debug("mujica: compare_symbol removed", "name", (*symbols)[removed].Name)
|
||||
*symbols = append((*symbols)[:removed:removed], (*symbols)[removed+1:]...)
|
||||
}
|
||||
return ChoiceResult{row, removed >= 0}
|
||||
}
|
||||
|
||||
// alertsCheck fires callbacks when the latest close crosses thresholds.
|
||||
func (m *mktCore) alertsCheck() {
|
||||
if m.opts.Alerts != nil {
|
||||
seen := map[string]bool{}
|
||||
for _, a := range *m.opts.Alerts {
|
||||
if a.ID == "" || seen[a.ID] || math.IsNaN(a.Price) || math.IsInf(a.Price, 0) {
|
||||
panic("mujica: alerts need unique IDs and finite prices")
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
}
|
||||
if m.opts.Alerts == nil || len(m.candles) == 0 {
|
||||
return
|
||||
}
|
||||
last := m.candles[len(m.candles)-1]
|
||||
if !m.ch.res.Element.IsDisabled() && m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil {
|
||||
for _, a := range *m.opts.Alerts {
|
||||
if (m.st.lastClose-a.Price)*(last.Close-a.Price) <= 0 && m.st.lastClose != a.Price {
|
||||
slog.Debug("mujica: price_alert_line crossed", "id", a.ID)
|
||||
m.opts.OnAlert(a, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.st.lastClose, m.st.alertSeen = last.Close, true
|
||||
}
|
||||
|
||||
// alertsInput drags the threshold captured by the initial press.
|
||||
func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bool {
|
||||
if m.opts.Alerts == nil || m.base != 0 {
|
||||
return false
|
||||
}
|
||||
alerts := *m.opts.Alerts
|
||||
if !held {
|
||||
m.st.grab = -1
|
||||
return false
|
||||
}
|
||||
if m.st.grab < 0 {
|
||||
for i, a := range alerts {
|
||||
if g.plot.Contains(x, y-dy) && math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 14 {
|
||||
m.st.grab = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.st.grab < 0 || m.st.grab >= len(alerts) {
|
||||
return false
|
||||
}
|
||||
spec := numberInputResolve(NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec})
|
||||
alerts[m.st.grab].Price = spec.round(g.ys.Invert(y))
|
||||
slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID)
|
||||
return true
|
||||
}
|
||||
|
||||
// paintAlerts draws dashed thresholds and price labels.
|
||||
func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) {
|
||||
if m.opts.Alerts == nil || m.base != 0 {
|
||||
return
|
||||
}
|
||||
for i, a := range *m.opts.Alerts {
|
||||
y := internal.Snap(p, g.ys.Map(a.Price))
|
||||
if i == m.st.alertFocus {
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, g.plot.X+8), y, 9), m.k.Warning)
|
||||
}
|
||||
for x := g.plot.X; x < g.plot.X+g.plot.W; x += 8 {
|
||||
p.Line(x, y, min(x+4, g.plot.X+g.plot.W), y, 1, m.k.Warning)
|
||||
}
|
||||
text := priceUngrouped(a.Price, m.dec)
|
||||
if a.Label != "" {
|
||||
text = a.Label + " " + text
|
||||
}
|
||||
mktTag(p, g.plot.X+g.plot.W, y, fmt.Sprintf(msg(m.c, "price_alert_line.tag"), text), size, m.k.Warning, m.k.Surface)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMarketEmptyWindow(t *testing.T) {
|
||||
from, to := marketWindow(MarketView{}, 0, 200)
|
||||
if from != 0 || to != 0 {
|
||||
t.Fatalf("empty window %d..%d", from, to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDisabledAlert(t *testing.T) {
|
||||
cs := sampleCandles(2)
|
||||
v := MarketView{}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: cs[1].Close + 1}}
|
||||
disabled, fired := false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Price"}, Alerts: &alerts,
|
||||
OnAlert: func(PriceAlertLine, Candle) { fired++ }})
|
||||
})
|
||||
}, 500, 320)
|
||||
disabled = true
|
||||
next := cs[1]
|
||||
next.Time = next.Time.Add(3600e9)
|
||||
next.Close += 2
|
||||
next.High = max(next.High, next.Close)
|
||||
cs = append(cs, next)
|
||||
tt.Frame()
|
||||
if fired != 0 {
|
||||
t.Fatalf("disabled alert fired %d times", fired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDisabledLayoutCopy(t *testing.T) {
|
||||
state := MultiChartState{Count: 2}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() {
|
||||
MultiChartLayout(c, &state, MultiChartLayoutOptions{}, func(_ int, cell *ChartCell) {
|
||||
cell.Symbol = "changed"
|
||||
})
|
||||
})
|
||||
}, 600, 600)
|
||||
if state.Cells[0].Symbol != "" || state.Cells[1].Symbol != "" {
|
||||
t.Fatalf("disabled layout wrote cells: %+v", state.Cells)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDisabledParamsCopy(t *testing.T) {
|
||||
var params []float64
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() { IndicatorParamsField(c, IndicatorMA, ¶ms) })
|
||||
}, 500, 100)
|
||||
if params != nil {
|
||||
t.Fatalf("disabled field initialized caller parameters: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketInvalidFiniteData(t *testing.T) {
|
||||
for _, v := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("nonfinite candle accepted: %v", v)
|
||||
}
|
||||
}()
|
||||
cs := sampleCandles(1)
|
||||
cs[0].High = v
|
||||
marketCheckCandles(cs)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketLockedUndo(t *testing.T) {
|
||||
d := DrawingTools{Items: []Drawing{{Tool: ToolHorizontal}}}
|
||||
d.Clear()
|
||||
d.Locked = true
|
||||
if d.Undo() || len(d.Items) != 0 {
|
||||
t.Fatal("locked drawings accepted undo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"image"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMarketSyncRange(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
short := cs[:200]
|
||||
s := ChartSync{Count: 40, End: cs[249].Time}
|
||||
var a, b MarketView
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"})
|
||||
CandlestickChart(c, short, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"})
|
||||
})
|
||||
}, 640, 400)
|
||||
t.Logf("A=%+v B=%+v sync.End=%v", a, b, s.End)
|
||||
if a.End != 250 || s.End != cs[249].Time {
|
||||
t.Error("B erased A's explicit historical range")
|
||||
}
|
||||
}
|
||||
func TestMarketPaneKeyboardGeometry(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
v := MarketView{Count: 40}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorOBV}}}
|
||||
var r IndicatorPaneResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r = IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC", Height: 220})
|
||||
}, 640, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
from, to := marketWindow(v, 300, 192)
|
||||
t.Logf("Point=%d visible=%d..%d view=%+v", r.Point, from, to, v)
|
||||
if r.Point < from || r.Point >= to {
|
||||
t.Error("focused readout has no visible crosshair")
|
||||
}
|
||||
}
|
||||
func TestMarketAlertsWithDrawingTools(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{}
|
||||
a := []PriceAlertLine{{ID: "a", Price: 100}}
|
||||
d := DrawingTools{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Alerts: &a, Drawings: &d})
|
||||
}, 640, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
t.Logf("alert=%v", a[0].Price)
|
||||
if a[0].Price != 100.01 {
|
||||
t.Error("alert keyboard adjustment inaccessible with idle drawing tools")
|
||||
}
|
||||
}
|
||||
func TestMarketDrawingSwitchCancelsPending(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{}
|
||||
d := DrawingTools{Tool: ToolTrendLine}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Children(func() {
|
||||
DrawingToolbar(c, &d, DrawingToolbarOptions{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Drawings: &d}).Element.Grow(1)
|
||||
})
|
||||
}, 640, 450)
|
||||
tt.ClickAt(250, 100)
|
||||
if d.pending == nil {
|
||||
t.Fatal("setup missing pending point")
|
||||
}
|
||||
tt.Key(ui.Alt, ui.Key1)
|
||||
t.Logf("Tool=%d pending=%+v", d.Tool, d.pending)
|
||||
before := v
|
||||
tt.Press(300, 150)
|
||||
tt.Move(400, 150)
|
||||
tt.Release(400, 150)
|
||||
t.Logf("before=%+v after=%+v pending=%+v", before, v, d.pending)
|
||||
if d.Tool == ToolSelect && d.pending != nil {
|
||||
t.Error("Select retains previous tool preview and captures pan input")
|
||||
}
|
||||
}
|
||||
func TestMarketCountdownDST(t *testing.T) {
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 3, 8, 23, 30, 0, 0, loc)
|
||||
close := Interval("1h").Close(now, loc)
|
||||
t.Logf("now=%s closes=%s", now, close)
|
||||
if close.Hour() != 0 {
|
||||
t.Error("hourly candle closes off the wall-clock hour")
|
||||
}
|
||||
}
|
||||
func TestMarketPaneResizeGeometry(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
v := MarketView{Count: 40}
|
||||
p := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI}}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
IndicatorPane(c, cs, &v, &p, IndicatorPaneOptions{Label: "BTC", Height: 320})
|
||||
}, 640, 400)
|
||||
for i := 0; i < 12; i++ {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused("Resize MACD(12,26,9) and RSI(14)") {
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Logf("texts=%q", tt.Texts())
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
top, _ := tt.Find("MACD(12,26,9)")
|
||||
plot, _ := tt.Find("BTC MACD(12,26,9)")
|
||||
t.Logf("sizes=%v top=%+v plot=%+v", p.Sizes, top, plot)
|
||||
if plot.H == 0 {
|
||||
t.Error("resizing to advertised minimum completely hides the chart")
|
||||
}
|
||||
}
|
||||
func TestMarketMissingComparisonCache(t *testing.T) {
|
||||
cs := sampleCandles(100000)
|
||||
v := MarketView{}
|
||||
s := []CompareSymbol{{Name: "missing", Closes: make([]float64, len(cs))}}
|
||||
for i := range s[0].Closes {
|
||||
s[0].Closes[i] = math.NaN()
|
||||
}
|
||||
var r ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r = CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Compare: &s})
|
||||
}, 640, 320)
|
||||
memo := ui.Local(r.Element, "mujica.memo.compare.missing", func() chartMemoEntry { return chartMemoEntry{} })
|
||||
before := chartID(memo.val.([]float64))
|
||||
tt.Frame()
|
||||
memo = ui.Local(r.Element, "mujica.memo.compare.missing", func() chartMemoEntry { return chartMemoEntry{} })
|
||||
after := chartID(memo.val.([]float64))
|
||||
t.Logf("before=%s after=%s", before, after)
|
||||
if before != after {
|
||||
t.Error("unchanged missing comparison allocated and recomputed 100000 values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDailyAxisLocale(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
for i := range candles {
|
||||
candles[i].Time = candles[0].Time.AddDate(0, 0, i)
|
||||
}
|
||||
render := func(locale Locale) *image.RGBA {
|
||||
view := MarketView{}
|
||||
return ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: locale})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}})
|
||||
}, 640, 320, 1)
|
||||
}
|
||||
en, zh := render(EnUS), render(ZhCN)
|
||||
for y := 214; y < 240; y++ {
|
||||
for x := 60; x < 620; x++ {
|
||||
if en.RGBAAt(x, y) != zh.RGBAAt(x, y) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("Chinese daily time axis retained English labels")
|
||||
}
|
||||
|
||||
func TestMarketSyncDifferentIntervals(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
var slower []Candle
|
||||
for i := 0; i < len(cs); i += 2 {
|
||||
slower = append(slower, cs[i])
|
||||
}
|
||||
s := ChartSync{Count: 40, End: cs[199].Time}
|
||||
var a, b MarketView
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"})
|
||||
CandlestickChart(c, slower, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"})
|
||||
})
|
||||
}, 640, 400)
|
||||
tt.Frame()
|
||||
from, to := marketWindow(b, len(slower), 200)
|
||||
if b.Count != 20 || slower[from].Time != cs[160].Time || slower[to-1].Time != cs[198].Time || s.End != cs[199].Time {
|
||||
t.Fatalf("intervals drifted: a=%+v b=%+v sync=%+v", a, b, s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"mujicaui/theme"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) {
|
||||
for _, name := range []string{"Candlestick", "OHLC", "HeikinAshi", "Quote", "Volume", "Depth", "Profile", "Overlay", "Compare", "Alerts", "Drawings"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
raw := false
|
||||
bids, asks := sampleBook()
|
||||
bins := VolumeProfileOf(cs, 12)
|
||||
quotes, _, _ := samplePoints()
|
||||
symbols := []CompareSymbol{{Name: "Other", Closes: closesOf(cs)}}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: 100}}
|
||||
drawings := DrawingTools{}
|
||||
status := DataReady
|
||||
disabled, empty := false, false
|
||||
var r ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Density: theme.Compact})
|
||||
ui.Column(c).Disabled(disabled).Width(600).Children(func() {
|
||||
candles := cs
|
||||
if empty {
|
||||
candles = nil
|
||||
}
|
||||
frame := ChartFrame{Label: "Market", Height: 180, Status: status, Error: "Archive unavailable"}
|
||||
opts := CandlestickOptions{Frame: frame}
|
||||
switch name {
|
||||
case "OHLC":
|
||||
r = OHLCChart(c, candles, &v, opts)
|
||||
case "HeikinAshi":
|
||||
r = HeikinAshiChart(c, candles, &v, &raw, opts)
|
||||
case "Quote":
|
||||
q := quotes
|
||||
if empty {
|
||||
q = nil
|
||||
}
|
||||
r = LineQuoteChart(c, q, LineQuoteChartOptions{Frame: frame, PrevClose: 100})
|
||||
case "Volume":
|
||||
r = VolumeChart(c, candles, &v, VolumeChartOptions{Frame: frame})
|
||||
case "Depth":
|
||||
b, a := bids, asks
|
||||
if empty {
|
||||
b = nil
|
||||
a = nil
|
||||
}
|
||||
r = DepthChart(c, b, a, DepthChartOptions{Frame: frame})
|
||||
case "Profile":
|
||||
b := bins
|
||||
if empty {
|
||||
b = nil
|
||||
}
|
||||
r = VolumeProfile(c, b, VolumeProfileOptions{Frame: frame})
|
||||
default:
|
||||
if name == "Overlay" {
|
||||
opts.Overlays = []IndicatorOverlay{{Kind: IndicatorMA}}
|
||||
}
|
||||
if name == "Compare" {
|
||||
opts.Compare = &symbols
|
||||
}
|
||||
if name == "Alerts" {
|
||||
opts.Alerts = &alerts
|
||||
}
|
||||
if name == "Drawings" {
|
||||
opts.Drawings = &drawings
|
||||
}
|
||||
r = CandlestickChart(c, candles, &v, opts)
|
||||
}
|
||||
})
|
||||
}, 620, 350)
|
||||
for range 10 {
|
||||
if tt.Focused("Market") {
|
||||
break
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if r.Point < 0 || len(tt.Announcements()) == 0 {
|
||||
t.Fatalf("keyboard path point %d", r.Point)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
first := r.Point
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if name != "Depth" && name != "Profile" && r.Point != first {
|
||||
t.Fatalf("left boundary %d", r.Point)
|
||||
}
|
||||
disabled = true
|
||||
before := v
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
tt.Scroll(200, 120, 0, 20)
|
||||
tt.ClickAt(200, 120)
|
||||
if v != before {
|
||||
t.Fatalf("disabled window changed: %+v", v)
|
||||
}
|
||||
disabled = false
|
||||
tt.Frame()
|
||||
height := r.Element.Bounds().H
|
||||
for _, st := range []DataStatus{DataLoading, DataFailed, DataReady} {
|
||||
status = st
|
||||
empty = st == DataReady
|
||||
tt.Frame()
|
||||
if r.Point != -1 || r.Element.Bounds().H != height {
|
||||
t.Fatalf("state %v: point %d height %v want %v", st, r.Point, r.Element.Bounds().H, height)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"candlestick_chart.daily_time": {"Jan 2", "1月2日"},
|
||||
"candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"},
|
||||
"candlestick_chart.volume": {"Vol", "量"},
|
||||
"heikin_ashi_chart.smoothed": {"Heikin-Ashi", "平均 K 线"},
|
||||
"heikin_ashi_chart.raw": {"Candles", "原 K 线"},
|
||||
"heikin_ashi_chart.switch": {"Candle style", "K 线样式"},
|
||||
"compare_symbol.label": {"Compared symbols", "对比品种"},
|
||||
"compare_symbol.remove": {"Remove %s", "移除 %s"},
|
||||
"price_alert_line.tag": {"Alert %s", "预警 %s"},
|
||||
"chart_sync.link": {"Sync with other charts", "与其他图表同步"},
|
||||
"chart_sync.linked": {"Synced", "已同步"},
|
||||
"chart_sync.unlinked": {"Not synced", "未同步"},
|
||||
"indicator_pane.remove": {"Remove %s", "移除 %s"},
|
||||
"line_quote_chart.price": {"Price", "价格"},
|
||||
"line_quote_chart.average": {"Average", "均价"},
|
||||
"line_quote_chart.pre": {"Pre-market", "盘前"},
|
||||
"line_quote_chart.after": {"After hours", "盘后"},
|
||||
"depth_chart.bids": {"Bids", "买盘"},
|
||||
"depth_chart.asks": {"Asks", "卖盘"},
|
||||
"depth_chart.bid": {"Bid", "买"},
|
||||
"depth_chart.ask": {"Ask", "卖"},
|
||||
"depth_chart.total": {"cumulative %s", "累计 %s"},
|
||||
"depth_chart.mid": {"Mid", "中间价"},
|
||||
"volume_profile.value_area": {"Value area", "价值区"},
|
||||
"volume_profile.other": {"Outside", "区外"},
|
||||
"volume_profile.poc": {"POC", "控制点"},
|
||||
"volume_profile.vah": {"VAH", "价值区上沿"},
|
||||
"volume_profile.val": {"VAL", "价值区下沿"},
|
||||
"time_range_selector.custom": {"Custom", "自定义"},
|
||||
"time_range_selector.label": {"Time range", "时间范围"},
|
||||
"interval_selector.label": {"Interval", "周期"},
|
||||
"interval_selector.all": {"All intervals", "全部周期"},
|
||||
"interval_selector.minutes": {"Minutes", "分钟"},
|
||||
"interval_selector.hours": {"Hours", "小时"},
|
||||
"interval_selector.days": {"Days and longer", "日及以上"},
|
||||
"interval_selector.favorite": {"Add %s to favorites", "收藏 %s"},
|
||||
"interval_selector.unfavorite": {"Remove %s from favorites", "取消收藏 %s"},
|
||||
"chart_type_switcher.label": {"Chart type", "图表类型"},
|
||||
"chart_type_switcher.type0": {"Candles", "K 线"},
|
||||
"chart_type_switcher.type1": {"OHLC", "美国线"},
|
||||
"chart_type_switcher.type2": {"Line", "折线"},
|
||||
"chart_type_switcher.type3": {"Area", "面积"},
|
||||
"chart_type_switcher.type4": {"Heikin-Ashi", "平均 K 线"},
|
||||
"candle_countdown.label": {"%s candle closes in %s", "%s K 线 %s 后收盘"},
|
||||
"multi_chart_layout.label": {"Chart layout", "图表布局"},
|
||||
"multi_chart_layout.count": {"%d charts", "%d 宫格"},
|
||||
"multi_chart_layout.cell": {"Chart %d %s", "图表 %d %s"},
|
||||
"drawing_toolbar.label": {"Drawing tools", "画线工具"},
|
||||
"drawing_toolbar.tool0": {"Select", "选择"},
|
||||
"drawing_toolbar.tool1": {"Trend line", "趋势线"},
|
||||
"drawing_toolbar.tool2": {"Horizontal line", "水平线"},
|
||||
"drawing_toolbar.tool3": {"Fibonacci", "斐波那契"},
|
||||
"drawing_toolbar.tool4": {"Rectangle", "矩形"},
|
||||
"drawing_toolbar.tool5": {"Text", "文字"},
|
||||
"drawing_toolbar.lock": {"Lock drawings", "锁定画线"},
|
||||
"drawing_toolbar.unlock": {"Unlock drawings", "解锁画线"},
|
||||
"drawing_toolbar.undo": {"Undo", "撤销"},
|
||||
"drawing_toolbar.clear": {"Clear drawings", "清除画线"},
|
||||
"indicator_selector.button": {"Indicators (%d)", "指标(%d)"},
|
||||
"indicator_selector.title": {"Indicators", "指标"},
|
||||
"indicator_selector.search": {"Search indicators", "搜索指标"},
|
||||
"indicator_selector.available": {"Available", "可添加"},
|
||||
"indicator_selector.active": {"On the chart", "已添加"},
|
||||
"indicator_selector.overlay": {"Over prices", "主图叠加"},
|
||||
"indicator_selector.pane": {"Own pane", "副图"},
|
||||
"indicator_selector.add": {"Add", "添加"},
|
||||
"indicator_selector.add_named": {"Add %s", "添加 %s"},
|
||||
"indicator_selector.none": {"No indicator matches", "没有匹配的指标"},
|
||||
"drawing_tools.selected": {"Drawing %d selected", "已选择画线 %d"},
|
||||
"drawing_tools.note": {"Note", "标注"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartCell holds one chart's symbol, interval, and window.
|
||||
type ChartCell struct {
|
||||
Symbol string
|
||||
Interval Interval
|
||||
View MarketView
|
||||
}
|
||||
|
||||
// MultiChartState preserves six cells across layout changes.
|
||||
type MultiChartState struct {
|
||||
// Count is 1, 2, 4 or 6; zero is 1.
|
||||
Count int
|
||||
// Cells preserve their settings while hidden.
|
||||
Cells [6]ChartCell
|
||||
Active int
|
||||
}
|
||||
|
||||
// MultiChartLayoutOptions configure a MultiChartLayout.
|
||||
type MultiChartLayoutOptions struct {
|
||||
// Height of the grid; zero is 480.
|
||||
Height float32
|
||||
}
|
||||
|
||||
// MultiChartLayout builds 1, 2, 4, or 6 independent charts.
|
||||
func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayoutOptions, cell func(i int, cell *ChartCell)) ChoiceResult {
|
||||
if state == nil || cell == nil {
|
||||
panic("mujica: MultiChartLayout needs a state and a cell builder")
|
||||
}
|
||||
root := ui.Column(c).Gap(Density(c).Unit() * 2).FillWidth()
|
||||
if root.IsDisabled() {
|
||||
copy := *state
|
||||
state = ©
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: MultiChartLayout Height must be non-negative")
|
||||
}
|
||||
if state.Count == 0 {
|
||||
state.Count = 1
|
||||
}
|
||||
cols := map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count]
|
||||
if cols == 0 {
|
||||
panic(fmt.Sprintf("mujica: MultiChartLayout Count %d must be 1, 2, 4 or 6", state.Count))
|
||||
}
|
||||
if state.Active < 0 || state.Active >= state.Count {
|
||||
panic(fmt.Sprintf("mujica: MultiChartLayout Active %d outside %d cells", state.Active, state.Count))
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 480
|
||||
}
|
||||
changed := false
|
||||
root.Children(func() {
|
||||
sel := []int{state.Count}
|
||||
var items []ToggleItem[int]
|
||||
for _, n := range []int{1, 2, 4, 6} {
|
||||
items = append(items, ToggleItem[int]{Value: n, Label: fmt.Sprint(n), Name: fmt.Sprintf(msg(c, "multi_chart_layout.count"), n)})
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
if ToggleGroup(c, &sel, items, ToggleGroupOptions{Label: msg(c, "multi_chart_layout.label")}).Changed() {
|
||||
state.Count, changed = sel[0], true
|
||||
state.Active = min(state.Active, state.Count-1)
|
||||
slog.Debug("mujica: multi_chart_layout count", "count", state.Count)
|
||||
}
|
||||
})
|
||||
cols = map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count]
|
||||
rows := (state.Count + cols - 1) / cols
|
||||
grid := ui.Box(c).Grid().Columns(cols).Gap(u * 2).Height(h).FillWidth()
|
||||
grid.Children(func() {
|
||||
for i := range state.Count {
|
||||
box := ui.Column(c).Key(i).MinWidth(0).MinHeight(0).Height((h - u*2*float32(rows-1)) / float32(rows)).Padding(u * 2).
|
||||
Background(k.Surface).Radius(theme.CardRadius).Clip().Role(ui.RoleGroup).
|
||||
Label(fmt.Sprintf(msg(c, "multi_chart_layout.cell"), i+1, state.Cells[i].Symbol))
|
||||
if i == state.Active && state.Count > 1 {
|
||||
box.Border(2, k.AccentText)
|
||||
box.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Frame(p, r, 3, 5, k.Ornament) })
|
||||
} else {
|
||||
box.Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
if !box.IsDisabled() && (box.FocusWithin() || box.Clicked()) {
|
||||
if state.Active != i {
|
||||
state.Active, changed = i, true
|
||||
}
|
||||
}
|
||||
box.Children(func() { cell(i, &state.Cells[i]) })
|
||||
}
|
||||
})
|
||||
})
|
||||
return ChoiceResult{root, changed}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMultiChartLayoutKeepsCellsAndDisabled(t *testing.T) {
|
||||
state := MultiChartState{Count: 6, Active: 5}
|
||||
for i := range state.Cells {
|
||||
state.Cells[i] = ChartCell{Symbol: "BTC", Interval: "1h", View: MarketView{Count: 20 + i}}
|
||||
}
|
||||
disabled := false
|
||||
built := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
built = 0
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 200}, func(_ int, cell *ChartCell) { built++; ui.Text(c, cell.Symbol) })
|
||||
})
|
||||
}, 700, 300)
|
||||
if built != 6 {
|
||||
t.Fatalf("built %d", built)
|
||||
}
|
||||
if err := tt.Click("6 charts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if state.Count != 4 || state.Active != 3 || built != 4 {
|
||||
t.Fatalf("layout %+v built %d", state, built)
|
||||
}
|
||||
if state.Cells[5].View.Count != 25 {
|
||||
t.Fatal("hidden state lost")
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if state.Count != 6 || state.Cells[5].View.Count != 25 {
|
||||
t.Fatal("restored state lost")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if state.Count != 6 {
|
||||
t.Fatal("disabled layout changed")
|
||||
}
|
||||
}
|
||||
func TestMultiChartLayoutSnapshot(t *testing.T) {
|
||||
state := MultiChartState{Count: 4}
|
||||
snapshot(t, "multi_chart_layout", 640, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 280}, func(i int, cell *ChartCell) { ui.Text(c, "BTC · 1h") })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestOHLCChartSharesDataAndDiffersInLook(t *testing.T) {
|
||||
candles := sampleCandles(60)
|
||||
view := MarketView{Count: 40}
|
||||
ohlc := true
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ohlc {
|
||||
res = OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
} else {
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
}
|
||||
}, 600, 320)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 59 || mktState0(res).drawn != 40 {
|
||||
t.Fatalf("point %d drawn %d", res.Point, mktState0(res).drawn)
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
ohlc = false
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) {
|
||||
t.Fatal("OHLC bars look the same as candles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOHLCChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(60)
|
||||
view := MarketView{Count: 50}
|
||||
snapshot(t, "ohlc_chart", 640, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars", Height: 240}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,19 @@ func overlayEscape(c *ui.Context, key any) bool {
|
||||
// The root is around every focus, so it takes Escape before MyGo's overlays.
|
||||
byFocus := c.Root().Shortcut(0, ui.KeyEscape)
|
||||
byWindow := c.Shortcut(0, ui.KeyEscape)
|
||||
return byFocus || byWindow
|
||||
if byFocus || byWindow {
|
||||
s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} })
|
||||
for i, k := range s.keys {
|
||||
if k == key {
|
||||
s.keys = append(s.keys[:i], s.keys[i+1:]...)
|
||||
s.seen = append(s.seen[:i], s.seen[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Invalidate()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// overlaySiteState follows the focus of a layer after it closes.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestPriceAlertLineDragAndTrigger(t *testing.T) {
|
||||
candles := sampleCandles(60)
|
||||
view := MarketView{Count: 60}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: 100, Label: "Buy"}}
|
||||
var fired []string
|
||||
var res ChartResult
|
||||
_ = res
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Alerts: &alerts,
|
||||
OnAlert: func(a PriceAlertLine, last Candle) { fired = append(fired, a.ID) }})
|
||||
}, 800, 400)
|
||||
if !tt.HasText("BTC") {
|
||||
t.Fatal("not built")
|
||||
}
|
||||
// Drag the line from its own y, as painted.
|
||||
st := mktState0(res)
|
||||
plot := st.plot
|
||||
y := st.ys.Map(100)
|
||||
tt.Press(plot.X+100, y)
|
||||
tt.Move(plot.X+100, y-40)
|
||||
tt.Release(plot.X+100, y-40)
|
||||
if alerts[0].Price <= 100 || view.End != 0 {
|
||||
t.Fatalf("drag did not move the alert up, or panned: %+v %+v", alerts, view)
|
||||
}
|
||||
// A new close crossing the alert fires it once.
|
||||
alerts[0].Price = candles[59].Close + 0.5
|
||||
next := candles[59]
|
||||
next.Time = next.Time.Add(3600e9)
|
||||
next.Open, next.Close, next.High, next.Low = next.Close, next.Close+1, next.Close+1.2, next.Close-0.1
|
||||
candles = append(candles, next)
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if len(fired) != 1 || fired[0] != "a" {
|
||||
t.Fatalf("fired %v", fired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceAlertLineSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(60)
|
||||
view := MarketView{Count: 60}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: 104, Label: "Take profit"}, {ID: "b", Price: 96}}
|
||||
snapshot(t, "price_alert_line", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}, Alerts: &alerts})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 8.6 KiB |