Documentation
¶
Overview ¶
Package state materializes a stream.Session's event stream into current state: latest ticker and orderbook per symbol, recent public trades, the account's orders, fills, and balances, plus connection health. It is the state layer the TUI renders from; the stream package delivers events, this package answers "what is true right now".
The Store is NOT goroutine-safe by design: it is built to be confined to a single consumer goroutine (the TUI applies events inside its update loop). Apply never blocks and never does I/O.
Reconciliation rules (the reason this package exists):
- Events are not globally ordered across origins (a REST backfill patching an old gap arrives after newer live frames), so nothing is applied by arrival order. Ticker/orderbook (per symbol) order by newest Data.ServerTime — they are public WS-only, so that is a homogeneous same-clock comparison.
- Balances (per account+currency) carry no transport timestamp either. A balance has no intrinsic monotonic progress, so two updates are ordered by the private-connection epoch plus origin (see balanceSupersedes): a newer connection's REST snapshot re-baselines older state, and within one connection a live myAsset frame supersedes the reconnect snapshot and the snapshot never clobbers a value a live frame already set. The snapshot is also authoritative for disappearance: a prior-epoch balance absent from it was zeroed while disconnected and is dropped (reconcileBalanceSnapshot).
- Every private record (order, fill, balance) carries the sub-account it belongs to (AccountSeq; 1 = main), so a session subscribed to more than one accountSeq keeps them apart — balances key on account+currency, not currency alone. The seq is the live frame's wrapper accountSeq or the backfill call's accountSeq (Data.AccountSeq), defaulting to 1 when the server/request did not tag it.
- Orders do NOT order by the transport timestamp: a frame's server send-time, or a backfill's fetch-start estimate, is not the order's own event time. Two updates to the same orderId are ordered by the order's intrinsic monotonic progress instead — a terminal status is absorbing (any status other than the open set pending/open/partiallyFilled — a closed order can never be reopened), then a higher lifecycle rank (pending<open<partiallyFilled), then a larger cumulative filledQty — so the more-advanced state always wins regardless of arrival order or origin. See orderProgress / supersedes in orderprogress.go, the single source of truth for this rule.
- A /v2/openOrders backfill payload is an authoritative snapshot of what is open for its symbol: an open order absent from it that was last known from an EARLIER connection (a lower epoch — see Store.epoch) closed while disconnected; its status becomes StatusClosedUnknown until (usually) the matching /v2/allOrders gap rows deliver the real terminal status. Orders first seen in the current connection are spared (placed after the snapshot, or their close arrives as a lossless live terminal frame or a backfilled /v2/allOrders row).
- Trades and fills are deduped by tradeId here as well: the stream layer already dedupes, but it deliberately re-emits verbatim frames when a rebuild fails (duplicates over silent loss), so the materialized view must be idempotent.
- WS and REST spell the same concepts differently; rows are normalized to one shape (REST "open" for the WS "unfilled" status; Fill.Fee from WS "fee" / REST "feeQty"; Fill.Time from WS "filledAt" / REST "tradedAt"). Decimal values stay strings, untouched, end to end — with one read-side exception: Balance.Available is re-derived (exact decimal math) while local holds are active, see localhold.go.
Index ¶
- Constants
- type Balance
- type Config
- type Direction
- type EndpointHealth
- type Fill
- type Health
- type HoldIntent
- type Order
- type Orderbook
- type PriceLevel
- type Store
- func (s *Store) AddLocalHold(accountSeq int, clientOrderID, currency, amount string)
- func (s *Store) Apply(ev stream.Event)
- func (s *Store) BalanceRev() uint64
- func (s *Store) Balances() []Balance
- func (s *Store) BalancesFor(accountSeq int) []Balance
- func (s *Store) BalancesReady(accountSeq int) bool
- func (s *Store) BookRev() uint64
- func (s *Store) ClosedOrdersFor(accountSeq int, symbol string, limit int) []Order
- func (s *Store) FillRev() uint64
- func (s *Store) Fills() []Fill
- func (s *Store) FillsFor(accountSeq int) []Fill
- func (s *Store) Health() Health
- func (s *Store) HealthRev() uint64
- func (s *Store) LastTick(symbol string) Direction
- func (s *Store) MarkBookStale(symbol string)
- func (s *Store) MarkMarketStale(symbol string)
- func (s *Store) NoticeRev() uint64
- func (s *Store) Notices(n int) []stream.Notice
- func (s *Store) OpenOrders(symbol string) []Order
- func (s *Store) OpenOrdersFor(accountSeq int, symbol string) []Order
- func (s *Store) OpenOrdersReady(accountSeq int, symbol string) bool
- func (s *Store) Order(orderID int64) (Order, bool)
- func (s *Store) OrderRev() uint64
- func (s *Store) Orderbook(symbol string) (Orderbook, bool)
- func (s *Store) OrderbookReady(symbol string) bool
- func (s *Store) ReleaseLocalHold(accountSeq int, clientOrderID string)
- func (s *Store) Ticker(symbol string) (Ticker, bool)
- func (s *Store) TickerReady(symbol string) bool
- func (s *Store) TickerRev() uint64
- func (s *Store) TradeRev() uint64
- func (s *Store) Trades(symbol string) []Trade
- func (s *Store) TradesReady(symbol string) bool
- func (s *Store) TradesSince(symbol string, afterID int64) []Trade
- type Ticker
- type Trade
Constants ¶
const StatusClosedUnknown = "closed"
Synthetic order status: the order disappeared from an authoritative /v2/openOrders snapshot while disconnected and no history row has told us its real terminal status (yet). Terminal for display purposes.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Balance ¶
type Balance struct {
AccountSeq int // sub-account (1 = main); from the frame/backfill, default 1
Currency string
Balance string
Available string
TradeInUse string
WithdrawalInUse string
AvgPrice string
UpdatedAt int64 // per-row server timestamp (0 from REST rows)
// contains filtered or unexported fields
}
Balance is the latest balance state for one currency. The WS myAsset row and the REST /v2/balance row share their field names (REST has no updatedAt).
type Config ¶
type Config struct {
TradeCap int // recent public trades kept per symbol (default 200)
FillCap int // recent fills kept across symbols (default 200)
NoticeCap int // recent notices kept (default 100)
// Log is the optional operational logger for the correctness-driving
// reconcile decisions: the private-connection epoch bump and a gap-closed
// (synthetic "closed") order. nil is silent. Debug level — these are the
// mechanics behind the materialized view, not program output.
Log *slog.Logger
}
Config bounds the Store's memory. Zero fields take defaults.
type Direction ¶
type Direction int8
Direction is a price-tick direction for display. The zero value is Neutral.
type EndpointHealth ¶
type EndpointHealth struct {
Known bool // a CONNECTED/DISCONNECTED notice has been seen
Up bool
LastChangeAt int64
LastError string // last CONNECT_FAILED/DISCONNECTED reason while down
// Delayed and Unreliable are standing degradation conditions, each set by
// its warning notice (DATA_DELAYED / CONNECTION_UNRELIABLE) and cleared by
// its recovery notice (DATA_CURRENT / CONNECTION_STABLE) — so a UI can show
// "currently degraded" rather than a stale warning that never clears.
// Delayed also clears on (re)connect: a fresh connection has no evidence of
// lag until a late frame re-raises it. Unreliable is NOT cleared by a
// reconnect — the stream owns its lifecycle (recent drops keep it set until
// they age out) and announces recovery with CONNECTION_STABLE.
//
// Both describe a connection that is up but degraded, so they are meaningful
// only while Up: a consumer that surfaces them should gate on Up (a down or
// fatal endpoint, signaled by Up=false, may still carry a set Unreliable that
// never had a chance to clear).
Delayed bool
Unreliable bool
}
EndpointHealth is the connection state of one WebSocket endpoint.
type Fill ¶
type Fill struct {
TradeID int64
OrderID int64
AccountSeq int // sub-account (1 = main); from the frame/backfill, default 1
Symbol string
Side string
Price string
Qty string
Fee string
FeeCurrency string
IsTaker bool
Time int64 // unix ms (WS filledAt / REST tradedAt)
}
Fill is one of the account's fills, normalized across the WS myTrade row (fee, filledAt) and the REST /v2/myTrades row (feeQty, tradedAt).
type Health ¶
type Health struct {
Public EndpointHealth
Private EndpointHealth
Backfilling bool
GapCount int // DATA_GAP notices seen
LastDataAt int64 // local receive time of the newest data event
DataCount int64
}
Health is the stream's reliability state, derived from notices.
type HoldIntent ¶
type HoldIntent struct {
Side string // buy | sell
Price string // limit price ("" when the type carries none)
Qty string // base quantity ("" for an amount-sized order)
Amt string // quote amount ("" for a quantity-sized order)
Base string
Quote string
// QuoteFeeRate is the fee rate to reserve on top of a quote-funded buy
// ("" = none). The exchange reserves notional*(1+maxFeeRate) when buys pay
// their fee in the quote currency (see the fees operation's guidance), so
// the caller passes maxFeeRate exactly when buyFeeCurrency is the quote.
QuoteFeeRate string
}
HoldIntent describes an order about to be placed, in the wire decimal strings it will carry, for estimating the reservation the exchange will make for it. Base/Quote are the symbol's two currencies (e.g. btc/krw for btc_krw). The order type is deliberately absent: the reservation follows from which size fields the order carries, so every type — including ones the API adds later — estimates correctly from its shape alone.
func (HoldIntent) EstimateHold ¶
func (h HoldIntent) EstimateHold() (currency, amount string, ok bool)
EstimateHold returns the currency and amount to hold for the intent, derived from which size fields the order carries (the place sizing matrix: a sell is qty-sized whatever its type; a buy carries amt for the amount-sized types — market and best — or price+qty for limit). A sell reserves the base quantity; a buy reserves the quote notional — amt when present (an amt-sized order spends at most amt, so it bounds the notional even when the execution price is the server's to pick), else price×qty — plus the QuoteFeeRate headroom. ok=false means the reservation is not computable (unusable values, or a shape carrying no bounded notional) — the caller simply skips the hold; missing one order's hold degrades to the no-holds status quo for that order. The estimate may be slightly conservative (headroom on shapes the exchange reserves less for); it must not be optimistic.
type Order ¶
type Order struct {
OrderID int64
AccountSeq int // sub-account (1 = main); from the frame/backfill, default 1
ClientOrderID string
Symbol string
Side string
OrderType string
Price string
Qty string
Amt string
FilledQty string
FilledAmt string
AvgPrice string
Status string // normalized: WS "unfilled" becomes "open"
CreatedAt int64
LastFilledAt int64
// ClosedAt is when the store saw the order transition into a terminal
// status (the store's clock). A real terminal status is absorbing, so
// the stamp moves only when a synthetic gap-close is revived by a live
// frame and the order later truly closes. The API carries no close
// timestamp, so this local observation stamp is the only "recently
// closed" ordering available; it is a display key, not a server time,
// and for a close that happened during a disconnect it is the
// reconnect's reconcile time, not the actual close time.
ClosedAt int64
// contains filtered or unexported fields
}
Order is the materialized state of one of the account's orders, merged from WS myOrder frames and /v2/openOrders + /v2/allOrders backfill rows.
type Orderbook ¶
type Orderbook struct {
Symbol string
Timestamp int64 // the book's own timestamp (data.timestamp)
Bids []PriceLevel // best (highest) first, as delivered
Asks []PriceLevel // best (lowest) first, as delivered
ServerTime int64 // ordering key (frame timestamp)
}
Orderbook is the latest complete book for one symbol (every WS orderbook frame carries the full book).
type PriceLevel ¶
PriceLevel is one orderbook level.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store materializes events. Create with New, feed with Apply, read with the accessor methods. Not goroutine-safe; confine to one goroutine.
func New ¶
New builds an empty Store. now stamps Health.LastDataAt on each data event; nil defaults to the wall clock.
func (*Store) AddLocalHold ¶
AddLocalHold registers a local reservation of amount (a positive decimal string) of currency against accountSeq's Available, keyed by the clientOrderId of the order about to be sent. Call it before sending the order; the hold is released as documented above. A no-op when any argument is unusable (an unregistrable hold is dropped, not an error — the layer is advisory). Re-registering a clientOrderId replaces its hold.
func (*Store) Apply ¶
Apply folds one event into the state. Unknown channels and malformed payloads are ignored (the stream layer already surfaces reliability problems as notices; a frame this package cannot parse must not kill the consumer).
func (*Store) BalanceRev ¶
func (*Store) Balances ¶
Balances returns the latest balances across every sub-account, sorted by account then currency. A per-account consumer uses BalancesFor instead. Available is net of the active local holds (see localhold.go); the read also expires overdue holds, so it may mutate hold state (fine on the store's single consumer goroutine).
func (*Store) BalancesFor ¶
BalancesFor returns one sub-account's latest balances, sorted by currency — the per-account view of Balances (paired with BalancesReady for the same account). Available is net of the active local holds, as in Balances.
func (*Store) BalancesReady ¶
BalancesReady reports whether accountSeq's balances have been (re)snapshotted since the last private (re)connect. False ⇒ the displayed balances may be stale; show loading. Keyed per sub-account: a caller covering several accounts must check each (there is no "all accounts" rollup here — see balReady).
func (*Store) ClosedOrdersFor ¶
ClosedOrdersFor returns accountSeq's terminal orders for symbol ("" = all symbols), most recently OBSERVED closed first (Order.ClosedAt — a local stamp, not a server time), capped at limit (<= 0 = uncapped). The set is session-scoped: it holds the terminal transitions this store has seen (live frames, snapshots, backfill rows), not the account's order history.
func (*Store) Fills ¶
Fills returns the recent fills across symbols and sub-accounts, newest first. A per-account consumer uses FillsFor instead.
func (*Store) FillsFor ¶
FillsFor returns the recent fills of one sub-account, newest first — the per-account view of Fills. Note the retained window (Config.FillCap) is shared across accounts: a busy account can age another's fills out.
func (*Store) LastTick ¶
LastTick reports the price-tick direction of symbol's most recent trade relative to the previous trade at a different price. It is Neutral when the trades aren't current (cold start, market switch, or a feed drop), a detected trade gap is still being patched or could not be fully recovered, or no price change is visible in the retained window. The value is maintained on each trade frame (see applyTrade), so this read is O(1).
func (*Store) MarkBookStale ¶
MarkBookStale resets only the orderbook freshness latch for symbol. The consumer calls it when it re-points the orderbook subscription alone (an orderbook grouping-level change): the retained book is at the old granularity and must hide until the new subscription's snapshot lands, while the untouched trade feed stays current — clearing the trade latch too would strand the trades pane on "loading" with no snapshot coming.
func (*Store) MarkMarketStale ¶
MarkMarketStale resets the orderbook/trade freshness latches for symbol so the UI hides those panes until the next subscription snapshot lands. The consumer calls it when it re-points the active-symbol orderbook/trade subscriptions (a market switch): the retained book/trades are from the prior subscription and must not be shown as current until the resubscribe snapshot arrives. The data itself is kept — the trade ring's high-water mark still drives gap-patching.
func (*Store) OpenOrders ¶
OpenOrders returns the orders currently open for symbol ("" = all symbols), newest created first, across every sub-account. A per-account consumer uses OpenOrdersFor instead.
func (*Store) OpenOrdersFor ¶
OpenOrdersFor returns the orders currently open for one sub-account and symbol ("" = all symbols), newest created first — the per-account view of OpenOrders. A consumer rendering one sub-account of a multi-account session reads through this (paired with OpenOrdersReady for the same key) so another account's rows never leak into its panes.
func (*Store) OpenOrdersReady ¶
OpenOrdersReady reports whether accountSeq's open orders for symbol have an authoritative snapshot since the last (re)connect. False means it is still loading (or that account+symbol is not being tracked): the live account-wide feed may already show some orders, but the set is not yet known to be complete. A UI hint, not a correctness gate. Keyed per {account, symbol}: a caller covering several sub-accounts must check each — there is no "any/all account" rollup here (the aggregate belongs to the consumer that knows the subscribed set).
func (*Store) OrderbookReady ¶
OrderbookReady reports whether symbol's orderbook is current: a book frame has arrived since the last public (re)connect and since the symbol was last (re)subscribed. False means the displayed book (if any) is from a prior subscription or a dropped feed and should show as loading, not as live depth.
func (*Store) ReleaseLocalHold ¶
ReleaseLocalHold drops the hold registered for {accountSeq, clientOrderID}, if any. The consumer calls it when the place call fails — a definitive rejection, or an unresolved outcome (the invariant: unsure whether the order is in flight ⇒ release).
func (*Store) TickerReady ¶
TickerReady reports whether symbol's ticker is current (a frame has arrived since the last public connect). False ⇒ the last price is stale; show loading.
func (*Store) TickerRev ¶
Section revisions: monotonic counters that change whenever that section's data (or its freshness/readiness, which gates display) changes. A consumer keys a render cache on the revision(s) it depends on instead of diffing the data. Only equality is meaningful. Orderbook/trade/ticker frames arrive only for the active symbol, so these are process-wide scalars; a symbol-specific consumer also carries the active symbol in its key.
func (*Store) TradesReady ¶
TradesReady reports whether symbol's public trades are current — the trade analogue of OrderbookReady. False ⇒ the retained trades are stale (prior subscription or dropped feed); show loading rather than present them as recent.
func (*Store) TradesSince ¶
TradesSince returns symbol's public trades with TradeID > afterID, oldest first — the tail a candle fold has not yet consumed. It allocates only that tail (nil when nothing is newer), so a consumer polling every stream batch does no work proportional to the whole retained ring when nothing arrived.
type Ticker ¶
type Ticker struct {
Symbol string
Open string
High string
Low string
Close string
PrevClose string
PriceChange string
PriceChangePercent string
Volume string
QuoteVolume string
BestBidPrice string
BestAskPrice string
LastTradedAt int64
ServerTime int64 // ordering key (frame timestamp)
}
Ticker is the latest ticker state for one symbol (WS ticker frames carry complete state). All prices/quantities are decimal strings.