195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
package mujica
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// OrderStatus is where an order stands.
|
|
type OrderStatus int
|
|
|
|
const (
|
|
OrderOpen OrderStatus = iota
|
|
OrderPartial
|
|
OrderFilled
|
|
OrderCanceled
|
|
OrderRejected
|
|
)
|
|
|
|
// working reports an order that can still fill.
|
|
func (s OrderStatus) working() bool { return s == OrderOpen || s == OrderPartial }
|
|
|
|
// OrderRow is one order; ID must be stable across updates.
|
|
type OrderRow struct {
|
|
ID, Symbol string
|
|
Side OrderSide
|
|
Type OrderType
|
|
Price float64
|
|
Quantity float64
|
|
Filled float64
|
|
Status OrderStatus
|
|
}
|
|
|
|
// OrderFilter picks which orders an OrderTable shows.
|
|
type OrderFilter int
|
|
|
|
const (
|
|
OrdersAll OrderFilter = iota
|
|
OrdersWorking
|
|
OrdersFilled
|
|
OrdersClosed
|
|
)
|
|
|
|
func (f OrderFilter) keeps(s OrderStatus) bool {
|
|
switch f {
|
|
case OrdersWorking:
|
|
return s.working()
|
|
case OrdersFilled:
|
|
return s == OrderFilled
|
|
case OrdersClosed:
|
|
return s == OrderCanceled || s == OrderRejected
|
|
}
|
|
return true
|
|
}
|
|
|
|
// OrderTableOptions configure an OrderTable.
|
|
type OrderTableOptions struct {
|
|
// Decimals of prices and quantities; zero means 2 and 0.
|
|
Decimals, QuantityDecimals int
|
|
// Version must change when rows change in place.
|
|
Version int
|
|
Status DataStatus
|
|
Error string
|
|
Disabled bool
|
|
}
|
|
|
|
// OrderTableState is the status filter and table state.
|
|
type OrderTableState struct {
|
|
Filter OrderFilter
|
|
Table DataTableState[string]
|
|
cache tradingRows[OrderRow]
|
|
}
|
|
|
|
// OrderTable lists orders with filter, progress and Cancel.
|
|
func OrderTable(c *ui.Context, s *OrderTableState, rows []OrderRow, opts OrderTableOptions) TableActionResult {
|
|
dec := priceDecimals("OrderTable", opts.Decimals)
|
|
if opts.QuantityDecimals < 0 {
|
|
panic("mujica: OrderTable with negative QuantityDecimals")
|
|
}
|
|
qd := opts.QuantityDecimals
|
|
shown := s.cache.view(rows, opts.Version, s.Filter, func(o OrderRow) bool {
|
|
if o.Quantity <= 0 || o.Filled < 0 || o.Filled > o.Quantity {
|
|
panic(fmt.Sprintf("mujica: OrderTable order %s fills %v of %v", o.ID, o.Filled, o.Quantity))
|
|
}
|
|
return s.Filter.keeps(o.Status)
|
|
})
|
|
var r TableActionResult
|
|
text := func(id, key string, t func(OrderRow) string) DataColumn[OrderRow] {
|
|
return DataColumn[OrderRow]{ID: id, Title: msg(c, key), Sortable: true, Text: t,
|
|
Compare: func(a, b OrderRow) int { return cmp.Compare(t(a), t(b)) }}
|
|
}
|
|
cols := []DataColumn[OrderRow]{
|
|
text("symbol", "position_table.symbol", func(o OrderRow) string { return o.Symbol }),
|
|
{ID: "side", Title: msg(c, "order_table.side"), Text: func(o OrderRow) string { return msg(c, fmt.Sprintf("order_entry.side%d", o.Side)) },
|
|
Cell: func(o OrderRow) { tradingSide(c, o.Side) }},
|
|
text("type", "order_entry.type", func(o OrderRow) string { return msg(c, fmt.Sprintf("order_entry.type%d", o.Type)) }),
|
|
{ID: "price", Title: msg(c, "order_book.price"), Align: ui.End, Sortable: true,
|
|
Compare: func(a, b OrderRow) int { return cmp.Compare(a.Price, b.Price) },
|
|
Text: func(o OrderRow) string {
|
|
if o.Type == OrderMarket {
|
|
return msg(c, "order_table.market")
|
|
}
|
|
return statisticFormat(o.Price, dec)
|
|
}},
|
|
{ID: "filled", Title: msg(c, "order_table.filled"), Width: 140,
|
|
Text: func(o OrderRow) string {
|
|
return statisticFormat(o.Filled, qd) + " / " + statisticFormat(o.Quantity, qd)
|
|
},
|
|
Cell: func(o OrderRow) { orderTableProgress(c, o, qd) }},
|
|
text("status", "order_table.status", func(o OrderRow) string { return msg(c, fmt.Sprintf("order_table.status%d", o.Status)) }),
|
|
{ID: "action", Title: msg(c, "position_table.action"), Width: 96, Text: func(OrderRow) string { return "" },
|
|
Cell: func(o OrderRow) {
|
|
if o.Status.working() && Button(c, msg(c, "order_table.cancel"), ButtonOptions{Variant: Secondary}).
|
|
Label(msg(c, "order_table.cancel")+" "+o.Symbol).Clicked() {
|
|
r.id = o.ID
|
|
}
|
|
}},
|
|
}
|
|
d := Density(c)
|
|
r.Element = ui.Column(c).Gap(d.Unit() * 2).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0)
|
|
r.Element.Children(func() {
|
|
var segs []Segment
|
|
for f := OrdersAll; f <= OrdersClosed; f++ {
|
|
segs = append(segs, Segment{Label: msg(c, fmt.Sprintf("order_table.filter%d", f))})
|
|
}
|
|
at := int(s.Filter)
|
|
if SegmentedControl(c, &at, segs, SegmentedControlOptions{Label: msg(c, "order_table.filter"), Disabled: opts.Disabled}).Changed() {
|
|
s.Filter = OrderFilter(at)
|
|
slog.Debug("mujica: order_table filter", "filter", s.Filter)
|
|
}
|
|
r.Table = DataTable(c, &s.Table, shown, DataTableOptions[OrderRow, string]{Columns: cols, Key: func(o OrderRow) string { return o.ID },
|
|
Status: opts.Status, Error: opts.Error, Empty: msg(c, "order_table.empty")})
|
|
r.Table.Element.Grow(1)
|
|
})
|
|
if r.Element.IsDisabled() {
|
|
r.id = ""
|
|
}
|
|
if r.id != "" {
|
|
slog.Debug("mujica: order_table cancel")
|
|
}
|
|
return r
|
|
}
|
|
|
|
// orderTableProgress shows the filled share as bar and count.
|
|
func orderTableProgress(c *ui.Context, o OrderRow, qd int) {
|
|
k := Tokens(c)
|
|
share := float32(o.Filled / o.Quantity)
|
|
ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).FillWidth().Children(func() {
|
|
ui.Box(c).Size(48, 6).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
|
p.Fill(r, k.SurfaceHover, theme.ControlRadius)
|
|
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * share, H: r.H}, k.AccentText, theme.ControlRadius)
|
|
})
|
|
ui.Text(c, statisticFormat(o.Filled, qd)+" / "+statisticFormat(o.Quantity, qd)).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
|
})
|
|
}
|
|
|
|
// tradingSide names a side with its arrow and trend color.
|
|
func tradingSide(c *ui.Context, side OrderSide) {
|
|
ui.Row(c).Gap(2).AlignItems(ui.Center).TextColor(trendColor(c, side.sign())).Children(func() {
|
|
ui.Icon(c, trendIcon(side.sign())).Size(12, 12)
|
|
ui.Text(c, msg(c, fmt.Sprintf("order_entry.side%d", side))).FontWeight(600).SingleLine()
|
|
})
|
|
}
|
|
|
|
// tradingRows is a filtered view, rebuilt only on change.
|
|
type tradingRows[R any] struct {
|
|
first *R
|
|
n, ver int
|
|
filter any
|
|
valid bool
|
|
visible []R
|
|
}
|
|
|
|
func (t *tradingRows[R]) view(rows []R, version int, filter any, keep func(R) bool) []R {
|
|
var first *R
|
|
if len(rows) > 0 {
|
|
first = &rows[0]
|
|
}
|
|
if t.valid && t.first == first && t.n == len(rows) && t.ver == version && t.filter == filter {
|
|
return t.visible
|
|
}
|
|
t.first, t.n, t.ver, t.filter, t.valid = first, len(rows), version, filter, true
|
|
t.visible = make([]R, 0, len(rows))
|
|
for _, r := range rows {
|
|
if keep(r) {
|
|
t.visible = append(t.visible, r)
|
|
}
|
|
}
|
|
return t.visible
|
|
}
|