@aetherwealth/sdk 0.1.36 → 0.2.0
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 +1 -1
- package/README.md +67 -8
- package/dist/client.d.ts +4 -2
- package/dist/client.js +13 -3
- package/dist/client.node.d.ts +5 -0
- package/dist/client.node.js +12 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +24 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/index.node.d.ts +2 -0
- package/dist/index.node.js +2 -0
- package/dist/resources/market.d.ts +9 -5
- package/dist/resources/market.js +430 -4
- package/dist/types.d.ts +58 -0
- package/package.json +6 -9
package/LICENSE
CHANGED
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
|
|
8
|
-
> (`aw_live_…`) sent as a bearer token.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 **
|
|
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 {
|
|
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 **
|
|
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';
|
|
@@ -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
|
-
*
|
|
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 {};
|
package/dist/resources/market.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
4
|
-
"description": "Official
|
|
3
|
+
"version": "0.2.0",
|
|
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 <
|
|
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
|
-
"
|
|
50
|
+
"email": "infra@aetherwealth.ai"
|
|
54
51
|
}
|
|
55
52
|
}
|