state

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

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

View Source
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.

const (
	Neutral Direction = iota
	Up
	Down
)

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.

func (Order) Open

func (o Order) Open() bool

Open reports whether the order should appear in an open-orders view.

func (Order) Terminal

func (o Order) Terminal() bool

Terminal reports whether the order can never change again.

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

type PriceLevel struct {
	Price string `json:"price"`
	Qty   string `json:"qty"`
}

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

func New(cfg Config, now func() int64) *Store

New builds an empty Store. now stamps Health.LastDataAt on each data event; nil defaults to the wall clock.

func (*Store) AddLocalHold

func (s *Store) AddLocalHold(accountSeq int, clientOrderID, currency, amount string)

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

func (s *Store) Apply(ev stream.Event)

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 (s *Store) BalanceRev() uint64

func (*Store) Balances

func (s *Store) Balances() []Balance

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

func (s *Store) BalancesFor(accountSeq int) []Balance

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

func (s *Store) BalancesReady(accountSeq int) bool

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) BookRev

func (s *Store) BookRev() uint64

func (*Store) ClosedOrdersFor

func (s *Store) ClosedOrdersFor(accountSeq int, symbol string, limit int) []Order

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) FillRev

func (s *Store) FillRev() uint64

func (*Store) Fills

func (s *Store) Fills() []Fill

Fills returns the recent fills across symbols and sub-accounts, newest first. A per-account consumer uses FillsFor instead.

func (*Store) FillsFor

func (s *Store) FillsFor(accountSeq int) []Fill

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) Health

func (s *Store) Health() Health

Health returns the stream's reliability state.

func (*Store) HealthRev

func (s *Store) HealthRev() uint64

func (*Store) LastTick

func (s *Store) LastTick(symbol string) Direction

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

func (s *Store) MarkBookStale(symbol string)

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

func (s *Store) MarkMarketStale(symbol string)

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) NoticeRev

func (s *Store) NoticeRev() uint64

func (*Store) Notices

func (s *Store) Notices(n int) []stream.Notice

Notices returns up to n recent notices, newest first (n <= 0 = all kept).

func (*Store) OpenOrders

func (s *Store) OpenOrders(symbol string) []Order

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

func (s *Store) OpenOrdersFor(accountSeq int, symbol string) []Order

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

func (s *Store) OpenOrdersReady(accountSeq int, symbol string) bool

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) Order

func (s *Store) Order(orderID int64) (Order, bool)

Order returns one order by id.

func (*Store) OrderRev

func (s *Store) OrderRev() uint64

func (*Store) Orderbook

func (s *Store) Orderbook(symbol string) (Orderbook, bool)

Orderbook returns the latest book for symbol.

func (*Store) OrderbookReady

func (s *Store) OrderbookReady(symbol string) bool

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

func (s *Store) ReleaseLocalHold(accountSeq int, clientOrderID string)

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) Ticker

func (s *Store) Ticker(symbol string) (Ticker, bool)

Ticker returns the latest ticker for symbol.

func (*Store) TickerReady

func (s *Store) TickerReady(symbol string) bool

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

func (s *Store) TickerRev() uint64

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) TradeRev

func (s *Store) TradeRev() uint64

func (*Store) Trades

func (s *Store) Trades(symbol string) []Trade

Trades returns the recent public trades for symbol, newest first.

func (*Store) TradesReady

func (s *Store) TradesReady(symbol string) bool

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

func (s *Store) TradesSince(symbol string, afterID int64) []Trade

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.

type Trade

type Trade struct {
	TradeID      int64  `json:"tradeId"`
	Timestamp    int64  `json:"timestamp"`
	Price        string `json:"price"`
	Qty          string `json:"qty"`
	IsBuyerTaker bool   `json:"isBuyerTaker"`
}

Trade is one public trade. The WS frame rows and the /v2/trades backfill rows share this shape.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL