# Hyperliquid — TWAP orders: placement, slices, and how TWAP appears in data

## TL;DR

- **TWAP executions (slices) do not appear in `userFills` / `userFillsByTime`.** This is true with both `aggregateByTime: true` and `false`. 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 in `clearinghouseState` changing in small steps, no corresponding open orders, and an empty `userFills`. Confirm it with `userTwapSliceFills`.
- **`userTwapSliceFills` returns at most 2000 records, specifically the newest ones.** `userFillsByTime` does the opposite: at its 2000 cap, it returns the oldest records from `startTime`. 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 to `userTwapSliceFills`.
- The position in `clearinghouseState` reflects 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.ts` files before use.

### 2.1. `twapOrder` (exchange action)

```json
{
  "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:
```json
{"status":"ok","response":{"type":"twapOrder","data":{"status":{"running":{"twapId":77738308}}}}}
```
Error response. The outer `status` is still `"ok"`; the error is inside `data.status`:
```json
{"status":"ok","response":{"type":"twapOrder","data":{"status":{"error":"Invalid TWAP duration: 1 min(s)"}}}}
```

### 2.2. `twapCancel`

```json
{ "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 `m` range 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)

```ts
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

```http
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 `fill` field (documented as `{fill, twapId}`) or the fill itself. Safe read: `const f = x.fill || x`.
- Inside `fill` are the same fields as in an ordinary fill, including `coin`, `sz`, `px`, `dir`, `closedPnl`, and `time`.
- `dir` is a string such as `"Open Long"`, `"Close Long"`, `"Open Short"`, or `"Close Short"`, as in `userFills`.
- Slice notional: `Number(sz) * Number(px)`.
- Limit: **2000** records, with the **newest** returned.

Feed parsing (verified live):

```ts
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 with `capped`: 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

1. The position in `clearinghouseState` changes in small steps.
2. Nothing corresponding to those changes rests in `frontendOpenOrders`.
3. `userFills` / `userFillsByTime` is empty over the position-change window.
4. Confirmation is a nonempty `userTwapSliceFills` with recent `time` values.

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 || x` |
| 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` / `twapCancel` were not verified live.** Parameters `a,b,s,r,m,t`, response shapes, error strings, 30-second step, 3% slippage, 3× catch-up, and the `minutes` range come from public documentation. Check them on testnet. The first live-check procedure is in §7.1. Also verify:
  - exact `randomize` semantics;
  - minimum TWAP and slice notional;
  - `reduceOnly` behavior when the position closes before the TWAP ends.
- **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 `twapId` to check it.
- **Conflict around `twapId` in `userFills`.** The field exists in the fill schema, but live slices do not arrive in `userFills`. Resolution: the live check (2026-09-05) takes priority over the schema. Detect through `userTwapSliceFills`. Whether `twapId != null` ever appears in `userFills` was not verified.
- **Does `userTwapSliceFills` honor `startTime` (and `endTime`)?** 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 by `fill.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 provides `userTwapSliceFillsByTime` (`user`, `startTime`, `endTime?`) for time windows, while `{type:"twapHistory", user}` returns TWAP statuses. Neither request was verified (see §7.1).
- **WS.** The `userTwapSliceFills` and `userTwapHistory` subscriptions (TWAP statuses: activated / finished / terminated / error) were not verified, nor was the `twapHistory` info request for a TWAP list with `executedSz` / `executedNtl`. They are needed to observe an active TWAP directly rather than by indirect signs.
- **Is WS `userFills` silent during TWAP execution?** This follows logically from the absence of slices in the REST feed but was not fully verified.
- **The `userTwapSliceFills` weight in HL limits** was not measured. The ~200–300 req/min figure is empirical under mixed load.
- **The `twap_id` and `special_trade_type` columns in the daily builder-fills CSV** were only parsed from the header. Their values and whether `twap_id` matches API `twapId` were not verified.

---

## 7. Verifying TWAP placement

> **The procedure is based on public HL documentation and already verified regular-order mechanics. `twapOrder` itself 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).

1. Place `twapOrder` on a liquid coin with `m` = 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.
2. Check whether SDK 0.27.x throws `ApiRequestError` on `data.status.error` (as for `order`) or returns an object. Parse both forms.
3. After 1–2 minutes, check that `userTwapSliceFills` has slices carrying the response's `twapId`; `frontendOpenOrders` is empty; those trades are absent from `userFills`; and the position in `clearinghouseState` grows in steps.
4. Call `twapCancel({ a, t: twapId })`. After 60 seconds, make sure there are no new slices. A repeated cancellation should return `TWAP was never placed, already canceled, or filled.`
5. Repeat with `r:true` on a position smaller than the TWAP size. Record what happens after the position reaches zero.
6. Measure slice pace from `time` (documentation says about once every 30 seconds) and group slices by `twapId`. This also resolves the pace conflict in §6.

Ambiguous outcomes:
- `twapOrder` is not idempotent. Retry only on `429`.
- 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.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting the material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license._
