Files
MujicaUI/order_entry.go
T

223 lines
7.6 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// OrderType is limit, market, stop or stop limit.
type OrderType int
const (
OrderLimit OrderType = iota
OrderMarket
OrderStop
OrderStopLimit
)
// TimeInForce is how long an order rests.
type TimeInForce int
const (
TIFDay TimeInForce = iota
TIFGoodTillCanceled
TIFImmediateOrCancel
TIFFillOrKill
)
// OrderDraft is an order as entered.
type OrderDraft struct {
Side OrderSide
Type OrderType
Quantity float64
Price float64
Trigger float64
TIF TimeInForce
}
// OrderEntryOptions configure an OrderEntry. Symbol is required.
type OrderEntryOptions struct {
Symbol string
// Last is the last price; zero means unknown.
Last float64
// TickSize and LotSize step prices and quantities; zero: 0.01, 1.
TickSize, LotSize float64
// MaxQuantity caps the quantity; zero means no cap.
MaxQuantity float64
// Initial is what Reset restores; a zero Side means buy.
Initial OrderDraft
// Validate adds the caller's rules, returning a message or "".
Validate func(o OrderDraft) string
// Check starts an async check, finished with OrderEntryState.Resolve.
Check func(o OrderDraft, version uint64)
Disabled bool
}
// OrderEntryState holds the draft and its form state.
type OrderEntryState struct {
Draft OrderDraft
Form FormState
started bool
}
// Resolve delivers Check's result; stale versions return false.
func (s *OrderEntryState) Resolve(version uint64, err string) bool {
return s.Form.Resolve("quantity", version, err)
}
// EndSubmit finishes a submit; a formError shows, keeping the draft.
func (s *OrderEntryState) EndSubmit(formError string) { s.Form.EndSubmit(formError) }
// OrderEntryResult is a built OrderEntry and its submitted order.
type OrderEntryResult struct {
Element *ui.Element
order OrderDraft
submitted bool
}
// Submitted returns the checked order; call EndSubmit once placed.
func (r OrderEntryResult) Submitted() (OrderDraft, bool) { return r.order, r.submitted }
// OrderEntry edits an order and submits it once checked.
func OrderEntry(c *ui.Context, s *OrderEntryState, opts OrderEntryOptions) OrderEntryResult {
if opts.Symbol == "" {
panic("mujica: OrderEntry needs a Symbol")
}
if opts.TickSize < 0 || opts.LotSize < 0 || opts.MaxQuantity < 0 || opts.Last < 0 {
panic("mujica: OrderEntry TickSize, LotSize, MaxQuantity and Last cannot be negative")
}
tick, lot := opts.TickSize, opts.LotSize
if tick == 0 {
tick = 0.01
}
if lot == 0 {
lot = 1
}
initial := opts.Initial
if initial.Side == 0 {
initial.Side = SideBuy
}
if !s.started {
s.started, s.Draft = true, initial
}
priceSpec := numberInputResolve(NumberInputOptions{Step: tick})
k := Tokens(c)
u := Density(c).Unit()
var r OrderEntryResult
r.Element = ui.Column(c).Gap(u * 4).MaxWidth(360).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(msg(c, "order_entry.label"))
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
side := 0
if s.Draft.Side == SideSell {
side = 1
}
sides := []Segment{{Label: msg(c, "order_entry.buy")}, {Label: msg(c, "order_entry.sell")}}
SegmentedControl(c, &side, sides, SegmentedControlOptions{Equal: true, Label: msg(c, "order_entry.side"), Disabled: disabled})
s.Draft.Side = SideBuy + OrderSide(side)
var types []SelectOption[OrderType]
for t := OrderLimit; t <= OrderStopLimit; t++ {
types = append(types, SelectOption[OrderType]{Value: t, Label: msg(c, fmt.Sprintf("order_entry.type%d", t))})
}
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
ui.Text(c, opts.Symbol).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
Select(c, &s.Draft.Type, types, SelectOptions{Label: msg(c, "order_entry.type"), Disabled: disabled})
})
submit := fmt.Sprintf(msg(c, fmt.Sprintf("order_entry.submit%d", s.Draft.Side)), opts.Symbol)
res := Form(c, &s.Form, FormOptions{SubmitLabel: submit}, func(f *FormScope) {
orderEntryFields(c, f, s, opts, initial, tick, lot, disabled)
})
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, msg(c, "order_entry.estimate")).TextColor(k.TextMuted)
est := "—"
if p := orderEntryPrice(s.Draft, opts.Last); p > 0 && s.Draft.Quantity > 0 {
est = "≈ " + statisticFormat(p*s.Draft.Quantity, priceSpec.decimals)
}
ui.Text(c, est).FontFeatures("tnum").FontWeight(600)
})
if res.Submitted() {
r.order, r.submitted = s.Draft, true
slog.Debug("mujica: order_entry submitted", "side", s.Draft.Side, "type", s.Draft.Type)
}
})
return r
}
// orderEntryPrice is the expected fill price, or 0 if unknown.
func orderEntryPrice(o OrderDraft, last float64) float64 {
switch o.Type {
case OrderLimit, OrderStopLimit:
return o.Price
case OrderStop:
return o.Trigger
}
return last
}
// orderEntryFields registers the fields the order type needs.
func orderEntryFields(c *ui.Context, f *FormScope, s *OrderEntryState, opts OrderEntryOptions, initial OrderDraft, tick, lot float64, disabled bool) {
maxQ := opts.MaxQuantity
FormItem(c, f, "quantity", &s.Draft, FormItemOptions[OrderDraft]{Label: msg(c, "order_entry.quantity"), Required: true, Initial: initial,
Validate: func(o OrderDraft) string { return orderEntryCheck(c, o, opts, maxQ) },
Async: opts.Check,
}, func() *ui.Element {
return NumberInput(c, &s.Draft.Quantity, NumberInputOptions{Step: lot, Disabled: disabled, Error: s.Form.Error("quantity")}).Input
})
if s.Draft.Type == OrderStop || s.Draft.Type == OrderStopLimit {
FormItem(c, f, "trigger", &s.Draft.Trigger, FormItemOptions[float64]{Label: msg(c, "order_entry.trigger"), Required: true, Initial: initial.Trigger,
Validate: func(v float64) string { return orderEntryTrigger(c, s.Draft, opts.Last) },
}, func() *ui.Element {
return NumberInput(c, &s.Draft.Trigger, NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("trigger")}).Input
})
}
if s.Draft.Type == OrderLimit || s.Draft.Type == OrderStopLimit {
FormItem(c, f, "price", &s.Draft.Price, FormItemOptions[float64]{Label: msg(c, "order_entry.price"), Required: true, Initial: initial.Price,
Validate: func(v float64) string {
if v <= 0 {
return msg(c, "order_entry.price_positive")
}
return ""
},
}, func() *ui.Element {
return NumberInput(c, &s.Draft.Price, NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("price")}).Input
})
}
var tifs []SelectOption[TimeInForce]
for t := TIFDay; t <= TIFFillOrKill; t++ {
tifs = append(tifs, SelectOption[TimeInForce]{Value: t, Label: msg(c, fmt.Sprintf("order_entry.tif%d", t))})
}
FormItem(c, f, "tif", &s.Draft.TIF, FormItemOptions[TimeInForce]{Label: msg(c, "order_entry.tif"), Initial: initial.TIF}, func() *ui.Element {
return Select(c, &s.Draft.TIF, tifs, SelectOptions{Label: msg(c, "order_entry.tif"), Disabled: disabled}).Element
})
}
// orderEntryCheck checks the quantity, then the caller's rules.
func orderEntryCheck(c *ui.Context, o OrderDraft, opts OrderEntryOptions, maxQ float64) string {
switch {
case o.Quantity <= 0:
return msg(c, "order_entry.quantity_positive")
case maxQ > 0 && o.Quantity > maxQ:
return fmt.Sprintf(msg(c, "order_entry.quantity_max"), statisticFormat(maxQ, 0))
}
if opts.Validate != nil {
return opts.Validate(o)
}
return ""
}
// orderEntryTrigger checks a stop's side of the last price.
func orderEntryTrigger(c *ui.Context, o OrderDraft, last float64) string {
switch {
case o.Trigger <= 0:
return msg(c, "order_entry.trigger_positive")
case last > 0 && o.Side == SideBuy && o.Trigger <= last:
return msg(c, "order_entry.trigger_above")
case last > 0 && o.Side == SideSell && o.Trigger >= last:
return msg(c, "order_entry.trigger_below")
}
return ""
}