Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/docs/content/docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ of any CPU saving.
Separate actions are genuinely required only when the orders differ in a field the action carries once rather than
per order — `vaultAddress`, `expiresAfter`, `builder`, or `grouping` itself.

When the orders come from independent callers rather than one place in your code, the
[order batcher](#optional-order-batching) collects them into shared actions for you.

### Orders over WebSocket (low latency)

Every `ExchangeClient` method also works over [`WebSocketTransport`](transports.md#websocket) — the server accepts
Expand Down Expand Up @@ -819,6 +822,84 @@ The standalone `signAction`, `submitAction`, and `executeAction` functions in
`@bloxwap/hyperliquid/actions/execution` accept the same exchange config. Existing client methods and
`prepareRequest` / `submitPrepared` continue to work.

## Optional order batching

Independent callers that each place one order can share signatures and requests through an opt-in batcher. Each
caller still gets its own result:

```ts
import { createOrderBatcher } from "@bloxwap/hyperliquid/actions/orderBatcher";

const batcher = createOrderBatcher(exchange.config, {
maxQueueSize: 1000, // queued plus in-flight orders
maxBatchSize: 100, // orders per request
flushIntervalMs: 1, // longest wait for a partial batch
});
const outcome = await batcher.enqueue({
a: 0, b: true, p: "30000", s: "0.01", r: false, t: { limit: { tif: "Gtc" } },
});
if (typeof outcome === "object" && "error" in outcome) console.error(outcome.error);
await batcher.close();
```

Queued orders are sent when a batch reaches `maxBatchSize` (which flushes the whole queue), when `flushIntervalMs`
elapses, or when you call `flush()`. Each request carries one action, one signature, and one nonce, through the same
signing path as `exchange.execute`, so nonce management, the dispatch policy, and the transport's rate limiting
apply unchanged. `exchange.order()` and the other existing methods still dispatch immediately and keep their error
behavior.

Only orders that agree on everything the action or request carries once share a batch: `grouping`, `builder`,
`vaultAddress`, and `expiresAfter`. ALO orders are also kept apart from IOC and GTC orders so they keep their
prioritization, and under priority grouping (`{ p }`), where the exchange requires every order in the action to be IOC
or every order to be a non-reduce-only ALO, orders are further split by time-in-force and reduce-only flag. The batcher
is bound to one config, so signer and network never mix.

`grouping` accepts `"na"` (the default) or `{ p }`. The TP/SL groupings (`normalTpsl`, `positionTpsl`) link the orders
of one action, so a stop-loss from one caller could become the child of another caller's entry order. `enqueue`
rejects them; send a TP/SL group as one `exchange.order()` call instead.

Results:

- `enqueue` resolves with the server's status for that order, in the order it was submitted: `resting`, `filled`,
`waitingForFill`, `waitingForTrigger`, or `{ error }`. An error status for one order does not affect the others in
its batch; unlike `exchange.order()`, mixed responses are never turned into a rejection.
- A whole-batch failure rejects every caller in that batch: a top-level `status: "err"`, a response whose statuses do
not match the submitted orders one to one, or a transport failure.
- An invalid order rejects only its own `enqueue`, before it is queued.
- A full queue rejects `enqueue` immediately with `Order batcher queue is full`.
- An order whose `expiresAfter` has passed before its batch is sent rejects without being sent.

Cancellation and shutdown:

- Aborting an `enqueue` signal before its batch is sent removes the order; nothing is sent for it.
- Aborting after the batch is sent only stops that caller waiting. It does not cancel the order, which the exchange may
already have accepted, and its slot counts against `maxQueueSize` until the batch settles.
- `flush()` sends everything queued and waits for every batch in flight.
- `close()` stops new enqueues and drains: queued orders are sent and their callers settle normally.
- `close({ drain: false })` rejects every waiting caller and stops waiting for in-flight batches. Orders already sent
may still be accepted; the rejection does not mean they were cancelled.
- The batcher never retries a batch. A failure after sending is ambiguous (the exchange may have applied the action),
so reconcile with open orders or fills before placing the orders again.

Rate limits: each request costs `1 + floor(orders / 40)` of the per-IP REST weight, so 100 orders in one batch cost 3
instead of 100. Address-based limits still count every order individually; batching does not raise them.

Batching trades latency for throughput. It helps when many orders arrive together, and costs a timer hop when they
arrive one at a time. Measured with 300 single-order callers, real secp256k1 signing, and a 5 ms mock transport
(`bun .dev/perf/order_batching.ts`, Bun 1.4.0, Apple M3 Max, median of 5 rounds):

| Callers arrive | Mode | Orders/s | p50 / p99 latency | Signatures and requests | Weight |
| ------------------- | --------------------- | -------- | ----------------- | ----------------------- | ------ |
| All at once | `order()` per caller | ~3,800 | 74 / 76 ms | 300 | 300 |
| All at once | batch 5, flush 1 ms | ~16,000 | 13 / 18 ms | 60 | 60 |
| All at once | batch 100, flush 0 ms | ~43,000 | 6 / 7 ms | 3 | 9 |
| One per millisecond | `order()` per caller | ~900 | 5.1 / 6.5 ms | 300 | 300 |
| One per millisecond | batch 100, flush 1 ms | ~680 | 6.5 / 9.2 ms | 300 | 300 |
| One per millisecond | batch 100, flush 5 ms | ~680 | 9.1 / 12.3 ms | 75 | 75 |

With steady arrivals, a flush interval shorter than the gap between orders sends batches of one and only adds delay;
a longer one batches more but makes every caller wait for it. Measure with your own traffic before choosing values.

## Bounded dispatch for remote signing

By default the SDK signs concurrently but sends a signer's requests in nonce-issuance order, so one slow remote
Expand Down
4 changes: 3 additions & 1 deletion packages/hyperliquid/.dev/build/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid";
import { allMids } from "@bloxwap/hyperliquid/api/info/allMids";
import { buildOrder } from "@bloxwap/hyperliquid/actions/order";
import { executeAction, signAction, submitAction } from "@bloxwap/hyperliquid/actions/execution";
import { createOrderBatcher, type OrderOutcome } from "@bloxwap/hyperliquid/actions/orderBatcher";
import type { OrderSuccessResponse } from "@bloxwap/hyperliquid/api/exchange/order";
import type { ExchangeConfig } from "@bloxwap/hyperliquid/api/exchange";
// @ts-expect-error Private declaration paths remain closed by the published export map.
Expand All @@ -21,7 +22,8 @@ const submitted: OrderSuccessResponse = await client.submit(signed);
const executed: OrderSuccessResponse = await client.execute(action);
const staged: OrderSuccessResponse = await submitAction(config, await signAction(config, action));
const direct: OrderSuccessResponse = await executeAction(config, action);
void [submitted, executed, staged, direct];
const outcome: OrderOutcome = await createOrderBatcher(config).enqueue(input);
void [submitted, executed, staged, direct, outcome];
// @ts-expect-error The published type exposes an immutable signature.
signed.signature.v = 28;
// @ts-expect-error Signed action fields are immutable as well.
Expand Down
160 changes: 160 additions & 0 deletions packages/hyperliquid/.dev/perf/order_batching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Order batching: throughput, latency, queue depth, and signature/request counts across batch sizes
* and flush intervals, against per-caller `order()` dispatch.
* bun .dev/perf/order_batching.ts [--filter burst] [--out /tmp/order-batching.json]
*
* Independent callers each place one order, either as a single burst or open-loop at one order per
* millisecond. Every request is signed with a real secp256k1 key and spends a fixed time on a mock
* transport. Reported per configuration:
* - caller latency p50/p99, from enqueue to settled outcome;
* - throughput, orders over the time from the first arrival to the last outcome;
* - peak queue depth (`batcher.pending`: queued plus in-flight orders);
* - signatures, requests, and REST weight (`1 + floor(orders / 40)` per request).
*
* Configurations alternate inside every round so machine drift hits each one equally, and each
* metric is the median over rounds. Numbers are wall-clock timings with real timers and mocks, not
* production latency.
* @module
*/
import { writeFile } from "node:fs/promises";
import { privateKeyToAccount } from "viem/accounts";
import type { ExchangeConfig } from "../../src/api/exchange/_methods/_base/_config.ts";
import { order } from "../../src/api/exchange/_methods/order.ts";
import { createOrderBatcher } from "../../src/actions/orderBatcher.ts";
import { delay } from "../../src/transport/runtime.ts";

const filterIndex = process.argv.indexOf("--filter");
const filter = filterIndex >= 0 ? process.argv[filterIndex + 1] : undefined;
const outIndex = process.argv.indexOf("--out");
const rounds = 5;
const orders = 300;
/** Gap between arrivals: `1` is open-loop at 1000 orders/s, `0` submits every order at once. */
const arrivals = [0, 1];
const transportMs = 5;
const batchSizes = [1, 5, 25, 100];
const flushIntervals = [0, 1, 5];
const key = privateKeyToAccount(`0x${"22".repeat(32)}`);
const input = { a: 0, b: true, p: "30000", s: "0.1", r: false, t: { limit: { tif: "Gtc" as const } } };

function percentile(values: number[], p: number): number {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * p) - 1)];
}

const median = (values: number[]): number => percentile(values, 0.5);

interface Sample {
ordersPerSecond: number;
p50Ms: number;
p99Ms: number;
peakQueue: number;
signatures: number;
requests: number;
weight: number;
}

/** `undefined` batching options measure one `order()` call per caller. */
async function run(batching: { maxBatchSize: number; flushIntervalMs: number } | undefined, arrivalEveryMs: number) {
let signatures = 0;
let requests = 0;
let weight = 0;
let lastNonce = 0;
const config: ExchangeConfig = {
wallet: {
address: key.address,
sign: (args) => {
signatures++;
return key.sign(args);
},
signTypedData: (typedData) => {
signatures++;
return key.signTypedData(typedData);
},
},
nonceManager: () => (lastNonce = Math.max(Date.now(), lastNonce + 1)),
transport: {
isTestnet: true,
async request<T>(_endpoint: "info" | "exchange", payload: unknown): Promise<T> {
const count = (payload as { action: { orders: unknown[] } }).action.orders.length;
requests++;
weight += 1 + Math.floor(count / 40);
await delay(transportMs);
const statuses = Array.from({ length: count }, (_, i) => ({ resting: { oid: i + 1 } }));
return { status: "ok", response: { type: "order", data: { statuses } } } as T;
},
},
};
const batcher = batching && createOrderBatcher(config, batching);
let outstanding = 0;
let peakQueue = 0;
const latencies: number[] = [];
const calls: Promise<void>[] = [];
const start = performance.now();
for (let i = 0; i < orders; i++) {
const arrived = performance.now();
outstanding++;
calls.push(
(batcher ? batcher.enqueue(input) : order(config, { orders: [input] })).then(() => {
latencies.push(performance.now() - arrived);
outstanding--;
}),
);
peakQueue = Math.max(peakQueue, batcher ? batcher.pending : outstanding);
if (arrivalEveryMs > 0) await delay(arrivalEveryMs);
}
await Promise.all(calls);
const elapsed = performance.now() - start;
await batcher?.close();
return {
ordersPerSecond: (orders * 1000) / elapsed,
p50Ms: percentile(latencies, 0.5),
p99Ms: percentile(latencies, 0.99),
peakQueue,
signatures,
requests,
weight,
} satisfies Sample;
}

const configs: [string, { maxBatchSize: number; flushIntervalMs: number } | undefined][] = [
["direct", undefined],
...batchSizes.flatMap((maxBatchSize) =>
flushIntervals.map((flushIntervalMs): [string, { maxBatchSize: number; flushIntervalMs: number }] => [
`batch ${maxBatchSize}/${flushIntervalMs}ms`,
{ maxBatchSize, flushIntervalMs },
]),
),
];

const report: Record<string, unknown>[] = [];
for (const arrivalEveryMs of arrivals) {
const arrival = arrivalEveryMs ? "1/ms" : "burst";
if (filter !== undefined && !arrival.includes(filter)) continue;
const samples = new Map<string, Sample[]>(configs.map(([name]) => [name, []]));
for (let round = 0; round < rounds; round++) {
const sequence = round % 2 === 0 ? configs : [...configs].reverse();
for (const [name, batching] of sequence) samples.get(name)!.push(await run(batching, arrivalEveryMs));
}
for (const [name, batching] of configs) {
const rows = samples.get(name)!;
const summary: Record<string, unknown> = {
arrival,
mode: batching ? "batcher" : "direct",
maxBatchSize: batching?.maxBatchSize ?? "-",
flushIntervalMs: batching?.flushIntervalMs ?? "-",
};
for (const metric of Object.keys(rows[0]) as (keyof Sample)[]) {
summary[metric] = Number(median(rows.map((row) => row[metric])).toFixed(2));
}
report.push(summary);
}
}

console.log(`${orders} orders per run, ${transportMs} ms transport, real signing, median of ${rounds} rounds`);
console.table(report);
if (outIndex >= 0) {
await writeFile(
process.argv[outIndex + 1],
JSON.stringify({ runtime: process.versions, orders, transportMs, rounds, report }, null, 2),
);
}
1 change: 1 addition & 0 deletions packages/hyperliquid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"./actions/vaultModify": "./src/actions/vaultModify.ts",
"./actions/vaultTransfer": "./src/actions/vaultTransfer.ts",
"./actions/withdraw3": "./src/actions/withdraw3.ts",
"./actions/orderBatcher": "./src/actions/orderBatcher.ts",
"./actions/stakingDeposit": "./src/actions/stakingDeposit.ts",
"./actions/stakingWithdraw": "./src/actions/stakingWithdraw.ts",
"./actions/validatorAction": "./src/actions/validatorAction.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/hyperliquid/src/actions/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export * from "./vaultModify.ts";
export * from "./vaultTransfer.ts";
export * from "./withdraw3.ts";

export * from "./orderBatcher.ts";
export * from "./stakingDeposit.ts";
export * from "./stakingWithdraw.ts";
export * from "./validatorAction.ts";
Expand Down
Loading
Loading