@flexemarkets/fm-sdk 0.0.10 → 0.1.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/dist/client.d.ts +221 -17
- package/dist/client.js +504 -65
- 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 +151 -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,19 +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,
|
|
296
|
+
sessionId: data.sessionId ?? null,
|
|
236
297
|
};
|
|
237
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
|
+
}
|
|
238
314
|
function parseApiRoot(data) {
|
|
239
315
|
const linksRaw = data._links ?? {};
|
|
240
316
|
const links = {};
|
|
@@ -248,6 +324,84 @@ function parseApiRoot(data) {
|
|
|
248
324
|
}
|
|
249
325
|
return { links };
|
|
250
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
|
+
}
|
|
251
405
|
// ---------------------------------------------------------------------------
|
|
252
406
|
// HATEOAS link resolution
|
|
253
407
|
// ---------------------------------------------------------------------------
|
|
@@ -255,10 +409,20 @@ function processTemplate(href) {
|
|
|
255
409
|
const idx = href.indexOf("{");
|
|
256
410
|
return idx >= 0 ? href.substring(0, idx) : href;
|
|
257
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
|
+
}
|
|
258
422
|
function uri(root, linkName) {
|
|
259
423
|
const href = root.links[linkName];
|
|
260
424
|
if (href === undefined)
|
|
261
|
-
throw new
|
|
425
|
+
throw new ApiError(`Link '${linkName}' not found in API root.`);
|
|
262
426
|
return processTemplate(href);
|
|
263
427
|
}
|
|
264
428
|
function uriId(root, linkName, id) {
|
|
@@ -368,10 +532,25 @@ function checkResponse(response, body) {
|
|
|
368
532
|
throw new AuthenticationError(body);
|
|
369
533
|
if (status === 403)
|
|
370
534
|
throw new AuthorizationError(body);
|
|
535
|
+
if (status === 409)
|
|
536
|
+
throw new ConflictError(body);
|
|
371
537
|
if (status >= 500)
|
|
372
538
|
throw new ConnectionFailedError(body);
|
|
373
|
-
throw new
|
|
539
|
+
throw new HttpError(status, body);
|
|
374
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
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Flexemarkets client
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
375
554
|
export class Flexemarkets {
|
|
376
555
|
_clientDescription;
|
|
377
556
|
_endpoint;
|
|
@@ -380,6 +559,7 @@ export class Flexemarkets {
|
|
|
380
559
|
_apiRoot;
|
|
381
560
|
_account;
|
|
382
561
|
_user;
|
|
562
|
+
_tokenObj;
|
|
383
563
|
_eventListener = null;
|
|
384
564
|
constructor(endpoint, baseUrl, bearerToken, clientDescription) {
|
|
385
565
|
this._endpoint = endpoint;
|
|
@@ -411,6 +591,7 @@ export class Flexemarkets {
|
|
|
411
591
|
const tokenObj = await signIn(baseUrl, config, desc);
|
|
412
592
|
const bearer = `Bearer ${tokenObj.token}`;
|
|
413
593
|
const fm = new Flexemarkets(ep, baseUrl, bearer, desc);
|
|
594
|
+
fm._tokenObj = tokenObj;
|
|
414
595
|
fm._account = tokenObj.account;
|
|
415
596
|
fm._user = tokenObj.person;
|
|
416
597
|
// Fetch API root for HATEOAS links
|
|
@@ -524,9 +705,159 @@ export class Flexemarkets {
|
|
|
524
705
|
checkResponse(resp, body);
|
|
525
706
|
return JSON.parse(body);
|
|
526
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
|
+
}
|
|
527
858
|
async _fetchApiRoot() {
|
|
528
859
|
const data = await this._get(this._baseUrl);
|
|
529
|
-
return parseApiRoot(data);
|
|
860
|
+
return rebaseApiRoot(parseApiRoot(data), this._baseUrl);
|
|
530
861
|
}
|
|
531
862
|
// ======================================================================
|
|
532
863
|
// REST APIs
|
|
@@ -547,21 +878,50 @@ export class Flexemarkets {
|
|
|
547
878
|
const data = await this._get(url);
|
|
548
879
|
return data.map(parseMarket);
|
|
549
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
|
+
}
|
|
550
905
|
async symbols(marketplaceId) {
|
|
551
906
|
const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "symbols");
|
|
552
907
|
return (await this._get(url));
|
|
553
908
|
}
|
|
554
909
|
// -- sessions --------------------------------------------------------------
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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);
|
|
565
925
|
}
|
|
566
926
|
async session(marketplaceId) {
|
|
567
927
|
const url = uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "currentSession");
|
|
@@ -581,6 +941,45 @@ export class Flexemarkets {
|
|
|
581
941
|
});
|
|
582
942
|
return parseOrder(data);
|
|
583
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
|
+
}
|
|
584
983
|
async submitCancel(marketplaceId, marketId, originalId) {
|
|
585
984
|
const url = uri(this._apiRoot, "orders");
|
|
586
985
|
const data = await this._post(url, {
|
|
@@ -595,28 +994,28 @@ export class Flexemarkets {
|
|
|
595
994
|
return parseOrder(data);
|
|
596
995
|
}
|
|
597
996
|
/**
|
|
598
|
-
*
|
|
997
|
+
* The active-orders snapshot: every resting limit order on the
|
|
599
998
|
* marketplace's current session, plus the `x-fm-as-of-seq` sequence
|
|
600
|
-
* the snapshot was read at. Used by `MarketView`
|
|
999
|
+
* the snapshot was read at. Used by `MarketView` seeding
|
|
601
1000
|
* — clients apply WS deltas whose seq is greater than the returned
|
|
602
1001
|
* value and skip those whose seq is less than or equal.
|
|
603
1002
|
*/
|
|
604
|
-
async
|
|
1003
|
+
async activeOrders(marketplaceId) {
|
|
605
1004
|
const baseRest = this._baseUrl;
|
|
606
1005
|
const url = `${baseRest}/v1/marketplaces/${marketplaceId}/orders/active`;
|
|
607
1006
|
const { data, asOfSeq } = await this._getSnapshot(url);
|
|
608
|
-
const orders = (data
|
|
1007
|
+
const orders = embeddedOrders(data).map(parseOrder);
|
|
609
1008
|
return { body: orders, asOfSeq };
|
|
610
1009
|
}
|
|
611
1010
|
/**
|
|
612
|
-
*
|
|
613
|
-
* 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
|
|
614
1013
|
* at 5000; default size is 1000.
|
|
615
1014
|
*/
|
|
616
|
-
async
|
|
1015
|
+
async recentTrades(marketplaceId, size = 1000) {
|
|
617
1016
|
const url = `${this._baseUrl}/v1/marketplaces/${marketplaceId}/orders/recent-trades?size=${size}`;
|
|
618
1017
|
const { data, asOfSeq } = await this._getSnapshot(url);
|
|
619
|
-
const orders = (data
|
|
1018
|
+
const orders = embeddedOrders(data).map(parseOrder);
|
|
620
1019
|
return { body: orders, asOfSeq };
|
|
621
1020
|
}
|
|
622
1021
|
async orders(marketplaceId, options) {
|
|
@@ -641,8 +1040,13 @@ export class Flexemarkets {
|
|
|
641
1040
|
const url = uriParamMarketplaceIdParam(this._apiRoot, "symbolTradesJson", marketplaceId, `symbol=${symbol}`);
|
|
642
1041
|
const data = await this._get(url);
|
|
643
1042
|
const orders = data.map(parseOrder);
|
|
644
|
-
for (const o of orders)
|
|
1043
|
+
for (const o of orders) {
|
|
1044
|
+
// The symbol-keyed route answers with the trade id in `original` and no
|
|
1045
|
+
// symbol, because the query already fixed it. Filling both in is what
|
|
1046
|
+
// makes the result a trade list rather than half-populated orders.
|
|
1047
|
+
o.id = o.original;
|
|
645
1048
|
o.symbol = symbol;
|
|
1049
|
+
}
|
|
646
1050
|
return orders;
|
|
647
1051
|
}
|
|
648
1052
|
// -- holdings --------------------------------------------------------------
|
|
@@ -662,15 +1066,16 @@ export class Flexemarkets {
|
|
|
662
1066
|
return parseHolding(await this._get(url));
|
|
663
1067
|
}
|
|
664
1068
|
// -- connections -----------------------------------------------------------
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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);
|
|
674
1079
|
}
|
|
675
1080
|
// -- management ------------------------------------------------------------
|
|
676
1081
|
//
|
|
@@ -739,8 +1144,17 @@ export class Flexemarkets {
|
|
|
739
1144
|
const data = await this._post(url, body);
|
|
740
1145
|
return allotmentsToHoldings(data.map(parseAllotment));
|
|
741
1146
|
}
|
|
742
|
-
/**
|
|
743
|
-
|
|
1147
|
+
/**
|
|
1148
|
+
* The holdings CSV, verbatim, for the current session or for given ones.
|
|
1149
|
+
*
|
|
1150
|
+
* The filter is spelled `sessions=` on this route and `sessionIds=` on
|
|
1151
|
+
* sessions and connections. Using the wrong one is not an error — it is an
|
|
1152
|
+
* unfiltered answer.
|
|
1153
|
+
*/
|
|
1154
|
+
async downloadHoldings(marketplaceId, sessionIds) {
|
|
1155
|
+
if (sessionIds && sessionIds.length > 0) {
|
|
1156
|
+
return this._getText(uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "holdings/downloads", `sessions=${sessionIds.join(",")}`));
|
|
1157
|
+
}
|
|
744
1158
|
return this._getText(uriIdSegment(this._apiRoot, "marketplaces", marketplaceId, "holdings/downloads"));
|
|
745
1159
|
}
|
|
746
1160
|
/**
|
|
@@ -824,6 +1238,27 @@ export class Flexemarkets {
|
|
|
824
1238
|
async listen(marketplaceId, callback) {
|
|
825
1239
|
this._eventListener = await this._connectEvents(marketplaceId, callback);
|
|
826
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
|
+
}
|
|
827
1262
|
/**
|
|
828
1263
|
* Package-private helper used by {@link DefaultMarketView} (Phase 2d)
|
|
829
1264
|
* to own its own EventListener subscription rather than clobbering
|
|
@@ -867,19 +1302,23 @@ export class Flexemarkets {
|
|
|
867
1302
|
async function signIn(baseUrl, config, clientDescription) {
|
|
868
1303
|
const tok = config.token ?? "";
|
|
869
1304
|
if (tok && isValidToken(tok)) {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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",
|
|
873
1317
|
headers: {
|
|
874
1318
|
Authorization: `Bearer ${tok}`,
|
|
875
|
-
"Content-Type": "application/json",
|
|
876
1319
|
Accept: "application/json",
|
|
877
1320
|
"User-Agent": FM_NETWORK_CLIENT,
|
|
878
1321
|
},
|
|
879
|
-
body: JSON.stringify({
|
|
880
|
-
username: `${config.account ?? ""}|${config.email ?? ""}`,
|
|
881
|
-
password: "",
|
|
882
|
-
}),
|
|
883
1322
|
});
|
|
884
1323
|
const body = await resp.text();
|
|
885
1324
|
if (resp.status === 401) {
|