Files
MujicaUI/trading_session_clock.go
T
2026-10-05 14:54:26 -04:00

113 lines
3.9 KiB
Go

package mujica
import (
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// TradingSession is a phase from Start to End, in minutes after the market's local midnight.
type TradingSession struct {
Phase MarketPhase
Start, End int
}
// SessionMarket is a market, its time zone from the caller, and its daily sessions.
type SessionMarket struct {
Name string
Location *time.Location
Sessions []TradingSession
// Holiday closes the market all day.
Holiday bool
}
// TradingSessionClockOptions configure a TradingSessionClock.
type TradingSessionClockOptions struct {
// Now is the instant shown; zero uses the window clock.
Now time.Time
}
// TradingSessionClock shows each market's local time, phase, next change and a day bar of its sessions.
func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSessionClockOptions) *ui.Element {
now := opts.Now
if now.IsZero() {
now = c.Now()
}
k := Tokens(c)
u := Density(c).Unit()
size := FontSize(c, theme.CaptionSize)
g := ui.Grid(c).ColumnTracks(ui.FitContent(), ui.FitContent(), ui.Fr(1)).GapX(u * 4).GapY(u * 3).AlignItems(ui.Center)
g.Children(func() {
for _, m := range markets {
tradingSessionCheck(m)
local := now.In(m.Location)
phase, next, nextPhase := tradingSessionPhase(m, local)
ui.Column(c).Key(m.Name).Gap(2).Children(func() {
ui.Text(c, m.Name).Font(theme.SerifFont).FontWeight(600)
ui.Text(c, local.Format("15:04 MST")).FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum")
})
MarketStatus(c, phase, MarketStatusOptions{Next: next, NextPhase: nextPhase, Now: now})
minute := float32(local.Hour()*60 + local.Minute())
ui.Box(c).MinWidth(160).Height(22).Draw(func(p *ui.Painter, r ui.Rect) {
bar := ui.Rect{X: r.X, Y: r.Y + 4, W: r.W, H: 10}
p.Fill(bar, k.Background, 2)
p.Stroke(bar, k.Border, 2, 1)
for _, s := range m.Sessions {
col := k.Info.Alpha(0.45)
if s.Phase == MarketOpen {
col = k.Success
}
x0, x1 := bar.X+bar.W*float32(s.Start)/1440, bar.X+bar.W*float32(s.End)/1440
p.Fill(ui.Rect{X: x0, Y: bar.Y + 1, W: x1 - x0, H: bar.H - 2}, col, 1)
}
x := internal.Snap(p, bar.X+bar.W*minute/1440)
p.FillPath(internal.Diamond(x, bar.Y+bar.H+3, 8), k.Text)
p.Line(x, bar.Y-2, x, bar.Y+bar.H, 1.5, k.Text)
}).Role(ui.RoleImage).Label(m.Name + " " + msg(c, marketPhases[phase].key))
}
})
// Times and phases move on the minute.
c.After(time.Minute - time.Duration(now.Second())*time.Second)
return g
}
func tradingSessionCheck(m SessionMarket) {
if m.Name == "" || m.Location == nil {
panic("mujica: SessionMarket needs a Name and a Location")
}
for i, s := range m.Sessions {
if s.Start < 0 || s.End > 1440 || s.Start >= s.End || i > 0 && s.Start < m.Sessions[i-1].End {
panic("mujica: TradingSession spans must be ordered within 0..1440 minutes")
}
if s.Phase != MarketPreMarket && s.Phase != MarketOpen && s.Phase != MarketAfterHours {
panic("mujica: TradingSession Phase must be pre-market, open or after-hours")
}
}
}
// tradingSessionPhase is the phase at local, the next change, and the phase it brings.
func tradingSessionPhase(m SessionMarket, local time.Time) (MarketPhase, time.Time, MarketPhase) {
if m.Holiday || len(m.Sessions) == 0 {
return MarketHoliday, time.Time{}, MarketClosed
}
mid := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, m.Location)
at := func(min int) time.Time { return mid.Add(time.Duration(min) * time.Minute) }
cur := local.Hour()*60 + local.Minute()
for i, s := range m.Sessions {
switch {
case cur < s.Start:
return MarketClosed, at(s.Start), s.Phase
case cur < s.End:
next := MarketClosed
if i+1 < len(m.Sessions) && m.Sessions[i+1].Start == s.End {
next = m.Sessions[i+1].Phase
}
return s.Phase, at(s.End), next
}
}
return MarketClosed, at(1440 + m.Sessions[0].Start), m.Sessions[0].Phase
}