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
- func IsPrivateChannel(channel string) bool
- func LogNotice(log *slog.Logger, n Notice)
- type Config
- type Conn
- type Data
- type Dialer
- type Event
- type Level
- type Notice
- type NoticeCode
- type OrderScope
- type Origin
- type Session
- func (s *Session) BackfillOpenOrders(scope OrderScope)
- func (s *Session) Events() <-chan Event
- func (s *Session) Run(ctx context.Context) error
- func (s *Session) SetTrackedOrderScopes(scopes []OrderScope)
- func (s *Session) Subscribe(sub Subscription) error
- func (s *Session) Unsubscribe(channel string, symbols []string) error
- type Subscription
- type Tunables
- type UpgradeError
Constants ¶
const ( ChannelTicker = "ticker" ChannelOrderbook = "orderbook" ChannelTrade = "trade" ChannelMyOrder = "myOrder" ChannelMyTrade = "myTrade" ChannelMyAsset = "myAsset" )
Channel names. A Subscription's Channel must be one of these.
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".
const ( DefaultPublicURL = "wss://ws-api.korbit.co.kr/v2/public" DefaultPrivateURL = "wss://ws-api.korbit.co.kr/v2/private" )
Production WebSocket endpoints.
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 ¶
IsPrivateChannel reports whether a channel rides the private endpoint and requires credentials.
func LogNotice ¶
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.
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 ¶
Dialer opens a WebSocket connection. A refused HTTP upgrade is reported as an *UpgradeError so the reconnect loop can classify it.
func DialerWithClient ¶
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 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.
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 ¶
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 (*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 ¶
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 ¶
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 ¶
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. |