TL;DR
- TWAP executions (slices) do not appear in
userFills/userFillsByTime. This is true with bothaggregateByTime: trueandfalse. They live in the separate info feed{"type":"userTwapSliceFills","user":"0x…"}. These are two distinct feeds, and the first does not contain the second. Verified live (2026-09-05). - Calculate account PnL, volume, and trading direction from the union of both feeds. If you look only at
userFills, TWAP-executed volume disappears, distorting both account direction and PnL: an account closing a position with TWAP can appear to be accumulating it. - An active TWAP is not visible in
frontendOpenOrders. Its signs in a snapshot are a position inclearinghouseStatechanging in small steps, no corresponding open orders, and an emptyuserFills. Confirm it withuserTwapSliceFills. userTwapSliceFillsreturns at most 2000 records, specifically the newest ones.userFillsByTimedoes the opposite: at its 2000 cap, it returns the oldest records fromstartTime. Over a long window the feeds cover different periods and cannot be compared directly.- A response with 2000 slices says nothing about current activity. Check the date of the latest slice. Two thousand slices may span hours for an active TWAP or many months for historical TWAPs.
- Placing TWAP through the API (
twapOrder/twapCancel) was not verified live. It is described below from public HL documentation and marked accordingly. Before using it in a bot, perform the smoke test in §7.1.
1. What TWAP is on HL
TWAP is a native exchange order type. You specify size and duration; the exchange divides the size into hundreds of small pieces (slices) and executes them evenly. The goal is to obtain an average price without moving the market with your own order.
Observed live:
- TWAP does not appear as an open order in
frontendOpenOrders. - Slices do not appear in
userFills; they go touserTwapSliceFills. - The position in
clearinghouseStatereflects TWAP execution accurately, step by step. - Slices are small and frequent: 5–9 slices per minute per coin were observed during an active TWAP (this conflicts with documentation; see §6).
2. Placement and cancellation through the API
According to public HL documentation; not verified live. Check request and response shapes against current documentation and SDK
.d.tsfiles before use.
2.1. twapOrder (exchange action)
{
"type": "twapOrder",
"twap": {
"a": 0,
"b": true,
"s": "10",
"r": false,
"m": 30,
"t": false
}
}
| Field | Type | Meaning |
|---|---|---|
a | number | asset index (as in a regular order) |
b | boolean | isBuy |
s | string | total size in the base coin (string, respecting szDecimals) |
r | boolean | reduceOnly |
m | number | duration in minutes (minutes) |
t | boolean | randomize — slice randomization |
Successful response:
{"status":"ok","response":{"type":"twapOrder","data":{"status":{"running":{"twapId":77738308}}}}}
Error response. The outer status is still "ok"; the error is inside data.status:
{"status":"ok","response":{"type":"twapOrder","data":{"status":{"error":"Invalid TWAP duration: 1 min(s)"}}}}
2.2. twapCancel
{ "type": "twapCancel", "a": 0, "t": 77738308 }
Here a is the asset index and t is the twapId returned by twapOrder.
Successful response: {"status":"ok","response":{"type":"twapCancel","data":{"status":"success"}}}.
Error response: data.status = {"error":"TWAP was never placed, already canceled, or filled."}.
2.3. Execution mechanics (according to documentation, not verified)
- A slice (suborder) is submitted about once every 30 seconds. Execution target:
elapsed / total × size. - Each slice has maximum slippage of 3%.
- If slices are underfilled (wide spread, little liquidity), TWAP catches up to the target with later slices. Catch-up is capped at 3× a regular slice.
- Slice execution is not guaranteed.
- TWAP does not work while the exchange is in post-only mode (network upgrades).
- The
mrange in the UI is roughly 5 minutes to 24 hours. Verify exact boundaries:Invalid TWAP duration: 1 min(s)proves only that one minute is too short.
2.4. TypeScript, @nktkas/hyperliquid 0.27.x (not verified)
import * as hl from "@nktkas/hyperliquid";
import { privateKeyToAccount } from "viem/accounts";
const transport = new hl.HttpTransport();
const wallet = privateKeyToAccount("0xAGENT_PRIVATE_KEY"); // agent/API wallet
const exch = new hl.ExchangeClient({ wallet, transport });
const info = new hl.InfoClient({ transport });
// Placement. Some SDK versions take flat fields without the twap wrapper; check the d.ts file.
// For a sub-account: ExchangeClient({ wallet, transport, defaultVaultAddress: "0xSUBACCOUNT_ADDRESS" }).
// The SDK may throw ApiRequestError on data.status.error instead of returning an object; handle both (§7.1).
const res = await exch.twapOrder({
twap: { a: 0, b: true, s: "10", r: false, m: 30, t: false },
});
const st: any = res.response.data.status;
if ("error" in st) throw new Error(st.error);
const twapId: number = st.running.twapId;
// Cancellation
await exch.twapCancel({ a: 0, t: twapId });
// Slices for an address (info request, works for any address)
const slices = await info.userTwapSliceFills({ user: "0xYOUR_ADDRESS" });
3. How TWAP appears in data
3.1. Which feeds show it
| Source | TWAP visible? | Comment |
|---|---|---|
userFills / userFillsByTime (both aggregateByTime modes) | No | Verified live: slices are completely absent |
frontendOpenOrders | No | An active TWAP is not visible as an open order |
clearinghouseState | Indirectly | The position shrinks or grows in small steps |
userTwapSliceFills (info) | Yes | Separate slice feed, newest records, maximum 2000 |
vaultDetails, subAccounts, ledger, spot | No | They do not explain a position change caused by TWAP |
| Daily HL builder-fills CSV dump (stats-data) | Yes, as columns | Contains twap_id and special_trade_type columns |
WS userFills | Probably not | Inferred from the absence of slices in userFills. Not fully verified |
3.2. The twapId field in a fill record
The userFills fill schema includes coin, px, sz, side, time, startPosition, dir, closedPnl, hash, oid, crossed, fee, tid, feeToken, twapId. By schema, twapId should identify a fill produced by TWAP (null for ordinary fills). Live, however, TWAP slices do not arrive in userFills at all. Therefore detection through twapId in userFills is unreliable; use userTwapSliceFills.
Fill records have no leverage field. Leverage can be obtained only from an open position in clearinghouseState.
3.3. The userTwapSliceFills request
POST https://api.hyperliquid.xyz/info
content-type: application/json
{"type":"userTwapSliceFills","user":"0xYOUR_ADDRESS","startTime":1757000000000}
- The response is an array. An element is either a wrapper with a
fillfield (documented as{fill, twapId}) or the fill itself. Safe read:const f = x.fill || x. - Inside
fillare the same fields as in an ordinary fill, includingcoin,sz,px,dir,closedPnl, andtime. diris a string such as"Open Long","Close Long","Open Short", or"Close Short", as inuserFills.- Slice notional:
Number(sz) * Number(px). - Limit: 2000 records, with the newest returned.
Feed parsing (verified live):
type Tally = { n: number; ntl: number; capped: boolean };
function tally(rows: unknown): Tally {
let n = 0, ntl = 0;
for (const x of Array.isArray(rows) ? rows : []) {
const f = (x as any).fill || x; // wrapper {fill, twapId} or the fill itself
n++;
ntl += Number(f.sz) * Number(f.px); // notional
}
return { n, ntl, capped: n >= 2000 }; // capped => the feed is truncated, totals are incomplete
}
async function info(body: object, tries = 3): Promise<any> {
for (let i = 0; i < tries; i++) {
try {
const r = await fetch("https://api.hyperliquid.xyz/info", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (r.status === 429) { await new Promise(s => setTimeout(s, 3000)); continue; }
return JSON.parse(await r.text());
} catch { await new Promise(s => setTimeout(s, 1200)); }
}
return null;
}
3.4. The 2000-record cap on both feeds
| Feed | Cap | What it returns at the cap |
|---|---|---|
userTwapSliceFills | 2000 | the newest 2000 |
userFillsByTime | 2000 | the oldest 2000 starting from startTime |
Consequences:
- Over a weekly window for an active account, both feeds hit 2000 and cover different periods. You cannot combine them as a “week” for PnL or volume: the sum would use nonmatching segments.
- Combine the feeds over a window where neither reaches the cap (often one day,
now − 864e5). - If
n >= 2000, mark the result withcapped: the feed total is incomplete. - During an active TWAP, 2000 slices may fit into a few hours. Do not mistake a few hours for a week.
4. How to identify TWAP in account data
4.1. Signs in a snapshot
- The position in
clearinghouseStatechanges in small steps. - Nothing corresponding to those changes rests in
frontendOpenOrders. userFills/userFillsByTimeis empty over the position-change window.- Confirmation is a nonempty
userTwapSliceFillswith recenttimevalues.
If two exchange sources contradict each other (the position changed but there are no fills), a third source has not yet been queried. Do not record this as “unexplained feed divergence” or noise.
4.2. Active or historical TWAP
A TWAP executing right now must be distinguished from a historical one.
- Active. The latest slice was minutes or hours ago.
- Historical. The feed contains 2000 slices, but they span many months and the latest one was long ago.
Rule: judge by the date of the latest slice, not the number of slices.
5. Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
| The account appears to be accumulating a position and PnL is distorted, although it is actually closing the position through TWAP | TWAP slices do not arrive in userFills / userFillsByTime; thousands of slices are invisible | Calculate all PnL, volume, and direction from userFills plus userTwapSliceFills |
| “The position shrinks but there are no fills” is dismissed as unexplained divergence | Both aggregateByTime modes of userFillsByTime, clearinghouseState, vaultDetails, subAccounts, ledger, and spot are checked, but userTwapSliceFills is not queried | A contradiction between two sources means a third exists. Query userTwapSliceFills first |
| PnL or volume from combined feeds is wrong over a long window | At their 2000 cap, the feeds return opposite ends of the window: newest versus oldest | Use a window where neither feed reaches the cap (usually a day), and set capped when n >= 2000 |
| “2000 slices means TWAP is active” | The feed always returns up to 2000, which may be months of history | Check the latest slice's time |
| Reconciliation lags while a TWAP executes on the account (likely) | WS userFills is silent as an “account executed” trigger because slices are absent from this feed | Do not rely only on WS userFills; keep a periodic timer reconciliation. A WS userTwapSliceFills subscription may help (not verified) |
| The parser crashes or loses slices | A response element is either a {fill, ...} wrapper or the fill itself | `const f = x.fill |
| Frequent info requests receive 429 and interfere with trading | Shared per-IP limit | Back off on 429 and network errors, stay near ~200–300 req/min (empirical, §6), and do not combine background reads with trading on one IP |
6. Open questions / not verified
twapOrder/twapCancelwere not verified live. Parametersa,b,s,r,m,t, response shapes, error strings, 30-second step, 3% slippage, 3× catch-up, and theminutesrange come from public documentation. Check them on testnet. The first live-check procedure is in §7.1. Also verify:- exact
randomizesemantics; - minimum TWAP and slice notional;
reduceOnlybehavior when the position closes before the TWAP ends.
- exact
- Conflict in slice pace. Five to nine slices per minute for one coin were observed, while documentation states one slice per 30 seconds (two per minute per TWAP). Multiple concurrent TWAPs on one coin are a likely explanation, but this was not verified. Group slices by
twapIdto check it. - Conflict around
twapIdinuserFills. The field exists in the fill schema, but live slices do not arrive inuserFills. Resolution: the live check (2026-09-05) takes priority over the schema. Detect throughuserTwapSliceFills. WhethertwapId != nullever appears inuserFillswas not verified. - Does
userTwapSliceFillshonorstartTime(andendTime)? The feed once returned slices spanning many months even though the requested window was shorter, so it may ignore the filter. Until verified, also filter client-side byfill.time >= startTime. If the filter is ignored, an empty feed still means “there are no slices at all,” while a window total may be inflated by old slices. Documentation providesuserTwapSliceFillsByTime(user,startTime,endTime?) for time windows, while{type:"twapHistory", user}returns TWAP statuses. Neither request was verified (see §7.1). - WS. The
userTwapSliceFillsanduserTwapHistorysubscriptions (TWAP statuses: activated / finished / terminated / error) were not verified, nor was thetwapHistoryinfo request for a TWAP list withexecutedSz/executedNtl. They are needed to observe an active TWAP directly rather than by indirect signs. - Is WS
userFillssilent during TWAP execution? This follows logically from the absence of slices in the REST feed but was not fully verified. - The
userTwapSliceFillsweight in HL limits was not measured. The ~200–300 req/min figure is empirical under mixed load. - The
twap_idandspecial_trade_typecolumns in the daily builder-fills CSV were only parsed from the header. Their values and whethertwap_idmatches APItwapIdwere not verified.
7. Verifying TWAP placement
The procedure is based on public HL documentation and already verified regular-order mechanics.
twapOrderitself was not verified live.
7.1. twapOrder smoke test (perform before using it in a bot)
Environment: a separate sub-account with a minimal balance. The main account's agent signs, and the client's defaultVaultAddress is the sub-account address, as with regular orders (details in accounts.md §3).
- Place
twapOrderon a liquid coin withm= 5,r:false,t:false. Use a size somewhat above the minimum with headroom: minimum TWAP and slice notional is not verified (§6), and a slice may fall below the minimum. Record the entire raw response. - Check whether SDK 0.27.x throws
ApiRequestErrorondata.status.error(as fororder) or returns an object. Parse both forms. - After 1–2 minutes, check that
userTwapSliceFillshas slices carrying the response'stwapId;frontendOpenOrdersis empty; those trades are absent fromuserFills; and the position inclearinghouseStategrows in steps. - Call
twapCancel({ a, t: twapId }). After 60 seconds, make sure there are no new slices. A repeated cancellation should returnTWAP was never placed, already canceled, or filled. - Repeat with
r:trueon a position smaller than the TWAP size. Record what happens after the position reaches zero. - Measure slice pace from
time(documentation says about once every 30 seconds) and group slices bytwapId. This also resolves the pace conflict in §6.
Ambiguous outcomes:
twapOrderis not idempotent. Retry only on429.- On 5xx or timeout, do not blindly repeat placement; reconcile through
userTwapSliceFills/twapHistory. According to documentation,{type:"twapHistory", user}returns TWAP statuses: activated / finished / terminated / error. - According to documentation,
userTwapSliceFillsByTime(user,startTime,endTime?) supplies a time window for slices. - None of this was verified. If the request does not work, filter client-side by
fill.time.
Knowledge snapshot: 2026-09; dates of individual checks are in the text. The HL API changes—verify limits and response shapes again.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting the material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license.