@aetherwealth/sdk 0.1.36 → 0.2.1

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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Aether Wealth
3
+ Copyright (c) 2026 Aether Wealth Advisors Pvt Ltd
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -4,9 +4,9 @@ Typed TypeScript client for the **Aether Wealth** public API. Programmatic
4
4
  access to your trading journal, accounts, analytics, alerts, market data, and
5
5
  diary over the public REST surface (`/api/public/v1/…`).
6
6
 
7
- > **Keep your API key secret.** Authentication uses a public API key
8
- > (`aw_live_…`) sent as a bearer token. Treat it like a password never commit
9
- > it or ship it to an untrusted client.
7
+ > **Keep your API key secret.** Authentication uses an API key for the public API
8
+ > (`aw_live_…`) sent as a bearer token. It is a server-side secret: treat it like
9
+ > a password — never commit it or ship it to an untrusted client.
10
10
 
11
11
  ## Install
12
12
 
@@ -32,7 +32,7 @@ const stats = await client.stats.summary({ pair: 'EURUSD' })
32
32
 
33
33
  ## Authentication
34
34
 
35
- Each request sends your public API key as `Authorization: Bearer <apiKey>`. The
35
+ Each request sends your secret API key as `Authorization: Bearer <apiKey>`. The
36
36
  key scopes every operation to its owner — you never pass `userId` in a request
37
37
  body. `auth` is a discriminated union (one member today, `apiKey`) so future
38
38
  auth modes can be added without breaking existing callers.
@@ -71,7 +71,7 @@ non-browser runtime that happens to define `window`, set
71
71
  | `client.accounts` | `list` · `create` · `update` · `delete` · `trades` · `tradesAll` |
72
72
  | `client.stats` | `summary` |
73
73
  | `client.alerts` | `list` · `createPrice` · `createTrendline` · `update` · `delete` · `listIndicator` · `createIndicator` · `updateIndicator` · `deleteIndicator` |
74
- | `client.market` | `config` · `calendar` · `macroSeries` · `macro` |
74
+ | `client.market` | `config` · `calendar` · `macroSeries` · `macro` · `subscribe` |
75
75
  | `client.diary` | `list` · `get` · `upsert` · `delete` · `pages` · `listAll` |
76
76
 
77
77
  List methods return `{ data, pagination }`; single-item methods return the
@@ -98,6 +98,62 @@ for await (const trade of client.trades.listAll({ pair: 'EURUSD' })) {
98
98
 
99
99
  Iteration starts at `query.page` if given, else page 1.
100
100
 
101
+ ## Live market stream
102
+
103
+ `client.market.subscribe` mints a short-lived ticket from aether-backend and
104
+ opens one reconnecting WebSocket for up to 25 symbols. The user API key needs
105
+ `market:read`; it is sent only on the HTTPS ticket request, never in the
106
+ WebSocket URL.
107
+
108
+ ```ts
109
+ const stop = client.market.subscribe(
110
+ { symbols: ['EURUSD', 'GBPUSD'] },
111
+ {
112
+ onUpdate(update) {
113
+ console.log(update.symbol, update.candle.close)
114
+ },
115
+ onError(error) {
116
+ console.error(error.message)
117
+ },
118
+ onStatus(status) {
119
+ if (status.state === 'terminal') {
120
+ console.error('stream stopped permanently', status.error)
121
+ }
122
+ },
123
+ },
124
+ )
125
+
126
+ // Idempotent; also available through the optional AbortSignal input.
127
+ stop()
128
+ ```
129
+
130
+ The V1 feed is fixed to `1m`. `candle.close` is the latest observed price, not
131
+ an executable bid/ask quote. Network failures, 5xx ticket responses, transient
132
+ 408/425/429 responses, missed heartbeat pongs, and transient socket closes
133
+ reconnect automatically with a fresh ticket; a 429 waits at least as long as
134
+ `Retry-After`. The backend's planned `4001` reauthentication close is also
135
+ transient and remints the ticket.
136
+
137
+ Other ticket 4xx responses, the backend's explicit
138
+ `market_stream_unavailable` response, unscoped non-retryable gateway errors,
139
+ and policy/auth socket closes stop that subscription. They call `onError` and
140
+ emit an `onStatus` event whose state is `terminal`; no later reconnect occurs.
141
+ Retryable gateway errors reconnect. A non-retryable error scoped to `symbol`
142
+ is reported as `AetherMarketStreamGatewayError` while the socket stays alive
143
+ for unaffected symbols. Local and whole-stream gateway policy failures use
144
+ `AetherMarketStreamTerminalError`; ticket HTTP failures keep their typed
145
+ `AetherApiError` subclass.
146
+
147
+ One `AetherClient` may have at most five active `market.subscribe()` calls,
148
+ with up to 25 symbols per call. A sixth call throws
149
+ `AetherMarketStreamTerminalError` with code `MARKET_STREAM_LIMIT`. Calling the
150
+ returned unsubscribe function—or reaching a terminal failure—immediately frees
151
+ that slot.
152
+
153
+ The package's conditional Node export supplies the `ws` transport. Runtimes
154
+ resolving the universal entry, including edge bundlers, use their native
155
+ `WebSocket`, keeping that entry free of Node-only built-ins.
156
+
101
157
  ## Timeouts & cancellation
102
158
 
103
159
  Every request is bounded by `timeoutMs` (default 30s), overridable per call. A
@@ -201,7 +257,10 @@ const trade = parseTrade(await client.trades.get(id)) // throws on shape drift
201
257
 
202
258
  ## Not covered
203
259
 
204
- Live candles and AI chat conversations are streamed/tRPC-only on the backend
205
- and are intentionally **not** part of this REST SDK — reach them through
206
- `@aetherwealth/client-core` (used by the CLI and MCP server). API-key
260
+ AI chat conversations remain tRPC-only and are not part of this SDK. API-key
207
261
  management is likewise out of scope: an API key can't provision other keys.
262
+
263
+ ## License
264
+
265
+ MIT. See the `LICENSE` file included with this package. Service use is governed
266
+ by the [Aether Wealth Terms of Service](https://aetherwealth.ai/terms).
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AccountsResource, AlertsResource, DiaryResource, MarketResource, StatsResource, TradesResource } from './types.js';
1
+ import type { AccountsResource, AetherWebSocketFactory, AlertsResource, DiaryResource, MarketResource, StatsResource, TradesResource } from './types.js';
2
2
  export type AetherFetchInit = {
3
3
  method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT';
4
4
  query?: Record<string, string | number | boolean | undefined | null | string[]>;
@@ -14,7 +14,7 @@ export type AetherFetchInit = {
14
14
  /** When set, sent as the `Idempotency-Key` header so a retried create dedups. */
15
15
  idempotencyKey?: string;
16
16
  };
17
- /** API-key auth: a public key (`aw_live_…`) sent as `Authorization: Bearer`. */
17
+ /** API-key auth: a user API key (`aw_live_…`) sent as `Authorization: Bearer`. */
18
18
  export type ApiKeyAuth = {
19
19
  type: 'apiKey';
20
20
  apiKey: string;
@@ -69,6 +69,8 @@ export type AetherClientConfig = {
69
69
  status: number;
70
70
  ms: number;
71
71
  }) => void;
72
+ /** Override WebSocket construction (primarily for tests or custom runtimes). */
73
+ webSocketFactory?: AetherWebSocketFactory;
72
74
  };
73
75
  /** Default per-request timeout budget (ms). */
74
76
  export declare const DEFAULT_TIMEOUT_MS = 30000;
package/dist/client.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * AetherClient — typed HTTP client for the AetherWealth **public API**
3
3
  * (`/api/public/v1/<domain>/<action>`, POST-RPC, `{success, <key>}` envelopes).
4
4
  *
5
- * Auth: every public route is authenticated with a public API key sent as
6
- * `Authorization: Bearer aw_live_…`. Configure it via
5
+ * Auth: every public route is authenticated with a user API key sent as
6
+ * `Authorization: Bearer aw_live_…`. The key is a server-side secret. Configure it via
7
7
  * `auth: {type: 'apiKey', apiKey}`. `auth` is a discriminated union so an
8
8
  * `oauth`/`hmac` member can be added later without breaking existing callers.
9
9
  *
@@ -78,6 +78,13 @@ function assertHttpsUnlessLoopback(baseUrl) {
78
78
  throw new Error(`AetherClient: baseUrl must not contain a query, fragment, or credentials: "${baseUrl}"`);
79
79
  }
80
80
  }
81
+ function defaultWebSocketFactory(url) {
82
+ const NativeWebSocket = globalThis.WebSocket;
83
+ if (!NativeWebSocket) {
84
+ throw new Error('AetherClient: this runtime has no native WebSocket; pass webSocketFactory explicitly');
85
+ }
86
+ return new NativeWebSocket(url);
87
+ }
81
88
  /** Append one query entry, skipping null/undefined and repeating array values. */
82
89
  function appendQueryParam(params, key, value) {
83
90
  if (value === undefined || value === null)
@@ -248,7 +255,10 @@ export class AetherClient {
248
255
  this.accounts = createAccountsResource(this);
249
256
  this.stats = createStatsResource(this);
250
257
  this.alerts = createAlertsResource(this);
251
- this.market = createMarketResource(this);
258
+ this.market = createMarketResource(this, {
259
+ baseUrl,
260
+ webSocketFactory: config.webSocketFactory ?? defaultWebSocketFactory
261
+ });
252
262
  this.diary = createDiaryResource(this);
253
263
  }
254
264
  /**
@@ -0,0 +1,5 @@
1
+ import { type AetherClientConfig, AetherClient as UniversalAetherClient } from './client.js';
2
+ /** Node transport entry; callers can still provide a custom factory. */
3
+ export declare class AetherClient extends UniversalAetherClient {
4
+ constructor(config: AetherClientConfig);
5
+ }
@@ -0,0 +1,12 @@
1
+ import NodeWebSocket from 'ws';
2
+ import { AetherClient as UniversalAetherClient } from './client.js';
3
+ /** Node transport entry; callers can still provide a custom factory. */
4
+ export class AetherClient extends UniversalAetherClient {
5
+ constructor(config) {
6
+ super({
7
+ ...config,
8
+ webSocketFactory: config.webSocketFactory ??
9
+ (url => new NodeWebSocket(url))
10
+ });
11
+ }
12
+ }
package/dist/errors.d.ts CHANGED
@@ -76,6 +76,27 @@ export declare class AetherRateLimitError extends AetherApiError {
76
76
  responseBody?: unknown;
77
77
  });
78
78
  }
79
+ export type AetherMarketStreamTerminalErrorCode = 'MARKET_STREAM_LIMIT' | 'MARKET_STREAM_TICKET_RESPONSE' | 'MARKET_STREAM_GATEWAY_REJECTED' | 'MARKET_STREAM_SOCKET_POLICY';
80
+ /** A structured error frame emitted by the live market gateway. */
81
+ export declare class AetherMarketStreamGatewayError extends Error {
82
+ readonly code: string;
83
+ readonly retryable: boolean;
84
+ readonly symbol: string | undefined;
85
+ constructor(message: string, opts: {
86
+ code: string;
87
+ retryable: boolean;
88
+ symbol?: string;
89
+ });
90
+ }
91
+ /** A local stream failure that reconnecting cannot resolve. */
92
+ export declare class AetherMarketStreamTerminalError extends Error {
93
+ readonly code: AetherMarketStreamTerminalErrorCode;
94
+ readonly cause: unknown;
95
+ constructor(message: string, opts: {
96
+ code: AetherMarketStreamTerminalErrorCode;
97
+ cause?: unknown;
98
+ });
99
+ }
79
100
  export declare class AetherNetworkError extends Error {
80
101
  readonly cause: unknown;
81
102
  readonly path: string;
package/dist/errors.js CHANGED
@@ -92,6 +92,30 @@ export class AetherRateLimitError extends AetherApiError {
92
92
  this.retryAfterSeconds = opts.retryAfterSeconds;
93
93
  }
94
94
  }
95
+ /** A structured error frame emitted by the live market gateway. */
96
+ export class AetherMarketStreamGatewayError extends Error {
97
+ code;
98
+ retryable;
99
+ symbol;
100
+ constructor(message, opts) {
101
+ super(message);
102
+ this.name = 'AetherMarketStreamGatewayError';
103
+ this.code = opts.code;
104
+ this.retryable = opts.retryable;
105
+ this.symbol = opts.symbol;
106
+ }
107
+ }
108
+ /** A local stream failure that reconnecting cannot resolve. */
109
+ export class AetherMarketStreamTerminalError extends Error {
110
+ code;
111
+ cause;
112
+ constructor(message, opts) {
113
+ super(message);
114
+ this.name = 'AetherMarketStreamTerminalError';
115
+ this.code = opts.code;
116
+ this.cause = opts.cause;
117
+ }
118
+ }
95
119
  export class AetherNetworkError extends Error {
96
120
  cause;
97
121
  path;
package/dist/index.d.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * (`/api/public/v1/<domain>/<action>`). Reusable in any Node/Bun/edge runtime
6
6
  * that needs programmatic access to AetherWealth data.
7
7
  *
8
- * Auth — every public route is authenticated with a **public API key**
9
- * (`aw_live_…`) sent as `Authorization: Bearer …`:
8
+ * Auth — every public route is authenticated with a **user API key**
9
+ * (`aw_live_…`) sent as `Authorization: Bearer …`. The key is a server-side secret:
10
10
  *
11
11
  * ```ts
12
12
  * const client = new AetherClient({
@@ -20,8 +20,9 @@
20
20
  */
21
21
  export type { AetherAuth, AetherClientConfig, AetherFetchInit, ApiKeyAuth } from './client.js';
22
22
  export { AetherClient, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS } from './client.js';
23
- export { AetherApiError, AetherAuthError, AetherForbiddenError, AetherNetworkError, AetherNotFoundError, AetherRateLimitError, AetherTimeoutError, AetherValidationError, classifyError, parseRetryAfter } from './errors.js';
23
+ export type { AetherMarketStreamTerminalErrorCode } from './errors.js';
24
+ export { AetherApiError, AetherAuthError, AetherForbiddenError, AetherMarketStreamGatewayError, AetherMarketStreamTerminalError, AetherNetworkError, AetherNotFoundError, AetherRateLimitError, AetherTimeoutError, AetherValidationError, classifyError, parseRetryAfter } from './errors.js';
24
25
  export type { RetryOptions } from './retry.js';
25
26
  export { computeDelay, withRetry } from './retry.js';
26
27
  export { accountSchema, alertSchema, dedupModeSchema, diaryEntrySchema, economicEventSchema, indicatorAlertSchema, indicatorConditionSchema, macroSeriesPointSchema, macroSeriesResultSchema, marketConfigSchema, paginationSchema, parseAccount, parseAlert, parseDiaryEntry, parseEconomicEvent, parseIndicatorAlert, parseMacroSeriesResult, parseMarketConfig, parseTrade, parseTradingStats, priceConditionSchema, tradeDirectionSchema, tradeSchema, tradeStatusSchema, tradingStatsSchema, triggerTypeSchema } from './schemas.js';
27
- export type { Account, AccountsResource, Alert, AlertsResource, AlertType, ApiEnvelope, CalendarQuery, CalendarResult, CloseTradeInput, CreateAccountInput, CreateIndicatorAlertInput, CreatePriceAlertInput, CreateTradeInput, CreateTrendlineAlertInput, DedupMode, DiaryEntry, DiaryListQuery, DiaryResource, EconomicEvent, EconomicEventMinimal, EconomicEventRich, IdempotencyOptions, IndicatorAlert, IndicatorCondition, ListAlertsQuery, MacroQuery, MacroRange, MacroSeriesQuery, MacroSeriesResult, MarketConfig, MarketResource, PaginatedResponse, PaginationMeta, PriceCondition, SeriesOp, StatsQuery, StatsResource, ThresholdOp, Trade, TradeDirection, TradeListQuery, TradeStatus, TradeStatusFilter, TradesResource, TradingStats, TriggerType, UpdateAccountInput, UpdateAlertInput, UpdateIndicatorAlertInput, UpdateTradeInput, UpsertDiaryInput } from './types.js';
28
+ export type { Account, AccountsResource, AetherWebSocket, AetherWebSocketFactory, Alert, AlertsResource, AlertType, ApiEnvelope, CalendarQuery, CalendarResult, CloseTradeInput, CreateAccountInput, CreateIndicatorAlertInput, CreatePriceAlertInput, CreateTradeInput, CreateTrendlineAlertInput, DedupMode, DiaryEntry, DiaryListQuery, DiaryResource, EconomicEvent, EconomicEventMinimal, EconomicEventRich, IdempotencyOptions, IndicatorAlert, IndicatorCondition, ListAlertsQuery, LiveCandle, LiveMarketUpdate, MacroQuery, MacroRange, MacroSeriesQuery, MacroSeriesResult, MarketConfig, MarketResource, MarketStreamState, MarketStreamStatus, PaginatedResponse, PaginationMeta, PriceCondition, SeriesOp, StatsQuery, StatsResource, SubscribeLiveMarketHandlers, SubscribeLiveMarketInput, ThresholdOp, Trade, TradeDirection, TradeListQuery, TradeStatus, TradeStatusFilter, TradesResource, TradingStats, TriggerType, UpdateAccountInput, UpdateAlertInput, UpdateIndicatorAlertInput, UpdateTradeInput, UpsertDiaryInput } from './types.js';
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * (`/api/public/v1/<domain>/<action>`). Reusable in any Node/Bun/edge runtime
6
6
  * that needs programmatic access to AetherWealth data.
7
7
  *
8
- * Auth — every public route is authenticated with a **public API key**
9
- * (`aw_live_…`) sent as `Authorization: Bearer …`:
8
+ * Auth — every public route is authenticated with a **user API key**
9
+ * (`aw_live_…`) sent as `Authorization: Bearer …`. The key is a server-side secret:
10
10
  *
11
11
  * ```ts
12
12
  * const client = new AetherClient({
@@ -19,6 +19,6 @@
19
19
  * modes can be added without breaking callers. See the README for setup.
20
20
  */
21
21
  export { AetherClient, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS } from './client.js';
22
- export { AetherApiError, AetherAuthError, AetherForbiddenError, AetherNetworkError, AetherNotFoundError, AetherRateLimitError, AetherTimeoutError, AetherValidationError, classifyError, parseRetryAfter } from './errors.js';
22
+ export { AetherApiError, AetherAuthError, AetherForbiddenError, AetherMarketStreamGatewayError, AetherMarketStreamTerminalError, AetherNetworkError, AetherNotFoundError, AetherRateLimitError, AetherTimeoutError, AetherValidationError, classifyError, parseRetryAfter } from './errors.js';
23
23
  export { computeDelay, withRetry } from './retry.js';
24
24
  export { accountSchema, alertSchema, dedupModeSchema, diaryEntrySchema, economicEventSchema, indicatorAlertSchema, indicatorConditionSchema, macroSeriesPointSchema, macroSeriesResultSchema, marketConfigSchema, paginationSchema, parseAccount, parseAlert, parseDiaryEntry, parseEconomicEvent, parseIndicatorAlert, parseMacroSeriesResult, parseMarketConfig, parseTrade, parseTradingStats, priceConditionSchema, tradeDirectionSchema, tradeSchema, tradeStatusSchema, tradingStatsSchema, triggerTypeSchema } from './schemas.js';
@@ -0,0 +1,2 @@
1
+ export { AetherClient } from './client.node.js';
2
+ export * from './index.js';
@@ -0,0 +1,2 @@
1
+ export { AetherClient } from './client.node.js';
2
+ export * from './index.js';
@@ -1,8 +1,12 @@
1
1
  import type { AetherClient } from '../client.js';
2
- import type { MarketResource } from '../types.js';
2
+ import type { AetherWebSocketFactory, MarketResource } from '../types.js';
3
+ type MarketResourceOptions = {
4
+ baseUrl: string;
5
+ webSocketFactory: AetherWebSocketFactory;
6
+ };
3
7
  /**
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.
8
+ * `market` — read-only market context via the public REST API plus live
9
+ * 1-minute candle updates through the authenticated backend WebSocket gateway.
7
10
  */
8
- export declare function createMarketResource(client: AetherClient): MarketResource;
11
+ export declare function createMarketResource(client: AetherClient, options: MarketResourceOptions): MarketResource;
12
+ export {};
@@ -1,13 +1,419 @@
1
+ import { AetherApiError, AetherMarketStreamGatewayError, AetherMarketStreamTerminalError, AetherRateLimitError } from '../errors.js';
1
2
  import { parseEconomicEvent, parseMacroSeriesResult, parseMarketConfig } from '../schemas.js';
2
3
  import { pluck } from './envelope.js';
3
4
  import { assertEachShape, assertShape } from './validate.js';
4
5
  const MACRO_RANGES = ['3m', '6m', '12m', '24m', 'all'];
6
+ const LIVE_TIMEFRAME = '1m';
7
+ const MAX_LIVE_SYMBOLS = 25;
8
+ const MAX_CONCURRENT_LIVE_STREAMS = 5;
9
+ const LIVE_SYMBOL_PATTERN = /^[A-Z0-9]{2,16}$/;
10
+ const HEARTBEAT_INTERVAL_MS = 30_000;
11
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
12
+ const MAX_RECONNECT_DELAY_MS = 30_000;
13
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
14
+ const TERMINAL_WEBSOCKET_CLOSE_CODES = new Set([1008, 1009, 4003, 4401, 4403]);
15
+ const PONG = Symbol('pong');
16
+ function toError(error) {
17
+ return error instanceof Error ? error : new Error(String(error));
18
+ }
19
+ function invokeSafely(callback, value) {
20
+ try {
21
+ callback?.(value);
22
+ }
23
+ catch {
24
+ // Consumer callbacks must not corrupt the socket lifecycle.
25
+ }
26
+ }
27
+ function socketCloseError(event) {
28
+ const message = `market.subscribe: WebSocket closed (${event.code}${event.reason ? ` ${event.reason}` : ''})`;
29
+ if (!TERMINAL_WEBSOCKET_CLOSE_CODES.has(event.code))
30
+ return new Error(message);
31
+ return new AetherMarketStreamTerminalError(message, { code: 'MARKET_STREAM_SOCKET_POLICY' });
32
+ }
33
+ function isTerminalTicketError(error) {
34
+ return (error instanceof AetherMarketStreamTerminalError ||
35
+ (error instanceof AetherApiError &&
36
+ (error.code === 'market_stream_unavailable' ||
37
+ (error.status >= 400 &&
38
+ error.status < 500 &&
39
+ ![408, 425, 429].includes(error.status)))));
40
+ }
41
+ function closeSafely(socket, code, reason) {
42
+ try {
43
+ socket.close(code, reason);
44
+ }
45
+ catch {
46
+ // Closing is cleanup; a custom WebSocket implementation must not block teardown.
47
+ }
48
+ }
49
+ function normalizeLiveSymbols(input) {
50
+ if (!Array.isArray(input) || input.length === 0) {
51
+ throw new Error('market.subscribe: at least one symbol is required');
52
+ }
53
+ const symbols = [
54
+ ...new Set(input.map(symbol => {
55
+ if (typeof symbol !== 'string' || symbol.trim().length === 0) {
56
+ throw new Error('market.subscribe: every symbol must be a non-empty string');
57
+ }
58
+ const normalized = symbol.trim().toUpperCase();
59
+ if (!LIVE_SYMBOL_PATTERN.test(normalized)) {
60
+ throw new Error('market.subscribe: symbols must contain 2-16 uppercase letters or digits');
61
+ }
62
+ return normalized;
63
+ }))
64
+ ];
65
+ if (symbols.length > MAX_LIVE_SYMBOLS) {
66
+ throw new Error(`market.subscribe: at most ${MAX_LIVE_SYMBOLS} symbols are allowed`);
67
+ }
68
+ return symbols;
69
+ }
70
+ function streamUrl(baseUrl, ticket) {
71
+ const url = new URL('/websocket/market-data', baseUrl);
72
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
73
+ url.searchParams.set('ticket', ticket);
74
+ return url.toString();
75
+ }
76
+ function frameText(data) {
77
+ if (typeof data === 'string')
78
+ return data;
79
+ if (data instanceof ArrayBuffer)
80
+ return new TextDecoder().decode(data);
81
+ if (ArrayBuffer.isView(data)) {
82
+ return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
83
+ }
84
+ return null;
85
+ }
86
+ function finiteNumber(value) {
87
+ let parsed = NaN;
88
+ if (typeof value === 'number')
89
+ parsed = value;
90
+ if (typeof value === 'string' && value.trim().length > 0)
91
+ parsed = Number(value);
92
+ return Number.isFinite(parsed) ? parsed : null;
93
+ }
94
+ function parseLiveUpdate(raw) {
95
+ if (!raw || typeof raw !== 'object')
96
+ return null;
97
+ const frame = raw;
98
+ if (frame['type'] !== 'live_tick')
99
+ return null;
100
+ if (typeof frame['symbol'] !== 'string' || frame['timeframe'] !== LIVE_TIMEFRAME) {
101
+ throw new Error('market.subscribe: received an invalid live_tick identity');
102
+ }
103
+ const data = frame['data'];
104
+ if (!data || typeof data !== 'object') {
105
+ throw new Error('market.subscribe: received live_tick without candle data');
106
+ }
107
+ const candle = data;
108
+ const epoch = finiteNumber(candle['epoch']);
109
+ const open = finiteNumber(candle['open']);
110
+ const high = finiteNumber(candle['high']);
111
+ const low = finiteNumber(candle['low']);
112
+ const close = finiteNumber(candle['close']);
113
+ const volume = finiteNumber(candle['volume']);
114
+ if ([epoch, open, high, low, close, volume].some(value => value === null)) {
115
+ throw new Error('market.subscribe: received invalid live_tick candle values');
116
+ }
117
+ const timestamp = frame['timestamp'];
118
+ if (timestamp !== undefined && finiteNumber(timestamp) === null) {
119
+ throw new Error('market.subscribe: received an invalid live_tick timestamp');
120
+ }
121
+ return {
122
+ symbol: frame['symbol'].toUpperCase(),
123
+ timeframe: LIVE_TIMEFRAME,
124
+ candle: {
125
+ epoch: epoch,
126
+ open: open,
127
+ high: high,
128
+ low: low,
129
+ close: close,
130
+ volume: volume
131
+ },
132
+ ...(timestamp === undefined ? {} : { timestamp: finiteNumber(timestamp) })
133
+ };
134
+ }
135
+ function assertStreamTicket(ticket) {
136
+ if (!ticket ||
137
+ typeof ticket.ticket !== 'string' ||
138
+ ticket.ticket.length === 0 ||
139
+ !Number.isFinite(ticket.expiresAt)) {
140
+ throw new AetherMarketStreamTerminalError('market.subscribe: ticket endpoint returned an invalid response', { code: 'MARKET_STREAM_TICKET_RESPONSE' });
141
+ }
142
+ }
143
+ function parseGatewayError(frame, symbolSet) {
144
+ if (!frame || typeof frame !== 'object' || frame.type !== 'error') {
145
+ return null;
146
+ }
147
+ const errorFrame = frame;
148
+ const message = typeof errorFrame['message'] === 'string' ? errorFrame['message'] : 'unknown error';
149
+ const symbol = typeof errorFrame['symbol'] === 'string'
150
+ ? errorFrame['symbol'].trim().toUpperCase()
151
+ : undefined;
152
+ if (symbol && !symbolSet.has(symbol)) {
153
+ throw new Error(`market.subscribe: received gateway error for unsubscribed symbol ${symbol}`);
154
+ }
155
+ return new AetherMarketStreamGatewayError(`market.subscribe: gateway error: ${message}`, {
156
+ code: typeof errorFrame['code'] === 'string' ? errorFrame['code'] : 'gateway_rejected',
157
+ retryable: errorFrame['retryable'] === true,
158
+ ...(symbol ? { symbol } : {})
159
+ });
160
+ }
161
+ function parseLiveMessage(data, symbolSet) {
162
+ const text = frameText(data);
163
+ if (text === 'pong')
164
+ return PONG;
165
+ if (text === null)
166
+ throw new Error('market.subscribe: received a non-text frame');
167
+ const frame = JSON.parse(text);
168
+ if (frame && typeof frame === 'object' && frame.type === 'pong')
169
+ return PONG;
170
+ const gatewayError = parseGatewayError(frame, symbolSet);
171
+ if (gatewayError)
172
+ throw gatewayError;
173
+ const update = parseLiveUpdate(frame);
174
+ if (update && !symbolSet.has(update.symbol)) {
175
+ throw new Error(`market.subscribe: received live_tick for unsubscribed symbol ${update.symbol}`);
176
+ }
177
+ return update;
178
+ }
179
+ function handleLiveMessageError(error, socket, reportError, terminate) {
180
+ if (error instanceof AetherMarketStreamGatewayError) {
181
+ if (error.retryable) {
182
+ reportError(error);
183
+ closeSafely(socket, 1012, 'retryable gateway error');
184
+ return;
185
+ }
186
+ if (error.symbol) {
187
+ reportError(error);
188
+ return;
189
+ }
190
+ terminate(new AetherMarketStreamTerminalError(error.message, {
191
+ code: 'MARKET_STREAM_GATEWAY_REJECTED',
192
+ cause: error
193
+ }));
194
+ return;
195
+ }
196
+ if (error instanceof AetherMarketStreamTerminalError) {
197
+ terminate(error);
198
+ return;
199
+ }
200
+ reportError(error);
201
+ }
202
+ function subscribeLiveMarket(client, options, input, handlers, releaseSlot) {
203
+ const symbols = normalizeLiveSymbols(input.symbols);
204
+ const symbolSet = new Set(symbols);
205
+ const reconnectJitter = 0.75 + Math.random() * 0.5;
206
+ const requestAbort = new AbortController();
207
+ let stopped = false;
208
+ let socket = null;
209
+ let reconnectAttempt = 0;
210
+ let generation = 0;
211
+ let reconnectTimer = null;
212
+ let heartbeatTimer = null;
213
+ let pongTimer = null;
214
+ const reportStatus = (state, error) => invokeSafely(handlers.onStatus, { state, reconnectAttempt, ...(error ? { error } : {}) });
215
+ const reportError = (error) => invokeSafely(handlers.onError, error);
216
+ const clearHeartbeatTimers = () => {
217
+ if (heartbeatTimer)
218
+ clearInterval(heartbeatTimer);
219
+ if (pongTimer)
220
+ clearTimeout(pongTimer);
221
+ heartbeatTimer = null;
222
+ pongTimer = null;
223
+ };
224
+ const clearTimers = () => {
225
+ if (reconnectTimer)
226
+ clearTimeout(reconnectTimer);
227
+ reconnectTimer = null;
228
+ clearHeartbeatTimers();
229
+ };
230
+ const terminate = (error) => {
231
+ if (stopped)
232
+ return;
233
+ stopped = true;
234
+ generation += 1;
235
+ clearTimers();
236
+ input.signal?.removeEventListener('abort', unsubscribe);
237
+ requestAbort.abort(error);
238
+ const activeSocket = socket;
239
+ socket = null;
240
+ if (activeSocket && (activeSocket.readyState === 0 || activeSocket.readyState === 1)) {
241
+ closeSafely(activeSocket, 1000, 'subscription terminated');
242
+ }
243
+ releaseSlot();
244
+ reportError(error);
245
+ reportStatus('terminal', error);
246
+ };
247
+ const scheduleReconnect = (error) => {
248
+ if (stopped || reconnectTimer)
249
+ return;
250
+ reconnectAttempt += 1;
251
+ const backoffDelay = Math.round(Math.min(1_000 * 2 ** (reconnectAttempt - 1) * reconnectJitter, MAX_RECONNECT_DELAY_MS));
252
+ const retryAfterDelay = error instanceof AetherRateLimitError && error.retryAfterSeconds !== null
253
+ ? error.retryAfterSeconds * 1_000
254
+ : 0;
255
+ const delay = Math.min(Math.max(backoffDelay, retryAfterDelay), MAX_TIMER_DELAY_MS);
256
+ reconnectTimer = setTimeout(() => {
257
+ reconnectTimer = null;
258
+ connect().catch(error => reportError(toError(error)));
259
+ }, delay);
260
+ reportStatus('reconnecting', error);
261
+ };
262
+ const isInactive = (candidateGeneration) => stopped || candidateGeneration !== generation;
263
+ const handleConnectFailure = (error) => {
264
+ if (stopped)
265
+ return;
266
+ const normalized = toError(error);
267
+ if (isTerminalTicketError(normalized)) {
268
+ terminate(normalized);
269
+ return;
270
+ }
271
+ reportError(normalized);
272
+ scheduleReconnect(normalized);
273
+ };
274
+ const handleSocketMessage = (data, activeSocket) => {
275
+ if (stopped || socket !== activeSocket)
276
+ return;
277
+ try {
278
+ const update = parseLiveMessage(data, symbolSet);
279
+ if (update === PONG) {
280
+ if (pongTimer)
281
+ clearTimeout(pongTimer);
282
+ pongTimer = null;
283
+ return;
284
+ }
285
+ if (update)
286
+ invokeSafely(handlers.onUpdate, update);
287
+ }
288
+ catch (error) {
289
+ handleLiveMessageError(toError(error), activeSocket, reportError, terminate);
290
+ }
291
+ };
292
+ const bindSocket = (nextSocket, socketGeneration) => {
293
+ nextSocket.onopen = () => {
294
+ if (isInactive(socketGeneration) || socket !== nextSocket)
295
+ return;
296
+ try {
297
+ nextSocket.send(JSON.stringify({
298
+ type: 'subscribe',
299
+ subscriptions: symbols.map(symbol => ({ symbol, timeframe: LIVE_TIMEFRAME }))
300
+ }));
301
+ heartbeatTimer = setInterval(() => {
302
+ if (!stopped && socket === nextSocket && nextSocket.readyState === 1) {
303
+ try {
304
+ nextSocket.send(JSON.stringify({ type: 'ping' }));
305
+ if (pongTimer)
306
+ clearTimeout(pongTimer);
307
+ pongTimer = setTimeout(() => {
308
+ if (stopped ||
309
+ socket !== nextSocket ||
310
+ nextSocket.readyState !== 1) {
311
+ return;
312
+ }
313
+ const error = new Error('market.subscribe: heartbeat pong timeout');
314
+ clearHeartbeatTimers();
315
+ socket = null;
316
+ closeSafely(nextSocket, 1012, 'heartbeat pong timeout');
317
+ reportError(error);
318
+ scheduleReconnect(error);
319
+ }, HEARTBEAT_TIMEOUT_MS);
320
+ }
321
+ catch (error) {
322
+ reportError(toError(error));
323
+ closeSafely(nextSocket, 1011, 'heartbeat send failed');
324
+ }
325
+ }
326
+ }, HEARTBEAT_INTERVAL_MS);
327
+ }
328
+ catch (error) {
329
+ reportError(toError(error));
330
+ closeSafely(nextSocket, 1011, 'subscription send failed');
331
+ return;
332
+ }
333
+ reconnectAttempt = 0;
334
+ reportStatus('connected');
335
+ };
336
+ nextSocket.onmessage = event => handleSocketMessage(event.data, nextSocket);
337
+ nextSocket.onerror = () => {
338
+ if (!stopped && socket === nextSocket) {
339
+ reportError(new Error('market.subscribe: WebSocket error'));
340
+ }
341
+ };
342
+ nextSocket.onclose = event => {
343
+ if (socket !== nextSocket)
344
+ return;
345
+ clearHeartbeatTimers();
346
+ socket = null;
347
+ if (isInactive(socketGeneration))
348
+ return;
349
+ const error = socketCloseError(event);
350
+ if (error instanceof AetherMarketStreamTerminalError)
351
+ terminate(error);
352
+ else
353
+ scheduleReconnect(error);
354
+ };
355
+ };
356
+ const connect = async () => {
357
+ if (stopped)
358
+ return;
359
+ const thisGeneration = ++generation;
360
+ reportStatus(reconnectAttempt === 0 ? 'connecting' : 'reconnecting');
361
+ if (stopped)
362
+ return;
363
+ try {
364
+ const ticket = await client.request('/api/market-data/stream-ticket', {
365
+ method: 'POST',
366
+ body: {},
367
+ signal: requestAbort.signal
368
+ });
369
+ if (isInactive(thisGeneration))
370
+ return;
371
+ assertStreamTicket(ticket);
372
+ const nextSocket = options.webSocketFactory(streamUrl(options.baseUrl, ticket.ticket));
373
+ if (isInactive(thisGeneration)) {
374
+ closeSafely(nextSocket, 1000, 'subscription cancelled');
375
+ return;
376
+ }
377
+ socket = nextSocket;
378
+ bindSocket(nextSocket, thisGeneration);
379
+ }
380
+ catch (error) {
381
+ handleConnectFailure(error);
382
+ }
383
+ };
384
+ const unsubscribe = () => {
385
+ if (stopped)
386
+ return;
387
+ stopped = true;
388
+ generation += 1;
389
+ clearTimers();
390
+ input.signal?.removeEventListener('abort', unsubscribe);
391
+ requestAbort.abort(new Error('market subscription cancelled'));
392
+ const activeSocket = socket;
393
+ socket = null;
394
+ if (activeSocket && (activeSocket.readyState === 0 || activeSocket.readyState === 1)) {
395
+ closeSafely(activeSocket, 1000, 'subscription cancelled');
396
+ }
397
+ releaseSlot();
398
+ reportStatus('disconnected');
399
+ };
400
+ if (input.signal?.aborted) {
401
+ stopped = true;
402
+ releaseSlot();
403
+ reportStatus('disconnected');
404
+ }
405
+ else {
406
+ input.signal?.addEventListener('abort', unsubscribe, { once: true });
407
+ connect().catch(error => reportError(toError(error)));
408
+ }
409
+ return unsubscribe;
410
+ }
5
411
  /**
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.
412
+ * `market` — read-only market context via the public REST API plus live
413
+ * 1-minute candle updates through the authenticated backend WebSocket gateway.
9
414
  */
10
- export function createMarketResource(client) {
415
+ export function createMarketResource(client, options) {
416
+ let activeStreams = 0;
11
417
  return {
12
418
  async config() {
13
419
  // `{success, data}` — client.request unwraps `data`.
@@ -85,6 +491,26 @@ export function createMarketResource(client) {
85
491
  const result = await client.request('/api/public/v1/market/macro', { method: 'POST', body });
86
492
  // `null` (no series) passes through; a present series is shape-checked.
87
493
  return assertShape(client.validateResponses, parseMacroSeriesResult, result);
494
+ },
495
+ subscribe(input, handlers) {
496
+ if (activeStreams >= MAX_CONCURRENT_LIVE_STREAMS) {
497
+ throw new AetherMarketStreamTerminalError(`market.subscribe: at most ${MAX_CONCURRENT_LIVE_STREAMS} concurrent streams are allowed per client`, { code: 'MARKET_STREAM_LIMIT' });
498
+ }
499
+ activeStreams += 1;
500
+ let released = false;
501
+ const releaseSlot = () => {
502
+ if (released)
503
+ return;
504
+ released = true;
505
+ activeStreams -= 1;
506
+ };
507
+ try {
508
+ return subscribeLiveMarket(client, options, input, handlers, releaseSlot);
509
+ }
510
+ catch (error) {
511
+ releaseSlot();
512
+ throw error;
513
+ }
88
514
  }
89
515
  };
90
516
  }
package/dist/types.d.ts CHANGED
@@ -475,6 +475,57 @@ export type MacroSeriesResult = {
475
475
  cachedAt?: number;
476
476
  };
477
477
  export type MacroRange = '3m' | '6m' | '12m' | '24m' | 'all';
478
+ /** The current in-progress 1-minute candle for a live market symbol. */
479
+ export type LiveCandle = {
480
+ epoch: number;
481
+ open: number;
482
+ high: number;
483
+ low: number;
484
+ /** Latest observed market price; it is not an executable quote. */
485
+ close: number;
486
+ volume: number;
487
+ };
488
+ export type LiveMarketUpdate = {
489
+ symbol: string;
490
+ /** Live public streams currently use the backend's fixed 1-minute feed. */
491
+ timeframe: '1m';
492
+ candle: LiveCandle;
493
+ timestamp?: number;
494
+ };
495
+ export type MarketStreamState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'terminal';
496
+ export type MarketStreamStatus = {
497
+ /** `terminal` means this subscription has stopped and will not reconnect. */
498
+ state: MarketStreamState;
499
+ reconnectAttempt: number;
500
+ error?: Error;
501
+ };
502
+ export type SubscribeLiveMarketInput = {
503
+ /** One to 25 market symbols. Timeframe is intentionally fixed by the backend. */
504
+ symbols: readonly string[];
505
+ signal?: AbortSignal;
506
+ };
507
+ export type SubscribeLiveMarketHandlers = {
508
+ onUpdate: (update: LiveMarketUpdate) => void;
509
+ onStatus?: (status: MarketStreamStatus) => void;
510
+ /** Reports malformed frames plus transient and terminal connection failures. */
511
+ onError?: (error: Error) => void;
512
+ };
513
+ /** Minimal WebSocket surface used by the SDK and injectable in tests. */
514
+ export interface AetherWebSocket {
515
+ readonly readyState: number;
516
+ onopen: (() => void) | null;
517
+ onmessage: ((event: {
518
+ data: unknown;
519
+ }) => void) | null;
520
+ onerror: ((event: unknown) => void) | null;
521
+ onclose: ((event: {
522
+ code: number;
523
+ reason: string;
524
+ }) => void) | null;
525
+ send(data: string): void;
526
+ close(code?: number, reason?: string): void;
527
+ }
528
+ export type AetherWebSocketFactory = (url: string) => AetherWebSocket;
478
529
  /** Args for `market.macro` — an object (not positional) so the two same-typed
479
530
  * `currency`/`indicator` strings can't be silently transposed. */
480
531
  export type MacroQuery = {
@@ -491,6 +542,13 @@ export interface MarketResource {
491
542
  macroSeries(query: MacroSeriesQuery): Promise<EconomicEventRich[]>;
492
543
  /** fxmacrodata time series for a currency+indicator (`/api/public/v1/market/macro`). */
493
544
  macro(query: MacroQuery): Promise<MacroSeriesResult | null>;
545
+ /**
546
+ * Open one independently managed backend stream for the supplied symbols.
547
+ * Returns an immediate, idempotent unsubscribe function.
548
+ * A client can hold at most five active streams; a sixth call throws
549
+ * `AetherMarketStreamTerminalError` until another stream stops.
550
+ */
551
+ subscribe(input: SubscribeLiveMarketInput, handlers: SubscribeLiveMarketHandlers): () => void;
494
552
  }
495
553
  export type DiaryEntry = {
496
554
  id: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aetherwealth/sdk",
3
- "version": "0.1.36",
4
- "description": "Official Aether Wealth SDK: a typed TypeScript client for the Aether Wealth public API — trades, accounts, analytics, alerts, market data, macro calendar, and diary.",
3
+ "version": "0.2.1",
4
+ "description": "Official typed TypeScript SDK for Aether Wealth journals, accounts, analytics, alerts, and forex and crypto market data.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "main": "./dist/index.js",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": {
12
12
  "types": "./dist/index.d.ts",
13
+ "node": "./dist/index.node.js",
13
14
  "import": "./dist/index.js",
14
15
  "default": "./dist/index.js"
15
16
  },
@@ -24,6 +25,7 @@
24
25
  "node": ">=18"
25
26
  },
26
27
  "dependencies": {
28
+ "ws": "^8.21.0",
27
29
  "zod": "^4.4.3"
28
30
  },
29
31
  "publishConfig": {
@@ -41,15 +43,10 @@
41
43
  "market data",
42
44
  "technical indicators"
43
45
  ],
44
- "author": "Aether Wealth <support@aetherwealth.ai>",
46
+ "author": "Aether Wealth Advisors Pvt Ltd <infra@aetherwealth.ai>",
45
47
  "license": "MIT",
46
48
  "homepage": "https://aetherwealth.ai/sdk",
47
- "repository": {
48
- "type": "git",
49
- "url": "git+https://github.com/Opus-Aether-AI/webapp.git",
50
- "directory": "packages/sdk"
51
- },
52
49
  "bugs": {
53
- "url": "https://github.com/Opus-Aether-AI/webapp/issues"
50
+ "email": "infra@aetherwealth.ai"
54
51
  }
55
52
  }