@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.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
- function parsePerson(data) {
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 ?? null,
52
- lastModifiedDate: data.lastModifiedDate ?? null,
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 ?? false,
123
+ approval: data.approval ?? null,
64
124
  approvalDescription: data.approvalDescription ?? null,
65
- createdDate: data.createdDate ?? null,
66
- lastModifiedDate: data.lastModifiedDate ?? null,
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 ?? null,
122
- closeDate: data.closeDate ?? null,
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 ?? null,
132
- side: data.side ?? null,
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 ?? null,
143
- lastModifiedDate: data.lastModifiedDate ?? null,
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 ?? null,
234
- terminated: data.terminated ?? null,
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 Error(`Link '${linkName}' not found in API root.`);
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 FlexemarketsError(`HTTP ${status}: ${body}`);
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
- async sessions(marketplaceId, sessionIds) {
556
- let url;
557
- if (sessionIds && sessionIds.length > 0) {
558
- url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "sessions", `${sessionIdsParam(sessionIds)}&format=application/json`);
559
- }
560
- else {
561
- url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "sessions", "format=application/json");
562
- }
563
- const data = await this._get(url);
564
- return data.map(parseSession);
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
- * V1 active-orders snapshot: every resting limit order on the
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` Phase 2a seeding
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 activeOrdersV1(marketplaceId) {
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._embedded?.orderDtoes ?? []).map(parseOrder);
1007
+ const orders = embeddedOrders(data).map(parseOrder);
609
1008
  return { body: orders, asOfSeq };
610
1009
  }
611
1010
  /**
612
- * V1 recent-trades snapshot for seeding the trade-history tape.
613
- * Same `x-fm-as-of-seq` contract as `activeOrdersV1`. Server caps
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 recentTradesV1(marketplaceId, size = 1000) {
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._embedded?.orderDtoes ?? []).map(parseOrder);
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
- async connections(marketplaceId, sessionIds) {
666
- // Canonical path is /marketplaces/{id}/connections ("/agents" is the
667
- // retained pre-FM-4 alias); format=application/json yields a plain list
668
- // (vs the HAL _embedded form).
669
- const sid = sessionIdsParam(sessionIds ?? null);
670
- const param = sid ? `${sid}&format=application/json` : "format=application/json";
671
- const url = uriIdSegmentParam(this._apiRoot, "marketplaces", marketplaceId, "connections", param);
672
- const data = await this._get(url);
673
- return data.map(parseConnection);
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
- /** The holdings CSV, verbatim, as the server renders it. */
743
- async downloadHoldings(marketplaceId) {
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
- const authUrl = `${baseUrl}/tokens`;
871
- const resp = await fetch(authUrl, {
872
- method: "POST",
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) {