src/orders/twap.ts
v0.3.0 · 7.7 KB
// Native TWAP orders. Everything here is @experimental: TWAP placement through the API is not verified
// live (twap.md §2, §7); shapes follow HL documentation and SDK 0.33.3.
import {
interpretExchangeError,
invokeAsync,
parseTwapResponse,
recommendRetry,
type ExchangeErrorKind,
type ExchangeFailure,
type RetryAdvice,
} from '../errors/index.js';
import { formatSize, type Numeric, type OrderSide } from '../format/index.js';
import { invalidArgument } from './errors.js';
import { exchangeCallOptions } from './place.js';
import type { AssetResolver, TwapExchange } from './types.js';
/** Shortest TWAP duration accepted (minutes). `Invalid TWAP duration: 1 min(s)` was observed for 1. */
export const MIN_TWAP_MINUTES = 5;
/** Longest TWAP duration accepted (minutes, 24 h). */
export const MAX_TWAP_MINUTES = 1440;
export interface TwapIntent {
coin: string;
side: OrderSide;
/** Total size in base units; floored to the lot. */
size: Numeric;
/** Integer duration, {@link MIN_TWAP_MINUTES}..{@link MAX_TWAP_MINUTES}. */
minutes: number;
reduceOnly?: boolean;
/** Randomize slice timing/size (exact semantics not verified). Default `false`. */
randomize?: boolean;
}
export interface TwapCallOptions {
vaultAddress?: string;
expiresAfter?: number;
signal?: AbortSignal;
}
export type TwapPlaceResult =
| { readonly status: 'running'; readonly twapId: number; readonly sz: string; readonly assetId: number }
/** Rejected by the exchange (e.g. `invalidTwapDuration`). Nothing runs. */
| { readonly status: 'rejected'; readonly message: string; readonly kind: ExchangeErrorKind }
/** Provably not applied (429 / 4xx), not signed, or rejected by SDK validation. */
| { readonly status: 'not_sent'; readonly message: string; readonly retry: RetryAdvice; readonly failure: ExchangeFailure }
/**
* Outcome unknown (5xx, timeout) or an unreadable answer: a TWAP may be running. `twapOrder` is not
* idempotent: do not re-send; check `userTwapSliceFills` (slices never appear in `userFills`).
*/
| { readonly status: 'unknown'; readonly message: string; readonly retry: RetryAdvice; readonly failure?: ExchangeFailure };
/** Validates a TWAP duration in minutes. @throws HlOrderError `INVALID_ARGUMENT`. */
export function assertTwapMinutes(minutes: number): void {
if (!Number.isSafeInteger(minutes) || minutes < MIN_TWAP_MINUTES || minutes > MAX_TWAP_MINUTES) {
invalidArgument(
`Invalid TWAP duration ${String(minutes)}: expected an integer ${MIN_TWAP_MINUTES}..${MAX_TWAP_MINUTES} minutes`,
);
}
}
/**
* Places a native TWAP (`twapOrder`).
*
* @experimental Not verified live. Known from documentation only: a slice about every 30 s,
* each capped at 3% slippage, catch-up limited to 3x a normal slice, no execution in post-only mode.
* The minimum notional of a TWAP and of its slices is unknown, so no $10 gate is applied here: size
* it with margin. Risks: a TWAP error arrives inside `data.status.error` with the outer status `'ok'`
* (handled), slices bypass `userFills` (PnL and position tracking must also read
* `userTwapSliceFills`). Run the smoke test from twap.md §7.1 on a small sub-account first.
*
* @throws HlOrderError for an invalid duration, side or a size below one lot.
*/
export async function placeTwap(
exchange: TwapExchange,
registry: AssetResolver,
intent: TwapIntent,
opts: TwapCallOptions = {},
): Promise<TwapPlaceResult> {
if (typeof intent !== 'object' || intent === null) invalidArgument('TWAP intent must be an object');
assertTwapMinutes(intent.minutes);
if (!['B', 'A', 'buy', 'sell'].includes(intent.side as string)) invalidArgument(`Invalid side ${JSON.stringify(intent.side)}`);
if (intent.reduceOnly !== undefined && typeof intent.reduceOnly !== 'boolean') invalidArgument('reduceOnly must be a boolean');
if (intent.randomize !== undefined && typeof intent.randomize !== 'boolean') invalidArgument('randomize must be a boolean');
const callOpts = exchangeCallOptions(opts);
const reduceOnly = intent.reduceOnly === true;
const asset = await registry.resolve(intent.coin, { rejectDelisted: !reduceOnly });
const sz = formatSize(intent.size, asset.szDecimals, 'floor');
if (sz === '0') invalidArgument(`TWAP size ${String(intent.size)} of ${asset.coin} is below one lot`);
const params = {
twap: {
a: asset.assetId,
b: intent.side === 'B' || intent.side === 'buy',
s: sz,
r: reduceOnly,
m: intent.minutes,
t: intent.randomize === true,
},
};
let response: unknown;
try {
response = await invokeAsync(() => (callOpts ? exchange.twapOrder(params, callOpts) : exchange.twapOrder(params)));
} catch (err) {
const failure = interpretExchangeError(err);
const retry = recommendRetry(err, { idempotent: false });
if (failure.type === 'rejected') return { status: 'rejected', message: failure.message, kind: failure.kind };
if (failure.type === 'validation' || failure.type === 'signing' || (failure.type === 'transport' && failure.info.outcome === 'not-applied')) {
return { status: 'not_sent', message: failure.message, retry, failure };
}
return { status: 'unknown', message: failure.message, retry, failure };
}
const parsed = parseTwapResponse(response);
if (parsed.status === 'running') return { status: 'running', twapId: parsed.twapId, sz, assetId: asset.assetId };
if (parsed.status === 'error') return { status: 'rejected', message: parsed.message, kind: parsed.kind };
return { status: 'unknown', message: parsed.status === 'invalid' ? parsed.detail : 'unexpected success shape', retry: 'reconcile' };
}
export type TwapCancelResult =
| { readonly status: 'success' }
/** `TWAP was never placed, already canceled, or filled.` */
| { readonly status: 'alreadyGone'; readonly message: string }
| { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
/** Not applied, unknown or unreadable. Cancels are idempotent: `retry` is safe for transient failures. */
| { readonly status: 'unconfirmed'; readonly message: string; readonly retry: RetryAdvice; readonly failure?: ExchangeFailure };
/**
* Cancels a TWAP by id (`twapCancel`). @experimental see {@link placeTwap}.
* @throws HlOrderError for an invalid `twapId`.
*/
export async function cancelTwap(
exchange: TwapExchange,
registry: AssetResolver,
input: { coin: string; twapId: number },
opts: TwapCallOptions = {},
): Promise<TwapCancelResult> {
if (typeof input !== 'object' || input === null) invalidArgument('TWAP cancel input must be an object');
if (!Number.isSafeInteger(input.twapId) || input.twapId < 0) invalidArgument(`Invalid twapId ${String(input.twapId)}`);
const callOpts = exchangeCallOptions(opts);
const asset = await registry.resolve(input.coin);
const params = { a: asset.assetId, t: input.twapId };
let response: unknown;
try {
response = await invokeAsync(() => (callOpts ? exchange.twapCancel(params, callOpts) : exchange.twapCancel(params)));
} catch (err) {
const failure = interpretExchangeError(err);
if (failure.type === 'rejected') return rejectedCancel(failure.message, failure.kind);
return { status: 'unconfirmed', message: failure.message, retry: recommendRetry(err, { idempotent: true }), failure };
}
const parsed = parseTwapResponse(response);
if (parsed.status === 'success') return { status: 'success' };
if (parsed.status === 'error') return rejectedCancel(parsed.message, parsed.kind);
return { status: 'unconfirmed', message: parsed.status === 'invalid' ? parsed.detail : 'unexpected shape', retry: 'retry' };
}
function rejectedCancel(message: string, kind: ExchangeErrorKind): TwapCancelResult {
return kind === 'orderNotFound' ? { status: 'alreadyGone', message } : { status: 'error', message, kind };
}