@aetherwealth/sdk 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { AetherClient } from '../client.js';
2
+ import type { MarketResource } from '../types.js';
3
+ /**
4
+ * `market` — read-only market context via the public API
5
+ * `POST /api/public/v1/market/*`. Live candles are NOT available over REST
6
+ * (tRPC-only) and are intentionally absent from the SDK.
7
+ */
8
+ export declare function createMarketResource(client: AetherClient): MarketResource;
@@ -0,0 +1,90 @@
1
+ import { parseEconomicEvent, parseMacroSeriesResult, parseMarketConfig } from '../schemas.js';
2
+ import { pluck } from './envelope.js';
3
+ import { assertEachShape, assertShape } from './validate.js';
4
+ const MACRO_RANGES = ['3m', '6m', '12m', '24m', 'all'];
5
+ /**
6
+ * `market` — read-only market context via the public API
7
+ * `POST /api/public/v1/market/*`. Live candles are NOT available over REST
8
+ * (tRPC-only) and are intentionally absent from the SDK.
9
+ */
10
+ export function createMarketResource(client) {
11
+ return {
12
+ async config() {
13
+ // `{success, data}` — client.request unwraps `data`.
14
+ const config = await client.request('/api/public/v1/market/config', {
15
+ method: 'POST',
16
+ body: {}
17
+ });
18
+ return assertShape(client.validateResponses, parseMarketConfig, config);
19
+ },
20
+ async calendar(query) {
21
+ const body = {};
22
+ if (query?.currency !== undefined)
23
+ body['currency'] = query.currency;
24
+ if (query?.from !== undefined)
25
+ body['from'] = query.from;
26
+ if (query?.to !== undefined)
27
+ body['to'] = query.to;
28
+ if (query?.indicator !== undefined)
29
+ body['indicator'] = query.indicator;
30
+ if (query?.impactMin !== undefined)
31
+ body['impactMin'] = query.impactMin;
32
+ if (query?.view !== undefined)
33
+ body['view'] = query.view;
34
+ const env = await client.request('/api/public/v1/market/calendar/list', { method: 'POST', body });
35
+ return {
36
+ events: assertEachShape(client.validateResponses, parseEconomicEvent, env.events),
37
+ view: env.view
38
+ };
39
+ },
40
+ async macroSeries(query) {
41
+ if (!query.currency || query.currency.length !== 3) {
42
+ throw new Error('market.macroSeries: currency must be a 3-letter code');
43
+ }
44
+ if (!query.indicator)
45
+ throw new Error('market.macroSeries: indicator is required');
46
+ if (query.limit !== undefined && (query.limit < 1 || query.limit > 1000)) {
47
+ throw new Error('market.macroSeries: limit must be between 1 and 1000');
48
+ }
49
+ const body = {
50
+ currency: query.currency,
51
+ indicator: query.indicator
52
+ };
53
+ if (query.from !== undefined)
54
+ body['from'] = query.from;
55
+ if (query.to !== undefined)
56
+ body['to'] = query.to;
57
+ if (query.limit !== undefined)
58
+ body['limit'] = query.limit;
59
+ const env = await client.request('/api/public/v1/market/macro-series', {
60
+ method: 'POST',
61
+ body
62
+ });
63
+ return assertEachShape(client.validateResponses, parseEconomicEvent, pluck(env, 'series'));
64
+ },
65
+ async macro(query) {
66
+ const { currency, indicator, range } = query;
67
+ // Fail-fast client validation mirroring the backend's accepted shapes.
68
+ if (!/^[A-Za-z]{3}$/.test(currency)) {
69
+ throw new Error('market.macro: currency must be a 3-letter code');
70
+ }
71
+ if (!/^[a-z0-9_]+$/i.test(indicator)) {
72
+ throw new Error('market.macro: indicator must be a slug (letters, digits, underscores)');
73
+ }
74
+ if (range !== undefined && !MACRO_RANGES.includes(range)) {
75
+ throw new Error(`market.macro: range must be one of ${MACRO_RANGES.join(', ')}`);
76
+ }
77
+ const body = {
78
+ currency: currency.toUpperCase(),
79
+ indicator: indicator.toLowerCase()
80
+ };
81
+ if (range !== undefined)
82
+ body['range'] = range;
83
+ // `{success, data, metadata}` — client.request unwraps `data`; the
84
+ // `metadata` sidecar is intentionally ignored (return type is the series).
85
+ const result = await client.request('/api/public/v1/market/macro', { method: 'POST', body });
86
+ // `null` (no series) passes through; a present series is shape-checked.
87
+ return assertShape(client.validateResponses, parseMacroSeriesResult, result);
88
+ }
89
+ };
90
+ }
@@ -0,0 +1,15 @@
1
+ import type { PaginatedResponse } from '../types.js';
2
+ /**
3
+ * Async iterator over paginated pages. Calls `fetchPage(page)` for successive
4
+ * pages starting at `startPage`, yielding each `PaginatedResponse` until the
5
+ * backend reports the last page (`page >= pagination.totalPages`) or returns an
6
+ * empty page — whichever comes first.
7
+ *
8
+ * Two independent stop conditions guard against an infinite loop even if a
9
+ * backend misreports `totalPages`:
10
+ * 1. an empty `data` array (nothing more to page through), or
11
+ * 2. the locally-tracked page number reaching `totalPages`.
12
+ */
13
+ export declare function paginate<T>(fetchPage: (page: number) => Promise<PaginatedResponse<T>>, startPage?: number): AsyncIterableIterator<PaginatedResponse<T>>;
14
+ /** Flatten a stream of pages into a stream of the individual items. */
15
+ export declare function flatten<T>(pages: AsyncIterableIterator<PaginatedResponse<T>>): AsyncIterableIterator<T>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Async iterator over paginated pages. Calls `fetchPage(page)` for successive
3
+ * pages starting at `startPage`, yielding each `PaginatedResponse` until the
4
+ * backend reports the last page (`page >= pagination.totalPages`) or returns an
5
+ * empty page — whichever comes first.
6
+ *
7
+ * Two independent stop conditions guard against an infinite loop even if a
8
+ * backend misreports `totalPages`:
9
+ * 1. an empty `data` array (nothing more to page through), or
10
+ * 2. the locally-tracked page number reaching `totalPages`.
11
+ */
12
+ export async function* paginate(fetchPage, startPage = 1) {
13
+ // Normalize to a finite positive integer. A `NaN`/`Infinity`/≤0 startPage
14
+ // would otherwise serialize as `page:null` AND leave the local counter
15
+ // non-finite (`NaN + 1 === NaN`), so `page >= totalPages` never trips and
16
+ // the loop spins forever.
17
+ let page = Number.isFinite(startPage) && startPage > 0 ? Math.floor(startPage) : 1;
18
+ while (true) {
19
+ const result = await fetchPage(page);
20
+ yield result;
21
+ // Empty page → nothing further to fetch.
22
+ if (result.data.length === 0)
23
+ return;
24
+ // A non-finite or ≤0 `totalPages` can't bound the loop — stop after this
25
+ // page rather than spin waiting to "reach" an unreachable count.
26
+ const { totalPages } = result.pagination;
27
+ if (!(Number.isFinite(totalPages) && totalPages > 0))
28
+ return;
29
+ if (page >= totalPages)
30
+ return;
31
+ page += 1;
32
+ }
33
+ }
34
+ /** Flatten a stream of pages into a stream of the individual items. */
35
+ export async function* flatten(pages) {
36
+ for await (const pageResult of pages) {
37
+ for (const item of pageResult.data) {
38
+ yield item;
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,8 @@
1
+ import type { AetherClient } from '../client.js';
2
+ import type { StatsResource } from '../types.js';
3
+ /**
4
+ * `stats` — aggregate trading analytics via the public API
5
+ * `POST /api/public/v1/stats/stats`. Filter by account, pair, and/or date
6
+ * range (ISO datetimes).
7
+ */
8
+ export declare function createStatsResource(client: AetherClient): StatsResource;
@@ -0,0 +1,28 @@
1
+ import { parseTradingStats } from '../schemas.js';
2
+ import { pluck } from './envelope.js';
3
+ import { assertShape } from './validate.js';
4
+ /**
5
+ * `stats` — aggregate trading analytics via the public API
6
+ * `POST /api/public/v1/stats/stats`. Filter by account, pair, and/or date
7
+ * range (ISO datetimes).
8
+ */
9
+ export function createStatsResource(client) {
10
+ return {
11
+ async summary(query) {
12
+ const body = {};
13
+ if (query?.accountId !== undefined)
14
+ body['accountId'] = query.accountId;
15
+ if (query?.pair !== undefined)
16
+ body['pair'] = query.pair;
17
+ if (query?.from !== undefined)
18
+ body['from'] = query.from;
19
+ if (query?.to !== undefined)
20
+ body['to'] = query.to;
21
+ const env = await client.request('/api/public/v1/stats/stats', {
22
+ method: 'POST',
23
+ body
24
+ });
25
+ return assertShape(client.validateResponses, parseTradingStats, pluck(env, 'stats'));
26
+ }
27
+ };
28
+ }
@@ -0,0 +1,8 @@
1
+ import type { AetherClient } from '../client.js';
2
+ import type { TradesResource } from '../types.js';
3
+ /**
4
+ * `trades` — public API `POST /api/public/v1/trades/*`. The API key scopes
5
+ * every call to its owner; never send `userId` in the body. Responses are
6
+ * `{success, trade|trades}`.
7
+ */
8
+ export declare function createTradesResource(client: AetherClient): TradesResource;
@@ -0,0 +1,113 @@
1
+ import { parseTrade } from '../schemas.js';
2
+ import { pluck } from './envelope.js';
3
+ import { resolveIdempotencyKey } from './idempotency.js';
4
+ import { flatten, paginate } from './paginate.js';
5
+ import { assertEachShape, assertShape } from './validate.js';
6
+ /**
7
+ * `trades` — public API `POST /api/public/v1/trades/*`. The API key scopes
8
+ * every call to its owner; never send `userId` in the body. Responses are
9
+ * `{success, trade|trades}`.
10
+ */
11
+ export function createTradesResource(client) {
12
+ const resource = {
13
+ async list(query) {
14
+ const body = {};
15
+ if (query?.accountId !== undefined)
16
+ body['accountId'] = query.accountId;
17
+ // 'ALL' is a client-side "no filter" sentinel — the backend status
18
+ // enum only accepts OPEN|CLOSED|CANCELLED, so omit it when ALL.
19
+ if (query?.status !== undefined && query.status !== 'ALL') {
20
+ body['status'] = query.status;
21
+ }
22
+ if (query?.pair !== undefined)
23
+ body['pair'] = query.pair;
24
+ if (query?.from !== undefined)
25
+ body['from'] = query.from;
26
+ if (query?.to !== undefined)
27
+ body['to'] = query.to;
28
+ if (query?.page !== undefined)
29
+ body['page'] = query.page;
30
+ if (query?.limit !== undefined)
31
+ body['limit'] = query.limit;
32
+ const env = await client.request('/api/public/v1/trades/list', {
33
+ method: 'POST',
34
+ body
35
+ });
36
+ return {
37
+ data: assertEachShape(client.validateResponses, parseTrade, pluck(env, 'trades')),
38
+ pagination: pluck(env, 'pagination')
39
+ };
40
+ },
41
+ async get(tradeId) {
42
+ if (!tradeId)
43
+ throw new Error('trades.get: tradeId is required');
44
+ const env = await client.request('/api/public/v1/trades/get', {
45
+ method: 'POST',
46
+ body: { id: tradeId }
47
+ });
48
+ return assertShape(client.validateResponses, parseTrade, pluck(env, 'trade'));
49
+ },
50
+ async create(input, opts) {
51
+ if (!input.accountId)
52
+ throw new Error('trades.create: accountId is required');
53
+ if (!input.pair)
54
+ throw new Error('trades.create: pair is required');
55
+ if (input.direction !== 'LONG' && input.direction !== 'SHORT') {
56
+ throw new Error('trades.create: direction must be LONG or SHORT');
57
+ }
58
+ // Backend `createTradeInputSchema.entryPrice` is `z.number().finite()`
59
+ // (not `.positive()`): zero/negative-priced instruments are valid.
60
+ if (!Number.isFinite(input.entryPrice)) {
61
+ throw new Error('trades.create: entryPrice must be a finite number');
62
+ }
63
+ if (!input.entryTime)
64
+ throw new Error('trades.create: entryTime is required');
65
+ const env = await client.request('/api/public/v1/trades/create', {
66
+ method: 'POST',
67
+ body: input,
68
+ idempotencyKey: resolveIdempotencyKey(opts)
69
+ });
70
+ return assertShape(client.validateResponses, parseTrade, pluck(env, 'trade'));
71
+ },
72
+ async update(tradeId, input) {
73
+ if (!tradeId)
74
+ throw new Error('trades.update: tradeId is required');
75
+ const env = await client.request('/api/public/v1/trades/update', {
76
+ method: 'POST',
77
+ body: { id: tradeId, ...input }
78
+ });
79
+ return assertShape(client.validateResponses, parseTrade, pluck(env, 'trade'));
80
+ },
81
+ async close(tradeId, input) {
82
+ if (!tradeId)
83
+ throw new Error('trades.close: tradeId is required');
84
+ // Backend `closeTradeInputSchema.exitPrice` is `z.number().finite()`.
85
+ if (!Number.isFinite(input.exitPrice)) {
86
+ throw new Error('trades.close: exitPrice must be a finite number');
87
+ }
88
+ if (!input.exitTime)
89
+ throw new Error('trades.close: exitTime is required');
90
+ const env = await client.request('/api/public/v1/trades/close', {
91
+ method: 'POST',
92
+ body: { id: tradeId, ...input }
93
+ });
94
+ return assertShape(client.validateResponses, parseTrade, pluck(env, 'trade'));
95
+ },
96
+ async delete(tradeId) {
97
+ if (!tradeId)
98
+ throw new Error('trades.delete: tradeId is required');
99
+ // Resolves on 2xx; a missing trade throws AetherNotFoundError upstream.
100
+ await client.request('/api/public/v1/trades/delete', {
101
+ method: 'POST',
102
+ body: { id: tradeId }
103
+ });
104
+ },
105
+ pages(query) {
106
+ return paginate(page => resource.list({ ...query, page }), query?.page ?? 1);
107
+ },
108
+ listAll(query) {
109
+ return flatten(resource.pages(query));
110
+ }
111
+ };
112
+ return resource;
113
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Opt-in response validation helpers.
3
+ *
4
+ * When `enabled` (the client's `validateResponses` flag), the Zod `parser` runs
5
+ * purely for its throwing side-effect — a `ZodError` on shape drift. The
6
+ * ORIGINAL value is always returned untouched, so additive backend fields
7
+ * survive and object identity is preserved (the parser is a gate, not a
8
+ * transform). When disabled, this is a zero-cost pass-through.
9
+ */
10
+ export declare function assertShape<T>(enabled: boolean, parser: (value: unknown) => unknown, value: T): T;
11
+ /** Same as {@link assertShape}, applied to every item of a list. */
12
+ export declare function assertEachShape<T>(enabled: boolean, parser: (value: unknown) => unknown, values: T[]): T[];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Opt-in response validation helpers.
3
+ *
4
+ * When `enabled` (the client's `validateResponses` flag), the Zod `parser` runs
5
+ * purely for its throwing side-effect — a `ZodError` on shape drift. The
6
+ * ORIGINAL value is always returned untouched, so additive backend fields
7
+ * survive and object identity is preserved (the parser is a gate, not a
8
+ * transform). When disabled, this is a zero-cost pass-through.
9
+ */
10
+ export function assertShape(enabled, parser, value) {
11
+ if (enabled)
12
+ parser(value);
13
+ return value;
14
+ }
15
+ /** Same as {@link assertShape}, applied to every item of a list. */
16
+ export function assertEachShape(enabled, parser, values) {
17
+ if (enabled) {
18
+ for (const value of values)
19
+ parser(value);
20
+ }
21
+ return values;
22
+ }
@@ -0,0 +1,40 @@
1
+ export type RetryOptions = {
2
+ /** Maximum number of attempts (including the first). Default 3. */
3
+ maxAttempts?: number;
4
+ /** Base backoff delay in ms (used for the first retry). Default 250. */
5
+ baseDelayMs?: number;
6
+ /** Cap on a single sleep, regardless of backoff or `Retry-After`. Default 30_000. */
7
+ maxDelayMs?: number;
8
+ /** Override the default error-class predicate. */
9
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
10
+ /**
11
+ * Sleep implementation — overridable for tests. Receives the
12
+ * computed delay (already clamped) and returns a promise that
13
+ * resolves after that delay.
14
+ */
15
+ sleep?: (ms: number) => Promise<void>;
16
+ /** Random source for jitter. Defaults to `Math.random`. */
17
+ random?: () => number;
18
+ /** Notification hook fired before each retry sleep. */
19
+ onRetry?: (info: {
20
+ attempt: number;
21
+ delayMs: number;
22
+ error: unknown;
23
+ }) => void;
24
+ };
25
+ /**
26
+ * Run `fn`, retrying transient failures with exponential-jittered
27
+ * backoff. Re-throws the last error after exhausting attempts.
28
+ */
29
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
30
+ /**
31
+ * Decide how long to sleep before the next attempt. Exposed for tests
32
+ * and for callers that want to compose their own retry policy.
33
+ */
34
+ export declare function computeDelay(args: {
35
+ attempt: number;
36
+ baseDelayMs: number;
37
+ maxDelayMs: number;
38
+ error: unknown;
39
+ random: () => number;
40
+ }): number;
package/dist/retry.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Opt-in retry helper for AetherClient calls.
3
+ *
4
+ * Why opt-in: the SDK can't decide for callers whether a 429 should
5
+ * pause or whether a transient network error should retry — that's a
6
+ * domain decision (e.g. CLI: yes; webhook handler: probably no, the
7
+ * outer system retries). So the client itself never retries; callers
8
+ * wrap individual calls with `withRetry()` when they want it.
9
+ *
10
+ * Retries on:
11
+ * - `AetherRateLimitError` (429), respecting `Retry-After`.
12
+ * - `AetherNetworkError` (DNS/TCP/TLS failures, no HTTP response).
13
+ * - `AetherTimeoutError` (client-side timeout abort) — a subclass of
14
+ * `AetherNetworkError`, so already covered, but called out here because a
15
+ * timeout is a transient network condition worth retrying.
16
+ *
17
+ * Does NOT retry on:
18
+ * - `AetherAuthError`/`AetherForbiddenError` — credentials won't fix
19
+ * themselves by trying again.
20
+ * - `AetherNotFoundError`/`AetherValidationError` — caller bug.
21
+ * - 5xx server errors that don't subclass to a retryable error — open
22
+ * question whether 502/503/504 should retry; default is no, callers
23
+ * can override via `shouldRetry`.
24
+ *
25
+ * **Retrying mutations is only safe with a stable idempotency key.** A create
26
+ * (`trades.create`, `accounts.create`, `alerts.create*`) auto-generates a fresh
27
+ * `Idempotency-Key` per call, which does NOT dedup across retries. To retry a
28
+ * create safely, pass a STABLE key so every attempt targets the same record:
29
+ * `withRetry(() => trades.create(input, {idempotencyKey: key}))`. Do NOT
30
+ * generate the key inside the retried closure.
31
+ *
32
+ * Backoff: exponential with full jitter, capped at `maxDelayMs`. When
33
+ * the server sends `Retry-After`, we honor it (clamped to maxDelayMs).
34
+ */
35
+ import { AetherNetworkError, AetherRateLimitError, AetherTimeoutError } from './errors.js';
36
+ const DEFAULTS = {
37
+ maxAttempts: 3,
38
+ baseDelayMs: 250,
39
+ maxDelayMs: 30_000
40
+ };
41
+ function defaultSleep(ms) {
42
+ return new Promise(resolve => {
43
+ setTimeout(resolve, ms);
44
+ });
45
+ }
46
+ function defaultShouldRetry(error) {
47
+ if (error instanceof AetherRateLimitError)
48
+ return true;
49
+ // AetherTimeoutError extends AetherNetworkError, so the next check already
50
+ // covers it; listed explicitly to document that timeouts are retryable.
51
+ if (error instanceof AetherTimeoutError)
52
+ return true;
53
+ if (error instanceof AetherNetworkError)
54
+ return true;
55
+ return false;
56
+ }
57
+ /**
58
+ * Run `fn`, retrying transient failures with exponential-jittered
59
+ * backoff. Re-throws the last error after exhausting attempts.
60
+ */
61
+ export async function withRetry(fn, opts = {}) {
62
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULTS.maxAttempts);
63
+ const baseDelayMs = Math.max(0, opts.baseDelayMs ?? DEFAULTS.baseDelayMs);
64
+ const maxDelayMs = Math.max(baseDelayMs, opts.maxDelayMs ?? DEFAULTS.maxDelayMs);
65
+ const sleep = opts.sleep ?? defaultSleep;
66
+ const random = opts.random ?? Math.random;
67
+ const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
68
+ let lastError = undefined;
69
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
70
+ try {
71
+ return await fn();
72
+ }
73
+ catch (error) {
74
+ lastError = error;
75
+ if (attempt >= maxAttempts)
76
+ break;
77
+ if (!shouldRetry(error, attempt))
78
+ break;
79
+ const delayMs = computeDelay({
80
+ attempt,
81
+ baseDelayMs,
82
+ maxDelayMs,
83
+ error,
84
+ random
85
+ });
86
+ opts.onRetry?.({ attempt, delayMs, error });
87
+ await sleep(delayMs);
88
+ }
89
+ }
90
+ throw lastError;
91
+ }
92
+ /**
93
+ * Decide how long to sleep before the next attempt. Exposed for tests
94
+ * and for callers that want to compose their own retry policy.
95
+ */
96
+ export function computeDelay(args) {
97
+ const { attempt, baseDelayMs, maxDelayMs, error, random } = args;
98
+ // Honor Retry-After when present and finite. Backend sends seconds;
99
+ // convert to ms and clamp to maxDelayMs to prevent a hostile or
100
+ // mis-configured server from pinning the client for an hour.
101
+ if (error instanceof AetherRateLimitError && error.retryAfterSeconds !== null) {
102
+ const requested = error.retryAfterSeconds * 1000;
103
+ return Math.min(maxDelayMs, Math.max(0, requested));
104
+ }
105
+ // Exponential with full jitter: random in [0, base * 2^(attempt-1)].
106
+ // Full jitter (vs equal jitter) is preferred for high-throughput
107
+ // clients to spread retries; see AWS Architecture Blog.
108
+ const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
109
+ return Math.floor(random() * cap);
110
+ }