@flexemarkets/fm-sdk 0.0.11 → 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/dist/client.d.ts +213 -15
- package/dist/client.js +486 -62
- package/dist/client.js.map +1 -1
- package/dist/hal.d.ts +15 -0
- package/dist/hal.js +14 -0
- package/dist/hal.js.map +1 -0
- package/dist/index.d.ts +5 -7
- package/dist/index.js +3 -8
- package/dist/index.js.map +1 -1
- package/dist/market-view.d.ts +9 -7
- package/dist/market-view.js +25 -18
- package/dist/market-view.js.map +1 -1
- package/dist/order-utils.d.ts +4 -4
- package/dist/order-utils.js +10 -6
- package/dist/order-utils.js.map +1 -1
- package/dist/stomp.d.ts +32 -5
- package/dist/stomp.js +37 -16
- package/dist/stomp.js.map +1 -1
- package/dist/ticker.js +3 -3
- package/dist/ticker.js.map +1 -1
- package/dist/timestamps.d.ts +30 -0
- package/dist/timestamps.js +39 -0
- package/dist/timestamps.js.map +1 -0
- package/dist/types.d.ts +144 -22
- package/dist/types.js +93 -14
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -7,8 +7,10 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { basename, join } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { orderedSecurities, toOrderType, toSide, unitGrid } from "./types.js";
|
|
11
|
+
import { toInstant } from "./timestamps.js";
|
|
10
12
|
import { EventListener, NO_SEQ } from "./stomp.js";
|
|
11
|
-
import { DefaultMarketView, MarketViewHandle } from "./market-view.js";
|
|
13
|
+
import { DefaultMarketView, MarketViewHandle, } from "./market-view.js";
|
|
12
14
|
function readVersion() {
|
|
13
15
|
try {
|
|
14
16
|
const dir = fileURLToPath(new URL(".", import.meta.url));
|
|
@@ -37,7 +39,65 @@ export class ConnectionFailedError extends FlexemarketsError {
|
|
|
37
39
|
}
|
|
38
40
|
export class ConfigurationError extends FlexemarketsError {
|
|
39
41
|
}
|
|
40
|
-
|
|
42
|
+
/**
|
|
43
|
+
* A response the SDK has no better name for, carrying its status and body.
|
|
44
|
+
*
|
|
45
|
+
* The fallback. A status with a meaning worth acting on gets its own type —
|
|
46
|
+
* {@link AuthenticationError}, {@link ConflictError} — and this is what is
|
|
47
|
+
* left, so a caller can read the status rather than parse a message.
|
|
48
|
+
*/
|
|
49
|
+
export class HttpError extends FlexemarketsError {
|
|
50
|
+
statusCode;
|
|
51
|
+
body;
|
|
52
|
+
constructor(statusCode, body) {
|
|
53
|
+
super(`HTTP ${statusCode}: ${body}`);
|
|
54
|
+
this.statusCode = statusCode;
|
|
55
|
+
this.body = body;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The call could not be completed: the transport failed, or the response was
|
|
60
|
+
* not something the SDK could read.
|
|
61
|
+
*
|
|
62
|
+
* Distinct from {@link HttpError}, which means the server answered and the
|
|
63
|
+
* answer was an error. This means there was no usable answer at all — a
|
|
64
|
+
* malformed body, or a link the API root does not carry.
|
|
65
|
+
*/
|
|
66
|
+
export class ApiError extends FlexemarketsError {
|
|
67
|
+
}
|
|
68
|
+
/** A 409. The Java and Python SDKs have raised this since the admin surface landed. */
|
|
69
|
+
export class ConflictError extends FlexemarketsError {
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An account name was taken, and the server proposed another.
|
|
73
|
+
*
|
|
74
|
+
* A subclass of {@link ConflictError} rather than a sibling, so a caller that
|
|
75
|
+
* handles conflicts generally still catches this one. The suggestion is worth
|
|
76
|
+
* surfacing rather than retrying blindly: it is the name the account would end
|
|
77
|
+
* up known by.
|
|
78
|
+
*/
|
|
79
|
+
export class AccountNameConflictError extends ConflictError {
|
|
80
|
+
requestedName;
|
|
81
|
+
suggestedName;
|
|
82
|
+
constructor(message, requestedName, suggestedName) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.requestedName = requestedName;
|
|
85
|
+
this.suggestedName = suggestedName;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A user could not be deleted because they still own marketplace data —
|
|
90
|
+
* orders or allotments. Deleting them would orphan it, so the server refuses;
|
|
91
|
+
* the caller has to decide what happens to the data first.
|
|
92
|
+
*/
|
|
93
|
+
export class PersonHasMarketplaceDataError extends ConflictError {
|
|
94
|
+
userId;
|
|
95
|
+
constructor(message, userId) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.userId = userId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function parsePerson(data) {
|
|
41
101
|
if (!data)
|
|
42
102
|
return null;
|
|
43
103
|
return {
|
|
@@ -48,11 +108,11 @@ function parsePerson(data) {
|
|
|
48
108
|
email: data.email ?? null,
|
|
49
109
|
roles: data.roles ?? [],
|
|
50
110
|
accountOwner: data.accountOwner ?? false,
|
|
51
|
-
createdDate: data.createdDate
|
|
52
|
-
lastModifiedDate: data.lastModifiedDate
|
|
111
|
+
createdDate: toInstant(data.createdDate),
|
|
112
|
+
lastModifiedDate: toInstant(data.lastModifiedDate),
|
|
53
113
|
};
|
|
54
114
|
}
|
|
55
|
-
function parseAccount(data) {
|
|
115
|
+
export function parseAccount(data) {
|
|
56
116
|
if (!data)
|
|
57
117
|
return null;
|
|
58
118
|
return {
|
|
@@ -60,13 +120,13 @@ function parseAccount(data) {
|
|
|
60
120
|
name: data.name ?? null,
|
|
61
121
|
description: data.description ?? null,
|
|
62
122
|
owner: parsePerson(data.owner),
|
|
63
|
-
approval: data.approval ??
|
|
123
|
+
approval: data.approval ?? null,
|
|
64
124
|
approvalDescription: data.approvalDescription ?? null,
|
|
65
|
-
createdDate: data.createdDate
|
|
66
|
-
lastModifiedDate: data.lastModifiedDate
|
|
125
|
+
createdDate: toInstant(data.createdDate),
|
|
126
|
+
lastModifiedDate: toInstant(data.lastModifiedDate),
|
|
67
127
|
};
|
|
68
128
|
}
|
|
69
|
-
function parseToken(data) {
|
|
129
|
+
export function parseToken(data) {
|
|
70
130
|
return {
|
|
71
131
|
requestUrl: data.requestUrl ?? null,
|
|
72
132
|
person: parsePerson(data.person),
|
|
@@ -74,7 +134,7 @@ function parseToken(data) {
|
|
|
74
134
|
token: data.token ?? null,
|
|
75
135
|
};
|
|
76
136
|
}
|
|
77
|
-
function parseSecurity(data) {
|
|
137
|
+
export function parseSecurity(data) {
|
|
78
138
|
return {
|
|
79
139
|
marketId: data.marketId ?? 0,
|
|
80
140
|
units: data.units ?? 0,
|
|
@@ -85,7 +145,7 @@ function parseSecurity(data) {
|
|
|
85
145
|
canSell: data.canSell ?? false,
|
|
86
146
|
};
|
|
87
147
|
}
|
|
88
|
-
function parseMarket(data) {
|
|
148
|
+
export function parseMarket(data) {
|
|
89
149
|
return {
|
|
90
150
|
id: data.id ?? 0,
|
|
91
151
|
marketplaceId: data.marketplaceId ?? 0,
|
|
@@ -101,7 +161,7 @@ function parseMarket(data) {
|
|
|
101
161
|
unitTick: data.unitTick ?? 0,
|
|
102
162
|
};
|
|
103
163
|
}
|
|
104
|
-
function parseMarketplace(data) {
|
|
164
|
+
export function parseMarketplace(data) {
|
|
105
165
|
return {
|
|
106
166
|
id: data.id ?? 0,
|
|
107
167
|
name: data.name ?? null,
|
|
@@ -109,7 +169,7 @@ function parseMarketplace(data) {
|
|
|
109
169
|
markets: (data.markets ?? []).map(parseMarket),
|
|
110
170
|
};
|
|
111
171
|
}
|
|
112
|
-
function parseSession(data) {
|
|
172
|
+
export function parseSession(data) {
|
|
113
173
|
return {
|
|
114
174
|
marketplaceId: data.marketplaceId ?? 0,
|
|
115
175
|
allocationId: data.allocationId ?? 0,
|
|
@@ -118,8 +178,8 @@ function parseSession(data) {
|
|
|
118
178
|
state: data.state ?? null,
|
|
119
179
|
name: data.name ?? null,
|
|
120
180
|
description: data.description ?? null,
|
|
121
|
-
openDate: data.openDate
|
|
122
|
-
closeDate: data.closeDate
|
|
181
|
+
openDate: toInstant(data.openDate),
|
|
182
|
+
closeDate: toInstant(data.closeDate),
|
|
123
183
|
};
|
|
124
184
|
}
|
|
125
185
|
export function parseOrder(data) {
|
|
@@ -128,8 +188,8 @@ export function parseOrder(data) {
|
|
|
128
188
|
original: data.original ?? 0,
|
|
129
189
|
supplier: data.supplier ?? 0,
|
|
130
190
|
consumer: data.consumer ?? null,
|
|
131
|
-
type: data.type
|
|
132
|
-
side: data.side
|
|
191
|
+
type: toOrderType(data.type),
|
|
192
|
+
side: toSide(data.side),
|
|
133
193
|
units: data.units ?? 0,
|
|
134
194
|
price: data.price ?? 0,
|
|
135
195
|
ownerId: data.ownerId ?? null,
|
|
@@ -139,8 +199,8 @@ export function parseOrder(data) {
|
|
|
139
199
|
marketId: data.marketId ?? 0,
|
|
140
200
|
ownerTarget: data.ownerTarget ?? null,
|
|
141
201
|
clientDescription: data.clientDescription ?? null,
|
|
142
|
-
createdDate: data.createdDate
|
|
143
|
-
lastModifiedDate: data.lastModifiedDate
|
|
202
|
+
createdDate: toInstant(data.createdDate),
|
|
203
|
+
lastModifiedDate: toInstant(data.lastModifiedDate),
|
|
144
204
|
};
|
|
145
205
|
}
|
|
146
206
|
function parseAllotment(data) {
|
|
@@ -154,7 +214,7 @@ function parseAllotment(data) {
|
|
|
154
214
|
id: assetsRaw.id ?? null,
|
|
155
215
|
name: assetsRaw.name ?? null,
|
|
156
216
|
cash: assetsRaw.cash ?? 0,
|
|
157
|
-
securities: securitiesRaw.map(parseSecurity),
|
|
217
|
+
securities: orderedSecurities(securitiesRaw.map(parseSecurity)),
|
|
158
218
|
};
|
|
159
219
|
}
|
|
160
220
|
return {
|
|
@@ -222,20 +282,35 @@ export function parseHolding(data) {
|
|
|
222
282
|
name: data.name ?? null,
|
|
223
283
|
cash: data.cash ?? 0,
|
|
224
284
|
availableCash: data.availableCash ?? 0,
|
|
225
|
-
securities: securitiesRaw.map(parseSecurity),
|
|
285
|
+
securities: orderedSecurities(securitiesRaw.map(parseSecurity)),
|
|
226
286
|
};
|
|
227
287
|
}
|
|
228
|
-
function parseConnection(data) {
|
|
288
|
+
export function parseConnection(data) {
|
|
229
289
|
return {
|
|
230
290
|
marketplaceId: data.marketplaceId ?? 0,
|
|
231
291
|
connectionId: data.id ?? data.connectionId ?? 0,
|
|
232
292
|
ownerId: data.ownerId ?? 0,
|
|
233
|
-
established: data.established
|
|
234
|
-
terminated: data.terminated
|
|
293
|
+
established: toInstant(data.established),
|
|
294
|
+
terminated: toInstant(data.terminated),
|
|
235
295
|
description: data.description ?? null,
|
|
236
296
|
sessionId: data.sessionId ?? null,
|
|
237
297
|
};
|
|
238
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* The orders inside a HAL envelope.
|
|
301
|
+
*
|
|
302
|
+
* The server embeds them under `orders`. It was `orderDtoes` — Spring HATEOAS
|
|
303
|
+
* pluralising `OrderDto` — and every SDK still read that name long after the
|
|
304
|
+
* server stopped sending it, so `activeOrders` and `recentTrades` returned an
|
|
305
|
+
* empty array always. `MarketView` seeds from `activeOrders`, so its books were
|
|
306
|
+
* never seeded; they filled from live deltas and looked plausible.
|
|
307
|
+
*
|
|
308
|
+
* Both names are accepted, so an older server still works.
|
|
309
|
+
*/
|
|
310
|
+
function embeddedOrders(data) {
|
|
311
|
+
const embedded = data._embedded;
|
|
312
|
+
return embedded?.orders ?? embedded?.orderDtoes ?? [];
|
|
313
|
+
}
|
|
239
314
|
function parseApiRoot(data) {
|
|
240
315
|
const linksRaw = data._links ?? {};
|
|
241
316
|
const links = {};
|
|
@@ -249,6 +324,84 @@ function parseApiRoot(data) {
|
|
|
249
324
|
}
|
|
250
325
|
return { links };
|
|
251
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* The most aggressive price this market will accept on `side`.
|
|
329
|
+
*
|
|
330
|
+
* Ticks are anchored at `priceMinimum`, not at zero — the server tests
|
|
331
|
+
* `(price - priceMinimum) % priceTick` — so the top of the range is only legal
|
|
332
|
+
* when the range is a whole number of ticks. The highest legal price is the
|
|
333
|
+
* last tick at or below `priceMaximum`. A tick of zero marks a fixed dimension,
|
|
334
|
+
* where the two bounds are equal and there is one legal price.
|
|
335
|
+
*/
|
|
336
|
+
export function marketableLimit(market, side) {
|
|
337
|
+
if (side?.toUpperCase() !== "BUY" || market.priceTick <= 0) {
|
|
338
|
+
return market.priceMinimum;
|
|
339
|
+
}
|
|
340
|
+
const span = market.priceMaximum - market.priceMinimum;
|
|
341
|
+
return market.priceMinimum + Math.floor(span / market.priceTick) * market.priceTick;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The `scheme://host:port` of an absolute http(s) URL, else undefined.
|
|
345
|
+
*
|
|
346
|
+
* A relative href already resolves against the origin it was fetched from,
|
|
347
|
+
* and a scheme that is not HTTP is not ours to rewrite.
|
|
348
|
+
*/
|
|
349
|
+
function httpOrigin(url) {
|
|
350
|
+
if (!url)
|
|
351
|
+
return undefined;
|
|
352
|
+
const end = url.indexOf("://");
|
|
353
|
+
if (end < 0)
|
|
354
|
+
return undefined;
|
|
355
|
+
const scheme = url.substring(0, end).toLowerCase();
|
|
356
|
+
if (scheme !== "http" && scheme !== "https")
|
|
357
|
+
return undefined;
|
|
358
|
+
const pathStart = url.indexOf("/", end + 3);
|
|
359
|
+
return pathStart < 0 ? url : url.substring(0, pathStart);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Point the API root's links back at the host that was dialled.
|
|
363
|
+
*
|
|
364
|
+
* The server builds these hrefs from the request it believes it received, and
|
|
365
|
+
* behind a proxy that belief can be wrong: an origin reached over a plaintext
|
|
366
|
+
* leg reports `http://` even though the caller arrived on `https://`. Every
|
|
367
|
+
* call that goes through a link — which is most of them — then leaves on plain
|
|
368
|
+
* HTTP and meets the edge's redirect. A GET survives it. A POST does not: a
|
|
369
|
+
* 301 is followed as a GET with the body dropped, so placing an order or
|
|
370
|
+
* opening a session fails with nothing placed and nothing pointing at the
|
|
371
|
+
* scheme.
|
|
372
|
+
*
|
|
373
|
+
* Only the origin is replaced. The path, query and any URI template are the
|
|
374
|
+
* server's to choose; where it is reachable is not, and the token in hand was
|
|
375
|
+
* issued by the origin dialled, not by whatever the links name.
|
|
376
|
+
*/
|
|
377
|
+
export function rebaseApiRoot(root, endpoint) {
|
|
378
|
+
const origin = httpOrigin(endpoint);
|
|
379
|
+
if (!origin)
|
|
380
|
+
return root;
|
|
381
|
+
const links = {};
|
|
382
|
+
const moved = [];
|
|
383
|
+
for (const [name, href] of Object.entries(root.links)) {
|
|
384
|
+
const named = httpOrigin(href);
|
|
385
|
+
if (!named || named === origin) {
|
|
386
|
+
links[name] = href;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (!moved.includes(named))
|
|
390
|
+
moved.push(named);
|
|
391
|
+
links[name] = origin + href.substring(named.length);
|
|
392
|
+
}
|
|
393
|
+
if (moved.length > 0) {
|
|
394
|
+
// Said out loud, because the rewrite would otherwise hide a deployment
|
|
395
|
+
// that is genuinely misconfigured — and a silent correction here is how it
|
|
396
|
+
// stays misconfigured. The SDK keeps working; the operator still gets told
|
|
397
|
+
// where to look.
|
|
398
|
+
console.warn(`[fm-sdk] The API root names ${moved.join(", ")} but this client dialled ${origin}; ` +
|
|
399
|
+
`rewriting ${moved.length} link origin(s) to match. The server is behind a proxy ` +
|
|
400
|
+
`that is not forwarding the request scheme, so its links are wrong. Fix it at the ` +
|
|
401
|
+
`edge — this rewrite only keeps calls working.`);
|
|
402
|
+
}
|
|
403
|
+
return { links };
|
|
404
|
+
}
|
|
252
405
|
// ---------------------------------------------------------------------------
|
|
253
406
|
// HATEOAS link resolution
|
|
254
407
|
// ---------------------------------------------------------------------------
|
|
@@ -256,10 +409,20 @@ function processTemplate(href) {
|
|
|
256
409
|
const idx = href.indexOf("{");
|
|
257
410
|
return idx >= 0 ? href.substring(0, idx) : href;
|
|
258
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* A V1 route, addressed from the server rather than through a HAL link.
|
|
414
|
+
*
|
|
415
|
+
* V1 is flat and versioned: the path is knowable without fetching the API root
|
|
416
|
+
* first, which is the point of it. Every call that moves here loses a HAL
|
|
417
|
+
* dependency as well as a version.
|
|
418
|
+
*/
|
|
419
|
+
function v1(endpoint, path) {
|
|
420
|
+
return `${server(endpoint)}/v1${path}`;
|
|
421
|
+
}
|
|
259
422
|
function uri(root, linkName) {
|
|
260
423
|
const href = root.links[linkName];
|
|
261
424
|
if (href === undefined)
|
|
262
|
-
throw new
|
|
425
|
+
throw new ApiError(`Link '${linkName}' not found in API root.`);
|
|
263
426
|
return processTemplate(href);
|
|
264
427
|
}
|
|
265
428
|
function uriId(root, linkName, id) {
|
|
@@ -369,10 +532,25 @@ function checkResponse(response, body) {
|
|
|
369
532
|
throw new AuthenticationError(body);
|
|
370
533
|
if (status === 403)
|
|
371
534
|
throw new AuthorizationError(body);
|
|
535
|
+
if (status === 409)
|
|
536
|
+
throw new ConflictError(body);
|
|
372
537
|
if (status >= 500)
|
|
373
538
|
throw new ConnectionFailedError(body);
|
|
374
|
-
throw new
|
|
539
|
+
throw new HttpError(status, body);
|
|
540
|
+
}
|
|
541
|
+
/** The server's proposed alternative name, when a 409 body carries one. */
|
|
542
|
+
function suggestedNameIn(body) {
|
|
543
|
+
try {
|
|
544
|
+
const parsed = JSON.parse(body);
|
|
545
|
+
return parsed.suggestedName ?? null;
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
375
550
|
}
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Flexemarkets client
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
376
554
|
export class Flexemarkets {
|
|
377
555
|
_clientDescription;
|
|
378
556
|
_endpoint;
|
|
@@ -381,6 +559,7 @@ export class Flexemarkets {
|
|
|
381
559
|
_apiRoot;
|
|
382
560
|
_account;
|
|
383
561
|
_user;
|
|
562
|
+
_tokenObj;
|
|
384
563
|
_eventListener = null;
|
|
385
564
|
constructor(endpoint, baseUrl, bearerToken, clientDescription) {
|
|
386
565
|
this._endpoint = endpoint;
|
|
@@ -412,6 +591,7 @@ export class Flexemarkets {
|
|
|
412
591
|
const tokenObj = await signIn(baseUrl, config, desc);
|
|
413
592
|
const bearer = `Bearer ${tokenObj.token}`;
|
|
414
593
|
const fm = new Flexemarkets(ep, baseUrl, bearer, desc);
|
|
594
|
+
fm._tokenObj = tokenObj;
|
|
415
595
|
fm._account = tokenObj.account;
|
|
416
596
|
fm._user = tokenObj.person;
|
|
417
597
|
// Fetch API root for HATEOAS links
|
|
@@ -525,9 +705,159 @@ export class Flexemarkets {
|
|
|
525
705
|
checkResponse(resp, body);
|
|
526
706
|
return JSON.parse(body);
|
|
527
707
|
}
|
|
708
|
+
// -- administration --------------------------------------------------------
|
|
709
|
+
/*
|
|
710
|
+
* Creating accounts and users, approving them, deleting them, and minting
|
|
711
|
+
* one-time passcodes. fm-server's administrative surface, carried here so
|
|
712
|
+
* that the tools which run a course have a client that is not fm-lib-net.
|
|
713
|
+
*
|
|
714
|
+
* Several are destructive and one issues credentials. They need an admin or
|
|
715
|
+
* manager and the server answers 401/403 otherwise, which is the only
|
|
716
|
+
* guard: possessing the method is not possessing the right.
|
|
717
|
+
*/
|
|
718
|
+
/**
|
|
719
|
+
* Register a new account and its owner, returning the owner's token.
|
|
720
|
+
*
|
|
721
|
+
* The owner's credentials go out as `ownerEmail`/`ownerPassword`. Sending
|
|
722
|
+
* `email`/`password` instead creates an account with an owner the server
|
|
723
|
+
* cannot sign in as.
|
|
724
|
+
*/
|
|
725
|
+
async signup(accountName, email, password, firstName, lastName) {
|
|
726
|
+
const url = uri(this._apiRoot, "accounts");
|
|
727
|
+
try {
|
|
728
|
+
const data = await this._post(url, {
|
|
729
|
+
accountName,
|
|
730
|
+
ownerEmail: email,
|
|
731
|
+
ownerPassword: password,
|
|
732
|
+
firstName: firstName ?? null,
|
|
733
|
+
lastName: lastName ?? null,
|
|
734
|
+
});
|
|
735
|
+
return parseToken(data);
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
// A taken name, with the server's proposed alternative. Raised as its
|
|
739
|
+
// own type so a caller can offer the suggestion rather than parsing it
|
|
740
|
+
// back out of a generic conflict.
|
|
741
|
+
if (e instanceof ConflictError) {
|
|
742
|
+
const suggested = suggestedNameIn(e.message);
|
|
743
|
+
throw new AccountNameConflictError(`Account name '${accountName}' is taken` +
|
|
744
|
+
(suggested === null ? "" : `; server suggests '${suggested}'`), accountName, suggested);
|
|
745
|
+
}
|
|
746
|
+
throw e;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** Approve an account by name, returning it as it now stands. */
|
|
750
|
+
async approveAccount(accountName) {
|
|
751
|
+
const url = `${server(this._endpoint)}/approvals`;
|
|
752
|
+
const data = await this._post(url, { name: accountName, approval: true });
|
|
753
|
+
return parseAccount(data.account);
|
|
754
|
+
}
|
|
755
|
+
/** One account by id. */
|
|
756
|
+
async accountById(accountId) {
|
|
757
|
+
return parseAccount(await this._get(uriId(this._apiRoot, "accounts", accountId)));
|
|
758
|
+
}
|
|
759
|
+
/** One user by id. */
|
|
760
|
+
async userById(userId) {
|
|
761
|
+
return parsePerson(await this._get(v1(this._endpoint, `/users/${userId}`)));
|
|
762
|
+
}
|
|
763
|
+
/** The marketplace's private-trader identifiers. */
|
|
764
|
+
async identifiers(marketplaceId) {
|
|
765
|
+
const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "privateTraders");
|
|
766
|
+
return (await this._get(url));
|
|
767
|
+
}
|
|
768
|
+
/** Delete the caller's own account. Its own route, not accounts/{yourId}. */
|
|
769
|
+
async deleteMyAccount() {
|
|
770
|
+
await this._delete(`${server(this._endpoint)}/accounts/me`);
|
|
771
|
+
}
|
|
772
|
+
/** Every account on the server. Admin-only. */
|
|
773
|
+
async accounts() {
|
|
774
|
+
const url = uriParam(this._apiRoot, "accounts", "format=application/json");
|
|
775
|
+
const data = await this._get(url);
|
|
776
|
+
return data.map(parseAccount);
|
|
777
|
+
}
|
|
778
|
+
/** Delete an account. Destructive, and takes its users with it. */
|
|
779
|
+
async deleteAccount(accountId) {
|
|
780
|
+
await this._delete(uriId(this._apiRoot, "accounts", accountId));
|
|
781
|
+
}
|
|
782
|
+
/** Create a user in the caller's account. */
|
|
783
|
+
async createUser(email, password, firstName, lastName, roles = []) {
|
|
784
|
+
const url = v1(this._endpoint, "/users");
|
|
785
|
+
const data = await this._post(url, { email, password, firstName, lastName, roles });
|
|
786
|
+
return parsePerson(data);
|
|
787
|
+
}
|
|
788
|
+
/** Delete a user. Destructive. */
|
|
789
|
+
async deleteUser(userId) {
|
|
790
|
+
try {
|
|
791
|
+
await this._delete(uriId(this._apiRoot, "users", userId));
|
|
792
|
+
}
|
|
793
|
+
catch (e) {
|
|
794
|
+
// The user still owns orders or allotments. Deleting them would orphan
|
|
795
|
+
// it, so the server refuses and the caller has to decide what happens to
|
|
796
|
+
// the data first.
|
|
797
|
+
if (e instanceof ConflictError) {
|
|
798
|
+
throw new PersonHasMarketplaceDataError(`User ${userId} has marketplace data and cannot be deleted.`, userId);
|
|
799
|
+
}
|
|
800
|
+
throw e;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/** Create an empty marketplace. See also {@link createMarketplaceFromJson}. */
|
|
804
|
+
/** Delete a marketplace, and with it its sessions and their history. */
|
|
805
|
+
async deleteMarketplace(marketplaceId) {
|
|
806
|
+
await this._delete(uriId(this._apiRoot, "marketplaces", marketplaceId));
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Add a market to a marketplace.
|
|
810
|
+
*
|
|
811
|
+
* Both dimensions are the caller's. Unit bounds used to be fixed at 1/100/1
|
|
812
|
+
* with no way to say otherwise, on a call that set the price grid three
|
|
813
|
+
* arguments earlier — and the server enforces the two identically, refusing
|
|
814
|
+
* an order for "units is not on a tic" exactly as for a price. Omitting
|
|
815
|
+
* `units` keeps the old default.
|
|
816
|
+
*/
|
|
817
|
+
async createMarket(marketplaceId, symbol, name, price, units = unitGrid(), privateMarket = false) {
|
|
818
|
+
const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "markets");
|
|
819
|
+
return parseMarket(await this._post(url, {
|
|
820
|
+
symbol,
|
|
821
|
+
name,
|
|
822
|
+
priceMinimum: price.minimum,
|
|
823
|
+
priceMaximum: price.maximum,
|
|
824
|
+
priceTick: price.tick,
|
|
825
|
+
unitMinimum: units.minimum,
|
|
826
|
+
unitMaximum: units.maximum,
|
|
827
|
+
unitTick: units.tick,
|
|
828
|
+
privateMarket,
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Mint one-time passcodes for the given users.
|
|
833
|
+
*
|
|
834
|
+
* These are credentials: not to be logged, not to be persisted, and
|
|
835
|
+
* delivered to the person they belong to.
|
|
836
|
+
*/
|
|
837
|
+
async managerOtpBundle(userIds) {
|
|
838
|
+
const url = `${server(this._endpoint)}/otp/manager`;
|
|
839
|
+
const data = await this._post(url, { userIds });
|
|
840
|
+
return {
|
|
841
|
+
expiresAt: toInstant(data.expiresAt),
|
|
842
|
+
otps: (data.otps ?? []).map((o) => ({
|
|
843
|
+
userId: o.userId ?? 0,
|
|
844
|
+
email: o.email ?? null,
|
|
845
|
+
otp: o.otp ?? null,
|
|
846
|
+
})),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/** DELETE, whose answer is a status and nothing worth parsing. */
|
|
850
|
+
async _delete(url) {
|
|
851
|
+
const resp = await fetch(url.startsWith("/") ? `${this._baseUrl}${url}` : url, {
|
|
852
|
+
method: "DELETE",
|
|
853
|
+
headers: { ...this._authHeaders(), Accept: "application/json" },
|
|
854
|
+
});
|
|
855
|
+
const body = await resp.text();
|
|
856
|
+
checkResponse(resp, body);
|
|
857
|
+
}
|
|
528
858
|
async _fetchApiRoot() {
|
|
529
859
|
const data = await this._get(this._baseUrl);
|
|
530
|
-
return parseApiRoot(data);
|
|
860
|
+
return rebaseApiRoot(parseApiRoot(data), this._baseUrl);
|
|
531
861
|
}
|
|
532
862
|
// ======================================================================
|
|
533
863
|
// REST APIs
|
|
@@ -548,21 +878,50 @@ export class Flexemarkets {
|
|
|
548
878
|
const data = await this._get(url);
|
|
549
879
|
return data.map(parseMarket);
|
|
550
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* The token this connection signed in with.
|
|
883
|
+
*
|
|
884
|
+
* Exposed so a caller can open a sibling connection on the same identity
|
|
885
|
+
* without holding the password again.
|
|
886
|
+
*/
|
|
887
|
+
token() {
|
|
888
|
+
return this._tokenObj;
|
|
889
|
+
}
|
|
890
|
+
/** Whether this connection's user holds ROLE_ADMIN. */
|
|
891
|
+
isAdmin() {
|
|
892
|
+
return this.hasRole("ROLE_ADMIN");
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Whether this connection's user holds ROLE_MANAGER — the role that runs a
|
|
896
|
+
* study: opening and closing sessions, staging allocations, minting
|
|
897
|
+
* passcodes. Python has had it since the management surface landed.
|
|
898
|
+
*/
|
|
899
|
+
isManager() {
|
|
900
|
+
return this.hasRole("ROLE_MANAGER");
|
|
901
|
+
}
|
|
902
|
+
hasRole(role) {
|
|
903
|
+
return (this._user?.roles ?? []).includes(role);
|
|
904
|
+
}
|
|
551
905
|
async symbols(marketplaceId) {
|
|
552
906
|
const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "symbols");
|
|
553
907
|
return (await this._get(url));
|
|
554
908
|
}
|
|
555
909
|
// -- sessions --------------------------------------------------------------
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
910
|
+
/**
|
|
911
|
+
* The marketplace's sessions — all of them.
|
|
912
|
+
*
|
|
913
|
+
* There is no server-side filter. The route takes no session argument, so
|
|
914
|
+
* the `sessionIds` this used to accept was silently ignored: it returned the
|
|
915
|
+
* whole history and looked like it had filtered. Filter the result; fm-ui
|
|
916
|
+
* already does.
|
|
917
|
+
*
|
|
918
|
+
* On `GET /api/v1/marketplaces/{id}/sessions`, which answers with the same
|
|
919
|
+
* fields as the V0 route it replaces — verified against a running server,
|
|
920
|
+
* not assumed — and needs no `format=application/json` to avoid HAL.
|
|
921
|
+
*/
|
|
922
|
+
async sessions(marketplaceId) {
|
|
923
|
+
const url = v1(this._endpoint, `/marketplaces/${marketplaceId}/sessions`);
|
|
924
|
+
return (await this._get(url)).map(parseSession);
|
|
566
925
|
}
|
|
567
926
|
async session(marketplaceId) {
|
|
568
927
|
const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "currentSession");
|
|
@@ -582,6 +941,45 @@ export class Flexemarkets {
|
|
|
582
941
|
});
|
|
583
942
|
return parseOrder(data);
|
|
584
943
|
}
|
|
944
|
+
/**
|
|
945
|
+
* Cross the book: buy at the highest price this market allows, sell at the
|
|
946
|
+
* lowest. Immediate or cancel — whatever does not fill is cancelled.
|
|
947
|
+
*
|
|
948
|
+
* There is no market order on the server. Its type switch falls through to
|
|
949
|
+
* `LIMIT`, so every submission is bounds-checked against the market and must
|
|
950
|
+
* sit on a tick — which is why this asks the marketplace for the market
|
|
951
|
+
* first, and costs a round trip {@link submitLimit} does not.
|
|
952
|
+
*
|
|
953
|
+
* The cancel is unconditional: the exchange consumes a cancel by itself when
|
|
954
|
+
* no units remain, so a complete fill costs a harmless round trip rather than
|
|
955
|
+
* an inspection that would race the book. Without it, a market order that did
|
|
956
|
+
* not fill would rest at the market's extreme — the best price in the book,
|
|
957
|
+
* standing, for anyone to take.
|
|
958
|
+
*
|
|
959
|
+
* Returns the limit order as submitted. What it filled is a property of the
|
|
960
|
+
* book afterwards, not of this value.
|
|
961
|
+
*/
|
|
962
|
+
async submitMarket(marketplaceId, marketId, side, units) {
|
|
963
|
+
const market = await this._market(marketplaceId, marketId);
|
|
964
|
+
const limit = await this.submitLimit(marketplaceId, marketId, side, units, marketableLimit(market, side));
|
|
965
|
+
try {
|
|
966
|
+
await this.submitCancel(marketplaceId, marketId, limit.id);
|
|
967
|
+
}
|
|
968
|
+
catch (e) {
|
|
969
|
+
// The order is placed. Reporting only "cancel failed" would invite a
|
|
970
|
+
// caller to retry the whole thing and trade twice.
|
|
971
|
+
throw new FlexemarketsError(`Order ${limit.id} was placed but its remainder could not be cancelled; ` +
|
|
972
|
+
`it may still be resting. Do not resubmit — cancel it. (${String(e)})`);
|
|
973
|
+
}
|
|
974
|
+
return limit;
|
|
975
|
+
}
|
|
976
|
+
async _market(marketplaceId, marketId) {
|
|
977
|
+
for (const candidate of await this.markets(marketplaceId)) {
|
|
978
|
+
if (candidate.id === marketId)
|
|
979
|
+
return candidate;
|
|
980
|
+
}
|
|
981
|
+
throw new InvalidArgumentError(`Market ${marketId} is not in marketplace ${marketplaceId}`);
|
|
982
|
+
}
|
|
585
983
|
async submitCancel(marketplaceId, marketId, originalId) {
|
|
586
984
|
const url = uri(this._apiRoot, "orders");
|
|
587
985
|
const data = await this._post(url, {
|
|
@@ -596,28 +994,28 @@ export class Flexemarkets {
|
|
|
596
994
|
return parseOrder(data);
|
|
597
995
|
}
|
|
598
996
|
/**
|
|
599
|
-
*
|
|
997
|
+
* The active-orders snapshot: every resting limit order on the
|
|
600
998
|
* marketplace's current session, plus the `x-fm-as-of-seq` sequence
|
|
601
|
-
* the snapshot was read at. Used by `MarketView`
|
|
999
|
+
* the snapshot was read at. Used by `MarketView` seeding
|
|
602
1000
|
* — clients apply WS deltas whose seq is greater than the returned
|
|
603
1001
|
* value and skip those whose seq is less than or equal.
|
|
604
1002
|
*/
|
|
605
|
-
async
|
|
1003
|
+
async activeOrders(marketplaceId) {
|
|
606
1004
|
const baseRest = this._baseUrl;
|
|
607
1005
|
const url = `${baseRest}/v1/marketplaces/${marketplaceId}/orders/active`;
|
|
608
1006
|
const { data, asOfSeq } = await this._getSnapshot(url);
|
|
609
|
-
const orders = (data
|
|
1007
|
+
const orders = embeddedOrders(data).map(parseOrder);
|
|
610
1008
|
return { body: orders, asOfSeq };
|
|
611
1009
|
}
|
|
612
1010
|
/**
|
|
613
|
-
*
|
|
614
|
-
* Same `x-fm-as-of-seq` contract as `
|
|
1011
|
+
* The recent-trades snapshot, for seeding the trade-history tape.
|
|
1012
|
+
* Same `x-fm-as-of-seq` contract as `activeOrders`. Server caps
|
|
615
1013
|
* at 5000; default size is 1000.
|
|
616
1014
|
*/
|
|
617
|
-
async
|
|
1015
|
+
async recentTrades(marketplaceId, size = 1000) {
|
|
618
1016
|
const url = `${this._baseUrl}/v1/marketplaces/${marketplaceId}/orders/recent-trades?size=${size}`;
|
|
619
1017
|
const { data, asOfSeq } = await this._getSnapshot(url);
|
|
620
|
-
const orders = (data
|
|
1018
|
+
const orders = embeddedOrders(data).map(parseOrder);
|
|
621
1019
|
return { body: orders, asOfSeq };
|
|
622
1020
|
}
|
|
623
1021
|
async orders(marketplaceId, options) {
|
|
@@ -668,15 +1066,16 @@ export class Flexemarkets {
|
|
|
668
1066
|
return parseHolding(await this._get(url));
|
|
669
1067
|
}
|
|
670
1068
|
// -- connections -----------------------------------------------------------
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1069
|
+
/**
|
|
1070
|
+
* Who is attached to the marketplace — all of them.
|
|
1071
|
+
*
|
|
1072
|
+
* No server-side filter, for the reason {@link sessions} gives. A connection
|
|
1073
|
+
* carries the session it belonged to, so "who was present in that run" is a
|
|
1074
|
+
* filter on the result.
|
|
1075
|
+
*/
|
|
1076
|
+
async connections(marketplaceId) {
|
|
1077
|
+
const url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "connections", "format=application/json");
|
|
1078
|
+
return (await this._get(url)).map(parseConnection);
|
|
680
1079
|
}
|
|
681
1080
|
// -- management ------------------------------------------------------------
|
|
682
1081
|
//
|
|
@@ -839,6 +1238,27 @@ export class Flexemarkets {
|
|
|
839
1238
|
async listen(marketplaceId, callback) {
|
|
840
1239
|
this._eventListener = await this._connectEvents(marketplaceId, callback);
|
|
841
1240
|
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Open an *independent* event subscription, delivering to `callback` until
|
|
1243
|
+
* the returned unsubscribe function is invoked.
|
|
1244
|
+
*
|
|
1245
|
+
* Unlike {@link listen}, which is one per connection and replaces itself,
|
|
1246
|
+
* several of these coexist: each has its own stream and its own lifetime.
|
|
1247
|
+
* That is what lets more than one MarketView live in one connection without
|
|
1248
|
+
* trampling each other — the mechanism was already here for exactly that, as
|
|
1249
|
+
* the package-private `_connectEvents`, but a caller who wanted a second
|
|
1250
|
+
* stream of their own had no way to ask for one.
|
|
1251
|
+
*
|
|
1252
|
+
* Returns an unsubscribe function rather than an object with `close()`,
|
|
1253
|
+
* matching what MarketView's `on*` handlers already return here. Java
|
|
1254
|
+
* returns a `Subscription`; both names describe the same lifetime.
|
|
1255
|
+
*/
|
|
1256
|
+
async subscribe(marketplaceId, callback) {
|
|
1257
|
+
const listener = await this._connectEvents(marketplaceId, callback);
|
|
1258
|
+
return () => {
|
|
1259
|
+
void listener.close();
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
842
1262
|
/**
|
|
843
1263
|
* Package-private helper used by {@link DefaultMarketView} (Phase 2d)
|
|
844
1264
|
* to own its own EventListener subscription rather than clobbering
|
|
@@ -882,19 +1302,23 @@ export class Flexemarkets {
|
|
|
882
1302
|
async function signIn(baseUrl, config, clientDescription) {
|
|
883
1303
|
const tok = config.token ?? "";
|
|
884
1304
|
if (tok && isValidToken(tok)) {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1305
|
+
// A caller who already holds a token has no account/email/password to
|
|
1306
|
+
// present, so signing in is not available: POSTing /tokens with blanks is
|
|
1307
|
+
// rejected -- the server answers 400 MESSAGE_NOT_READABLE for an empty
|
|
1308
|
+
// password, which is what this used to send. Refreshing the token both
|
|
1309
|
+
// validates it and returns the account and person behind it.
|
|
1310
|
+
//
|
|
1311
|
+
// This is the third time. fm-lib-net carried the branch, an earlier rewrite
|
|
1312
|
+
// dropped it, and the Java SDK restored it with a test. This SDK and the
|
|
1313
|
+
// Python one never had it, so token auth returned 400 in both from the day
|
|
1314
|
+
// it was written.
|
|
1315
|
+
const resp = await fetch(`${baseUrl}/tokens/refresh`, {
|
|
1316
|
+
method: "GET",
|
|
888
1317
|
headers: {
|
|
889
1318
|
Authorization: `Bearer ${tok}`,
|
|
890
|
-
"Content-Type": "application/json",
|
|
891
1319
|
Accept: "application/json",
|
|
892
1320
|
"User-Agent": FM_NETWORK_CLIENT,
|
|
893
1321
|
},
|
|
894
|
-
body: JSON.stringify({
|
|
895
|
-
username: `${config.account ?? ""}|${config.email ?? ""}`,
|
|
896
|
-
password: "",
|
|
897
|
-
}),
|
|
898
1322
|
});
|
|
899
1323
|
const body = await resp.text();
|
|
900
1324
|
if (resp.status === 401) {
|