WIP mktchart 1
This commit is contained in:
15 files changed
+1909
No files matched your search
@@ -0,0 +1,287 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartType is how a price chart draws candles. The zero value is ChartCandles.
|
||||
type ChartType int
|
||||
|
||||
const (
|
||||
ChartCandles ChartType = iota
|
||||
ChartOHLC
|
||||
ChartLine
|
||||
ChartArea
|
||||
ChartHeikinAshi
|
||||
)
|
||||
|
||||
// CandlestickOptions configure a CandlestickChart and its layers.
|
||||
type CandlestickOptions struct {
|
||||
Frame ChartFrame
|
||||
Type ChartType
|
||||
// Decimals of prices; zero means 2.
|
||||
Decimals int
|
||||
// Overlays draw indicators over the prices.
|
||||
Overlays []IndicatorOverlay
|
||||
// Compare overlays other symbols in percent; the chart turns to percent while any show.
|
||||
Compare *[]CompareSymbol
|
||||
// Alerts are draggable price lines; OnAlert runs when the last close crosses one.
|
||||
Alerts *[]PriceAlertLine
|
||||
OnAlert func(a PriceAlertLine, last Candle)
|
||||
// Drawings, when set, takes clicks for the current tool.
|
||||
Drawings *DrawingTools
|
||||
// Sync links the view and crosshair to other charts under SyncID.
|
||||
Sync *ChartSync
|
||||
SyncID string
|
||||
}
|
||||
|
||||
// mktState is a price chart's own frame-to-frame state.
|
||||
type mktState struct {
|
||||
drawn int // candles painted last frame
|
||||
dragRest float32 // pan drag left over, under a candle
|
||||
lastClose float64
|
||||
alertSeen bool
|
||||
grab int // alert index dragged, -1 none
|
||||
focus int // keyboard mark last frame, to follow only when it moves
|
||||
}
|
||||
|
||||
// mktCore is one price chart being built.
|
||||
type mktCore struct {
|
||||
c *ui.Context
|
||||
k theme.Tokens
|
||||
ch *chart
|
||||
candles []Candle
|
||||
view *MarketView
|
||||
from, to int
|
||||
lo, hi float64
|
||||
base float64 // percent mode divides by this close; 0 is prices
|
||||
dec int
|
||||
opts CandlestickOptions
|
||||
st *mktState
|
||||
overlays []mktLine
|
||||
}
|
||||
|
||||
// mktLine is one drawn indicator or compare line.
|
||||
type mktLine struct {
|
||||
name string
|
||||
values []float64
|
||||
color ui.Color
|
||||
shape int
|
||||
}
|
||||
|
||||
// CandlestickChart draws candles of the view's window, building and painting only those; rises and falls use the window's Trend colors and hollow or filled bodies.
|
||||
func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult {
|
||||
if view == nil {
|
||||
panic("mujica: CandlestickChart needs a view")
|
||||
}
|
||||
if opts.Type < ChartCandles || opts.Type > ChartHeikinAshi {
|
||||
panic(fmt.Sprintf("mujica: unknown ChartType %d", opts.Type))
|
||||
}
|
||||
if opts.Sync != nil && opts.SyncID == "" {
|
||||
panic("mujica: CandlestickChart Sync needs a SyncID")
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
shown := chartMemo(ch, "candles", [2]any{chartID(candles), opts.Type}, func() []Candle {
|
||||
marketCheckCandles(candles)
|
||||
if opts.Type == ChartHeikinAshi {
|
||||
return HeikinAshi(candles)
|
||||
}
|
||||
return candles
|
||||
})
|
||||
m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: shown, view: view, opts: opts, dec: priceDecimals("CandlestickChart", opts.Decimals)}
|
||||
m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1} })
|
||||
if s := opts.Sync; s != nil {
|
||||
s.load(opts.SyncID, shown, view)
|
||||
}
|
||||
m.window()
|
||||
m.overlays = mktOverlayLines(ch, m, candles)
|
||||
m.compareLines()
|
||||
m.yDomain()
|
||||
m.alertsCheck()
|
||||
res, plot := ch.build(m.spec())
|
||||
if plot != nil {
|
||||
m.input(plot)
|
||||
}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.store(opts.SyncID, shown, view, res.Point)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// OHLCChart is CandlestickChart drawn as open-high-low-close bars.
|
||||
func OHLCChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult {
|
||||
opts.Type = ChartOHLC
|
||||
return CandlestickChart(c, candles, view, opts)
|
||||
}
|
||||
|
||||
// HeikinAshiChart is CandlestickChart of Heikin-Ashi candles computed from candles; *raw switches back to them.
|
||||
func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *bool, opts CandlestickOptions) ChartResult {
|
||||
if raw == nil {
|
||||
panic("mujica: HeikinAshiChart needs raw")
|
||||
}
|
||||
opts.Type = ChartHeikinAshi
|
||||
if *raw {
|
||||
opts.Type = ChartCandles
|
||||
}
|
||||
var res ChartResult
|
||||
col := ui.Column(c).Gap(Density(c).Unit() * 2).MinWidth(0)
|
||||
col.Children(func() {
|
||||
ui.Row(c).Children(func() {
|
||||
v := []bool{*raw}
|
||||
if ToggleGroup(c, &v, []ToggleItem[bool]{{Value: false, Label: msg(c, "heikin_ashi.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi.raw")}},
|
||||
ToggleGroupOptions{Label: msg(c, "heikin_ashi.switch")}).Changed() {
|
||||
*raw = v[0]
|
||||
slog.Debug("mujica: heikin_ashi switch", "raw", *raw)
|
||||
}
|
||||
})
|
||||
res = CandlestickChart(c, candles, view, opts)
|
||||
})
|
||||
res.Element = col
|
||||
return res
|
||||
}
|
||||
|
||||
// window resolves the shown candles from the view and last frame's width.
|
||||
func (m *mktCore) window() {
|
||||
m.from, m.to = marketWindow(*m.view, len(m.candles), m.room())
|
||||
}
|
||||
|
||||
// room is how many candles fit, three DIPs each.
|
||||
func (m *mktCore) room() int {
|
||||
w := m.ch.res.Element.Bounds().W - 64
|
||||
if w <= 0 {
|
||||
return 200
|
||||
}
|
||||
return max(10, int(w/3))
|
||||
}
|
||||
|
||||
// yDomain spans the window's lows and highs, overlays and alerts, recomputed only when they change.
|
||||
func (m *mktCore) yDomain() {
|
||||
if m.to <= m.from {
|
||||
return
|
||||
}
|
||||
var alerts []PriceAlertLine
|
||||
if m.opts.Alerts != nil {
|
||||
alerts = *m.opts.Alerts
|
||||
}
|
||||
ids := fmt.Sprintf("%s|%d|%d|%v|%d|%v", chartID(m.candles), m.from, m.to, m.base, len(m.overlays), alerts)
|
||||
for _, l := range m.overlays {
|
||||
ids += chartID(l.values)
|
||||
}
|
||||
d := chartMemo(m.ch, "ydomain", ids, func() [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for i := m.from; i < m.to; i++ {
|
||||
lo, hi = min(lo, m.val(m.candles[i].Low)), max(hi, m.val(m.candles[i].High))
|
||||
for _, l := range m.overlays {
|
||||
if v := l.values[i]; !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.base == 0 {
|
||||
for _, a := range alerts {
|
||||
lo, hi = min(lo, a.Price), max(hi, a.Price)
|
||||
}
|
||||
}
|
||||
return [2]float64{lo, hi}
|
||||
})
|
||||
m.lo, m.hi = d[0], d[1]
|
||||
}
|
||||
|
||||
// val maps a price to what the axis shows: itself, or percent from base.
|
||||
func (m *mktCore) val(p float64) float64 {
|
||||
if m.base == 0 {
|
||||
return p
|
||||
}
|
||||
return (p/m.base - 1) * 100
|
||||
}
|
||||
|
||||
// mktGeom places candles and prices in a plot.
|
||||
type mktGeom struct {
|
||||
plot ui.Rect
|
||||
band internal.Band
|
||||
ys internal.Linear
|
||||
ticks []float64
|
||||
step float64
|
||||
}
|
||||
|
||||
func (m *mktCore) geom(r ui.Rect) mktGeom {
|
||||
plot := chartInset(r, 60, 26)
|
||||
lo, hi, step := internal.Nice(m.lo, m.hi, 4)
|
||||
return mktGeom{
|
||||
plot: plot,
|
||||
band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)},
|
||||
ticks: internal.Ticks(lo, hi, step),
|
||||
step: step,
|
||||
}
|
||||
}
|
||||
|
||||
func (g mktGeom) x(m *mktCore, i int) float32 { return g.band.Center(i - m.from) }
|
||||
|
||||
// index is the candle under x, clamped to the window.
|
||||
func (g mktGeom) index(m *mktCore, x float32) int {
|
||||
i := m.from + int((x-g.band.R0)/g.band.Step())
|
||||
return max(m.from, min(m.to-1, i))
|
||||
}
|
||||
|
||||
func (m *mktCore) spec() chartSpec {
|
||||
n := len(m.candles)
|
||||
var keys []chartKey
|
||||
for _, l := range m.overlays {
|
||||
keys = append(keys, chartKey{Name: l.name, Color: l.color, Shape: l.shape})
|
||||
}
|
||||
spec := chartSpec{n: n, keys: keys}
|
||||
if m.to <= m.from {
|
||||
spec.n = 0
|
||||
return spec
|
||||
}
|
||||
spec.pick = func(r ui.Rect, x, y float32) int {
|
||||
g := m.geom(r)
|
||||
if !g.plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return g.index(m, x)
|
||||
}
|
||||
spec.move = func(i int, key ui.Key) int {
|
||||
if key == ui.KeyLeft || key == ui.KeyUp {
|
||||
return max(i-1, 0)
|
||||
}
|
||||
return min(i+1, n-1)
|
||||
}
|
||||
spec.readout = m.readout
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) }
|
||||
return spec
|
||||
}
|
||||
|
||||
// readout says the candle's time, prices, change and direction, and the overlays there.
|
||||
func (m *mktCore) readout(i int) string {
|
||||
c := m.candles[i]
|
||||
f := func(v float64) string { return priceUngrouped(v, m.dec) }
|
||||
prev := c.Open
|
||||
if i > 0 {
|
||||
prev = m.candles[i-1].Close
|
||||
}
|
||||
sign := trendSign(c.Close - prev)
|
||||
pct := 0.0
|
||||
if prev != 0 {
|
||||
pct = (c.Close/prev - 1) * 100
|
||||
}
|
||||
parts := []string{
|
||||
c.Time.Format("2006-01-02 15:04"),
|
||||
fmt.Sprintf(msg(m.c, "candlestick_chart.ohlc"), f(c.Open), f(c.High), f(c.Low), f(c.Close)),
|
||||
fmt.Sprintf("%+.2f%% %s", pct, trendWord(m.c, sign)),
|
||||
msg(m.c, "candlestick_chart.volume") + " " + statisticFormat(c.Volume, 0),
|
||||
}
|
||||
for _, l := range m.overlays {
|
||||
parts = append(parts, l.name+" "+chartValue(m.c, l.values[i], f))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// sampleCandles is a deterministic random walk, hourly from 2026-01-01.
|
||||
func sampleCandles(n int) []Candle {
|
||||
out := make([]Candle, n)
|
||||
price := 100.0
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range out {
|
||||
o := price
|
||||
c := o + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8
|
||||
hi, lo := max(o, c)+0.6+math.Abs(math.Sin(float64(i)))*0.5, min(o, c)-0.5-math.Abs(math.Cos(float64(i)))*0.4
|
||||
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: o, High: hi, Low: lo, Close: c, Volume: 1000 + float64(i%17)*130}
|
||||
price = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mktState0(r ChartResult) *mktState {
|
||||
return *ui.Local(r.Element, "mujica.mkt", func() *mktState { return nil })
|
||||
}
|
||||
|
||||
func TestCandlestickChartDrawsOnlyVisible(t *testing.T) {
|
||||
candles := sampleCandles(100000)
|
||||
var view MarketView
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})
|
||||
}, 900, 400)
|
||||
tt.Frame()
|
||||
st := mktState0(res)
|
||||
if st.drawn != 120 {
|
||||
t.Fatalf("drew %d candles of 100000", st.drawn)
|
||||
}
|
||||
// Zooming out is capped by the width: three DIPs a candle.
|
||||
tt.Key(0, ui.KeyTab)
|
||||
for range 40 {
|
||||
tt.Key(0, ui.KeyMinus)
|
||||
}
|
||||
if st.drawn > 900/3 || view.Count > 900/3 {
|
||||
t.Fatalf("zoomed out: drew %d, view %+v", st.drawn, view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartKeyboardPanZoomReadout(t *testing.T) {
|
||||
candles := sampleCandles(500)
|
||||
view := MarketView{Count: 50}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})
|
||||
}, 900, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 499 || !tt.HasText("2026-01-21 19:00") {
|
||||
t.Fatalf("End: point %d texts %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyLeft)
|
||||
if view.End != 495 || view.Count != 50 {
|
||||
t.Fatalf("Shift+Left: %+v", view)
|
||||
}
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
if view.Count != 40 {
|
||||
t.Fatalf("zoom in: %+v", view)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
if f, _ := marketWindow(view, 500, 300); f != 0 {
|
||||
t.Fatalf("Home did not bring the first candle into view: %+v", view)
|
||||
}
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "O 100.00") && strings.Contains(s, "Vol 1,000")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartDragPansAndRejects(t *testing.T) {
|
||||
candles := sampleCandles(300)
|
||||
view := MarketView{Count: 100}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}).Element.Width(860)
|
||||
}, 900, 400)
|
||||
tt.Frame()
|
||||
tt.Press(400, 150)
|
||||
tt.Move(560, 150) // about 20 candles of 8 DIPs
|
||||
tt.Release(560, 150)
|
||||
if view.End < 275 || view.End > 285 {
|
||||
t.Fatalf("drag right shows older candles: %+v", view)
|
||||
}
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"type": func(c *ui.Context) {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Type: 9})
|
||||
},
|
||||
"order": func(c *ui.Context) {
|
||||
bad := []Candle{candles[1], candles[0]}
|
||||
CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
"prices": func(c *ui.Context) {
|
||||
bad := []Candle{{Time: candles[0].Time, Open: 5, High: 4, Low: 3, Close: 4}}
|
||||
CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartTrendColors(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
view := MarketView{Count: 30}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
UseTrend(c, RedUp)
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "沪深300"}})
|
||||
}, 600, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if res.Point != 0 || !tt.HasText("开 100.00") {
|
||||
t.Fatalf("zh readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(80)
|
||||
view := MarketView{Count: 60}
|
||||
snapshot(t, "candlestick_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// paint draws axes, the window's candles, layers and the crosshair at active.
|
||||
func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) {
|
||||
g := m.geom(r)
|
||||
k := m.k
|
||||
size := FontSize(m.c, theme.CaptionSize)
|
||||
label := func(v float64) string {
|
||||
if m.base != 0 {
|
||||
return internal.FormatTick(v, g.step) + "%"
|
||||
}
|
||||
return internal.FormatTick(v, g.step)
|
||||
}
|
||||
internal.AxisY(p, g.plot, g.ys, g.ticks, label, size, k)
|
||||
m.paintTimeAxis(p, g, size)
|
||||
p.Clip(g.plot, 0, func() {
|
||||
m.st.drawn = m.paintCandles(p, g)
|
||||
for i, l := range m.overlays {
|
||||
mktPaintLine(p, g, m, l.values, l.color, i)
|
||||
}
|
||||
m.paintAlerts(p, g, size)
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
d.paint(p, g, m, size)
|
||||
}
|
||||
})
|
||||
at := active
|
||||
if at < 0 && m.opts.Sync != nil {
|
||||
at = m.opts.Sync.cursorIndex(m.opts.SyncID, m.candles)
|
||||
}
|
||||
if at >= m.from && at < m.to {
|
||||
x := internal.Snap(p, g.x(m, at))
|
||||
y := internal.Snap(p, g.ys.Map(m.val(m.candles[at].Close)))
|
||||
p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted)
|
||||
p.Line(g.plot.X, y, g.plot.X+g.plot.W, y, 1, k.TextMuted.Alpha(0.6))
|
||||
mktTag(p, g.plot.X+g.plot.W, y, label(m.val(m.candles[at].Close)), size, k.Text, k.Surface)
|
||||
}
|
||||
}
|
||||
|
||||
// paintTimeAxis labels candles about 90 DIPs apart.
|
||||
func (m *mktCore) paintTimeAxis(p *ui.Painter, g mktGeom, size float32) {
|
||||
every := max(1, int(math.Ceil(float64(90/g.band.Step()))))
|
||||
layout := "Jan 2"
|
||||
if m.to-m.from > 1 && m.candles[m.from+1].Time.Sub(m.candles[m.from].Time) < 24*time.Hour {
|
||||
layout = "15:04"
|
||||
}
|
||||
var xs []float32
|
||||
var labels []string
|
||||
for i := m.from; i < m.to; i += every {
|
||||
xs = append(xs, g.x(m, i))
|
||||
labels = append(labels, m.candles[i].Time.Format(layout))
|
||||
}
|
||||
internal.AxisX(p, g.plot, xs, labels, size, m.k)
|
||||
}
|
||||
|
||||
// paintCandles draws the window as the chart's type and returns how many candles it drew.
|
||||
func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int {
|
||||
k := m.k
|
||||
w := max(1, g.band.Width())
|
||||
if m.opts.Type == ChartLine || m.opts.Type == ChartArea {
|
||||
closes := make([]float64, len(m.candles))
|
||||
for i := m.from; i < m.to; i++ {
|
||||
closes[i] = m.val(m.candles[i].Close)
|
||||
}
|
||||
var line, fill ui.Path
|
||||
for i := m.from; i < m.to; i++ {
|
||||
x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(closes[i]))
|
||||
if i == m.from {
|
||||
line.MoveTo(x, y)
|
||||
fill.MoveTo(x, g.plot.Y+g.plot.H).LineTo(x, y)
|
||||
} else {
|
||||
line.LineTo(x, y)
|
||||
fill.LineTo(x, y)
|
||||
}
|
||||
}
|
||||
if m.opts.Type == ChartArea {
|
||||
fill.LineTo(internal.Snap(p, g.x(m, m.to-1)), g.plot.Y+g.plot.H).Close()
|
||||
p.FillPath(&fill, k.AccentText.Alpha(0.18))
|
||||
}
|
||||
p.StrokePath(&line, 2, k.AccentText)
|
||||
return m.to - m.from
|
||||
}
|
||||
for i := m.from; i < m.to; i++ {
|
||||
c := m.candles[i]
|
||||
up := c.Close >= c.Open
|
||||
col := trendColor(m.c, 1)
|
||||
if !up {
|
||||
col = trendColor(m.c, -1)
|
||||
}
|
||||
x := internal.Snap(p, g.x(m, i))
|
||||
yo, yc := g.ys.Map(m.val(c.Open)), g.ys.Map(m.val(c.Close))
|
||||
yh, yl := g.ys.Map(m.val(c.High)), g.ys.Map(m.val(c.Low))
|
||||
if m.opts.Type == ChartOHLC {
|
||||
p.Line(x, yh, x, yl, 1.5, col)
|
||||
p.Line(x-w/2, yo, x, yo, 1.5, col)
|
||||
p.Line(x, yc, x+w/2, yc, 1.5, col)
|
||||
continue
|
||||
}
|
||||
top, bot := min(yo, yc), max(yo, yc)
|
||||
body := ui.Rect{X: x - w/2, Y: top, W: w, H: max(1, bot-top)}
|
||||
p.Line(x, yh, x, top, 1, col)
|
||||
p.Line(x, bot, x, yl, 1, col)
|
||||
// Rises are hollow and falls filled, so direction reads without color.
|
||||
if up && w >= 3 {
|
||||
p.Fill(body, k.Surface, 0)
|
||||
p.Stroke(body, col, 0, 1)
|
||||
} else {
|
||||
p.Fill(body, col, 0)
|
||||
}
|
||||
}
|
||||
return m.to - m.from
|
||||
}
|
||||
|
||||
// mktPaintLine draws values over the window with sparse markers of shape.
|
||||
func mktPaintLine(p *ui.Painter, g mktGeom, m *mktCore, values []float64, col ui.Color, shape int) {
|
||||
var path ui.Path
|
||||
open := false
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v := values[i]
|
||||
if math.IsNaN(v) {
|
||||
open = false
|
||||
continue
|
||||
}
|
||||
x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(v))
|
||||
if open {
|
||||
path.LineTo(x, y)
|
||||
} else {
|
||||
path.MoveTo(x, y)
|
||||
open = true
|
||||
}
|
||||
}
|
||||
p.StrokePath(&path, 1.5, col)
|
||||
every := max(1, int(60/max(g.band.Step(), 0.1)))
|
||||
for i := m.from + shape%every; i < m.to; i += every {
|
||||
if v := values[i]; !math.IsNaN(v) {
|
||||
internal.Marker(p, shape, g.x(m, i), g.ys.Map(v), 6, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mktTag draws a price label at the plot's right edge, centered on y.
|
||||
func mktTag(p *ui.Painter, right, y float32, s string, size float32, fg, bg ui.Color) {
|
||||
w, h := p.MeasureText(0, ui.Span{Text: s, Size: size})
|
||||
b := ui.Rect{X: right - w - 8, Y: y - h/2 - 2, W: w + 8, H: h + 4}
|
||||
p.Fill(b, bg, theme.ControlRadius)
|
||||
p.Stroke(b, fg, theme.ControlRadius, 1)
|
||||
p.Text(b.X+4, b.Y+2, s, size, fg)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartSync links charts by time: the same window, zoom and crosshair. The caller holds it.
|
||||
type ChartSync struct {
|
||||
// Count is the linked zoom in candles; zero is the default 120.
|
||||
Count int
|
||||
// End is the newest linked time shown; zero follows each chart's newest candle.
|
||||
End time.Time
|
||||
// Cursor is the crosshair time; zero shows none.
|
||||
Cursor time.Time
|
||||
// Unlinked charts keep their own view and crosshair.
|
||||
Unlinked map[string]bool
|
||||
owner string
|
||||
}
|
||||
|
||||
func (s *ChartSync) linked(id string) bool { return !s.Unlinked[id] }
|
||||
|
||||
// load puts the linked window into view before a chart builds.
|
||||
func (s *ChartSync) load(id string, candles []Candle, view *MarketView) {
|
||||
if !s.linked(id) {
|
||||
return
|
||||
}
|
||||
view.Count, view.End = s.Count, 0
|
||||
if !s.End.IsZero() {
|
||||
if i := marketIndexAt(candles, s.End); i+1 < len(candles) {
|
||||
view.End = max(1, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store writes a linked chart's window and crosshair back after it builds.
|
||||
func (s *ChartSync) store(id string, candles []Candle, view *MarketView, active int) {
|
||||
if !s.linked(id) {
|
||||
return
|
||||
}
|
||||
s.Count = view.Count
|
||||
s.End = time.Time{}
|
||||
if view.End > 0 && view.End <= len(candles) {
|
||||
s.End = candles[view.End-1].Time
|
||||
}
|
||||
switch {
|
||||
case active >= 0:
|
||||
s.Cursor, s.owner = candles[active].Time, id
|
||||
case s.owner == id:
|
||||
s.Cursor, s.owner = time.Time{}, ""
|
||||
}
|
||||
}
|
||||
|
||||
// cursorIndex is the candle at the linked crosshair, or -1.
|
||||
func (s *ChartSync) cursorIndex(id string, candles []Candle) int {
|
||||
if !s.linked(id) || s.Cursor.IsZero() || s.owner == id {
|
||||
return -1
|
||||
}
|
||||
return marketIndexAt(candles, s.Cursor)
|
||||
}
|
||||
|
||||
// Link builds a toggle that links or unlinks chart id; Changed reports a switch.
|
||||
func (s *ChartSync) Link(c *ui.Context, id string) ChoiceResult {
|
||||
if id == "" {
|
||||
panic("mujica: ChartSync.Link needs an id")
|
||||
}
|
||||
k := Tokens(c)
|
||||
on := s.linked(id)
|
||||
pressed := on
|
||||
b := ui.ToggleBase(c, &pressed).MinHeight(theme.MinHitSize).Padding(0, Density(c).Unit()*2).Gap(Density(c).Unit()).
|
||||
Radius(theme.ControlRadius).Label(msg(c, "chart_sync.link"))
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
changed := b.Changed() && !b.IsDisabled()
|
||||
if changed {
|
||||
if s.Unlinked == nil {
|
||||
s.Unlinked = map[string]bool{}
|
||||
}
|
||||
on = pressed
|
||||
s.Unlinked[id] = !on
|
||||
if s.owner == id {
|
||||
s.Cursor, s.owner = time.Time{}, ""
|
||||
}
|
||||
slog.Debug("mujica: chart_sync link", "chart", id, "linked", on)
|
||||
}
|
||||
fg, bg, word := k.TextMuted, ui.Transparent, msg(c, "chart_sync.unlinked")
|
||||
if on {
|
||||
fg, bg, word = k.AccentText, k.Selection, msg(c, "chart_sync.linked")
|
||||
}
|
||||
if b.Hovered() && !on {
|
||||
bg = k.SurfaceHover
|
||||
}
|
||||
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, fg).Transition(colorFade)
|
||||
b.Children(func() {
|
||||
mark := "circle"
|
||||
if on {
|
||||
mark = "check"
|
||||
}
|
||||
ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, mark, fg) })
|
||||
ui.Text(c, word).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
return ChoiceResult{b, changed}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// DrawingTool is what clicks on a price chart do. The zero value selects and moves drawings.
|
||||
type DrawingTool int
|
||||
|
||||
const (
|
||||
ToolSelect DrawingTool = iota
|
||||
ToolTrendLine
|
||||
ToolHorizontal
|
||||
ToolFibonacci
|
||||
ToolRectangle
|
||||
ToolText
|
||||
)
|
||||
|
||||
// DrawingPoint anchors a drawing at a candle time and a price, so it survives panning and new data.
|
||||
type DrawingPoint struct {
|
||||
Time time.Time
|
||||
Price float64
|
||||
}
|
||||
|
||||
// Drawing is one annotation; one-point tools use A only.
|
||||
type Drawing struct {
|
||||
Tool DrawingTool
|
||||
A, B DrawingPoint
|
||||
Text string
|
||||
}
|
||||
|
||||
// DrawingTools is the caller-held set of drawings, the current tool and an undo history.
|
||||
type DrawingTools struct {
|
||||
Items []Drawing
|
||||
Tool DrawingTool
|
||||
// Text is what the next text annotation says.
|
||||
Text string
|
||||
// Locked stops selecting, moving, deleting and drawing.
|
||||
Locked bool
|
||||
|
||||
sel int // selected item plus one; zero is none
|
||||
pending *Drawing
|
||||
undo [][]Drawing
|
||||
moving bool
|
||||
}
|
||||
|
||||
// fibLevels are the retracement ratios drawn between a Fibonacci's two points.
|
||||
var fibLevels = []float64{0, 0.236, 0.382, 0.5, 0.618, 0.786, 1}
|
||||
|
||||
// Selected returns the selected drawing's index, or -1.
|
||||
func (d *DrawingTools) Selected() int { return d.sel - 1 }
|
||||
|
||||
// CanUndo reports whether Undo has a step to take back.
|
||||
func (d *DrawingTools) CanUndo() bool { return len(d.undo) > 0 }
|
||||
|
||||
func (d *DrawingTools) save() {
|
||||
d.undo = append(d.undo, slices.Clone(d.Items))
|
||||
}
|
||||
|
||||
// Undo restores the drawings before the last change and reports whether there was one.
|
||||
func (d *DrawingTools) Undo() bool {
|
||||
n := len(d.undo)
|
||||
if n == 0 {
|
||||
return false
|
||||
}
|
||||
d.Items, d.undo = d.undo[n-1], d.undo[:n-1]
|
||||
d.sel, d.pending = 0, nil
|
||||
slog.Debug("mujica: drawing_tools undo", "items", len(d.Items))
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete removes the selected drawing, undoably.
|
||||
func (d *DrawingTools) Delete() bool {
|
||||
if d.Locked || d.sel == 0 || d.sel > len(d.Items) {
|
||||
return false
|
||||
}
|
||||
d.save()
|
||||
d.Items = slices.Delete(slices.Clone(d.Items), d.sel-1, d.sel)
|
||||
d.sel = 0
|
||||
slog.Debug("mujica: drawing_tools delete")
|
||||
return true
|
||||
}
|
||||
|
||||
// Clear removes every drawing, undoably.
|
||||
func (d *DrawingTools) Clear() bool {
|
||||
if d.Locked || len(d.Items) == 0 {
|
||||
return false
|
||||
}
|
||||
d.save()
|
||||
d.Items, d.sel, d.pending = nil, 0, nil
|
||||
slog.Debug("mujica: drawing_tools clear")
|
||||
return true
|
||||
}
|
||||
|
||||
// twoPoint reports whether t needs a second click.
|
||||
func (t DrawingTool) twoPoint() bool {
|
||||
return t == ToolTrendLine || t == ToolFibonacci || t == ToolRectangle
|
||||
}
|
||||
|
||||
// point turns a plot position into a drawing point on the nearest candle.
|
||||
func (d *DrawingTools) point(m *mktCore, g mktGeom, x, y float32) DrawingPoint {
|
||||
return DrawingPoint{Time: m.candles[g.index(m, x)].Time, Price: m.priceAt(g.ys.Invert(y))}
|
||||
}
|
||||
|
||||
// priceAt turns an axis value back into a price, rounded to the decimals.
|
||||
func (m *mktCore) priceAt(v float64) float64 {
|
||||
if m.base != 0 {
|
||||
v = m.base * (1 + v/100)
|
||||
}
|
||||
p := math.Pow(10, float64(m.dec))
|
||||
return math.Round(v*p) / p
|
||||
}
|
||||
|
||||
// xy places a drawing point in the plot.
|
||||
func (d *DrawingTools) xy(m *mktCore, g mktGeom, pt DrawingPoint) (float32, float32) {
|
||||
i := max(0, marketIndexAt(m.candles, pt.Time))
|
||||
return g.x(m, i), g.ys.Map(m.val(pt.Price))
|
||||
}
|
||||
|
||||
// input places, selects, drags, nudges and deletes drawings; it reports taking the pointer.
|
||||
func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool {
|
||||
if d.Tool < ToolSelect || d.Tool > ToolText {
|
||||
panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool))
|
||||
}
|
||||
if plot.Shortcut(ui.Cmd, ui.KeyZ) {
|
||||
d.Undo()
|
||||
}
|
||||
if d.Locked {
|
||||
d.pending, d.moving = nil, false
|
||||
return false
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyDelete) || plot.Shortcut(0, ui.KeyBackspace) {
|
||||
d.Delete()
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEscape) {
|
||||
d.pending, d.sel = nil, 0
|
||||
}
|
||||
if s := d.sel - 1; s >= 0 && s < len(d.Items) {
|
||||
step := map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, 1}, ui.KeyDown: {0, -1}}
|
||||
for key, v := range step {
|
||||
if plot.Shortcut(ui.Alt, key) {
|
||||
d.save()
|
||||
d.nudge(m, s, v[0], float64(v[1])*g.step/10)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !over && !held {
|
||||
return false
|
||||
}
|
||||
if plot.Clicked() && d.Tool != ToolSelect {
|
||||
pt := d.point(m, g, x, y)
|
||||
switch {
|
||||
case d.Tool.twoPoint() && d.pending == nil:
|
||||
d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt}
|
||||
default:
|
||||
item := Drawing{Tool: d.Tool, A: pt, B: pt, Text: d.Text}
|
||||
if d.pending != nil {
|
||||
item.A = d.pending.A
|
||||
}
|
||||
if d.Tool == ToolText && item.Text == "" {
|
||||
item.Text = msg(m.c, "drawing_tools.note")
|
||||
}
|
||||
d.save()
|
||||
d.Items = append(slices.Clone(d.Items), item)
|
||||
d.sel, d.pending, d.Tool = len(d.Items), nil, ToolSelect
|
||||
slog.Debug("mujica: drawing_tools add", "tool", item.Tool)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if d.pending != nil {
|
||||
d.pending.B = d.point(m, g, x, y)
|
||||
return true
|
||||
}
|
||||
if d.Tool != ToolSelect {
|
||||
return held
|
||||
}
|
||||
switch {
|
||||
case held && !d.moving:
|
||||
hit := d.hit(m, g, x-dx, y-dy)
|
||||
if hit < 0 {
|
||||
d.sel = 0
|
||||
return false
|
||||
}
|
||||
d.sel, d.moving = hit+1, true
|
||||
d.save()
|
||||
case !held:
|
||||
d.moving = false
|
||||
if plot.Clicked() {
|
||||
d.sel = d.hit(m, g, x, y) + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
if dx != 0 || dy != 0 {
|
||||
steps := int(math.Round(float64(dx / g.band.Step())))
|
||||
d.nudge(m, d.sel-1, steps, m.priceAt(g.ys.Invert(y))-m.priceAt(g.ys.Invert(y-dy)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nudge moves item i by candles and price, keeping it on the chart's candles.
|
||||
func (d *DrawingTools) nudge(m *mktCore, i, candles int, price float64) {
|
||||
it := &d.Items[i]
|
||||
move := func(pt *DrawingPoint) {
|
||||
j := max(0, min(len(m.candles)-1, marketIndexAt(m.candles, pt.Time)+candles))
|
||||
pt.Time, pt.Price = m.candles[j].Time, pt.Price+price
|
||||
}
|
||||
move(&it.A)
|
||||
move(&it.B)
|
||||
}
|
||||
|
||||
// hit returns the drawing within 6 DIPs of (x, y), newest first, or -1.
|
||||
func (d *DrawingTools) hit(m *mktCore, g mktGeom, x, y float32) int {
|
||||
for i := len(d.Items) - 1; i >= 0; i-- {
|
||||
it := d.Items[i]
|
||||
ax, ay := d.xy(m, g, it.A)
|
||||
bx, by := d.xy(m, g, it.B)
|
||||
near := false
|
||||
switch it.Tool {
|
||||
case ToolHorizontal:
|
||||
near = math.Abs(float64(y-ay)) <= 6
|
||||
case ToolText:
|
||||
near = math.Abs(float64(x-ax)) <= 40 && math.Abs(float64(y-ay)) <= 10
|
||||
case ToolRectangle, ToolFibonacci:
|
||||
l, r, t, b := min(ax, bx)-6, max(ax, bx)+6, min(ay, by)-6, max(ay, by)+6
|
||||
near = x >= l && x <= r && y >= t && y <= b
|
||||
default:
|
||||
near = drawingSegmentDist(x, y, ax, ay, bx, by) <= 6
|
||||
}
|
||||
if near {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// drawingSegmentDist is the distance from (x, y) to the segment a-b.
|
||||
func drawingSegmentDist(x, y, ax, ay, bx, by float32) float64 {
|
||||
vx, vy := float64(bx-ax), float64(by-ay)
|
||||
t := 0.0
|
||||
if l := vx*vx + vy*vy; l > 0 {
|
||||
t = max(0, min(1, (float64(x-ax)*vx+float64(y-ay)*vy)/l))
|
||||
}
|
||||
return math.Hypot(float64(x-ax)-t*vx, float64(y-ay)-t*vy)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// paint draws every drawing, the one being placed, and the selected one's handles.
|
||||
func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) {
|
||||
k := m.k
|
||||
items := d.Items
|
||||
if d.pending != nil {
|
||||
items = append(append([]Drawing(nil), items...), *d.pending)
|
||||
}
|
||||
for i, it := range items {
|
||||
col := k.Info
|
||||
width := float32(1.5)
|
||||
selected := i == d.sel-1
|
||||
if selected {
|
||||
col, width = k.AccentText, 2.5
|
||||
}
|
||||
ax, ay := d.xy(m, g, it.A)
|
||||
bx, by := d.xy(m, g, it.B)
|
||||
ax, ay, bx, by = internal.Snap(p, ax), internal.Snap(p, ay), internal.Snap(p, bx), internal.Snap(p, by)
|
||||
switch it.Tool {
|
||||
case ToolTrendLine:
|
||||
p.Line(ax, ay, bx, by, width, col)
|
||||
case ToolHorizontal:
|
||||
p.Line(g.plot.X, ay, g.plot.X+g.plot.W, ay, width, col)
|
||||
chartText(p, priceUngrouped(it.A.Price, m.dec), g.plot.X+4, ay-2, size, col, 0, 1)
|
||||
case ToolRectangle:
|
||||
r := ui.Rect{X: min(ax, bx), Y: min(ay, by), W: max(bx, ax) - min(ax, bx), H: max(by, ay) - min(ay, by)}
|
||||
p.Fill(r, col.Alpha(0.1), 0)
|
||||
p.Stroke(r, col, 0, width)
|
||||
case ToolFibonacci:
|
||||
for _, lv := range fibLevels {
|
||||
price := it.B.Price + (it.A.Price-it.B.Price)*lv
|
||||
y := internal.Snap(p, g.ys.Map(m.val(price)))
|
||||
p.Line(min(ax, bx), y, max(ax, bx), y, 1, col)
|
||||
chartText(p, strconv.FormatFloat(lv, 'f', -1, 64)+" · "+priceUngrouped(price, m.dec), max(ax, bx)+4, y, size, col, 0, 0.5)
|
||||
}
|
||||
p.Line(ax, ay, bx, by, 1, col.Alpha(0.5))
|
||||
case ToolText:
|
||||
w, h := chartText(p, it.Text, ax, ay, size, k.Text, 0.5, 0.5)
|
||||
p.Stroke(ui.Rect{X: ax - w/2 - 4, Y: ay - h/2 - 2, W: w + 8, H: h + 4}, col, 2, width/1.5)
|
||||
}
|
||||
if selected {
|
||||
for _, pt := range [][2]float32{{ax, ay}, {bx, by}} {
|
||||
p.FillPath(internal.Diamond(pt[0], pt[1], 9), k.Surface)
|
||||
p.FillPath(internal.Diamond(pt[0], pt[1], 7), col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// IndicatorKind names a technical indicator.
|
||||
type IndicatorKind int
|
||||
|
||||
const (
|
||||
IndicatorMA IndicatorKind = iota
|
||||
IndicatorEMA
|
||||
IndicatorBOLL
|
||||
IndicatorVWAP
|
||||
IndicatorMACD
|
||||
IndicatorRSI
|
||||
IndicatorKDJ
|
||||
IndicatorOBV
|
||||
)
|
||||
|
||||
// indicatorInfo is a kind's name, default parameters and where it draws.
|
||||
var indicatorInfo = []struct {
|
||||
name string
|
||||
params []float64
|
||||
overlay bool
|
||||
}{
|
||||
{"MA", []float64{20}, true}, {"EMA", []float64{20}, true}, {"BOLL", []float64{20, 2}, true}, {"VWAP", nil, true},
|
||||
{"MACD", []float64{12, 26, 9}, false}, {"RSI", []float64{14}, false}, {"KDJ", []float64{9, 3, 3}, false}, {"OBV", nil, false},
|
||||
}
|
||||
|
||||
func (k IndicatorKind) String() string {
|
||||
if k < IndicatorMA || k > IndicatorOBV {
|
||||
panic(fmt.Sprintf("mujica: unknown IndicatorKind %d", int(k)))
|
||||
}
|
||||
return indicatorInfo[k].name
|
||||
}
|
||||
|
||||
// DefaultParams returns a fresh copy of the kind's usual parameters.
|
||||
func (k IndicatorKind) DefaultParams() []float64 {
|
||||
_ = k.String()
|
||||
return append([]float64(nil), indicatorInfo[k].params...)
|
||||
}
|
||||
|
||||
// IndicatorOverlay is an indicator drawn over the price chart: MA, EMA, BOLL or VWAP.
|
||||
type IndicatorOverlay struct {
|
||||
Kind IndicatorKind
|
||||
// Params are the periods (and BOLL's width); nil uses the kind's defaults.
|
||||
Params []float64
|
||||
// Values, when set, are the caller's own series, one per candle, drawn instead of the built-in result.
|
||||
Values []float64
|
||||
}
|
||||
|
||||
// indicatorParams resolves and checks params for kind.
|
||||
func indicatorParams(k IndicatorKind, params []float64) []float64 {
|
||||
want := indicatorInfo[k].params
|
||||
if params == nil {
|
||||
return want
|
||||
}
|
||||
if len(params) != len(want) {
|
||||
panic(fmt.Sprintf("mujica: %v takes %d parameters, not %d", k, len(want), len(params)))
|
||||
}
|
||||
for i, p := range params {
|
||||
if !(p > 0) || (k != IndicatorBOLL || i == 0) && p != float64(int(p)) {
|
||||
panic(fmt.Sprintf("mujica: %v parameter %v must be a positive whole number", k, p))
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// indicatorName is the kind with its parameters, as "MACD(12,26,9)".
|
||||
func indicatorName(k IndicatorKind, params []float64) string {
|
||||
if len(params) == 0 {
|
||||
return k.String()
|
||||
}
|
||||
parts := make([]string, len(params))
|
||||
for i, p := range params {
|
||||
parts[i] = strconv.FormatFloat(p, 'f', -1, 64)
|
||||
}
|
||||
return k.String() + "(" + strings.Join(parts, ",") + ")"
|
||||
}
|
||||
|
||||
// indicatorSeries computes kind over candles: named lines, one value per candle.
|
||||
func indicatorSeries(k IndicatorKind, params []float64, candles []Candle) []ChartSeries {
|
||||
p := indicatorParams(k, params)
|
||||
closes := make([]float64, len(candles))
|
||||
for i, c := range candles {
|
||||
closes[i] = c.Close
|
||||
}
|
||||
name := indicatorName(k, p)
|
||||
switch k {
|
||||
case IndicatorMA:
|
||||
return []ChartSeries{{name, SMA(closes, int(p[0]))}}
|
||||
case IndicatorEMA:
|
||||
return []ChartSeries{{name, EMA(closes, int(p[0]))}}
|
||||
case IndicatorBOLL:
|
||||
mid, up, lo := Bollinger(closes, int(p[0]), p[1])
|
||||
return []ChartSeries{{name, mid}, {name + " ↑", up}, {name + " ↓", lo}}
|
||||
case IndicatorVWAP:
|
||||
return []ChartSeries{{name, VWAP(candles)}}
|
||||
case IndicatorMACD:
|
||||
dif, dea, hist := MACD(closes, int(p[0]), int(p[1]), int(p[2]))
|
||||
return []ChartSeries{{"DIF", dif}, {"DEA", dea}, {"MACD", hist}}
|
||||
case IndicatorRSI:
|
||||
return []ChartSeries{{name, RSI(closes, int(p[0]))}}
|
||||
case IndicatorKDJ:
|
||||
kk, d, j := KDJ(candles, int(p[0]), int(p[1]), int(p[2]))
|
||||
return []ChartSeries{{"K", kk}, {"D", d}, {"J", j}}
|
||||
}
|
||||
return []ChartSeries{{name, OBV(candles)}}
|
||||
}
|
||||
|
||||
// mktOverlayLines resolves the chart's overlays, computing each only when its candles or settings change.
|
||||
func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine {
|
||||
pal := internal.Palette(m.k)
|
||||
var out []mktLine
|
||||
for i, o := range m.opts.Overlays {
|
||||
if _ = o.Kind.String(); !indicatorInfo[o.Kind].overlay {
|
||||
panic(fmt.Sprintf("mujica: %v is not an overlay indicator", o.Kind))
|
||||
}
|
||||
col := pal[(len(pal)-2-i+len(pal))%len(pal)]
|
||||
if o.Values != nil {
|
||||
if len(o.Values) != len(candles) {
|
||||
panic("mujica: IndicatorOverlay Values need one value per candle")
|
||||
}
|
||||
out = append(out, mktLine{name: indicatorName(o.Kind, indicatorParams(o.Kind, o.Params)), values: o.Values, color: col, shape: len(out)})
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d|%v", chartID(candles), o.Kind, o.Params)
|
||||
series := chartMemo(ch, fmt.Sprint("overlay.", i), key, func() []ChartSeries { return indicatorSeries(o.Kind, o.Params, candles) })
|
||||
for _, s := range series {
|
||||
out = append(out, mktLine{name: s.Name, values: s.Values, color: col, shape: len(out)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IndicatorParamsField edits one indicator's parameters in place; Changed reports a valid new value.
|
||||
func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) ChoiceResult {
|
||||
if params == nil {
|
||||
panic("mujica: IndicatorParamsField needs params")
|
||||
}
|
||||
if *params == nil {
|
||||
*params = kind.DefaultParams()
|
||||
}
|
||||
changed := false
|
||||
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String())
|
||||
row.Children(func() {
|
||||
for i := range *params {
|
||||
v := (*params)[i]
|
||||
step, prec := 1.0, 0
|
||||
if kind == IndicatorBOLL && i == 1 {
|
||||
step, prec = 0.1, 1
|
||||
}
|
||||
if NumberInput(c, &v, NumberInputOptions{Min: step, Max: 500, Step: step, Precision: prec, Label: fmt.Sprintf("%v %d", kind, i+1)}).Element.Width(110).Key(i).Changed() || v != (*params)[i] {
|
||||
if kind == IndicatorMACD && (i == 0 && v >= (*params)[1] || i == 1 && v <= (*params)[0]) {
|
||||
continue
|
||||
}
|
||||
(*params)[i], changed = v, true
|
||||
}
|
||||
}
|
||||
})
|
||||
return ChoiceResult{row, changed}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Indicator functions are pure: same input, same output, NaN until enough data.
|
||||
|
||||
// indicatorCheck panics on a period below one.
|
||||
func indicatorCheck(name string, n int) {
|
||||
if n < 1 {
|
||||
panic(fmt.Sprintf("mujica: %s period %d must be at least 1", name, n))
|
||||
}
|
||||
}
|
||||
|
||||
func indicatorNaN(n int) []float64 {
|
||||
out := make([]float64, n)
|
||||
for i := range out {
|
||||
out[i] = math.NaN()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SMA is the simple moving average of the last n values.
|
||||
func SMA(vals []float64, n int) []float64 {
|
||||
indicatorCheck("SMA", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
var sum float64
|
||||
for i, v := range vals {
|
||||
sum += v
|
||||
if i >= n {
|
||||
sum -= vals[i-n]
|
||||
}
|
||||
if i >= n-1 {
|
||||
out[i] = sum / float64(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EMA is the exponential moving average, k = 2/(n+1), seeded with the SMA of the first n values.
|
||||
func EMA(vals []float64, n int) []float64 {
|
||||
indicatorCheck("EMA", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
k := 2 / float64(n+1)
|
||||
var sum, prev float64
|
||||
for i, v := range vals {
|
||||
switch {
|
||||
case i < n-1:
|
||||
sum += v
|
||||
case i == n-1:
|
||||
prev = (sum + v) / float64(n)
|
||||
out[i] = prev
|
||||
default:
|
||||
prev = v*k + prev*(1-k)
|
||||
out[i] = prev
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bollinger returns the n-period SMA and the bands width population deviations away.
|
||||
func Bollinger(vals []float64, n int, width float64) (mid, upper, lower []float64) {
|
||||
indicatorCheck("Bollinger", n)
|
||||
if !(width > 0) {
|
||||
panic("mujica: Bollinger width must be positive")
|
||||
}
|
||||
mid = SMA(vals, n)
|
||||
upper, lower = indicatorNaN(len(vals)), indicatorNaN(len(vals))
|
||||
for i := n - 1; i < len(vals); i++ {
|
||||
var ss float64
|
||||
for _, v := range vals[i-n+1 : i+1] {
|
||||
ss += (v - mid[i]) * (v - mid[i])
|
||||
}
|
||||
d := width * math.Sqrt(ss/float64(n))
|
||||
upper[i], lower[i] = mid[i]+d, mid[i]-d
|
||||
}
|
||||
return mid, upper, lower
|
||||
}
|
||||
|
||||
// VWAP is the cumulative volume-weighted average of the typical price (H+L+C)/3.
|
||||
func VWAP(candles []Candle) []float64 {
|
||||
out := indicatorNaN(len(candles))
|
||||
var pv, vol float64
|
||||
for i, c := range candles {
|
||||
pv += (c.High + c.Low + c.Close) / 3 * c.Volume
|
||||
vol += c.Volume
|
||||
if vol > 0 {
|
||||
out[i] = pv / vol
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MACD returns DIF (fast EMA - slow EMA), DEA (signal EMA of DIF) and the histogram DIF - DEA.
|
||||
func MACD(vals []float64, fast, slow, signal int) (dif, dea, hist []float64) {
|
||||
if fast >= slow {
|
||||
panic("mujica: MACD fast period must be shorter than slow")
|
||||
}
|
||||
f, s := EMA(vals, fast), EMA(vals, slow)
|
||||
dif = indicatorNaN(len(vals))
|
||||
for i := range vals {
|
||||
dif[i] = f[i] - s[i]
|
||||
}
|
||||
dea = indicatorNaN(len(vals))
|
||||
if len(vals) >= slow {
|
||||
tail := EMA(dif[slow-1:], signal)
|
||||
copy(dea[slow-1:], tail)
|
||||
}
|
||||
hist = indicatorNaN(len(vals))
|
||||
for i := range vals {
|
||||
hist[i] = dif[i] - dea[i]
|
||||
}
|
||||
return dif, dea, hist
|
||||
}
|
||||
|
||||
// RSI is Wilder's relative strength index over n periods, 0 to 100.
|
||||
func RSI(vals []float64, n int) []float64 {
|
||||
indicatorCheck("RSI", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
var gain, loss float64
|
||||
for i := 1; i < len(vals); i++ {
|
||||
d := vals[i] - vals[i-1]
|
||||
g, l := max(d, 0), max(-d, 0)
|
||||
if i <= n {
|
||||
gain += g
|
||||
loss += l
|
||||
if i < n {
|
||||
continue
|
||||
}
|
||||
gain, loss = gain/float64(n), loss/float64(n)
|
||||
} else {
|
||||
gain = (gain*float64(n-1) + g) / float64(n)
|
||||
loss = (loss*float64(n-1) + l) / float64(n)
|
||||
}
|
||||
if loss == 0 {
|
||||
out[i] = 100
|
||||
} else {
|
||||
out[i] = 100 - 100/(1+gain/loss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KDJ is the stochastic oscillator: RSV over n, K and D smoothed by m1 and m2 from 50, J = 3K - 2D.
|
||||
func KDJ(candles []Candle, n, m1, m2 int) (k, d, j []float64) {
|
||||
indicatorCheck("KDJ", n)
|
||||
indicatorCheck("KDJ m1", m1)
|
||||
indicatorCheck("KDJ m2", m2)
|
||||
k, d, j = indicatorNaN(len(candles)), indicatorNaN(len(candles)), indicatorNaN(len(candles))
|
||||
pk, pd := 50.0, 50.0
|
||||
for i := n - 1; i < len(candles); i++ {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, c := range candles[i-n+1 : i+1] {
|
||||
lo, hi = min(lo, c.Low), max(hi, c.High)
|
||||
}
|
||||
rsv := 50.0
|
||||
if hi > lo {
|
||||
rsv = (candles[i].Close - lo) / (hi - lo) * 100
|
||||
}
|
||||
pk = (float64(m1-1)*pk + rsv) / float64(m1)
|
||||
pd = (float64(m2-1)*pd + pk) / float64(m2)
|
||||
k[i], d[i], j[i] = pk, pd, 3*pk-2*pd
|
||||
}
|
||||
return k, d, j
|
||||
}
|
||||
|
||||
// OBV is on-balance volume: volume added on up closes, subtracted on down closes.
|
||||
func OBV(candles []Candle) []float64 {
|
||||
out := make([]float64, len(candles))
|
||||
for i := 1; i < len(candles); i++ {
|
||||
out[i] = out[i-1]
|
||||
switch {
|
||||
case candles[i].Close > candles[i-1].Close:
|
||||
out[i] += candles[i].Volume
|
||||
case candles[i].Close < candles[i-1].Close:
|
||||
out[i] -= candles[i].Volume
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeikinAshi turns candles into Heikin-Ashi candles, keeping times and volumes.
|
||||
func HeikinAshi(candles []Candle) []Candle {
|
||||
out := make([]Candle, len(candles))
|
||||
for i, c := range candles {
|
||||
h := Candle{Time: c.Time, Volume: c.Volume, Close: (c.Open + c.High + c.Low + c.Close) / 4}
|
||||
if i == 0 {
|
||||
h.Open = (c.Open + c.Close) / 2
|
||||
} else {
|
||||
h.Open = (out[i-1].Open + out[i-1].Close) / 2
|
||||
}
|
||||
h.High, h.Low = max(c.High, h.Open, h.Close), min(c.Low, h.Open, h.Close)
|
||||
out[i] = h
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func approx(t *testing.T, name string, got, want []float64) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: len %d, want %d", name, len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if math.IsNaN(want[i]) != math.IsNaN(got[i]) || !math.IsNaN(want[i]) && math.Abs(got[i]-want[i]) > 1e-9 {
|
||||
t.Errorf("%s[%d] = %v, want %v", name, i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nan = math.NaN()
|
||||
|
||||
func TestSMAAndEMA(t *testing.T) {
|
||||
v := []float64{1, 2, 3, 4, 5, 6}
|
||||
approx(t, "SMA", SMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
||||
// k = 0.5: seed 2, then 4*0.5+2*0.5 = 3, 5*0.5+3*0.5 = 4, 6*0.5+4*0.5 = 5.
|
||||
approx(t, "EMA", EMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
||||
approx(t, "EMA2", EMA([]float64{10, 20, 10}, 1), []float64{10, 20, 10})
|
||||
approx(t, "EMA3", EMA([]float64{2, 4, 8}, 2), []float64{nan, 3, 3 + (8-3)*2.0/3})
|
||||
}
|
||||
|
||||
func TestBollinger(t *testing.T) {
|
||||
mid, up, lo := Bollinger([]float64{2, 4, 4, 4, 5, 5, 7, 9}, 8, 2)
|
||||
// Mean 5, population deviation 2.
|
||||
approx(t, "mid", mid[7:], []float64{5})
|
||||
approx(t, "upper", up[7:], []float64{9})
|
||||
approx(t, "lower", lo[7:], []float64{1})
|
||||
if !math.IsNaN(up[6]) {
|
||||
t.Fatal("band before the period")
|
||||
}
|
||||
}
|
||||
|
||||
func candlesOf(rows ...[5]float64) []Candle {
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
out := make([]Candle, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: r[0], High: r[1], Low: r[2], Close: r[3], Volume: r[4]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestVWAPAndOBV(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 12, 9, 12, 100}, [5]float64{12, 15, 12, 12, 300}, [5]float64{12, 12, 6, 9, 100})
|
||||
// Typical prices 11, 13, 9.
|
||||
approx(t, "VWAP", VWAP(cs), []float64{11, (1100 + 3900) / 400.0, (1100 + 3900 + 900) / 500.0})
|
||||
approx(t, "OBV", OBV(cs), []float64{0, 0, -100})
|
||||
}
|
||||
|
||||
func TestMACD(t *testing.T) {
|
||||
v := []float64{1, 2, 3, 4, 5, 6, 7}
|
||||
dif, dea, hist := MACD(v, 2, 3, 2)
|
||||
f, s := EMA(v, 2), EMA(v, 3)
|
||||
for i := 2; i < len(v); i++ {
|
||||
if math.Abs(dif[i]-(f[i]-s[i])) > 1e-12 {
|
||||
t.Fatalf("dif[%d]", i)
|
||||
}
|
||||
}
|
||||
// DEA is the EMA(2) of DIF from index 2: seed is the mean of dif[2], dif[3].
|
||||
want := EMA(dif[2:], 2)
|
||||
approx(t, "dea", dea[2:], want)
|
||||
if !math.IsNaN(dea[1]) || math.Abs(hist[6]-(dif[6]-dea[6])) > 1e-12 {
|
||||
t.Fatal("hist or warm-up wrong")
|
||||
}
|
||||
// A straight line: both EMAs trail by a constant, DIF = 0.5, DEA = 0.5, hist = 0.
|
||||
approx(t, "dif line", dif[2:], []float64{0.5, 0.5, 0.5, 0.5, 0.5})
|
||||
approx(t, "hist line", hist[3:], []float64{0, 0, 0, 0})
|
||||
}
|
||||
|
||||
func TestRSI(t *testing.T) {
|
||||
// Gains 1,1 then a loss of 1: first RSI (n=2) at i=2 is all gains = 100; then Wilder smoothing.
|
||||
r := RSI([]float64{1, 2, 3, 2}, 2)
|
||||
approx(t, "RSI", r, []float64{nan, nan, 100, 100 - 100/(1+0.5/0.5)})
|
||||
}
|
||||
|
||||
func TestKDJ(t *testing.T) {
|
||||
cs := candlesOf([5]float64{1, 2, 0, 1, 0}, [5]float64{1, 4, 0, 4, 0}, [5]float64{4, 4, 2, 3, 0})
|
||||
k, d, j := KDJ(cs, 2, 3, 3)
|
||||
// i=1: range 0..4, RSV 100, K = (2*50+100)/3, D = (2*50+K)/3.
|
||||
k1 := 200.0 / 3
|
||||
d1 := (100 + k1) / 3
|
||||
// i=2: range 0..4, RSV 75.
|
||||
k2 := (2*k1 + 75) / 3
|
||||
d2 := (2*d1 + k2) / 3
|
||||
approx(t, "K", k, []float64{nan, k1, k2})
|
||||
approx(t, "D", d, []float64{nan, d1, d2})
|
||||
approx(t, "J", j, []float64{nan, 3*k1 - 2*d1, 3*k2 - 2*d2})
|
||||
}
|
||||
|
||||
func TestHeikinAshi(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 14, 8, 12, 5}, [5]float64{12, 13, 9, 10, 6})
|
||||
h := HeikinAshi(cs)
|
||||
if h[0].Open != 11 || h[0].Close != 11 || h[0].High != 14 || h[0].Low != 8 {
|
||||
t.Fatalf("first %+v", h[0])
|
||||
}
|
||||
if h[1].Open != 11 || h[1].Close != 11 || h[1].High != 13 || h[1].Low != 9 || h[1].Volume != 6 {
|
||||
t.Fatalf("second %+v", h[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorsPanicOnBadPeriods(t *testing.T) {
|
||||
for name, f := range map[string]func(){
|
||||
"SMA": func() { SMA(nil, 0) },
|
||||
"MACD": func() { MACD(nil, 26, 12, 9) },
|
||||
"BOLL": func() { Bollinger(nil, 20, 0) },
|
||||
"KDJ m": func() { KDJ(nil, 9, 0, 3) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketWindowPanZoom(t *testing.T) {
|
||||
v := MarketView{}
|
||||
if f, to := marketWindow(v, 1000, 400); f != 880 || to != 1000 {
|
||||
t.Fatalf("default %d..%d", f, to)
|
||||
}
|
||||
marketPan(&v, 1000, 400, -100)
|
||||
if v.End != 900 || v.Count != 120 {
|
||||
t.Fatalf("pan back %+v", v)
|
||||
}
|
||||
marketZoom(&v, 1000, 400, 0.5)
|
||||
if f, to := marketWindow(v, 1000, 400); f != 840 || to != 900 {
|
||||
t.Fatalf("zoom in %d..%d", f, to)
|
||||
}
|
||||
marketZoom(&v, 1000, 100, 10)
|
||||
if v.Count != 100 {
|
||||
t.Fatalf("zoom out capped by room: %+v", v)
|
||||
}
|
||||
marketPan(&v, 1000, 100, 5000)
|
||||
if v.End != 0 {
|
||||
t.Fatalf("pan to the newest follows: %+v", v)
|
||||
}
|
||||
if f, to := marketWindow(MarketView{Count: 50, End: 10}, 1000, 400); f != 0 || to != 50 {
|
||||
t.Fatalf("start clamp %d..%d", f, to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntervalClose(t *testing.T) {
|
||||
loc := time.UTC
|
||||
at := time.Date(2026, 10, 7, 13, 47, 10, 0, loc) // a Wednesday
|
||||
for iv, want := range map[Interval]time.Time{
|
||||
"1m": time.Date(2026, 10, 7, 13, 48, 0, 0, loc),
|
||||
"15m": time.Date(2026, 10, 7, 14, 0, 0, 0, loc),
|
||||
"4h": time.Date(2026, 10, 7, 16, 0, 0, 0, loc),
|
||||
"1D": time.Date(2026, 10, 8, 0, 0, 0, 0, loc),
|
||||
"1W": time.Date(2026, 10, 12, 0, 0, 0, 0, loc),
|
||||
"1M": time.Date(2026, 11, 1, 0, 0, 0, 0, loc),
|
||||
} {
|
||||
if got := iv.Close(at, loc); !got.Equal(want) {
|
||||
t.Errorf("%s: %v, want %v", iv, got, want)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown interval accepted")
|
||||
}
|
||||
}()
|
||||
Interval("7m").Close(at, loc)
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candle is one period of trading: open, high, low, close and volume.
|
||||
type Candle struct {
|
||||
Time time.Time
|
||||
Open, High, Low, Close float64
|
||||
Volume float64
|
||||
}
|
||||
|
||||
// marketCheckCandles panics on candles out of time order or with impossible prices.
|
||||
func marketCheckCandles(candles []Candle) {
|
||||
for i, c := range candles {
|
||||
if c.Time.IsZero() || i > 0 && !c.Time.After(candles[i-1].Time) {
|
||||
panic(fmt.Sprintf("mujica: candle %d time is zero or not after the one before", i))
|
||||
}
|
||||
if !(c.Low <= min(c.Open, c.Close)) || !(c.High >= max(c.Open, c.Close)) || c.Volume < 0 || math.IsNaN(c.Volume) {
|
||||
panic(fmt.Sprintf("mujica: candle %d has impossible prices or volume", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarketView is the caller-held window of a market chart: zoom and pan.
|
||||
type MarketView struct {
|
||||
// Count is how many candles show; zero shows 120.
|
||||
Count int
|
||||
// End is one past the last shown candle; zero follows the newest.
|
||||
End int
|
||||
}
|
||||
|
||||
// marketWindow resolves the view to [from, to) over n candles, at most room wide.
|
||||
func marketWindow(v MarketView, n, room int) (from, to int) {
|
||||
if v.Count < 0 || v.End < 0 {
|
||||
panic("mujica: MarketView Count and End must not be negative")
|
||||
}
|
||||
count := v.Count
|
||||
if count == 0 {
|
||||
count = 120
|
||||
}
|
||||
count = max(1, min(count, room, n))
|
||||
to = n
|
||||
if v.End > 0 && v.End < n {
|
||||
to = max(v.End, count)
|
||||
}
|
||||
return to - count, to
|
||||
}
|
||||
|
||||
// marketPan moves the view by delta candles; reaching the newest follows it again.
|
||||
func marketPan(v *MarketView, n, room, delta int) {
|
||||
from, to := marketWindow(*v, n, room)
|
||||
v.Count = to - from
|
||||
to = max(v.Count, min(n, to+delta))
|
||||
if to >= n {
|
||||
v.End = 0
|
||||
} else {
|
||||
v.End = to
|
||||
}
|
||||
}
|
||||
|
||||
// marketZoom scales the shown count by f, keeping the newest shown candle, within 10 and room.
|
||||
func marketZoom(v *MarketView, n, room int, f float64) {
|
||||
from, to := marketWindow(*v, n, room)
|
||||
count := int(math.Round(float64(to-from) * f))
|
||||
if f > 1 && count == to-from {
|
||||
count++
|
||||
}
|
||||
v.Count = max(min(10, n), min(count, room, n))
|
||||
if to < n {
|
||||
v.End = max(to, v.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// marketIndexAt finds the candle at or before t, or -1 before the first.
|
||||
func marketIndexAt(candles []Candle, t time.Time) int {
|
||||
return sort.Search(len(candles), func(i int) bool { return candles[i].Time.After(t) }) - 1
|
||||
}
|
||||
|
||||
// Interval names a candle period: 1m 3m 5m 15m 30m 1h 2h 4h 6h 12h 1D 3D 1W 1M.
|
||||
type Interval string
|
||||
|
||||
// Intervals lists every Interval, shortest first.
|
||||
var Intervals = []Interval{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1D", "3D", "1W", "1M"}
|
||||
|
||||
// Close returns when the candle holding t closes: periods align to the clock in loc, weeks start Monday.
|
||||
func (iv Interval) Close(t time.Time, loc *time.Location) time.Time {
|
||||
t = t.In(loc)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
step := func(d time.Duration) time.Time {
|
||||
since := t.Sub(day)
|
||||
return day.Add((since/d + 1) * d)
|
||||
}
|
||||
switch iv {
|
||||
case "1m", "3m", "5m", "15m", "30m":
|
||||
m, _ := time.ParseDuration(string(iv))
|
||||
return step(m)
|
||||
case "1h", "2h", "4h", "6h", "12h":
|
||||
h, _ := time.ParseDuration(string(iv))
|
||||
return step(h)
|
||||
case "1D":
|
||||
return day.AddDate(0, 0, 1)
|
||||
case "3D":
|
||||
days := int(day.Sub(time.Date(1970, 1, 1, 0, 0, 0, 0, loc)).Hours()/24 + 0.5)
|
||||
return day.AddDate(0, 0, 3-days%3)
|
||||
case "1W":
|
||||
back := (int(day.Weekday()) + 6) % 7
|
||||
return day.AddDate(0, 0, 7-back)
|
||||
case "1M":
|
||||
return time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv)))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// input pans, zooms, drags alerts and drawings, and keeps the focused candle in view.
|
||||
func (m *mktCore) input(plot *ui.Element) {
|
||||
if plot.IsDisabled() {
|
||||
return
|
||||
}
|
||||
n := len(m.candles)
|
||||
room := m.room()
|
||||
changed := false
|
||||
plot.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputScroll || plot.IsDisabled() || ev.DY == 0 {
|
||||
return false
|
||||
}
|
||||
f := 0.9
|
||||
if ev.DY > 0 {
|
||||
f = 1 / 0.9
|
||||
}
|
||||
marketZoom(m.view, n, m.room(), f)
|
||||
return true
|
||||
})
|
||||
switch {
|
||||
case plot.Shortcut(ui.Shift, ui.KeyLeft):
|
||||
marketPan(m.view, n, room, -max(1, (m.to-m.from)/10))
|
||||
changed = true
|
||||
case plot.Shortcut(ui.Shift, ui.KeyRight):
|
||||
marketPan(m.view, n, room, max(1, (m.to-m.from)/10))
|
||||
changed = true
|
||||
case plot.Shortcut(0, ui.KeyEqual):
|
||||
marketZoom(m.view, n, room, 0.8)
|
||||
changed = true
|
||||
case plot.Shortcut(0, ui.KeyMinus):
|
||||
marketZoom(m.view, n, room, 1.25)
|
||||
changed = true
|
||||
}
|
||||
// The keyboard mark may leave the window: the view follows it.
|
||||
f := m.ch.st.focus
|
||||
if f >= 0 && f != m.st.focus && !changed && (f < m.from || f >= m.to) {
|
||||
if f < m.from {
|
||||
marketPan(m.view, n, room, f-m.from)
|
||||
} else {
|
||||
marketPan(m.view, n, room, f-m.to+1)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
m.st.focus = f
|
||||
b := plot.Bounds()
|
||||
g := m.geom(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: b.H})
|
||||
px, py, over := plot.PointerPosition()
|
||||
x, y := b.X+px, b.Y+py
|
||||
dx, dy, held := plot.Dragged()
|
||||
used := false
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
used = d.input(m, plot, g, x, y, over, held, dx, dy)
|
||||
}
|
||||
if !used {
|
||||
used = m.alertsInput(g, x, y, held, dy)
|
||||
}
|
||||
if !used && held && dx != 0 && g.band.Step() > 0 {
|
||||
m.st.dragRest += dx
|
||||
if steps := int(m.st.dragRest / g.band.Step()); steps != 0 {
|
||||
m.st.dragRest -= float32(steps) * g.band.Step()
|
||||
marketPan(m.view, n, room, -steps)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
m.st.dragRest = 0
|
||||
}
|
||||
if changed {
|
||||
slog.Debug("mujica: candlestick_chart view", "count", m.view.Count, "end", m.view.End)
|
||||
m.c.Invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// PriceAlertLine is a caller-held price alert drawn as a dashed line that the pointer drags.
|
||||
type PriceAlertLine struct {
|
||||
ID string
|
||||
Price float64
|
||||
// Label shows beside the price; empty shows the price only.
|
||||
Label string
|
||||
}
|
||||
|
||||
// CompareSymbol is another symbol's closes, aligned to the chart's candles, shown in percent.
|
||||
type CompareSymbol struct {
|
||||
Name string
|
||||
Closes []float64
|
||||
}
|
||||
|
||||
// compareLines adds the compare symbols' percent lines and sets percent mode.
|
||||
func (m *mktCore) compareLines() {
|
||||
if m.opts.Compare == nil || len(*m.opts.Compare) == 0 || m.to <= m.from {
|
||||
return
|
||||
}
|
||||
m.base = m.candles[m.from].Close
|
||||
pal := internal.Palette(m.k)
|
||||
for i, s := range *m.opts.Compare {
|
||||
if s.Name == "" || len(s.Closes) != len(m.candles) {
|
||||
panic(fmt.Sprintf("mujica: CompareSymbol %q needs a Name and one close per candle", s.Name))
|
||||
}
|
||||
base := math.NaN()
|
||||
for j := m.from; j < m.to && math.IsNaN(base); j++ {
|
||||
base = s.Closes[j]
|
||||
}
|
||||
closes := s.Closes
|
||||
vals := chartMemo(m.ch, "compare."+s.Name, [2]any{chartID(closes), base}, func() []float64 {
|
||||
out := make([]float64, len(closes))
|
||||
for j, v := range closes {
|
||||
out[j] = (v/base - 1) * 100
|
||||
}
|
||||
return out
|
||||
})
|
||||
m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: len(m.overlays)})
|
||||
}
|
||||
}
|
||||
|
||||
// CompareLegend lists the compare symbols with their colors and a remove button each; Changed reports a removal.
|
||||
func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) ChoiceResult {
|
||||
if symbols == nil {
|
||||
panic("mujica: CompareLegend needs symbols")
|
||||
}
|
||||
k := Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
u := Density(c).Unit()
|
||||
removed := -1
|
||||
row := ui.Row(c).Wrap().Gap(u * 2).Role(ui.RoleList).Label(msg(c, "compare_symbol.label"))
|
||||
row.Children(func() {
|
||||
for i, s := range *symbols {
|
||||
ui.Row(c).Key(s.Name).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() {
|
||||
col := pal[(i+1)%len(pal)]
|
||||
ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, i+1, r.X+5, r.Y+5, 9, col) })
|
||||
ui.Text(c, s.Name).SingleLine()
|
||||
if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "compare_symbol.remove"), s.Name)).Clicked() && !row.IsDisabled() {
|
||||
removed = i
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if removed >= 0 {
|
||||
slog.Debug("mujica: compare_symbol removed", "name", (*symbols)[removed].Name)
|
||||
*symbols = append((*symbols)[:removed:removed], (*symbols)[removed+1:]...)
|
||||
}
|
||||
return ChoiceResult{row, removed >= 0}
|
||||
}
|
||||
|
||||
// alertsCheck runs OnAlert for each alert the newest close crossed since the last frame.
|
||||
func (m *mktCore) alertsCheck() {
|
||||
if m.opts.Alerts == nil || len(m.candles) == 0 {
|
||||
return
|
||||
}
|
||||
last := m.candles[len(m.candles)-1]
|
||||
if m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil {
|
||||
for _, a := range *m.opts.Alerts {
|
||||
if (m.st.lastClose-a.Price)*(last.Close-a.Price) <= 0 && m.st.lastClose != a.Price {
|
||||
slog.Debug("mujica: price_alert_line crossed", "id", a.ID)
|
||||
m.opts.OnAlert(a, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.st.lastClose, m.st.alertSeen = last.Close, true
|
||||
}
|
||||
|
||||
// alertsInput drags an alert line that the press started on; it reports taking the drag.
|
||||
func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bool {
|
||||
if m.opts.Alerts == nil || m.base != 0 {
|
||||
return false
|
||||
}
|
||||
alerts := *m.opts.Alerts
|
||||
if !held {
|
||||
m.st.grab = -1
|
||||
return false
|
||||
}
|
||||
if m.st.grab < 0 {
|
||||
for i, a := range alerts {
|
||||
if math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 5 {
|
||||
m.st.grab = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.st.grab < 0 || m.st.grab >= len(alerts) {
|
||||
return false
|
||||
}
|
||||
p := math.Pow(10, float64(m.dec))
|
||||
alerts[m.st.grab].Price = math.Round(g.ys.Invert(y)*p) / p
|
||||
slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID)
|
||||
return true
|
||||
}
|
||||
|
||||
// paintAlerts draws each alert as a dashed line with its price tag.
|
||||
func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) {
|
||||
if m.opts.Alerts == nil || m.base != 0 {
|
||||
return
|
||||
}
|
||||
for _, a := range *m.opts.Alerts {
|
||||
y := internal.Snap(p, g.ys.Map(a.Price))
|
||||
for x := g.plot.X; x < g.plot.X+g.plot.W; x += 8 {
|
||||
p.Line(x, y, min(x+4, g.plot.X+g.plot.W), y, 1, m.k.Warning)
|
||||
}
|
||||
text := priceUngrouped(a.Price, m.dec)
|
||||
if a.Label != "" {
|
||||
text = a.Label + " " + text
|
||||
}
|
||||
mktTag(p, g.plot.X+g.plot.W, y, fmt.Sprintf(msg(m.c, "price_alert_line.tag"), text), size, m.k.Warning, m.k.Surface)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"},
|
||||
"candlestick_chart.volume": {"Vol", "量"},
|
||||
"heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"},
|
||||
"heikin_ashi.raw": {"Candles", "原 K 线"},
|
||||
"heikin_ashi.switch": {"Candle style", "K 线样式"},
|
||||
"compare_symbol.label": {"Compared symbols", "对比品种"},
|
||||
"compare_symbol.remove": {"Remove %s", "移除 %s"},
|
||||
"price_alert_line.tag": {"Alert %s", "预警 %s"},
|
||||
"chart_sync.link": {"Sync with other charts", "与其他图表同步"},
|
||||
"chart_sync.linked": {"Synced", "已同步"},
|
||||
"chart_sync.unlinked": {"Not synced", "未同步"},
|
||||
"drawing_tools.note": {"Note", "标注"},
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Reference in new issue
Block a user