stream

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: 21 Imported by: 0

Documentation

Overview

Package stream is the resilient real-time data layer over the Korbit WebSocket API. It owns everything between "I want these channels" and a single ordered stream of events: connection management (signed upgrade for the private endpoint, reconnection with backoff, resubscription), REST backfill of whatever a disconnection may have missed, and health reporting (RTT, delivery delay, disconnect frequency, keepalives).

The package is built for two consumers: a line-oriented streaming command whose output is watched by an agent, and a TUI that materializes state from the same events. Both depend on the same property: the consumer must either be up to date or be TOLD, in-band, that it may not be. Every way the stream can silently fall behind therefore has a Notice code, and silence itself has one (KEEPALIVE).

One Session (see session.go) = up to two managed connections (public /v2/public, private /v2/private) + REST recovery + a single ordered event channel of Data and Notice values. The package is self-contained so the command surface above it stays stable as it grows.

The two endpoints, and the recovery matrix that follows

The two Korbit WebSocket endpoints have different reliability contracts, and the recovery strategy per channel follows from them.

Public is lossy — the server may drop messages under load — but every subscribe is answered with a snapshot, and ticker/orderbook frames each carry complete state. So ticker and orderbook self-heal on resubscribe. Public trade has per-symbol monotonic (NOT assumed contiguous) tradeIds: duplicates below the delivered high-water mark are dropped (a resubscribe snapshot of only already-seen trades therefore emits empty — the snapshot boundary is still delivered so a consumer is TOLD it is current, never left waiting), and a resubscribe snapshot whose oldest row is more than one id above the mark is patched from GET /v2/trades when backfill is enabled. Before that snapshot is emitted, the session emits either BACKFILL_START (endpoint=public, channel=trade, reason=gap, plus the id bounds) or, when recovery is disabled, BACKFILL_DISABLED followed by DATA_GAP. An enabled asynchronous patch ends with the paired BACKFILL_DONE at the same level as BACKFILL_START, with complete=true when the fetch reaches back to mark+1; anything less raises DATA_GAP with a saturated detail saying whether the loss is certain (the fetch hit its row limit) or merely possible. KNOWN LIMITATION: tradeIds are non-contiguous, so mark+1 is rarely the true next id and a resubscribe that missed nothing can still raise a spurious BACKFILL_START/DATA_GAP; keying completeness on truncation (saturated) instead is deferred. The public snapshot's row count is server-defined (it may be a single trade), so a trade subscription can request an initial history depth (Subscription.TradeHistory): on the first snapshot the session fetches that many trades preceding it from GET /v2/trades and emits them as origin=backfill. That is best-effort depth, not gap recovery — a short buffer just yields fewer rows and raises no DATA_GAP — and, like the gap patch, it lands asynchronously, so a consumer orders public trades by tradeId, never by arrival.

Private is lossless while connected — the server force-closes rather than drop — but sends NO snapshot on subscribe (myAsset frames are partial deltas). So private channels are backfilled from REST on every connect: balances and open orders always; after a reconnect also the gap's orders/fills via GET /v2/allOrders / GET /v2/myTrades (36h documented history window → BACKFILL_NOT_VIABLE beyond it). myTrade rows are deduped by tradeId across live and backfill (seenIDs), so a fill can never be double-delivered.

Private recovery self-heals: a recovery call that fails past its bounded in-call retry ladder (BACKFILL_FAILED) does not wait for the next reconnect — while the connection stays up, exactly the failed calls are re-run on a jittered exponential backoff, each attempt a fresh BACKFILL_START/BACKFILL_DONE pair with reason="retry", so a REST-side outage heals in place. A call that succeeded is never replayed — the retry shrinks to what is still failing, so it cannot feed a rate limit that caused the failure. Only transient failures (network/5xx/429) re-run; a definitive 4xx rejection waits for the next (re)connect, whose full pass re-attempts everything anyway. On-demand open-order snapshots (the LazyOpenOrders tracking path, scoped per {account, symbol} — see SetTrackedOrderScopes) self-heal through the same loop, so a scope tracked mid-session also becomes ready eventually while the connection stays up. A scope RE-tracked within the same private connection is not re-snapshotted at all: its recorded baseline plus the lossless account-wide live feed already make it current, so flipping focus back and forth costs no REST calls — only a reconnect (a new generation) re-baselines. (See retryFailedBackfill in backfill.go.)

Config.BackfillSnapshotOnly trims this to the snapshots only — balances and the open-order set — and skips the per-symbol allOrders/myTrades gap walks. It is for a session subscribing the account channels across many symbols, where the per-symbol history fan-out on every reconnect is a REST storm; the accepted trade-off (gap order/fill transitions are not recovered, though the open-order snapshot still reconciles what is open now) is announced with a BACKFILL_SNAPSHOT_ONLY notice on every connect.

History backfill never silently under-delivers

History backfill paginates until a short page proves coverage (fetchHistory): a page that fills the server's 1000-row limit was truncated, so the next page narrows the window (endTime = oldest seen +1; start-inclusive/end-exclusive). The server's sort order is observed, not contractual, so each page's direction is re-detected and an oldest-first page advances startTime forward instead. When the window cannot be fully covered (page cap, missing timestamps, no progress) the consumer is TOLD via DATA_GAP — never silently under-delivered.

Notice codes are a stable agent-facing contract

The Notice codes (events.go) are extended additively, never renamed. Anything that can make the consumer silently fall behind must have a code; silence itself has one (KEEPALIVE, which also reports how many connections are up).

Notice levels (the log spec)

Each Notice carries a Level so a consumer can threshold on it (the monitor's --stream-log-level is exactly such a threshold). The governing rule: a degradation and the recovery that clears it share a level, so one threshold catches both the problem and its resolution — never a warning with no visible "all clear". A code's level is fixed EXCEPT where the same code means different things in different contexts (CONNECTED, SUBSCRIBE_FAILED, BACKFILL_SNAPSHOT_ONLY), which is why level is a field on each emission, not a property of the code.

level  notices                                              meaning
-----  ---------------------------------------------------  -------------------------------
error  FATAL, BACKFILL_FAILED, BACKFILL_NOT_VIABLE,         unrecoverable: data lost for
       SUBSCRIBE_FAILED (a dropped subscription)            good, or the session is ending
warn   DISCONNECTED, CONNECT_FAILED, DATA_GAP,              a problem AND its recovery —
       SERVER_ERROR, BACKFILL_DISABLED, SUBSCRIBE_FAILED    onset and clear share this level:
       (a benign unsubscribe reject); the recovery edges    UNRELIABLE↔STABLE, DELAYED↔CURRENT,
       CONNECTION_STABLE, DATA_CURRENT; and a CONNECTED      and a CONNECTED that follows a
       that resolves a prior drop/failed connect            DISCONNECTED/CONNECT_FAILED
info   a clean first CONNECTED, KEEPALIVE, BACKFILL_START,  routine operation, no action
       BACKFILL_DONE                                        needed

So a warn threshold yields "every problem and its resolution, no routine chatter"; info adds the routine lifecycle; error is the unrecoverable subset. (One refinement: a private BACKFILL_FAILED is final for its pass, not necessarily for the session — a transient failure is re-run with backoff while the connection stays up. The code keeps its error level because at emission time the data IS missing and recovery is not guaranteed; the retry's own BACKFILL_START/DONE pair reports the healing.)

BACKFILL_SNAPSHOT_ONLY (emitted only in BackfillSnapshotOnly mode, e.g. the TUI — never the monitor) is the third context-dependent code and the one unpaired warn: info on the initial connect (no gap yet), warn on a reconnect (where the skipped per-symbol history is a real, accepted loss). Its warn has no clearing notice by design — the loss is announced, not recovered.

Logging

This layer is logging-capable but NOT logging-opinionated: it logs through an OPTIONAL operational logger the frontend injects (Config.Log here, the state store's Config.Log in stream/state). nil = silent (resolved via logging.Or), so a layer with no logger wired stays quiet. The records split into three kinds, each tagged so a reader can filter, all sharing the operational logger's destination (the cli's --log-file file, else stderr):

kind        source                     tag(s)                               gated by             examples
----------  -------------------------  ------------------------------------ -------------------  --------------------------------
mechanics   internal/stream            component=stream                     --log-level          ws dial, ws upgrade rejected,
            (Config.Log)                                                                         subscribe, reconnect backoff,
                                                                                                 backfill window
state       internal/stream/state      component=stream/state               --log-level          state epoch bump,
            (state.Config.Log)                                                                   state order gap-closed
notices     the Notice event stream,   component=stream kind=stream_notice  --stream-log-level   DISCONNECTED, DATA_GAP, CONNECTED,
            mirrored by the frontend   code=<CODE>                          (default off)        BACKFILL_FAILED (see the level
            via LogNotice                                                                        table at the notice's level above)

So `grep component=stream` isolates the whole layer; `grep kind=stream_notice` just the reliability notices; `grep component=stream/state` just the reconcile trace. (Under --log-format json these tags are emitted as JSON fields.)

The mechanics/state are ordinary operational diagnostics (the same kind as the REST/clock/keychain logs): every call site emits at Debug and rides --log-level, so they are SILENT unless --log-level debug (or --debug). The NOTICES are different — they already appear on the frontend's stdout (monitor) / on-screen pane (tui), so logging them is a separate OPT-IN: the monitor's --stream-log-level is INDEPENDENT of --log-level and OFF by default; set it to also mirror notices into the log (e.g. when --jq/--max-events narrows stdout, or for a persistent reliability trail). Notices emit at their own level (the table in "Notice levels" above). The tui has no stdout notice stream and no --stream-log-level, so there notices follow --log-level into the --log-file. LogNotice (events.go) is a pure RENDERING helper (notice -> level + message + code attr); the DECISION to mirror — when, to which logger/threshold — lives in the frontend (monitor/tui), never here, so notices stay program output on the event stream first and a log only by the frontend's choice. Inside this layer, call x.log() (logging.Or(Config.Log)) and log plain key=value facts; the component tag is attached by the injected logger, so call sites never name it. NEVER log a secret — the signed upgrade is logged as host/path only, never the signed query/signature.

Payloads are verbatim source documents

Payloads pass through with decimal strings untouched: WebSocket frames for realtime/snapshot origins, REST response data for backfill (Data.Source carries the producing REST path, which also identifies the row shape). The one exception: a trade/myTrade frame whose rows were partially delivered before is re-emitted with only the fresh rows (rows stay verbatim; the enclosing frame is rebuilt — and on a rebuild failure the verbatim frame is emitted instead, because re-delivering duplicates is harmless while suppressing marked-seen rows is silent loss). A resubscribe trade snapshot of only already-seen rows is the one case that re-emits EMPTY rather than being dropped: the snapshot marks the (re)subscribe boundary, so it is delivered to keep the "up to date or TOLD" guarantee (a live all-duplicate frame, by contrast, is dropped). (This rule covers what a Session emits. The one non-verbatim Origin, OriginDerived — the monitor's CLI-authored candle payload — is produced by internal/candles on top of the session, never by a Session itself; see its doc in events.go.) Events are not globally ordered across origins — consumers order per key, not by arrival. Backfill events carry ServerTime = the server-clock estimate when the fetch began (conservative), so "newest ServerTime wins per key" orders them against live frames — this is what makes the no-ordering-key myAsset channel reconcilable.

The private upgrade is signed like a REST request

The signed upgrade query (timestamp=<ms>, plus recvWindow when the measured clock uncertainty needs a wider validity window, signature appended last) is produced by the session's Config.Client itself — Client.SignHandshake reuses the SAME ordered-encode + signature-last core as REST signing — and the X-KAPI-KEY header carries Client.APIKeyID. Re-signed fresh on every dial. The server clock is measured proactively — synchronously, before the first dial — when Config.ProactiveTimeSync is set; otherwise the first re-measurement is reactive: an EXCEED_TIME_WINDOW handshake rejection (before the retry), or the delivery-delay check kicking a resync when it suspects lag against an unmeasured clock (Client.Resync, the shared clock.Syncer, leaned into the past by the measurement uncertainty). A 4xx upgrade rejection carrying a Korbit error envelope is fatal (reconnecting cannot fix credentials); a 4xx WITHOUT the envelope may come from an intermediary and is retried with backoff.

One Client, one shared clock

The session takes a single Config.Client (a *korbit.Client): it performs REST backfill, signs the WS upgrade, and owns the shared clock (read via Client.Clock, resync via Client.Resync). A consumer that needs signed REST (the monitor command's JavaScript korbit.* bindings) builds its OWN korbit.Client over the SAME clock.Syncer — so the WS upgrade, backfill, and the consumer's calls all sign against one estimate, measured through one single-flight/cooldown path, and an EXCEED_TIME_WINDOW resync on any surface fixes them all at once. Cursorless window-walk paging lives in internal/ops as WalkHistory (the engine behind this layer's fetchHistory); stream imports ops for it (the allowed direction is stream → ops). The stream layer itself stays journal-agnostic: it imports no journal package; the Client journals each backfill call through its own per-call recorder, governed by the consumer's callrec policy, which exempts the stream-backfill surface (recovery reads, not actions), so they are not recorded.

Liveness is actively probed

Client pings every PingIntervalMs double as RTT samples; a missed pong tears the connection down (half-open detection) and the reconnect loop (jittered exponential backoff, fatal-aware) takes over. Reconnect/backoff, keepalive, delay and unreliability thresholds all live in Tunables so tests run the real machinery in milliseconds. Config.NoReconnect turns the loop off: the first failed connect or drop emits its lifecycle notice plus a terminal FATAL and returns the error, so Run ends (any one endpoint dropping ends the session); the one-shot EXCEED_TIME_WINDOW resync still runs, as it establishes the first connection rather than reconnecting.

Tests

stream_test.go drives the full lifecycle through fake conns/dialers and a stub REST doer; dialer_test.go exercises the real coder/websocket adapter against an in-process server. e2e_test.go is opt-in (KORBIT_STREAM_E2E_BASE pointing at a running sandbox, plus KORBIT_STREAM_E2E_KEY_ID/KORBIT_STREAM_E2E_PEM_FILE for the private test and KORBIT_STREAM_E2E_SOAK_MS for a hold-open soak) and verifies snapshots, the signed upgrade, REST backfill, and a real placed order arriving on myOrder.

Index

Constants

View Source
const (
	ChannelTicker    = "ticker"
	ChannelOrderbook = "orderbook"
	ChannelTrade     = "trade"
	ChannelMyOrder   = "myOrder"
	ChannelMyTrade   = "myTrade"
	ChannelMyAsset   = "myAsset"
)

Channel names. A Subscription's Channel must be one of these.

View Source
const (
	LogComponentKey    = "component"
	LogComponentStream = "stream"       // connection mechanics (internal/stream)
	LogComponentState  = "stream/state" // state reconcile (internal/stream/state)
	LogKindKey         = "kind"
	LogKindNotice      = "stream_notice" // a mirrored Notice
	LogCodeKey         = "code"          // the notice's symbolic code
)

Log-tag vocabulary for the stream layer. Every stream-layer record carries LogComponentKey = LogComponentStream (connection mechanics) or LogComponentState (the materialized state store), so `grep component=stream` isolates the whole layer; a mirrored reliability notice adds LogKindKey = LogKindNotice (+ LogCodeKey = <CODE>), so `grep kind=stream_notice` isolates just those. Under --log-format json these become real JSON fields.

The vocabulary lives here, with the layer it describes, so the cli frontends that build the stream loggers (monitor/tui) and stream.LogNotice (which stamps the code) all reference one source and cannot drift. See doc.go "Logging".

View Source
const (
	DefaultPublicURL  = "wss://ws-api.korbit.co.kr/v2/public"
	DefaultPrivateURL = "wss://ws-api.korbit.co.kr/v2/private"
)

Production WebSocket endpoints.

View Source
const HistoryWindowMs = ops.HistoryWindowMs

HistoryWindowMs re-exports the documented /v2/allOrders + /v2/myTrades retention (36h) for any stream consumer that references it. The canonical value and the window-walk live in internal/ops.

Variables

This section is empty.

Functions

func IsPrivateChannel

func IsPrivateChannel(channel string) bool

IsPrivateChannel reports whether a channel rides the private endpoint and requires credentials.

func LogNotice

func LogNotice(log *slog.Logger, n Notice)

LogNotice renders one notice to log at its own level, as "<message> code=<CODE>". It is a rendering helper only — the DECISION to mirror notices into a log (and which logger/threshold) belongs to the consumer (the monitor/tui frontends), not to this layer; notices remain program output on the event stream. log must be non-nil.

Types

type Config

type Config struct {
	// PublicURL/PrivateURL override the production WebSocket endpoints
	// (e.g. to point at the local sandbox).
	PublicURL  string
	PrivateURL string
	// Subscriptions is the channel set to stream. At least one is required;
	// it is fixed for the session's lifetime.
	Subscriptions []Subscription
	// Client is the session's single Korbit API handle (required): it performs
	// REST backfill + public-trade gap patching, signs the private WebSocket
	// upgrade (Client.SignHandshake), and owns the shared server-clock estimate
	// (read via Client.Clock, resync via Client.Resync). Its Creds (set for a
	// private session) sign the upgrade and the account-data reads; a public-only
	// session passes a creds-less client whose Clock still drives delivery-delay
	// detection. The session records nothing of its own — the Client journals each
	// backfill call through its own per-call recorder (the caller's callrec policy,
	// which exempts the stream-backfill surface). The Client's BaseURL is required
	// whenever any private channel is subscribed, or the trade channel is
	// subscribed with backfill enabled.
	Client *korbit.Client
	// DisableBackfill turns off all REST recovery. Private (re)connections then
	// raise BACKFILL_DISABLED, and detected public trade gaps raise
	// BACKFILL_DISABLED + DATA_GAP before the resubscribe snapshot is emitted.
	DisableBackfill bool
	// NoReconnect ends the session the first time a connection cannot be kept
	// up, instead of reconnecting. With it set, a failed initial connect, a
	// rejected subscribe write, or a connection that drops after serving all
	// emit their usual lifecycle notice (CONNECT_FAILED / DISCONNECTED) followed
	// by a terminal FATAL notice, and Run returns the underlying error. Because
	// Run stops on the first connManager error and cancels the rest, ANY one of
	// the (up to two) endpoints dropping ends the whole session. The one-shot
	// EXCEED_TIME_WINDOW clock resync is preserved (it is part of establishing
	// the first connection, not a reconnect). When false (the default) the
	// session reconnects indefinitely with jittered backoff and REST backfill.
	NoReconnect bool
	// BackfillSnapshotOnly trims private backfill to the per-connect SNAPSHOTS
	// only — balances and the authoritative open-order set (GET /v2/openOrders)
	// — and skips the per-symbol gap WALKS (GET /v2/allOrders order history and
	// GET /v2/myTrades fills). It exists for a session subscribing the account
	// channels across MANY symbols (e.g. the TUI watching every launched pair),
	// where fanning the history walks out over all of them on every reconnect is
	// a REST storm. The trade-off is explicit and announced: order/fill
	// transitions that happened DURING a disconnect are not recovered (a
	// BACKFILL_SNAPSHOT_ONLY notice says so). The open-order snapshot still
	// re-syncs the current open set, but stream/state only infers disappearances
	// for orders known from a prior private-connection epoch; same-epoch closes are
	// expected to arrive as live myOrder terminal frames. Public trade backfill
	// (Subscription.TradeHistory seed and the gap patch) is unaffected. Ignored
	// when DisableBackfill is set.
	BackfillSnapshotOnly bool
	// LazyOpenOrders scopes the open-order SNAPSHOT backfill to a dynamic
	// "tracked" scope set (SetTrackedOrderScopes — {accountSeq, symbol} pairs)
	// instead of every subscribed myOrder symbol × account. The live myOrder
	// channel is account-wide regardless, so this changes only which scopes get
	// a GET /v2/openOrders snapshot on (re)connect — letting a session that
	// subscribes the account channels across MANY symbols (and, with several
	// AccountSeqs, several sub-accounts) snapshot just the currently viewed
	// scope. Tracking a new scope snapshots it on demand, rather than fanning a
	// per-symbol×account snapshot out over every pair. In stream/state that
	// snapshot marks the scope ready and can close orders from an earlier
	// private-connection epoch; it is NOT a same-epoch substitute for a missing
	// live terminal myOrder frame. When false (the default, e.g. monitor) every
	// subscribed myOrder symbol is snapshotted for every subscribed account.
	// Independent of BackfillSnapshotOnly (which governs the gap WALKS).
	LazyOpenOrders bool
	// ProactiveTimeSync gates the proactive server-clock measurement at Run start.
	// When true (the monitor/tui --time-sync on) the clock is measured ONCE,
	// SYNCHRONOUSLY, before any connection is dialed, so the first frames' delay
	// check and the first private-upgrade signature already see a corrected clock —
	// no false-DATA_DELAYED flash on a skewed host clock. When false the estimate
	// stays the bare local clock and is corrected reactively: on an
	// EXCEED_TIME_WINDOW rejection, or when the delivery-delay check suspects lag
	// against an unmeasured clock and kicks a one-off resync (see the delayCheck).
	// That reactive kick works for a public-only session too — no signing needed —
	// as long as the Client carries a resync hook (omitted only under --time-sync
	// off). So with this false a skewed host clock trips one false DATA_DELAYED that
	// then self-corrects to DATA_CURRENT; set it true to measure up front and skip
	// the flash. Signing correctness never depends on this field: an out-of-window
	// signed request is corrected reactively whenever the Client carries a resync
	// hook.
	ProactiveTimeSync bool
	// BackfillRetryBudgetMs bounds the retry sleep budget of each backfill
	// REST call (default 15000).
	BackfillRetryBudgetMs int
	// EventBuffer is the event channel's capacity (default 1024). When the
	// buffer is full the session blocks rather than drop events; a consumer
	// that stops receiving eventually stalls the WebSocket reads.
	EventBuffer int
	// UserAgent is the User-Agent stamped on every WebSocket dial (the upgrade
	// handshake is a Korbit API request). Empty falls back to the bare
	// "korbit-cli/<version>". The REST/backfill side uses the Client's own
	// UserAgent. The caller composes both (see internal/useragent) so the stream
	// layer carries no environment-gathering of its own.
	UserAgent string
	// Log is the optional operational logger for the connection mechanics behind
	// the notice channel: the resolved dial target and signed-vs-public upgrade,
	// subscribe/unsubscribe frames, reconnect backoff/epoch timing, and the REST
	// backfill window math. nil is silent. These are Debug-dominant DIAGNOSTICS
	// that COMPLEMENT — never duplicate — the Notice channel (which is program
	// output); a --debug run shows them, a normal run does not. No secrets ever
	// reach it (the signed upgrade is logged as host/path only, never the signed
	// query). This is the layer's only logging surface; the cli wires it once
	// and it flows to every internal goroutine.
	Log *slog.Logger
	// Test seams. Dial defaults to DefaultDialer, Now to wall-clock unix ms,
	// Sleep to time.Sleep.
	Dial  Dialer
	Now   func() int64
	Sleep func(time.Duration)
	// Tunables are the timing/threshold knobs; zero fields take defaults.
	Tunables Tunables
}

Config describes a streaming session.

type Conn

type Conn interface {
	// Read returns the next text message. It must also service protocol
	// control frames (answer server pings, complete client pings).
	Read(ctx context.Context) ([]byte, error)
	Write(ctx context.Context, p []byte) error
	// Ping sends a protocol ping and returns when the pong arrives (requires a
	// concurrent Read to process it) or ctx expires.
	Ping(ctx context.Context) error
	// Close tears the connection down immediately. Safe to call more than once
	// and from any goroutine; it must unblock a concurrent Read.
	Close() error
}

Conn is the minimal WebSocket surface the manager needs. The default implementation wraps coder/websocket; tests inject fakes.

func DefaultDialer

func DefaultDialer(ctx context.Context, url string, header http.Header) (Conn, error)

DefaultDialer dials with coder/websocket.

type Data

type Data struct {
	// Channel is the logical channel: ticker | orderbook | trade | myOrder |
	// myTrade | myAsset.
	Channel string
	// Symbol is the trading pair, when the channel is per-symbol (empty for
	// myAsset).
	Symbol string
	// Origin tells which path the payload took and therefore its schema.
	Origin Origin
	// ServerTime is the server's timestamp for the data in unix ms: the frame
	// timestamp for WebSocket origins; for backfill, the session's
	// server-clock estimate when the REST fetch began — conservative, so
	// "newest ServerTime wins per key" never lets an older delta overwrite a
	// fresher REST snapshot (rows may additionally carry their own per-row
	// timestamps).
	ServerTime int64
	// Source is the REST path that produced an OriginBackfill payload (e.g.
	// "/v2/openOrders" — which also identifies the payload's row shape, and
	// distinguishes an authoritative open-orders snapshot from gap-history
	// rows). Empty for WebSocket origins.
	Source string
	// AccountSeq is the sub-account this private-channel event belongs to (1 =
	// main). It is the request's accountSeq for an OriginBackfill private payload
	// (whose bare-array rows do not echo it) and the wrapper's accountSeq for a
	// live private frame (order/trade/asset). nil when not applicable (public
	// channels) or not tagged — the server omits the wrapper accountSeq unless
	// the subscription explicitly requested accountSeqs, and an unpinned backfill
	// call carries none. A consumer that materializes per-account state reads nil
	// as the default account (1).
	AccountSeq *int
	// PrivateEpoch is the private-connection generation an OriginBackfill private
	// payload was fetched in (the connManager's connection count at backfill
	// start). It lets a state consumer tell a snapshot from a superseded
	// connection — one whose slow REST landed after a newer reconnect already
	// advanced the generation — from a current one, without a transport
	// timestamp. 0 means "not stamped": live frames leave it 0 because they are
	// always delivered before the next connection's CONNECTED (so a consumer reads
	// 0 as the current generation), and public events never set it. Internal to
	// the stream→state path; NOT surfaced in the monitor NDJSON or the bot event.
	PrivateEpoch int64
	// Payload is the verbatim source document, so decimal strings pass
	// through untouched. See Origin for the schema. The one exception to
	// verbatim: a trade/myTrade frame whose rows were partially delivered
	// before is re-emitted with the already-delivered rows removed (rows stay
	// verbatim; the enclosing frame is rebuilt). A resubscribe trade SNAPSHOT
	// whose rows were ALL already delivered is rebuilt empty (data:[]) and still
	// emitted — the snapshot marks the (re)subscribe boundary, so it is delivered
	// rather than suppressed; a live (non-snapshot) all-duplicate frame is dropped.
	Payload json.RawMessage
}

Data is one delivery of channel data.

Ordering: within OriginRealtime on one connection, events preserve server order. Across origins there is no global order — a backfill patching an old gap is emitted after newer realtime frames. Consumers that materialize state must order per key (tradeId for trades; per-order/per-currency timestamps otherwise), not by arrival.

type Dialer

type Dialer func(ctx context.Context, url string, header http.Header) (Conn, error)

Dialer opens a WebSocket connection. A refused HTTP upgrade is reported as an *UpgradeError so the reconnect loop can classify it.

func DialerWithClient

func DialerWithClient(hc *http.Client) Dialer

DialerWithClient is DefaultDialer with the handshake performed through hc, so the WebSocket connection honors the same outbound binding (--bind/--family) as the REST client. hc.Transport must speak HTTP/1.1 for the Upgrade.

type Event

type Event interface {
	// contains filtered or unexported methods
}

Event is one element of a Session's output stream: either a Data or a Notice. The stream is the only output a Session has; data and notices are interleaved in emission order.

type Level

type Level string

Level grades a Notice.

const (
	LevelInfo  Level = "info"
	LevelWarn  Level = "warn"
	LevelError Level = "error"
)

type Notice

type Notice struct {
	Code  NoticeCode
	Level Level
	// Message is one human-readable sentence.
	Message string
	// Details are structured, code-specific fields (documented per code).
	// Values are JSON-marshalable.
	Details map[string]any
	// Time is the local unix-ms time the notice was raised.
	Time int64
}

Notice is an out-of-band condition report, in-band in the event stream.

func (Notice) SlogLevel

func (n Notice) SlogLevel() slog.Level

SlogLevel maps the notice's stream level onto a slog threshold. A notice has no debug level (info is its floor), so the mapping is info/warn/error.

type NoticeCode

type NoticeCode string

NoticeCode enumerates everything the stream can tell its consumer besides data. The set and the meaning of each code are a stable contract for agents watching the stream — extend additively, never rename.

const (
	// Connected: a WebSocket connection was established (first time or after a
	// drop) and subscriptions were sent. Details: endpoint, attempt,
	// downtimeMs (0 on first connect).
	Connected NoticeCode = "CONNECTED"
	// Disconnected: a connection was lost; reconnection starts automatically.
	// Details: endpoint, reason.
	Disconnected NoticeCode = "DISCONNECTED"
	// ConnectFailed: a connection attempt failed; the session keeps retrying
	// with backoff. Details: endpoint, error, nextRetryMs.
	ConnectFailed NoticeCode = "CONNECT_FAILED"
	// SubscribeFailed: the server rejected one subscription item (e.g.
	// INVALID_SYMBOL). The subscription is dropped — that channel/symbol set
	// will produce no further data. Details: endpoint, channel, code, message.
	SubscribeFailed NoticeCode = "SUBSCRIBE_FAILED"
	// BackfillStart: recovery began. For private recovery, details: endpoint,
	// reason ("initial" | "reconnect" | "retry" — a self-heal re-run of the
	// leaf calls a prior pass left transiently failed, with `attempt` and
	// `units`, the exact calls re-run: channel, source, and where applicable
	// symbol and accountSeq), channels. For a public trade gap, details:
	// endpoint="public", reason="gap", channel="trade", symbol, afterTradeId,
	// beforeTradeId.
	BackfillStart NoticeCode = "BACKFILL_START"
	// BackfillDone: recovery finished. For private recovery, details:
	// endpoint, reason, channels, failures (and `attempt` on a retry pass). For
	// a public trade gap, details: endpoint="public", reason="gap",
	// channel="trade", symbol, afterTradeId, beforeTradeId, recoveredRows,
	// failures, complete. The level always matches the paired BackfillStart.
	//
	// PAIRING IS A CONTRACT: every BACKFILL_START is closed by exactly one
	// BACKFILL_DONE, even when the pass failed (failures/complete in details)
	// or its result was discarded — BACKFILL_FAILED is an additional alarm,
	// never a substitute for the DONE. Consumers may balance START against
	// DONE (the state store's Health.Backfilling does); every emitter,
	// including derived-channel synthesizers layered on the session
	// (internal/candles), must uphold it.
	BackfillDone NoticeCode = "BACKFILL_DONE"
	// BackfillFailed: a REST recovery call failed after retries, or configured
	// recovery could not run. Live frames keep flowing but the consumer's view may
	// be missing whatever the gap contained. For private recovery a transiently
	// failed call (network/5xx/429) is re-run automatically with backoff while
	// the connection stays up — each re-run is a fresh BACKFILL_START (reason
	// "retry") scoped to exactly the still-failing calls — but recovery is not
	// guaranteed; a definitive 4xx rejection is re-attempted only on the next
	// (re)connect. Details: endpoint, channel, error; public trade gap notices
	// also include reason="gap", symbol, source, and id bounds.
	BackfillFailed NoticeCode = "BACKFILL_FAILED"
	// BackfillNotViable: the gap cannot be recovered from REST (e.g. the
	// disconnection exceeded the server's 36-hour order/fill history window).
	// Details: endpoint, channel, reason.
	BackfillNotViable NoticeCode = "BACKFILL_NOT_VIABLE"
	// BackfillDisabled: the consumer disabled backfill and a (re)connection or
	// public trade gap happened, so gaps will NOT be patched. Details: endpoint,
	// reason; public trade gap notices also include channel="trade", symbol, and
	// id bounds.
	BackfillDisabled NoticeCode = "BACKFILL_DISABLED"
	// BackfillSnapshotOnly: the consumer chose snapshot-only private backfill
	// (Config.BackfillSnapshotOnly), so on every connect only balances and the
	// open-order snapshot are backfilled and the per-symbol order/fill history WALKS
	// are skipped — order/fill transitions during a disconnect are NOT recovered.
	// Details: endpoint, reason, channels.
	BackfillSnapshotOnly NoticeCode = "BACKFILL_SNAPSHOT_ONLY"
	// DataGap: data was provably or probably lost and could not be fully
	// patched (e.g. a public-trade gap wider than the REST window). Details:
	// channel, symbol, plus gap bounds when known.
	DataGap NoticeCode = "DATA_GAP"
	// DataDelayed: frames are arriving with server-to-client delay above the
	// configured threshold — the consumer is seeing the past. Rate-limited.
	// Details: endpoint, delayMs.
	DataDelayed NoticeCode = "DATA_DELAYED"
	// DataCurrent: the recovery edge for DataDelayed — a standing delay warning
	// has cleared, either because a later frame is current again (Details:
	// endpoint, delayMs) or because the feed went quiet, leaving no late frames
	// to report (Details: endpoint). Emitted once, when a prior DataDelayed
	// clears.
	DataCurrent NoticeCode = "DATA_CURRENT"
	// ConnectionUnreliable: the connection is up but degraded — repeated
	// recent drops or sustained high RTT. Rate-limited. Details: endpoint,
	// disconnects, windowMs and/or rttMs.
	ConnectionUnreliable NoticeCode = "CONNECTION_UNRELIABLE"
	// ConnectionStable: the recovery edge for ConnectionUnreliable — RTT has
	// fallen back under the threshold and recent drops have aged out of the
	// window, so the connection is reliable again. Emitted once, when a prior
	// ConnectionUnreliable clears. Details: endpoint.
	ConnectionStable NoticeCode = "CONNECTION_STABLE"
	// ServerError: the server sent an error control message
	// ({"status":"error", ...}) not tied to a subscription. Details: endpoint,
	// message, code.
	ServerError NoticeCode = "SERVER_ERROR"
	// Keepalive: no data event for the configured interval; the session is
	// alive (details say whether its connections are up), there has just been
	// no data to deliver. Repeats every interval while silence lasts.
	// Details: silentMs, connectionsUp, connectionsTotal.
	Keepalive NoticeCode = "KEEPALIVE"
	// Fatal: the session cannot continue and Run is about to return an error —
	// an auth/permission/config-class failure that reconnecting cannot fix, or
	// every subscription was rejected. Details: endpoint, error.
	Fatal NoticeCode = "FATAL"
)

type OrderScope

type OrderScope struct {
	AccountSeq int
	Symbol     string
}

OrderScope identifies one open-order snapshot unit in LazyOpenOrders mode: one symbol's open orders under one sub-account. AccountSeq <= 0 means the server's default account (the snapshot call carries no accountSeq parameter) — the only valid value when the myOrder subscription pinned no AccountSeqs. A scope's AccountSeq must otherwise be one of the subscription's pinned seqs; out-of-subscription scopes are ignored with a Debug log (see SetTrackedOrderScopes).

type Origin

type Origin string

Origin says which path an event's payload took, which also determines its schema: WebSocket frames for OriginRealtime/OriginSnapshot, REST response shapes for OriginBackfill.

const (
	// OriginRealtime is a live WebSocket frame. Payload is the verbatim frame.
	OriginRealtime Origin = "realtime"
	// OriginSnapshot is the WebSocket snapshot frame the public endpoint sends
	// first after a subscribe (`snapshot:true`). Payload is the verbatim frame.
	OriginSnapshot Origin = "snapshot"
	// OriginBackfill is data fetched over REST to backfill private channels or to
	// patch a gap. Payload is the REST response data (REST field shapes, which
	// differ slightly from the WebSocket frames — e.g. a REST fill has
	// feeQty where a WebSocket myTrade has fee).
	OriginBackfill Origin = "backfill"
	// OriginDerived is data the CLI synthesized itself rather than relayed — a
	// channel that does not exist on the Korbit WebSocket API (the monitor's
	// candle channel, built by internal/candles from the trade stream plus REST
	// seeds). Payload is a CLI-owned document, a stable contract of the emitting
	// layer. A Session never emits this origin.
	OriginDerived Origin = "derived"
)

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is one resilient streaming session: up to two managed WebSocket connections (public/private), REST recovery, and a single ordered event stream. Create with New, start with Run, consume Events until it closes.

func New

func New(cfg Config) (*Session, error)

New validates the config and builds a Session.

func (*Session) BackfillOpenOrders

func (s *Session) BackfillOpenOrders(scope OrderScope)

BackfillOpenOrders refreshes the authoritative open-order snapshot for one currently tracked scope (GET /v2/openOrders) on a background goroutine and emits it as an OriginBackfill event. It is an explicit refresh: unlike a re-track it does NOT consult the same-generation baseline memo, so a caller that wants a fresh snapshot despite a current baseline gets one. It does not add the scope to the tracked set; callers that change focus use SetTrackedOrderScopes, which records the tracked set and then snapshots newly tracked scopes. Untracked scopes' stored rows are reconciliation context for a future re-track; consumers gate display on the store's readiness. In stream/state a current tracked snapshot updates the scope's baseline and can mark orders from an earlier private-connection epoch as closed-unknown when absent; it deliberately does not close same-epoch orders that should be resolved by live terminal myOrder frames. Concurrent requests for the same tracked scope are deduped, including against a pending self-heal retry of a failed fetch (which already guarantees the snapshot). A no-op unless LazyOpenOrders is set, when the scope is untracked, when REST is not configured, or once the session has stopped.

func (*Session) Events

func (s *Session) Events() <-chan Event

Events is the session's output. It delivers Data and Notice values in emission order and is closed when Run returns; consume until closed.

func (*Session) Run

func (s *Session) Run(ctx context.Context) error

Run connects, streams, and recovers until ctx is canceled (returns nil) or a fatal condition stops the session (a Fatal notice precedes the error). Call once.

func (*Session) SetTrackedOrderScopes

func (s *Session) SetTrackedOrderScopes(scopes []OrderScope)

SetTrackedOrderScopes sets the {account, symbol} scope set whose open-order snapshot the session keeps authoritative (LazyOpenOrders mode). It is how a consumer follows a changing focus — e.g. the TUI snapshotting just the active pair's open orders under the active sub-account, or every pair in an "all pairs" view — without changing the account-wide live myOrder subscription. Newly-tracked scopes are snapshotted now (GET /v2/openOrders; a transient failure self-heals with backoff while the private connection stays up) when the session is already running; before Run, or while reconnecting, the next per-connect backfill covers the tracked set.

A re-tracked scope whose last successful snapshot was taken in the CURRENT private connection is NOT re-snapshotted while the feed is up: the myOrder feed is lossless and account-wide while connected, so the store's view of that scope has stayed current since its baseline — flipping focus back and forth within one connection costs no REST calls. Across a reconnect the memo generation no longer matches, so the scope is re-baselined the normal way (the per-connect pass when tracked at connect time, or on demand at the next re-track). Dropped scopes keep their stored state only as reconciliation context for that future re-baseline; consumers must gate display on the store's readiness (cleared on a private disconnect), never on the data's presence. A scope whose AccountSeq is not covered by the myOrder subscription is ignored (Debug-logged): the live feed doesn't cover it, so a snapshot would baseline state no frame ever updates.

Important stream/state contract: these snapshots are authoritative for baseline/readiness and for cross-epoch disappearance inference. They do not close an order first seen in the current private-connection epoch; same-epoch order closes must arrive as live terminal myOrder frames (the private feed's lossless-while-connected contract). Safe to call from any goroutine; never blocks. A no-op unless LazyOpenOrders is set, and inert once the session has stopped.

func (*Session) Subscribe

func (s *Session) Subscribe(sub Subscription) error

Subscribe adds a PUBLIC market-data subscription (orderbook or trade) to the live session: the request is sent now if a public connection is up, and the change is recorded so a reconnect re-subscribes it. It is how a consumer follows a changing focus — e.g. the TUI switching the active symbol's orderbook/trades — without tearing down the session. Safe to call from any goroutine and never blocks.

Restricted to the public orderbook/trade channels on purpose: ticker is subscribed up front (it powers the symbol list, which never shrinks), and the private channels are fixed at New because their per-connect backfill and per-key ordering assume a stable symbol set. A trade subscription may carry TradeHistory to seed depth on its first snapshot (see Subscription).

func (*Session) Unsubscribe

func (s *Session) Unsubscribe(channel string, symbols []string) error

Unsubscribe removes a public orderbook/trade subscription for the given symbols (the counterpart to Subscribe), matched by channel + symbols regardless of any grouping level. Same constraints; never blocks.

type Subscription

type Subscription struct {
	Channel string
	Symbols []string
	// Level is the optional orderbook grouping level (orderbook only).
	Level string
	// AccountSeqs is the optional account-sequence list (private channels
	// only); the server defaults to [1] (the main account) when omitted.
	AccountSeqs []int
	// TradeHistory seeds the trade channel with recent history on the initial
	// subscription (trade channel only). When > 0 the session fetches up to this
	// many trades preceding the live snapshot from GET /v2/trades and emits them
	// with Origin OriginBackfill, so a consumer starts with depth instead of only
	// whatever the WebSocket snapshot happened to carry (the public snapshot's
	// row count is server-defined and may be as small as one). Capped at the
	// server's 500-row page; ignored when backfill is disabled. The seed lands
	// asynchronously and may interleave with the first live frames — order by
	// tradeId, never by arrival.
	TradeHistory int
}

Subscription is one channel subscription. Channel must be one of the Channel* constants; Symbols is required for every channel except myAsset.

type Tunables

type Tunables struct {
	// DialTimeoutMs bounds one WebSocket dial (TCP+TLS+upgrade).
	DialTimeoutMs int64
	// WriteTimeoutMs bounds one outbound write (the subscribe batch).
	WriteTimeoutMs int64
	// ReconnectMinMs/ReconnectMaxMs bound the jittered exponential backoff
	// between reconnect attempts.
	ReconnectMinMs int64
	ReconnectMaxMs int64
	// StableAfterMs is how long a connection must stay up for the backoff
	// ladder to reset.
	StableAfterMs int64
	// PingIntervalMs is how often a protocol ping measures RTT and probes for
	// half-open connections; PongTimeoutMs is how long to wait for the pong
	// before declaring the connection dead.
	PingIntervalMs int64
	PongTimeoutMs  int64
	// KeepaliveAfterMs: with no data for this long, a KEEPALIVE notice is
	// emitted (and repeated each further interval of silence).
	KeepaliveAfterMs int64
	// DelayWarnMs: a frame whose server timestamp lags local receipt by more
	// than this triggers a DATA_DELAYED notice.
	DelayWarnMs int64
	// UnreliableRTTMs: an RTT sample above this counts the connection as
	// degraded.
	UnreliableRTTMs int64
	// UnreliableDisconnects within UnreliableWindowMs triggers
	// CONNECTION_UNRELIABLE.
	UnreliableDisconnects int
	UnreliableWindowMs    int64
	// NoticeMinIntervalMs rate-limits the repeatable warnings (DATA_DELAYED,
	// CONNECTION_UNRELIABLE) per connection.
	NoticeMinIntervalMs int64
	// BackfillRetryMinMs/BackfillRetryMaxMs bound the jittered exponential
	// backoff between self-heal re-runs of a private backfill pass whose
	// leaf calls stayed transiently failed (retryFailedBackfill). Distinct from
	// Config.BackfillRetryBudgetMs, which bounds the in-call retry ladder of
	// ONE REST call; these pace the pass-level re-runs after that ladder gave
	// up, while the connection stays up.
	BackfillRetryMinMs int64
	BackfillRetryMaxMs int64
}

Tunables are the session's timing and threshold knobs. The zero value is not usable; call (&Tunables{}).withDefaults() — Config does this. Every knob is injectable so tests can run the real machinery in milliseconds.

type UpgradeError

type UpgradeError struct {
	Status int
	// Code is the symbolic error code from the response's JSON error envelope
	// (error.message, e.g. EXCEED_TIME_WINDOW, KEY_NOT_FOUND), "" if none.
	Code string
	Body string
}

UpgradeError is a refused WebSocket handshake (non-101 response).

func (*UpgradeError) Error

func (e *UpgradeError) Error() string

Directories

Path Synopsis
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.
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.

Jump to

Keyboard shortcuts

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