@mfxhub/sdk 0.0.0 → 0.1.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/CHANGELOG.md +41 -0
- package/LICENSE +202 -0
- package/README.md +105 -3
- package/SECURITY.md +32 -0
- package/dist/arguments.d.ts +10 -0
- package/dist/arguments.js +37 -0
- package/dist/catalog.d.ts +25 -0
- package/dist/catalog.js +39 -0
- package/dist/client.d.ts +101 -0
- package/dist/client.js +200 -0
- package/dist/decimals.d.ts +10 -0
- package/dist/decimals.js +51 -0
- package/dist/errors.d.ts +115 -0
- package/dist/errors.js +162 -0
- package/dist/execution.d.ts +48 -0
- package/dist/execution.js +119 -0
- package/dist/generated/api.d.ts +5012 -0
- package/dist/generated/api.js +5 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +19 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +46 -0
- package/dist/results.d.ts +78 -0
- package/dist/results.js +119 -0
- package/dist/signing.d.ts +34 -0
- package/dist/signing.js +103 -0
- package/dist/stream.d.ts +139 -0
- package/dist/stream.js +387 -0
- package/dist/terminals.d.ts +55 -0
- package/dist/terminals.js +76 -0
- package/package.json +62 -5
package/dist/stream.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// `client.stream()`: the multiplexed WebSocket.
|
|
2
|
+
//
|
|
3
|
+
// One connection carries every channel a tenant is watching: ticks, closed candles, platform
|
|
4
|
+
// events and execution results. The caller writes one loop:
|
|
5
|
+
//
|
|
6
|
+
// const stream = await client.stream();
|
|
7
|
+
// await stream.subscribe(Channel.md(serverId, "EURUSD.sd"));
|
|
8
|
+
// await stream.subscribe(Channel.results(terminalId));
|
|
9
|
+
// for await (const event of stream) { ... }
|
|
10
|
+
//
|
|
11
|
+
// What this module is opinionated about, each because the alternative loses data quietly:
|
|
12
|
+
// - Channels are keyed by id, never by display name. `Channel` builds the keys and refuses one
|
|
13
|
+
// the platform would.
|
|
14
|
+
// - A reconnect resumes what can be resumed and says so about the rest. Events, results, account
|
|
15
|
+
// and expert channels carry a per-channel `seq` and are resumed from the last one seen. Ticks and
|
|
16
|
+
// candles are not replayed, by design (a stale tick is worse than none), so they subscribe again.
|
|
17
|
+
// - Almost nothing is buffered on the caller's behalf. When the caller falls behind, the socket is
|
|
18
|
+
// paused, so a slow consumer meets real backpressure and is eventually dropped by the server
|
|
19
|
+
// with 4008, which this reports, instead of reading minutes-old prices believing they are live.
|
|
20
|
+
// - Gaps are counted, not hidden. `seq` increases per channel; anything missing is arithmetic, and
|
|
21
|
+
// `stats.dropped` carries the total.
|
|
22
|
+
//
|
|
23
|
+
// The upgrade is signed exactly as a REST request is, over `GET /v1/stream`.
|
|
24
|
+
import WebSocket from "ws";
|
|
25
|
+
import { AuthError, MfxError, MfxTransportError, errorFor } from "./errors.js";
|
|
26
|
+
/** The server says why it hung up. 4001 is the one reconnecting cannot fix. */
|
|
27
|
+
export const CLOSE_REAUTHENTICATE = 4001;
|
|
28
|
+
export const CLOSE_SLOW_CONSUMER = 4008;
|
|
29
|
+
export const CLOSE_LIFETIME = 4009;
|
|
30
|
+
/** Exactly the platform's own pattern for a channel key (`StreamChannel`). */
|
|
31
|
+
const CHANNEL = /^(md:[0-9a-f-]{36}:[^:]+|candles:[0-9a-f-]{36}:[^:]+:(M1|M5|M15|M30|H1|H4|D1|W1|MN1)|events|results:[0-9a-f-]{36}|account:[0-9a-f-]{36}|ea:[0-9a-f-]{36}|book:[0-9a-f-]{36}:[^:]+)$/;
|
|
32
|
+
/**
|
|
33
|
+
* These resume from the last seq after a reconnect. `account:` answers a resume with a fresh
|
|
34
|
+
* snapshot rather than a replay, which is still what it needs. `book:` is a state the platform
|
|
35
|
+
* sends whole on every subscribe, so subscribing again loses nothing.
|
|
36
|
+
*/
|
|
37
|
+
const REPLAYABLE = ["events", "results:", "account:", "ea:"];
|
|
38
|
+
function checked(channel) {
|
|
39
|
+
if (!CHANNEL.test(channel)) {
|
|
40
|
+
throw new RangeError(`${JSON.stringify(channel)} is not a channel key the platform accepts. Ids are uuids and ` +
|
|
41
|
+
"a symbol carries no colon; see StreamChannel in the API description.");
|
|
42
|
+
}
|
|
43
|
+
return channel;
|
|
44
|
+
}
|
|
45
|
+
const replayable = (channel) => REPLAYABLE.some((p) => channel.startsWith(p));
|
|
46
|
+
/** Builds the channel keys the platform accepts, from ids rather than names. */
|
|
47
|
+
export const Channel = {
|
|
48
|
+
/** Every platform event for this tenant. */
|
|
49
|
+
events: "events",
|
|
50
|
+
/** Ticks for one symbol on one server, in the broker's own name for it. */
|
|
51
|
+
md: (serverId, symbol) => checked(`md:${serverId}:${symbol}`),
|
|
52
|
+
/** Closed candles. Only closed ones are published; a forming candle is not a fact yet. */
|
|
53
|
+
candles: (serverId, symbol, timeframe) => checked(`candles:${serverId}:${symbol}:${timeframe}`),
|
|
54
|
+
/** Execution results for one terminal: where a command id is finally answered. */
|
|
55
|
+
results: (terminalId) => checked(`results:${terminalId}`),
|
|
56
|
+
/** One terminal's live account: a snapshot, then marks. */
|
|
57
|
+
account: (terminalId) => checked(`account:${terminalId}`),
|
|
58
|
+
/** What the tenant's own expert advisor on this terminal writes. Needs `ea:read`. */
|
|
59
|
+
ea: (terminalId) => checked(`ea:${terminalId}`),
|
|
60
|
+
/** The symbol's order book, whole, each time it changes. */
|
|
61
|
+
book: (serverId, symbol) => checked(`book:${serverId}:${symbol}`),
|
|
62
|
+
};
|
|
63
|
+
/** The connection ended. `code` is the WebSocket close code, `reason` the server's words. */
|
|
64
|
+
export class StreamClosed extends MfxError {
|
|
65
|
+
code;
|
|
66
|
+
reason;
|
|
67
|
+
name = "StreamClosed";
|
|
68
|
+
constructor(code, reason = "") {
|
|
69
|
+
super(reason === "" ? `stream closed ${code}` : `stream closed ${code}: ${reason}`);
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.reason = reason;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** One message on one channel. */
|
|
75
|
+
export class StreamMessage {
|
|
76
|
+
channel;
|
|
77
|
+
seq;
|
|
78
|
+
type;
|
|
79
|
+
data;
|
|
80
|
+
base;
|
|
81
|
+
constructor(channel,
|
|
82
|
+
/** Increases per channel; a jump is counted in `stats.dropped`. */
|
|
83
|
+
seq,
|
|
84
|
+
/** The event type on `events` and `results` (`command.completed`); null on a tick or candle. */
|
|
85
|
+
type, data,
|
|
86
|
+
/** `account:` only: the seq of the snapshot this message applies to. */
|
|
87
|
+
base = null) {
|
|
88
|
+
this.channel = channel;
|
|
89
|
+
this.seq = seq;
|
|
90
|
+
this.type = type;
|
|
91
|
+
this.data = data;
|
|
92
|
+
this.base = base;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The position asked for is older than the replay window: read the current state over REST. */
|
|
96
|
+
export class Resync {
|
|
97
|
+
channel;
|
|
98
|
+
constructor(channel) {
|
|
99
|
+
this.channel = channel;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The platform refused one channel; every other channel carries on. The subscription is dropped,
|
|
104
|
+
* so a reconnect does not ask for it again and collect the same refusal for ever.
|
|
105
|
+
*/
|
|
106
|
+
export class ChannelRefused {
|
|
107
|
+
channel;
|
|
108
|
+
code;
|
|
109
|
+
message;
|
|
110
|
+
details;
|
|
111
|
+
constructor(channel,
|
|
112
|
+
/** The platform's own code: `not_found`, `forbidden`, `not_subscribed`. */
|
|
113
|
+
code, message, details = {}) {
|
|
114
|
+
this.channel = channel;
|
|
115
|
+
this.code = code;
|
|
116
|
+
this.message = message;
|
|
117
|
+
this.details = details;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** How many frames may wait for the caller before the socket is paused. */
|
|
121
|
+
const HIGH_WATER = 64;
|
|
122
|
+
/** The default transport, over `ws`, with backpressure: the socket pauses while frames wait. */
|
|
123
|
+
export const wsConnector = (url, headers) => new Promise((resolve, reject) => {
|
|
124
|
+
const socket = new WebSocket(url, { headers: { ...headers } });
|
|
125
|
+
const frames = [];
|
|
126
|
+
const waiting = [];
|
|
127
|
+
let ended = null;
|
|
128
|
+
socket.on("message", (data) => {
|
|
129
|
+
// binaryType is ws's default, "nodebuffer": every frame arrives as one Buffer.
|
|
130
|
+
const text = data.toString("utf8");
|
|
131
|
+
const next = waiting.shift();
|
|
132
|
+
if (next !== undefined) {
|
|
133
|
+
next.resolve(text);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
frames.push(text);
|
|
137
|
+
if (frames.length >= HIGH_WATER)
|
|
138
|
+
socket.pause();
|
|
139
|
+
});
|
|
140
|
+
socket.on("close", (code, reason) => {
|
|
141
|
+
ended = new StreamClosed(code, reason.toString("utf8"));
|
|
142
|
+
for (const next of waiting.splice(0))
|
|
143
|
+
next.reject(ended);
|
|
144
|
+
});
|
|
145
|
+
// Once the connection is open this reject does nothing: a later error is followed by "close".
|
|
146
|
+
socket.on("error", (error) => {
|
|
147
|
+
reject(new MfxTransportError(`the stream could not be opened: ${error.message}`, false));
|
|
148
|
+
});
|
|
149
|
+
socket.once("open", () => {
|
|
150
|
+
resolve({
|
|
151
|
+
send: (text) => new Promise((done, fail) => {
|
|
152
|
+
// ws calls back with null, not undefined, once the frame is written.
|
|
153
|
+
socket.send(text, (error) => {
|
|
154
|
+
if (error instanceof Error)
|
|
155
|
+
fail(ended ?? new StreamClosed(1006, error.message));
|
|
156
|
+
else
|
|
157
|
+
done();
|
|
158
|
+
});
|
|
159
|
+
}),
|
|
160
|
+
recv: () => {
|
|
161
|
+
const frame = frames.shift();
|
|
162
|
+
if (frame !== undefined) {
|
|
163
|
+
if (frames.length < HIGH_WATER / 2)
|
|
164
|
+
socket.resume();
|
|
165
|
+
return Promise.resolve(frame);
|
|
166
|
+
}
|
|
167
|
+
if (ended !== null)
|
|
168
|
+
return Promise.reject(ended);
|
|
169
|
+
return new Promise((done, fail) => waiting.push({ resolve: done, reject: fail }));
|
|
170
|
+
},
|
|
171
|
+
close: () => new Promise((done) => {
|
|
172
|
+
if (socket.readyState === WebSocket.CLOSED) {
|
|
173
|
+
done();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
socket.once("close", () => {
|
|
177
|
+
done();
|
|
178
|
+
});
|
|
179
|
+
socket.close(1000);
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
/** The stream's address beside the REST base: `api.dev.mfxhub.com/v1` is `ws.dev.mfxhub.com/v1/stream`. */
|
|
185
|
+
export function streamUrl(baseUrl) {
|
|
186
|
+
const url = baseUrl
|
|
187
|
+
.replace(/\/+$/, "")
|
|
188
|
+
.replace(/^https:\/\//, "wss://")
|
|
189
|
+
.replace(/^http:\/\//, "ws://")
|
|
190
|
+
.replace("://api.", "://ws.");
|
|
191
|
+
return url.endsWith("/stream") ? url : `${url}/stream`;
|
|
192
|
+
}
|
|
193
|
+
/** The multiplexed stream. Opened by `await client.stream()`; closed by `close()`. */
|
|
194
|
+
export class MfxStream {
|
|
195
|
+
#client;
|
|
196
|
+
#connect;
|
|
197
|
+
#url;
|
|
198
|
+
#maxReconnects;
|
|
199
|
+
/** Each subscribed channel, in the order asked for, with the last seq seen on it. */
|
|
200
|
+
#channels = new Map();
|
|
201
|
+
#stats = {
|
|
202
|
+
reconnects: 0,
|
|
203
|
+
dropped: 0,
|
|
204
|
+
resyncs: 0,
|
|
205
|
+
slowConsumerDrops: 0,
|
|
206
|
+
lifetimeReconnects: 0,
|
|
207
|
+
refused: 0,
|
|
208
|
+
};
|
|
209
|
+
#connection = null;
|
|
210
|
+
/** Use {@link MfxClient.stream}. */
|
|
211
|
+
constructor(client, options = {}) {
|
|
212
|
+
this.#client = client;
|
|
213
|
+
this.#connect = options.connect ?? wsConnector;
|
|
214
|
+
this.#url = options.url ?? streamUrl(client.baseUrl);
|
|
215
|
+
this.#maxReconnects = options.maxReconnects ?? null;
|
|
216
|
+
}
|
|
217
|
+
/** What has been missed so far. A snapshot; it never changes under the caller. */
|
|
218
|
+
get stats() {
|
|
219
|
+
return { ...this.#stats };
|
|
220
|
+
}
|
|
221
|
+
/** The channels currently subscribed, in the order they were asked for. */
|
|
222
|
+
get channels() {
|
|
223
|
+
return [...this.#channels.keys()];
|
|
224
|
+
}
|
|
225
|
+
/** Opens the connection. {@link MfxClient.stream} does this for you. */
|
|
226
|
+
async open() {
|
|
227
|
+
const path = new URL(this.#url).pathname;
|
|
228
|
+
this.#connection = await this.#connect(this.#url, this.#client.signer.headersFor("GET", path));
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
/** Subscribe to one channel. Subscribing twice is not an error. */
|
|
232
|
+
async subscribe(channel) {
|
|
233
|
+
checked(channel);
|
|
234
|
+
if (!this.#channels.has(channel))
|
|
235
|
+
this.#channels.set(channel, null);
|
|
236
|
+
await this.#send({ op: "subscribe", ch: channel });
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Bring one channel back up to date from the last seq seen, when the caller has noticed from
|
|
240
|
+
* the data itself that it is behind. On a channel that does not replay, or one with no seq yet,
|
|
241
|
+
* this subscribes instead: there is no position to resume from.
|
|
242
|
+
*/
|
|
243
|
+
async resume(channel) {
|
|
244
|
+
checked(channel);
|
|
245
|
+
const last = this.#channels.get(channel);
|
|
246
|
+
if (last === undefined || last === null || !replayable(channel)) {
|
|
247
|
+
await this.subscribe(channel);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await this.#send({ op: "resume", ch: channel, after_seq: last });
|
|
251
|
+
}
|
|
252
|
+
/** Stop watching a channel, and stop subscribing to it again after a reconnect. */
|
|
253
|
+
async unsubscribe(channel) {
|
|
254
|
+
this.#channels.delete(channel);
|
|
255
|
+
await this.#send({ op: "unsubscribe", ch: channel });
|
|
256
|
+
}
|
|
257
|
+
/** Close the connection, if one is open. */
|
|
258
|
+
async close() {
|
|
259
|
+
const connection = this.#connection;
|
|
260
|
+
this.#connection = null;
|
|
261
|
+
if (connection !== null)
|
|
262
|
+
await connection.close();
|
|
263
|
+
}
|
|
264
|
+
async *[Symbol.asyncIterator]() {
|
|
265
|
+
for (;;) {
|
|
266
|
+
const event = await this.next();
|
|
267
|
+
if (event === null)
|
|
268
|
+
return;
|
|
269
|
+
yield event;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The next event, reconnecting underneath as needed; null once the stream is over. Throws an
|
|
274
|
+
* AuthError on 4001, and the platform's typed error for an error that names no channel.
|
|
275
|
+
*/
|
|
276
|
+
async next() {
|
|
277
|
+
for (;;) {
|
|
278
|
+
const connection = this.#connection;
|
|
279
|
+
if (connection === null)
|
|
280
|
+
return null;
|
|
281
|
+
let raw;
|
|
282
|
+
try {
|
|
283
|
+
raw = await connection.recv();
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (!(error instanceof StreamClosed))
|
|
287
|
+
throw error;
|
|
288
|
+
if (!(await this.#reopen(error)))
|
|
289
|
+
return null;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const event = this.#interpret(raw);
|
|
293
|
+
if (event !== null)
|
|
294
|
+
return event;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
#interpret(raw) {
|
|
298
|
+
let parsed;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(raw);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null; // not a frame of this protocol; nothing a caller can act on
|
|
304
|
+
}
|
|
305
|
+
const message = (typeof parsed === "object" && parsed !== null ? parsed : {});
|
|
306
|
+
const channel = typeof message.ch === "string" ? message.ch : "";
|
|
307
|
+
if (message.op === "error") {
|
|
308
|
+
const body = (typeof message.error === "object" && message.error !== null ? message.error : {});
|
|
309
|
+
const code = typeof body.code === "string" ? body.code : "stream_error";
|
|
310
|
+
const text = typeof body.message === "string"
|
|
311
|
+
? body.message
|
|
312
|
+
: `stream error on ${channel === "" ? "the connection" : channel}`;
|
|
313
|
+
const details = (typeof body.details === "object" && body.details !== null ? body.details : {});
|
|
314
|
+
if (channel !== "") {
|
|
315
|
+
this.#channels.delete(channel);
|
|
316
|
+
this.#bump("refused");
|
|
317
|
+
return new ChannelRefused(channel, code, text, details);
|
|
318
|
+
}
|
|
319
|
+
// About the connection itself: typed by the platform's code, with no HTTP status.
|
|
320
|
+
throw errorFor(code, text, 0, details);
|
|
321
|
+
}
|
|
322
|
+
if (message.op === "resync") {
|
|
323
|
+
this.#bump("resyncs");
|
|
324
|
+
// The replay window has passed: forget the position so the next reconnect does not ask
|
|
325
|
+
// for the same dead seq, and so the jump to the new seq is not counted as a gap.
|
|
326
|
+
if (this.#channels.has(channel))
|
|
327
|
+
this.#channels.set(channel, null);
|
|
328
|
+
return new Resync(channel);
|
|
329
|
+
}
|
|
330
|
+
if (message.op !== "msg")
|
|
331
|
+
return null; // an ack, or an op added after this SDK shipped
|
|
332
|
+
const seq = Number.isInteger(message.seq) ? message.seq : null;
|
|
333
|
+
const last = this.#channels.get(channel);
|
|
334
|
+
if (last !== undefined && seq !== null) {
|
|
335
|
+
if (last !== null && seq > last + 1)
|
|
336
|
+
this.#bump("dropped", seq - last - 1);
|
|
337
|
+
this.#channels.set(channel, seq);
|
|
338
|
+
}
|
|
339
|
+
const data = message.data;
|
|
340
|
+
return new StreamMessage(channel, seq, typeof message.type === "string" ? message.type : null, typeof data === "object" && data !== null ? data : {}, Number.isInteger(message.base) ? message.base : null);
|
|
341
|
+
}
|
|
342
|
+
/** Come back after a close, or say the stream is over. 4001 throws: reconnecting cannot help. */
|
|
343
|
+
async #reopen(closed) {
|
|
344
|
+
if (closed.code === CLOSE_REAUTHENTICATE) {
|
|
345
|
+
this.#connection = null;
|
|
346
|
+
throw new AuthError(closed.reason === ""
|
|
347
|
+
? "the stream's credentials were rotated, expired or revoked"
|
|
348
|
+
: closed.reason, 401, "auth_error");
|
|
349
|
+
}
|
|
350
|
+
if (closed.code === CLOSE_SLOW_CONSUMER)
|
|
351
|
+
this.#bump("slowConsumerDrops");
|
|
352
|
+
if (closed.code === CLOSE_LIFETIME)
|
|
353
|
+
this.#bump("lifetimeReconnects");
|
|
354
|
+
if (this.#maxReconnects !== null && this.#stats.reconnects >= this.#maxReconnects) {
|
|
355
|
+
this.#connection = null;
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
this.#bump("reconnects");
|
|
359
|
+
// The client's own backoff, so the stream and the reads lean on this tenant's quota alike.
|
|
360
|
+
await this.#client.pause(this.#client.backoffMs(this.#stats.reconnects));
|
|
361
|
+
await this.open();
|
|
362
|
+
for (const [channel, last] of [...this.#channels]) {
|
|
363
|
+
if (replayable(channel) && last !== null) {
|
|
364
|
+
await this.#send({ op: "resume", ch: channel, after_seq: last });
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
// Ticks and candles are never replayed, so their position means nothing across a
|
|
368
|
+
// reconnect; forgetting it stops the next seq being counted as a gap.
|
|
369
|
+
this.#channels.set(channel, null);
|
|
370
|
+
await this.#send({ op: "subscribe", ch: channel });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
async #send(message) {
|
|
376
|
+
const connection = this.#connection;
|
|
377
|
+
if (connection === null)
|
|
378
|
+
throw new StreamClosed(1006, "the stream is not open");
|
|
379
|
+
await connection.send(JSON.stringify(message));
|
|
380
|
+
}
|
|
381
|
+
#bump(counter, by = 1) {
|
|
382
|
+
this.#stats = { ...this.#stats, [counter]: this.#stats[counter] + by };
|
|
383
|
+
}
|
|
384
|
+
toString() {
|
|
385
|
+
return `MfxStream(${this.#url}, channels=${this.#channels.size})`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { MfxClient } from "./client.js";
|
|
2
|
+
import type { components } from "./generated/api.js";
|
|
3
|
+
import { type Page } from "./pages.js";
|
|
4
|
+
/** A terminal, as the API describes it. */
|
|
5
|
+
export type Terminal = components["schemas"]["TenantTerminal"];
|
|
6
|
+
/** The states a terminal moves through. Only ACTIVE can be used; see {@link isUsable}. */
|
|
7
|
+
export type TerminalState = Terminal["state"];
|
|
8
|
+
/** How a terminal is replicated. */
|
|
9
|
+
export type Redundancy = components["schemas"]["TerminalCreate"]["redundancy"];
|
|
10
|
+
/** What {@link Terminals.create} takes. */
|
|
11
|
+
export interface CreateTerminal {
|
|
12
|
+
/** The broker, from the catalogue. */
|
|
13
|
+
readonly brokerId: string;
|
|
14
|
+
/** The broker's server the terminal logs in to. */
|
|
15
|
+
readonly serverId: string;
|
|
16
|
+
/** Required: the same key sent again returns the same terminal, not a second one. */
|
|
17
|
+
readonly idempotencyKey: string;
|
|
18
|
+
/** Leave it out and the platform chooses a farm; the one it chose is on the answer. */
|
|
19
|
+
readonly farmId?: string;
|
|
20
|
+
readonly streaming?: boolean;
|
|
21
|
+
readonly execution?: boolean;
|
|
22
|
+
/** The broker's own symbol names (`EURUSD.sd`). */
|
|
23
|
+
readonly instruments?: readonly string[];
|
|
24
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
25
|
+
readonly redundancy?: Redundancy;
|
|
26
|
+
}
|
|
27
|
+
/** What {@link Terminals.page} takes. */
|
|
28
|
+
export interface PageOptions {
|
|
29
|
+
/** The `nextCursor` of the page before; leave it out for the first page. */
|
|
30
|
+
readonly cursor?: string;
|
|
31
|
+
/** How many to a page, 1 to 200. */
|
|
32
|
+
readonly limit?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Can this terminal be used right now: market data, execution, account reads? True only in ACTIVE.
|
|
36
|
+
* OFFLINE means its cell is not reporting; the platform brings it back by itself, so retry rather
|
|
37
|
+
* than create another.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isUsable(terminal: Pick<Terminal, "state">): boolean;
|
|
40
|
+
/** Provision and inspect the terminals this key's tenant owns. */
|
|
41
|
+
export declare class Terminals {
|
|
42
|
+
#private;
|
|
43
|
+
constructor(client: MfxClient);
|
|
44
|
+
/** One terminal: its state, since when, and its observed conditions. Another tenant's is 404. */
|
|
45
|
+
get(terminalId: string): Promise<Terminal>;
|
|
46
|
+
/** One page of this tenant's terminals, newest first. One request. */
|
|
47
|
+
page(options?: PageOptions): Promise<Page<Terminal>>;
|
|
48
|
+
/** Every terminal, following the cursor, one request a page. There is no total. */
|
|
49
|
+
list(options?: Pick<PageOptions, "limit">): AsyncGenerator<Terminal, void, undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Ask for a terminal. Answered at once in state REQUESTED; the move to a cell happens after.
|
|
52
|
+
* Sent once and never retried: if the answer is lost, send the same idempotency key again.
|
|
53
|
+
*/
|
|
54
|
+
create(request: CreateTerminal): Promise<Terminal>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `client.terminals`: a tenant's terminals.
|
|
2
|
+
//
|
|
3
|
+
// The slice a tenant's first hour is made of: ask for a terminal, watch it reach ACTIVE, read it.
|
|
4
|
+
// Every write takes an idempotency key, and it has no default.
|
|
5
|
+
//
|
|
6
|
+
// DECISION: options are camelCase, as TypeScript callers write them, and what comes back is the
|
|
7
|
+
// API's own object, typed from the contract, with its field names as the API sends them
|
|
8
|
+
// (`state_since`, `created_at`). Renaming every field would put a hand-written copy of the
|
|
9
|
+
// contract between the caller and the API, and it would drift. The Page wrapper is the one
|
|
10
|
+
// exception, because it is the SDK's own type, not the API's.
|
|
11
|
+
import { idempotencyKey, limit as pageLimit, uuid } from "./arguments.js";
|
|
12
|
+
import { everyItem, pageOf } from "./pages.js";
|
|
13
|
+
/**
|
|
14
|
+
* Can this terminal be used right now: market data, execution, account reads? True only in ACTIVE.
|
|
15
|
+
* OFFLINE means its cell is not reporting; the platform brings it back by itself, so retry rather
|
|
16
|
+
* than create another.
|
|
17
|
+
*/
|
|
18
|
+
export function isUsable(terminal) {
|
|
19
|
+
return terminal.state === "ACTIVE";
|
|
20
|
+
}
|
|
21
|
+
/** Provision and inspect the terminals this key's tenant owns. */
|
|
22
|
+
export class Terminals {
|
|
23
|
+
#client;
|
|
24
|
+
constructor(client) {
|
|
25
|
+
this.#client = client;
|
|
26
|
+
}
|
|
27
|
+
/** One terminal: its state, since when, and its observed conditions. Another tenant's is 404. */
|
|
28
|
+
async get(terminalId) {
|
|
29
|
+
const id = uuid("terminalId", terminalId);
|
|
30
|
+
return (await this.#client.read({ method: "GET", path: `/terminals/${id}` }));
|
|
31
|
+
}
|
|
32
|
+
/** One page of this tenant's terminals, newest first. One request. */
|
|
33
|
+
async page(options = {}) {
|
|
34
|
+
const limit = pageLimit(options.limit);
|
|
35
|
+
if (options.cursor !== undefined && typeof options.cursor !== "string") {
|
|
36
|
+
throw new TypeError("cursor is the nextCursor of the page before");
|
|
37
|
+
}
|
|
38
|
+
const body = await this.#client.read({
|
|
39
|
+
method: "GET",
|
|
40
|
+
path: "/terminals",
|
|
41
|
+
query: { cursor: options.cursor, limit },
|
|
42
|
+
});
|
|
43
|
+
return pageOf(body);
|
|
44
|
+
}
|
|
45
|
+
/** Every terminal, following the cursor, one request a page. There is no total. */
|
|
46
|
+
list(options = {}) {
|
|
47
|
+
const limit = pageLimit(options.limit);
|
|
48
|
+
return everyItem((cursor) => this.page({
|
|
49
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
50
|
+
...(limit === undefined ? {} : { limit }),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Ask for a terminal. Answered at once in state REQUESTED; the move to a cell happens after.
|
|
55
|
+
* Sent once and never retried: if the answer is lost, send the same idempotency key again.
|
|
56
|
+
*/
|
|
57
|
+
async create(request) {
|
|
58
|
+
const key = idempotencyKey(request.idempotencyKey);
|
|
59
|
+
const body = {
|
|
60
|
+
broker_id: uuid("brokerId", request.brokerId),
|
|
61
|
+
server_id: uuid("serverId", request.serverId),
|
|
62
|
+
streaming: request.streaming ?? false,
|
|
63
|
+
execution: request.execution ?? false,
|
|
64
|
+
redundancy: request.redundancy ?? "none",
|
|
65
|
+
...(request.farmId === undefined ? {} : { farm_id: uuid("farmId", request.farmId) }),
|
|
66
|
+
...(request.instruments === undefined ? {} : { instruments: [...request.instruments] }),
|
|
67
|
+
...(request.labels === undefined ? {} : { labels: { ...request.labels } }),
|
|
68
|
+
};
|
|
69
|
+
return (await this.#client.write({
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: "/terminals",
|
|
72
|
+
body,
|
|
73
|
+
idempotencyKey: key,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mfxhub/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Typed TypeScript client for the MFX Hub API: hosted trading terminals, market data, execution and a live event stream",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"SECURITY.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"keywords": [
|
|
27
|
+
"mfx",
|
|
28
|
+
"mfx-hub",
|
|
29
|
+
"trading",
|
|
30
|
+
"market-data",
|
|
31
|
+
"trading-api"
|
|
32
|
+
],
|
|
33
|
+
"homepage": "https://docs.mfxhub.com",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/mfx-platform/mfx-sdk-typescript.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/mfx-platform/mfx-sdk-typescript/issues"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"format": "prettier --check .",
|
|
46
|
+
"test": "vitest run --coverage",
|
|
47
|
+
"test:conformance": "vitest run --config vitest.conformance.config.ts",
|
|
48
|
+
"gen": "openapi-typescript spec/openapi.yaml -o src/generated/api.ts && prettier --write src/generated/api.ts",
|
|
49
|
+
"check:gen": "node tools/check-generated.mjs"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"ws": "^8.22.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@eslint/js": "^10.0.1",
|
|
56
|
+
"@types/node": "^22.20.5",
|
|
57
|
+
"@types/ws": "^8.18.2",
|
|
58
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
59
|
+
"eslint": "^10.13.0",
|
|
60
|
+
"openapi-typescript": "^7.13.0",
|
|
61
|
+
"prettier": "^3.9.10",
|
|
62
|
+
"typescript": "^5.9.3",
|
|
63
|
+
"typescript-eslint": "^8.72.1",
|
|
64
|
+
"vitest": "^5.0.5"
|
|
65
|
+
}
|
|
9
66
|
}
|