This document describes the current async-first architecture of binapi2.
- Each request: build query -> sign -> HTTP request -> HTTP response -> parse JSON
- Authentication: API key header + signed query (Ed25519 by default, HMAC-SHA256 as fallback)
- Connection: one fresh TCP+TLS connection per request (no pooling)
- Stateless: no session, no login
- Connect once, then logon once (session auth)
- Subsequent requests: JSON-RPC envelope -> send frame -> read frame -> parse response
- Connection: single persistent WebSocket, reused for all requests
- Stateful: session logon, request ID correlation
- Auth mode dispatched per request via
ws_auth_modeenum in traits
- Connect to endpoint, then receive-only (no request after connect)
- Generator pattern:
subscribe(subscription)returnscobalt::generator<result<Event>> - User data stream:
subscribe(listen_key)returnscobalt::generator<result<user_stream_event_t>>whereuser_stream_event_tis astd::variantof 10 event types
The core implementation is always async coroutine (cobalt::task).
Everything else is a wrapper that provides the result to the caller differently.
These are single-shot: one request in, one response out.
| Call model | REST | WS API |
|---|---|---|
| true async | co_await async_execute(req) -> result<T> |
co_await async_execute(req) -> result<T> |
| future | spawn(exec, task, use_future) -> future.get() |
same |
| callback | spawn(exec, task, callback) |
same |
| sync | io.run_sync(async_execute(req)) -> blocks |
same |
Streams produce a continuous sequence of events. The generator pattern is primary.
| Call model | How it works |
|---|---|
| generator (recommended) | auto g = streams.subscribe(sub); while (g) { auto e = co_await g; } |
| async loop | co_await async_connect(sub); while (...) { auto e = co_await async_read_event<E>(); } |
| raw text | co_await async_connect(target); while (...) { auto msg = co_await async_read_text(); } |
| future | N/A -- generators cannot be driven via use_future |
| Model | Executor | Blocking? | Thread |
|---|---|---|---|
| true async | Caller provides (cobalt::main, io_context) | No | Runs on caller's event loop |
| future | Executor to drive task | Caller blocks on future.get() |
Task runs on executor thread |
| callback | Executor to post completion | No | Callback invoked on executor thread |
| sync | io_thread (executor + thread) |
Yes | Dedicated background thread |
Key insight: All models need the same thing -- an executor to run the async task. They differ only in how the caller receives the result. Streams add a second dimension: how the caller consumes multiple results over time.
signing.hpp -- hmac_sha256_hex, ed25519_sign_base64, sign_query, inject_auth_query, build_query_string
query.hpp -- to_query_map<T> (reflection-based serialization)
json_opts.hpp -- parse options
decode.hpp -- decode_response<T> (JSON -> typed result)
No executor, no transport, no config. Pure CPU.
transport::http_client -- config-only constructor, async_request only
transport::websocket_client -- config-only constructor, async_connect, async_read_text,
async_write_text, async_close
These are cobalt::task-returning coroutines. They use co_await this_coro::executor
to get their executor from whoever drives them. They don't own an executor.
rest::pipeline -- sign -> build query -> http_client::async_request -> decode
Generic async_execute<Request>() resolves endpoint_traits.
websocket_api::client -- async_connect -> async_session_logon -> async_execute(req)
JSON-RPC envelope, auth mode dispatch via if constexpr,
4 auth modes: inject, none, signed_base, api_key_only.
streams::market_streams -- subscribe(subscription) -> generator<result<Event>>
stream_traits<Subscription>::target() builds WS URL,
stream_traits<Subscription>::event_type is the parsed type.
streams::user_streams -- subscribe(listen_key) -> generator<result<user_stream_event_t>>
Detects event type from "e" field, parses into variant.
streams::local_order_book -- async_run(symbol, depth_limit) -> task<result<void>>
Takes market_streams& + rest::pipeline& references.
All return cobalt::task or cobalt::generator. No executor ownership. No sync
wrappers. This is the true async API.
detail::io_thread -- User creates; owns io_context + background thread.
run_sync(task<T>) -> T blocks caller via future.get().
cobalt::main -- Cobalt provides executor. User co_awaits directly.
Required for generator pattern (streams).
thread_pool -- Multiple threads, strand per task.
spawn(pool, task, use_future) -> future.
manual io_context -- User creates + runs io_context.
spawn(io, task, use_future) -> future.
client {
config cfg;
http_client http;
rest::pipeline pipeline;
// 5 REST services (hold pipeline&):
account_service account;
convert_service convert;
market_data_service market_data;
trade_service trade;
user_data_stream_service user_data_streams;
// Lazy WebSocket components:
websocket_api::client& ws_api();
streams::market_streams& streams();
streams::user_streams& user_streams();
}
The client is a pure container -- it holds config, transport, pipeline, and typed
accessors. It does not own execution. Single constructor: client(config).
| Context | Who creates io_context | Who runs it | Lifetime |
|---|---|---|---|
cobalt::main |
cobalt runtime | cobalt runtime | program lifetime |
io_thread |
user | dedicated background thread | object lifetime |
thread_pool |
user | N worker threads | user-controlled |
io_context (manual) |
user | user calls io_context.run() |
user-controlled |
| Model | Signature | Caller does |
|---|---|---|
| true async | task<result<T>> async_execute(req) |
co_await |
| future | spawn on executor -> std::future<result<T>> |
future.get() (blocks) |
| callback | spawn on executor, post callback on completion | register callback, continue |
| sync | create io_thread, spawn, block on future | call and wait |
Bridging (Layer 4) is a one-liner:
// True async -- in a coroutine
auto r = co_await pipeline.async_execute(req);
// Future -- from any thread
auto future = cobalt::spawn(executor, pipeline.async_execute(req), use_future);
auto r = future.get();
// Callback -- from any thread
cobalt::spawn(executor, pipeline.async_execute(req), [](auto r) { handle(r); });
// Sync -- convenience
auto r = io_thread.run_sync(pipeline.async_execute(req));The library provides one method variant (cobalt::task) and the user provides the
execution environment.
| Aspect | Current | Ideal |
|---|---|---|
| Connections | 1 per request (no reuse) | Connection pool with keep-alive |
| Threading | User-provided executor | Configurable: single thread or pool |
| Priority | None | Separate connections for priority orders |
| Rate limiting | None (server-side only) | Client-side rate limiter |
| Aspect | Current | Ideal |
|---|---|---|
| Connection | 1 persistent | 1 persistent + spare for failover |
| Request pipelining | Sequential (write -> read) | Concurrent with ID correlation |
| Reconnection | None | Automatic with backoff |
| Aspect | Current | Ideal |
|---|---|---|
| Connection | 1 per stream or combined | Combined preferred (fewer connections) |
| Parsing | Same coroutine as I/O | Option to offload to parser task |
| Buffering | None (generator yields directly) | Optional bounded channel between I/O and consumer |
| Backpressure | Consumer pace controls I/O | Channel overflow policy |
Option A: Single event loop (cobalt::main)
cobalt::main ── REST request ── parse ── deliver
── WS API write/read ── parse ── deliver
── Stream generator ── parse ── yield
Simple. All I/O serialized on one thread. Natural for generators.
Option B: Thread per protocol
[rest_io] ── REST I/O ── parse ── deliver
[ws_api_io] ── WS API I/O ── parse ── deliver
[stream_io] ── Stream I/O ── parse ── deliver
Isolation between protocols. Stream processing can't starve REST.
Option C: Pipeline stages
[io_task] ── read raw frames ──> channel ──> [parse_task] ── parse ──> channel ──> [logic_task]
Maximum throughput but complex. Useful for high-frequency stream processing.
The library does not impose a threading model. It provides async coroutines (Layer 3). The user composes them with their chosen executor and threading strategy.
| Principle | Implication |
|---|---|
| Async-first | Primary API returns cobalt::task<result<T>> or cobalt::generator<result<T>> |
| No executor ownership | Client/services don't own io_thread or io_context |
| User provides executor | cobalt::main, io_thread, thread_pool -- user's choice |
| Bridging is trivial | io.run_sync(task), spawn(exec, task, use_future), spawn(exec, task, callback) |
| Client is a container | Holds config + typed protocol objects, no execution logic |
| Layer separation | Pure functions -> async I/O -> protocol logic -> execution policy -> facade |
| Generator for streams | subscribe() returns cobalt::generator, natural async iteration |
| Auth mode dispatch | WS API uses ws_auth_mode enum in traits, resolved via if constexpr |