Documentation
¶
Overview ¶
Package botapi is the monitor command's JavaScript bot runtime: a goja event loop on a dedicated script goroutine, an asynchronous Promise-based korbit.* API generated from the command spec, and a script-local SQLite db.* surface — so a small script passed via --init/--where/--on can be a complete trading bot.
The execution model is the load-bearing part:
- ALL JavaScript runs on one event-loop goroutine (goja runtimes are not goroutine-safe). The WebSocket pipeline runs no JS.
- Every korbit.* / db.* method returns a Promise immediately and hands its blocking work (REST, SQLite) to a bounded worker-goroutine pool; the worker resolves the Promise back onto the loop. The loop therefore NEVER blocks on I/O, and Promise.all of several calls is genuine concurrency (bounded by the pool size).
- --where stays a cheap synchronous filter with no korbit./db. access; it shares the runtime with --init/--on, so indicator state built anywhere is visible everywhere.
- --on handler invocations are serialized by the caller (RunHandler returns only when the handler's promise settles): no two --on handlers ever overlap. But serialization is at JOB granularity, not at handler granularity — while a handler is parked at an `await`, the loop runs other ready jobs, so a timer callback (setTimeout/setInterval) and the continuation of an un-awaited fire-and-forget call left running by an EARLIER handler can interleave at the await point. Shared-state safety is therefore absolute only for a script that AWAITs everything it starts; concurrency within one handler is still explicit (Promise.all).
- There is no per-event watchdog: with I/O off the loop only a pure-CPU runaway can stall it, and the signal context interrupts the VM.
- The worker pool bounds IN-FLIGHT work, not PENDING work: submission never blocks the loop, so a handler that issues calls in an unbounded loop accumulates pending goroutines parked on the pool semaphore. This is an accepted limit — the runtime defends against accidents, not against a hostile script.
The retry/idempotency policy lives in the Go bindings, not in scripts: reads and idempotent writes auto-retry, order placement uses the clientOrderId reconcile protocol, and the no-idempotency-key money movers are strictly single-shot. Money values stay decimal strings end to end — a JS number where a decimal is expected throws.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrStopped = errors.New("stopped while a handler was running")
ErrStopped is returned by RunHandler when the signal context was canceled while a handler was in flight: a deliberate stop, not a script failure.
Functions ¶
This section is empty.
Types ¶
type Event ¶
type Event struct {
Type string // "data" | "notice"
Channel string
Symbol string
Origin string
ServerTime int64
Source string
// AccountSeq is the sub-account of a private-channel data event (surfaced as
// ev.accountSeq), nil when not applicable/untagged (public channels, notices,
// or a frame the server did not tag with an accountSeq).
AccountSeq *int
Payload []byte
}
Event is one stream event as the script sees it. Payload is the verbatim JSON document (a WebSocket frame, REST backfill data, or a notice object).
type Options ¶
type Options struct {
// Where is the per-data-event predicate expression (optional).
Where string
// On is the per-event handler body, run after Where passes and for every
// notice (optional). May use await; korbit.*/db.* are available.
On string
// Init is a script run once before streaming (optional). May use await;
// korbit.*/db.* are available. No time budget — only SignalCtx bounds it.
Init string
// API is the L2 operations layer behind every korbit.* method — it owns the
// retry/idempotency policy and the place/history/candles/funding protocols.
// nil makes korbit.* unavailable. The bindings carry NO call policy of their
// own: they parse+validate, then call API.{PlaceOrder,History,Candles,
// FundingHistory,Invoke}. The order-row journal seam, the clock-resync hook,
// the reconcile Sleep, and the per-call retry budget all live on the API.
API *ops.API
// Surface is the frontend identity for the operations ledger (e.g. "monitor",
// "mcp"), threaded into every operation's RunInput.
Surface string
// KeyName / APIKeyID identify the signing key for the operations ledger (the
// public api-key id; never a secret). "" for a public-only session.
KeyName string
APIKeyID string
// KeyManager provides per-key metadata lookups (MetaDefaultAccountSeq) so
// the bot's accountSeq resolution honors a stored key's configured default.
// It is ignored when KeyName is empty or when Inline is set (an inline
// environment credential has no stored per-key metadata).
KeyManager *keys.Manager
// Inline is true when the signing credential came from inline environment
// material rather than a stored key (mirrors keys.Selection.Inline), so the
// per-key metadata lookup is skipped.
Inline bool
// CredsErr, when non-empty, is the message authenticated methods throw —
// set when no signing key could be resolved.
CredsErr string
// DBPath is the SQLite file behind db.*, opened lazily on first use.
// Empty makes db.* unavailable.
DBPath string
// NoFsync opens the db.* database with PRAGMA synchronous=OFF (faster writes,
// weaker crash-durability) — the --no-fsync/KORBIT_CLI_NO_FSYNC opt-in.
NoFsync bool
// Stateful builds a materialized state.Store fed by Ingest and exposes it as
// the synchronous state.* global. When false the Store is never built or fed
// (zero cost) and every state.* method throws.
Stateful bool
// AccountSeqs is the set of private sub-accounts the session subscribed
// (effAccountSeq-normalized, ≥1), used to roll the store's PER-ACCOUNT
// readiness up into the single state.ready().balances / .openOrders booleans:
// ready is true only when EVERY subscribed account is ready, so a partial
// snapshot (one account healed, another still failing its backfill) never
// reads as complete. Empty when no private channels are subscribed, which
// keeps those flags false (as they were when balances/orders never latched).
AccountSeqs []int
// MaxConcurrency caps in-flight korbit.*/db.* work (default 8).
MaxConcurrency int
// ServerNow is the session's server-clock estimate, behind korbit.now() (a bot
// reads it to compare against server-stamped timestamps). It is NOT used for any
// local record — see Now.
ServerNow func() int64
// Now is the local system clock (unix ms), used only to stamp the materialized
// state Store's Health.LastDataAt — a local receive time, compared against the
// reader's own "now", so it stays in the system frame (never the server-clock
// estimate). nil = time.Now().UnixMilli().
Now func() int64
// Sleep delays db.* retries and is injectable for tests (default time.Sleep).
// The korbit.* reconcile sleeps use ops.API.Sleep instead.
Sleep func(time.Duration)
// Stderr receives console output and runtime warnings (default: discard).
Stderr io.Writer
// Log is the optional operational logger passed to the materialized state
// Store (state reconcile mechanics, Debug). nil = silent. Only used when
// Stateful is set.
Log *slog.Logger
// SignalCtx, when set, interrupts running JavaScript on cancellation
// (Ctrl-C/SIGTERM) — the only bound on --init and on a runaway script.
SignalCtx context.Context
}
Options configures a Runtime.
type Runtime ¶
type Runtime struct {
// contains filtered or unexported fields
}
Runtime is one bot-script runtime. Create with New (which runs Init); call Match/RunHandler from a single controller goroutine; Close when done.
func New ¶
New builds the runtime: starts the event loop, installs the prelude and the korbit./db. bindings, compiles Where/On, and runs Init to completion. Errors are user-facing and name the flag they came from. If SignalCtx is canceled during Init, the returned error wraps the context error so the caller can tell "user aborted" from "init is broken".
func (*Runtime) Close ¶
func (r *Runtime) Close()
Close drains in-flight worker calls, stops the event loop, and closes the script database. Draining the pool first is load-bearing: a worker mid-call (e.g. an order placement still writing to the journal) must finish before the caller's deferred teardown — closing the journal, etc. — runs, or it would hit closed resources. A fire-and-forget korbit.* call left running by a handler that already returned is included; each worker is bounded by the REST timeout, so the wait is finite. Close must only be called after Match/RunHandler callers are done.
func (*Runtime) Fatal ¶
Fatal exposes the first unhandled promise rejection (a fire-and-forget korbit.* call that failed with nobody listening). The controller should select on it alongside its event queue and treat a received error as fatal.
func (*Runtime) Ingest ¶
Ingest folds one stream event into the materialized state.Store, on the loop goroutine, BLOCKING until applied. The caller (the monitor controller) calls it for every event — data and notices — BEFORE Match/RunHandler, so a later state.* read observes up-to-date state (apply-before-evaluate), and a --where-filtered event still updates state. A no-op when --stateful is off (the Store is nil). It must be called from the same controller goroutine as Match/RunHandler (never nested inside them).
func (*Runtime) Match ¶
Match evaluates --where against one data event. With no --where it matches everything. A returned ErrStopped means a deliberate shutdown interrupted the evaluation (a pure-CPU runaway predicate broken by Ctrl-C/--duration); any other error is a per-event JS exception the caller may skip and continue past.
func (*Runtime) RunHandler ¶
RunHandler invokes --on for one event and returns when the handler's promise settles (serializing handlers is the caller's contract — it must not pull the next event before RunHandler returns). A returned error is fatal to the bot, except ErrStopped (deliberate stop via the signal or --duration context). With no --on it is a no-op.
func (*Runtime) WatchInterrupt ¶
WatchInterrupt installs the streaming-phase shutdown context: a goroutine interrupts the VM when ctx is canceled (Ctrl-C/SIGTERM or --duration), so a pure-CPU runaway in --where/--on — the only thing that can stall the loop with I/O off it — still unwinds and exits. Call once, after New and before streaming. ctx should be canceled by both the signal and --duration.