@kkkermit/trading212.js 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -0
- package/dist/chunk-AVOYEH6N.js +2661 -0
- package/dist/chunk-K766TWIM.cjs +2700 -0
- package/dist/cli.js +2804 -0
- package/dist/client-BSQXqdxj.d.cts +1684 -0
- package/dist/client-BSQXqdxj.d.ts +1684 -0
- package/dist/csv.cjs +54 -0
- package/dist/csv.d.cts +21 -0
- package/dist/csv.d.ts +21 -0
- package/dist/csv.js +50 -0
- package/dist/index.cjs +154 -0
- package/dist/index.d.cts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +1 -0
- package/dist/testing.cjs +651 -0
- package/dist/testing.d.cts +159 -0
- package/dist/testing.d.ts +159 -0
- package/dist/testing.js +642 -0
- package/package.json +4 -4
|
@@ -0,0 +1,2700 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/cache.ts
|
|
4
|
+
function isList(value) {
|
|
5
|
+
return Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
var MemoryCache = class {
|
|
8
|
+
#entries = /* @__PURE__ */ new Map();
|
|
9
|
+
get(key) {
|
|
10
|
+
const entry = this.#entries.get(key);
|
|
11
|
+
if (!entry) return void 0;
|
|
12
|
+
if (Date.now() >= entry.expiresAt) {
|
|
13
|
+
this.#entries.delete(key);
|
|
14
|
+
return void 0;
|
|
15
|
+
}
|
|
16
|
+
return entry.value;
|
|
17
|
+
}
|
|
18
|
+
set(key, value, ttlMs) {
|
|
19
|
+
this.#entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
20
|
+
}
|
|
21
|
+
delete(key) {
|
|
22
|
+
this.#entries.delete(key);
|
|
23
|
+
}
|
|
24
|
+
clear() {
|
|
25
|
+
this.#entries.clear();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var CachedLoader = class {
|
|
29
|
+
#store;
|
|
30
|
+
#onError;
|
|
31
|
+
#inFlight = /* @__PURE__ */ new Map();
|
|
32
|
+
constructor(store, onError = () => void 0) {
|
|
33
|
+
this.#store = store;
|
|
34
|
+
this.#onError = onError;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The shared load is started without any caller's signal, so one caller giving up never fails the others; each
|
|
38
|
+
* caller stops waiting when its own `signal` aborts.
|
|
39
|
+
*/
|
|
40
|
+
async load(key, options, loader) {
|
|
41
|
+
const { ttlMs, refresh = false, signal, accept } = options;
|
|
42
|
+
if (signal?.aborted) throw signal.reason;
|
|
43
|
+
if (!refresh) {
|
|
44
|
+
let cached;
|
|
45
|
+
try {
|
|
46
|
+
cached = await this.#store.get(key);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
this.#onError(error, key);
|
|
49
|
+
}
|
|
50
|
+
if (cached !== void 0 && cached !== null) {
|
|
51
|
+
if (!accept || accept(cached)) return cached;
|
|
52
|
+
this.#onError(new TypeError("The cache store returned a value of the wrong shape."), key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const pending = this.#inFlight.get(key);
|
|
56
|
+
if (pending) return untilAborted(pending, signal);
|
|
57
|
+
const promise = (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const value = await loader();
|
|
60
|
+
try {
|
|
61
|
+
await this.#store.set(key, value, ttlMs);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.#onError(error, key);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
} finally {
|
|
67
|
+
this.#inFlight.delete(key);
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
this.#inFlight.set(key, promise);
|
|
71
|
+
return untilAborted(promise, signal);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function untilAborted(promise, signal) {
|
|
75
|
+
if (!signal) return promise;
|
|
76
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
77
|
+
let onAbort = () => void 0;
|
|
78
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
79
|
+
onAbort = () => {
|
|
80
|
+
reject(signal.reason);
|
|
81
|
+
};
|
|
82
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
83
|
+
});
|
|
84
|
+
return Promise.race([promise, aborted]).finally(() => {
|
|
85
|
+
signal.removeEventListener("abort", onAbort);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/environment.ts
|
|
90
|
+
var BASE_URLS = {
|
|
91
|
+
demo: "https://demo.trading212.com",
|
|
92
|
+
live: "https://live.trading212.com"
|
|
93
|
+
};
|
|
94
|
+
function isEnvironment(value) {
|
|
95
|
+
return value === "demo" || value === "live";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/errors/errors.ts
|
|
99
|
+
var BRAND = /* @__PURE__ */ Symbol.for("trading212.js/error");
|
|
100
|
+
function kindsOf(constructor) {
|
|
101
|
+
const kinds = [];
|
|
102
|
+
let current = constructor;
|
|
103
|
+
while (current && Object.hasOwn(current, "kind")) {
|
|
104
|
+
kinds.push(String(current.kind));
|
|
105
|
+
current = Object.getPrototypeOf(current);
|
|
106
|
+
}
|
|
107
|
+
return kinds;
|
|
108
|
+
}
|
|
109
|
+
function brandOf(value) {
|
|
110
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
111
|
+
const kinds = value[BRAND];
|
|
112
|
+
return Array.isArray(kinds) ? kinds : void 0;
|
|
113
|
+
}
|
|
114
|
+
var Trading212Error = class extends Error {
|
|
115
|
+
static kind = "Trading212Error";
|
|
116
|
+
code;
|
|
117
|
+
/** What to do about it, in plain English. */
|
|
118
|
+
hint;
|
|
119
|
+
status;
|
|
120
|
+
request;
|
|
121
|
+
rateLimit;
|
|
122
|
+
body;
|
|
123
|
+
apiCode;
|
|
124
|
+
/** Trading 212's reference for the failed request, worth quoting if you contact its support. */
|
|
125
|
+
traceId;
|
|
126
|
+
/** Set when a paginated read failed part-way: pass it as `cursor` to carry on from where it stopped. */
|
|
127
|
+
resumeCursor;
|
|
128
|
+
constructor(message, code, details = {}) {
|
|
129
|
+
super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
|
|
130
|
+
const kinds = kindsOf(new.target);
|
|
131
|
+
this.name = kinds[0] ?? "Trading212Error";
|
|
132
|
+
this.code = code;
|
|
133
|
+
this.hint = details.hint;
|
|
134
|
+
this.status = details.status;
|
|
135
|
+
this.request = details.request;
|
|
136
|
+
this.rateLimit = details.rateLimit;
|
|
137
|
+
this.body = details.body;
|
|
138
|
+
this.apiCode = details.apiCode;
|
|
139
|
+
this.traceId = details.traceId;
|
|
140
|
+
this.resumeCursor = void 0;
|
|
141
|
+
Object.defineProperty(this, BRAND, { value: kinds, enumerable: false });
|
|
142
|
+
}
|
|
143
|
+
static [Symbol.hasInstance](value) {
|
|
144
|
+
const kinds = brandOf(value);
|
|
145
|
+
if (kinds) return kinds.includes(this.kind);
|
|
146
|
+
return Function.prototype[Symbol.hasInstance].call(this, value);
|
|
147
|
+
}
|
|
148
|
+
/** A plain, redacted object for structured logs: every field that has a value, plus the cause's message. */
|
|
149
|
+
toLog() {
|
|
150
|
+
const fields = { name: this.name, message: this.message };
|
|
151
|
+
for (const [key, value] of Object.entries(this)) {
|
|
152
|
+
if (value !== void 0) fields[key] = value;
|
|
153
|
+
}
|
|
154
|
+
if (this.cause !== void 0) {
|
|
155
|
+
const cause = this.cause;
|
|
156
|
+
fields.cause = cause instanceof Error ? `${cause.name}: ${cause.message}` : typeof cause === "string" ? cause : "[non-error cause]";
|
|
157
|
+
}
|
|
158
|
+
return fields;
|
|
159
|
+
}
|
|
160
|
+
toJSON() {
|
|
161
|
+
return this.toLog();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
function isTrading212Error(value) {
|
|
165
|
+
return brandOf(value) !== void 0;
|
|
166
|
+
}
|
|
167
|
+
var ConfigurationError = class extends Trading212Error {
|
|
168
|
+
static kind = "ConfigurationError";
|
|
169
|
+
/** The option at fault, such as `apiKey`. */
|
|
170
|
+
field;
|
|
171
|
+
constructor(message, details = {}) {
|
|
172
|
+
super(message, "configuration", details);
|
|
173
|
+
this.field = details.field;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
var AuthenticationError = class extends Trading212Error {
|
|
177
|
+
static kind = "AuthenticationError";
|
|
178
|
+
environment;
|
|
179
|
+
constructor(message, details) {
|
|
180
|
+
super(message, "authentication", details);
|
|
181
|
+
this.environment = details.environment;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
var MissingScopeError = class extends Trading212Error {
|
|
185
|
+
static kind = "MissingScopeError";
|
|
186
|
+
/** The permission the key lacks, such as `orders:execute`. */
|
|
187
|
+
scope;
|
|
188
|
+
constructor(message, details) {
|
|
189
|
+
super(message, "missing_scope", details);
|
|
190
|
+
this.scope = details.scope;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var NotAvailableInDemoError = class extends Trading212Error {
|
|
194
|
+
static kind = "NotAvailableInDemoError";
|
|
195
|
+
constructor(message, details = {}) {
|
|
196
|
+
super(message, "not_available_in_demo", details);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var PermissionError = class extends Trading212Error {
|
|
200
|
+
static kind = "PermissionError";
|
|
201
|
+
constructor(message, details = {}) {
|
|
202
|
+
super(message, "permission", details);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
var ValidationError = class extends Trading212Error {
|
|
206
|
+
static kind = "ValidationError";
|
|
207
|
+
issues;
|
|
208
|
+
constructor(message, details = {}, code) {
|
|
209
|
+
super(message, code ?? "validation", details);
|
|
210
|
+
this.issues = details.issues ?? [];
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
var OrderRejectedError = class extends ValidationError {
|
|
214
|
+
static kind = "OrderRejectedError";
|
|
215
|
+
reason;
|
|
216
|
+
/** The smallest quantity Trading 212 accepts for this order, read from a `min-quantity-exceeded` refusal. */
|
|
217
|
+
minQuantity;
|
|
218
|
+
/** The largest quantity it accepts in one order, read from a `max-quantity-exceeded` refusal. */
|
|
219
|
+
maxQuantity;
|
|
220
|
+
/** The most decimal places a quantity may have, read from a `quantity-precision-mismatch` refusal. */
|
|
221
|
+
maxDecimals;
|
|
222
|
+
/** How many shares Trading 212 says the account owns, read from a `selling-equity-not-owned` refusal. */
|
|
223
|
+
ownedQuantity;
|
|
224
|
+
constructor(message, details) {
|
|
225
|
+
super(message, details, "order_rejected");
|
|
226
|
+
this.reason = details.reason;
|
|
227
|
+
this.minQuantity = details.minQuantity;
|
|
228
|
+
this.maxQuantity = details.maxQuantity;
|
|
229
|
+
this.maxDecimals = details.maxDecimals;
|
|
230
|
+
this.ownedQuantity = details.ownedQuantity;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
var NotFoundError = class extends Trading212Error {
|
|
234
|
+
static kind = "NotFoundError";
|
|
235
|
+
constructor(message, details = {}, code) {
|
|
236
|
+
super(message, code ?? "not_found", details);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
var InstrumentNotFoundError = class extends NotFoundError {
|
|
240
|
+
static kind = "InstrumentNotFoundError";
|
|
241
|
+
query;
|
|
242
|
+
suggestions;
|
|
243
|
+
constructor(message, details) {
|
|
244
|
+
super(message, details, "instrument_not_found");
|
|
245
|
+
this.query = details.query;
|
|
246
|
+
this.suggestions = details.suggestions;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
var RateLimitError = class extends Trading212Error {
|
|
250
|
+
static kind = "RateLimitError";
|
|
251
|
+
/** How long to wait before the next try. */
|
|
252
|
+
retryAfterMs;
|
|
253
|
+
/** When the budget resets. */
|
|
254
|
+
resetAt;
|
|
255
|
+
constructor(message, details) {
|
|
256
|
+
super(message, "rate_limited", details);
|
|
257
|
+
this.retryAfterMs = details.retryAfterMs;
|
|
258
|
+
this.resetAt = details.resetAt === void 0 ? void 0 : new Date(details.resetAt);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
var TimeoutError = class extends Trading212Error {
|
|
262
|
+
static kind = "TimeoutError";
|
|
263
|
+
constructor(message, details = {}) {
|
|
264
|
+
super(message, "timeout", details);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
var OrderOutcomeUnknownError = class extends Trading212Error {
|
|
268
|
+
static kind = "OrderOutcomeUnknownError";
|
|
269
|
+
/** What was sent, so it can be looked for. */
|
|
270
|
+
sent;
|
|
271
|
+
constructor(message, details) {
|
|
272
|
+
super(message, "outcome_unknown", details);
|
|
273
|
+
this.sent = details.sent;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
var TradingDisabledError = class extends Trading212Error {
|
|
277
|
+
static kind = "TradingDisabledError";
|
|
278
|
+
constructor(message, details = {}) {
|
|
279
|
+
super(message, "trading_disabled", details);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
var NetworkError = class extends Trading212Error {
|
|
283
|
+
static kind = "NetworkError";
|
|
284
|
+
sent;
|
|
285
|
+
constructor(message, details) {
|
|
286
|
+
super(message, "network", details);
|
|
287
|
+
this.sent = details.sent;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
var ServerError = class extends Trading212Error {
|
|
291
|
+
static kind = "ServerError";
|
|
292
|
+
constructor(message, details = {}) {
|
|
293
|
+
super(message, "server", details);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
var HttpError = class extends Trading212Error {
|
|
297
|
+
static kind = "HttpError";
|
|
298
|
+
constructor(message, details = {}) {
|
|
299
|
+
super(message, "http_error", details);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
var UnexpectedResponseError = class extends Trading212Error {
|
|
303
|
+
static kind = "UnexpectedResponseError";
|
|
304
|
+
constructor(message, details = {}) {
|
|
305
|
+
super(message, "unexpected_response", details);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/http/endpoint.ts
|
|
310
|
+
function freezeEndpoints(definitions) {
|
|
311
|
+
for (const definition of Object.values(definitions)) {
|
|
312
|
+
Object.freeze(definition.rateLimit);
|
|
313
|
+
Object.freeze(definition);
|
|
314
|
+
}
|
|
315
|
+
return Object.freeze(definitions);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/http/endpoints.generated.ts
|
|
319
|
+
var SPEC_VERSION = "v0";
|
|
320
|
+
var DEFINITIONS = {
|
|
321
|
+
cancelOrder: {
|
|
322
|
+
id: "cancelOrder",
|
|
323
|
+
method: "DELETE",
|
|
324
|
+
path: "/api/v0/equity/orders/{id}",
|
|
325
|
+
scope: "orders:execute",
|
|
326
|
+
rateLimit: { requests: 50, periodMs: 6e4 },
|
|
327
|
+
kind: "trade",
|
|
328
|
+
deprecated: false,
|
|
329
|
+
summary: "Cancel a pending order"
|
|
330
|
+
},
|
|
331
|
+
createPie: {
|
|
332
|
+
id: "createPie",
|
|
333
|
+
method: "POST",
|
|
334
|
+
path: "/api/v0/equity/pies",
|
|
335
|
+
scope: "pies:write",
|
|
336
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
337
|
+
kind: "trade",
|
|
338
|
+
deprecated: true,
|
|
339
|
+
summary: "Create pie"
|
|
340
|
+
},
|
|
341
|
+
deletePie: {
|
|
342
|
+
id: "deletePie",
|
|
343
|
+
method: "DELETE",
|
|
344
|
+
path: "/api/v0/equity/pies/{id}",
|
|
345
|
+
scope: "pies:write",
|
|
346
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
347
|
+
kind: "trade",
|
|
348
|
+
deprecated: true,
|
|
349
|
+
summary: "Delete pie"
|
|
350
|
+
},
|
|
351
|
+
duplicatePie: {
|
|
352
|
+
id: "duplicatePie",
|
|
353
|
+
method: "POST",
|
|
354
|
+
path: "/api/v0/equity/pies/{id}/duplicate",
|
|
355
|
+
scope: "pies:write",
|
|
356
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
357
|
+
kind: "trade",
|
|
358
|
+
deprecated: true,
|
|
359
|
+
summary: "Duplicate pie"
|
|
360
|
+
},
|
|
361
|
+
getAccountSummary: {
|
|
362
|
+
id: "getAccountSummary",
|
|
363
|
+
method: "GET",
|
|
364
|
+
path: "/api/v0/equity/account/summary",
|
|
365
|
+
scope: "account",
|
|
366
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
367
|
+
kind: "read",
|
|
368
|
+
deprecated: false,
|
|
369
|
+
summary: "Get account summary"
|
|
370
|
+
},
|
|
371
|
+
getOrder: {
|
|
372
|
+
id: "getOrder",
|
|
373
|
+
method: "GET",
|
|
374
|
+
path: "/api/v0/equity/orders/{id}",
|
|
375
|
+
scope: "orders:read",
|
|
376
|
+
rateLimit: { requests: 1, periodMs: 1e3 },
|
|
377
|
+
kind: "read",
|
|
378
|
+
deprecated: false,
|
|
379
|
+
summary: "Get a pending order by ID"
|
|
380
|
+
},
|
|
381
|
+
getPie: {
|
|
382
|
+
id: "getPie",
|
|
383
|
+
method: "GET",
|
|
384
|
+
path: "/api/v0/equity/pies/{id}",
|
|
385
|
+
scope: "pies:read",
|
|
386
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
387
|
+
kind: "read",
|
|
388
|
+
deprecated: true,
|
|
389
|
+
summary: "Fetch a pie"
|
|
390
|
+
},
|
|
391
|
+
listDividends: {
|
|
392
|
+
id: "listDividends",
|
|
393
|
+
method: "GET",
|
|
394
|
+
path: "/api/v0/equity/history/dividends",
|
|
395
|
+
scope: "history:dividends",
|
|
396
|
+
rateLimit: { requests: 20, periodMs: 6e4 },
|
|
397
|
+
kind: "read",
|
|
398
|
+
deprecated: false,
|
|
399
|
+
summary: "Get paid out dividends"
|
|
400
|
+
},
|
|
401
|
+
listExchanges: {
|
|
402
|
+
id: "listExchanges",
|
|
403
|
+
method: "GET",
|
|
404
|
+
path: "/api/v0/equity/metadata/exchanges",
|
|
405
|
+
scope: "metadata",
|
|
406
|
+
rateLimit: { requests: 1, periodMs: 3e4 },
|
|
407
|
+
kind: "read",
|
|
408
|
+
deprecated: false,
|
|
409
|
+
summary: "Get exchanges metadata"
|
|
410
|
+
},
|
|
411
|
+
listHistoricalOrders: {
|
|
412
|
+
id: "listHistoricalOrders",
|
|
413
|
+
method: "GET",
|
|
414
|
+
path: "/api/v0/equity/history/orders",
|
|
415
|
+
scope: "history:orders",
|
|
416
|
+
rateLimit: { requests: 20, periodMs: 6e4 },
|
|
417
|
+
kind: "read",
|
|
418
|
+
deprecated: false,
|
|
419
|
+
summary: "Get historical orders data"
|
|
420
|
+
},
|
|
421
|
+
listInstruments: {
|
|
422
|
+
id: "listInstruments",
|
|
423
|
+
method: "GET",
|
|
424
|
+
path: "/api/v0/equity/metadata/instruments",
|
|
425
|
+
scope: "metadata",
|
|
426
|
+
rateLimit: { requests: 1, periodMs: 5e4 },
|
|
427
|
+
kind: "read",
|
|
428
|
+
deprecated: false,
|
|
429
|
+
summary: "Get all available instruments"
|
|
430
|
+
},
|
|
431
|
+
listOrders: {
|
|
432
|
+
id: "listOrders",
|
|
433
|
+
method: "GET",
|
|
434
|
+
path: "/api/v0/equity/orders",
|
|
435
|
+
scope: "orders:read",
|
|
436
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
437
|
+
kind: "read",
|
|
438
|
+
deprecated: false,
|
|
439
|
+
summary: "Get all pending orders"
|
|
440
|
+
},
|
|
441
|
+
listPies: {
|
|
442
|
+
id: "listPies",
|
|
443
|
+
method: "GET",
|
|
444
|
+
path: "/api/v0/equity/pies",
|
|
445
|
+
scope: "pies:read",
|
|
446
|
+
rateLimit: { requests: 1, periodMs: 3e4 },
|
|
447
|
+
kind: "read",
|
|
448
|
+
deprecated: true,
|
|
449
|
+
summary: "Fetch all pies"
|
|
450
|
+
},
|
|
451
|
+
listPositions: {
|
|
452
|
+
id: "listPositions",
|
|
453
|
+
method: "GET",
|
|
454
|
+
path: "/api/v0/equity/positions",
|
|
455
|
+
scope: "portfolio",
|
|
456
|
+
rateLimit: { requests: 1, periodMs: 1e3 },
|
|
457
|
+
kind: "read",
|
|
458
|
+
deprecated: false,
|
|
459
|
+
summary: "Fetch all open positions"
|
|
460
|
+
},
|
|
461
|
+
listReports: {
|
|
462
|
+
id: "listReports",
|
|
463
|
+
method: "GET",
|
|
464
|
+
path: "/api/v0/equity/history/exports",
|
|
465
|
+
scope: null,
|
|
466
|
+
rateLimit: { requests: 1, periodMs: 6e4 },
|
|
467
|
+
kind: "read",
|
|
468
|
+
deprecated: false,
|
|
469
|
+
summary: "List generated reports"
|
|
470
|
+
},
|
|
471
|
+
listTransactions: {
|
|
472
|
+
id: "listTransactions",
|
|
473
|
+
method: "GET",
|
|
474
|
+
path: "/api/v0/equity/history/transactions",
|
|
475
|
+
scope: "history:transactions",
|
|
476
|
+
rateLimit: { requests: 20, periodMs: 6e4 },
|
|
477
|
+
kind: "read",
|
|
478
|
+
deprecated: false,
|
|
479
|
+
summary: "Get transactions"
|
|
480
|
+
},
|
|
481
|
+
placeLimitOrder: {
|
|
482
|
+
id: "placeLimitOrder",
|
|
483
|
+
method: "POST",
|
|
484
|
+
path: "/api/v0/equity/orders/limit",
|
|
485
|
+
scope: "orders:execute",
|
|
486
|
+
rateLimit: { requests: 1, periodMs: 2e3 },
|
|
487
|
+
kind: "trade",
|
|
488
|
+
deprecated: false,
|
|
489
|
+
summary: "Place a Limit order"
|
|
490
|
+
},
|
|
491
|
+
placeMarketOrder: {
|
|
492
|
+
id: "placeMarketOrder",
|
|
493
|
+
method: "POST",
|
|
494
|
+
path: "/api/v0/equity/orders/market",
|
|
495
|
+
scope: "orders:execute",
|
|
496
|
+
rateLimit: { requests: 50, periodMs: 6e4 },
|
|
497
|
+
kind: "trade",
|
|
498
|
+
deprecated: false,
|
|
499
|
+
summary: "Place a Market order"
|
|
500
|
+
},
|
|
501
|
+
placeStopLimitOrder: {
|
|
502
|
+
id: "placeStopLimitOrder",
|
|
503
|
+
method: "POST",
|
|
504
|
+
path: "/api/v0/equity/orders/stop_limit",
|
|
505
|
+
scope: "orders:execute",
|
|
506
|
+
rateLimit: { requests: 1, periodMs: 2e3 },
|
|
507
|
+
kind: "trade",
|
|
508
|
+
deprecated: false,
|
|
509
|
+
summary: "Place a StopLimit order"
|
|
510
|
+
},
|
|
511
|
+
placeStopOrder: {
|
|
512
|
+
id: "placeStopOrder",
|
|
513
|
+
method: "POST",
|
|
514
|
+
path: "/api/v0/equity/orders/stop",
|
|
515
|
+
scope: "orders:execute",
|
|
516
|
+
rateLimit: { requests: 1, periodMs: 2e3 },
|
|
517
|
+
kind: "trade",
|
|
518
|
+
deprecated: false,
|
|
519
|
+
summary: "Place a Stop order"
|
|
520
|
+
},
|
|
521
|
+
requestReport: {
|
|
522
|
+
id: "requestReport",
|
|
523
|
+
method: "POST",
|
|
524
|
+
path: "/api/v0/equity/history/exports",
|
|
525
|
+
scope: null,
|
|
526
|
+
rateLimit: { requests: 1, periodMs: 3e4 },
|
|
527
|
+
kind: "write",
|
|
528
|
+
deprecated: false,
|
|
529
|
+
summary: "Request a CSV report"
|
|
530
|
+
},
|
|
531
|
+
updatePie: {
|
|
532
|
+
id: "updatePie",
|
|
533
|
+
method: "POST",
|
|
534
|
+
path: "/api/v0/equity/pies/{id}",
|
|
535
|
+
scope: "pies:write",
|
|
536
|
+
rateLimit: { requests: 1, periodMs: 5e3 },
|
|
537
|
+
kind: "trade",
|
|
538
|
+
deprecated: true,
|
|
539
|
+
summary: "Update pie"
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
var ENDPOINTS = freezeEndpoints(DEFINITIONS);
|
|
543
|
+
|
|
544
|
+
// src/orders/quantity.ts
|
|
545
|
+
var QUANTITY_DECIMALS = 4;
|
|
546
|
+
function toPlainDecimal(value) {
|
|
547
|
+
const text = Math.abs(value).toString();
|
|
548
|
+
const sign = value < 0 ? "-" : "";
|
|
549
|
+
const match = /^(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text);
|
|
550
|
+
if (!match) return sign + text;
|
|
551
|
+
const [, whole = "", fraction = "", exponentText = "0"] = match;
|
|
552
|
+
const exponent = Number(exponentText);
|
|
553
|
+
const digits = whole + fraction;
|
|
554
|
+
const point = whole.length + exponent;
|
|
555
|
+
if (point <= 0) return `${sign}0.${"0".repeat(-point)}${digits}`;
|
|
556
|
+
if (point >= digits.length) return sign + digits + "0".repeat(point - digits.length);
|
|
557
|
+
return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
|
|
558
|
+
}
|
|
559
|
+
function decimalPlaces(value) {
|
|
560
|
+
if (!Number.isFinite(value)) return Number.NaN;
|
|
561
|
+
const fraction = toPlainDecimal(value).split(".")[1];
|
|
562
|
+
return fraction ? fraction.replace(/0+$/, "").length : 0;
|
|
563
|
+
}
|
|
564
|
+
function truncateQuantity(value, decimals = QUANTITY_DECIMALS) {
|
|
565
|
+
if (!Number.isFinite(value)) throw new RangeError("Cannot truncate a quantity that is not a finite number.");
|
|
566
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 20) throw new RangeError("decimals must be 0 to 20.");
|
|
567
|
+
const [whole = "0", fraction = ""] = toPlainDecimal(value).split(".");
|
|
568
|
+
const kept = fraction.slice(0, decimals);
|
|
569
|
+
const result = Number(kept ? `${whole}.${kept}` : whole);
|
|
570
|
+
return Object.is(result, -0) ? 0 : result;
|
|
571
|
+
}
|
|
572
|
+
function isValidQuantity(value) {
|
|
573
|
+
return Number.isFinite(value) && value !== 0 && decimalPlaces(value) <= QUANTITY_DECIMALS;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/errors/hints.ts
|
|
577
|
+
var HINTS = {
|
|
578
|
+
authentication: (environment) => `Trading 212 refused the key and secret. Check both were copied exactly, with no spaces. A key works in one environment only: this client is set to ${environment}, so the key must have been made while the app was in ${environment === "demo" ? "practice (demo)" : "real-money (live)"} mode.`,
|
|
579
|
+
missingScope: (scope) => `This API key does not have the "${scope}" permission. In the Trading 212 app, go to Settings > API (Beta) and create a key with "${scope}" ticked.`,
|
|
580
|
+
notAvailableInDemo: "Trading 212 does not offer this in the demo environment. It works on live only (CSV exports are one example).",
|
|
581
|
+
permission: "The key is not allowed to do this. If the key is restricted to IP addresses, check that this machine's public address is on its list.",
|
|
582
|
+
validation: "Trading 212 rejected the request as invalid. `error.body` holds its explanation, and `error.issues` any field problems found before sending.",
|
|
583
|
+
invalidOrder: "Trading 212 could not accept the fields of this order. The client checks them before sending, so if one of its methods sent it, Trading 212 may have changed its rules: please open an issue with `error.traceId`.",
|
|
584
|
+
timeout: (kind) => kind === "read" ? "Trading 212 did not answer in time. Reads are retried automatically; this one ran out of attempts." : "Trading 212 did not answer in time. Requests that change something are never retried automatically.",
|
|
585
|
+
outcomeUnknown: "The request may or may not have gone through. Do not resend it blindly: check `orders.list()` and `history.orders.list()` first.",
|
|
586
|
+
rateLimited: (endpoint, resetAt) => `Wait${resetAt === void 0 ? " a moment" : ` until ${new Date(resetAt).toISOString()}`} before calling ${endpoint ?? "this endpoint"} again. Limits apply per account, so every program using the same account shares them.`,
|
|
587
|
+
server: (kind) => kind === "read" ? "Trading 212 had a problem of its own. Reads are retried automatically; this one ran out of attempts." : "Trading 212 had a problem of its own. Requests that change something are never retried automatically.",
|
|
588
|
+
tradingDisabled: 'Trading is off for this client, which is the default on live and behind a custom baseUrl, so nothing was sent. Pass `trading: "enabled"` to place orders, or `{ dryRun: true }` to preview one.',
|
|
589
|
+
network: (sent, kind) => {
|
|
590
|
+
if (!sent) return "Could not reach Trading 212 at all: check the network, DNS and any proxy. Nothing was sent.";
|
|
591
|
+
return kind === "read" ? "The connection broke while waiting for Trading 212. Reads are retried automatically; this one ran out of attempts." : "The connection broke while waiting for Trading 212. Requests that change something are never retried automatically.";
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
var ORDER_REJECTION_HINTS = {
|
|
595
|
+
"min-quantity-exceeded": "The order is too small. Raise the quantity to at least `error.minQuantity`, the smallest Trading 212 accepts for it.",
|
|
596
|
+
"max-quantity-exceeded": "The order is too large for one order. Split it into orders of at most `error.maxQuantity` each.",
|
|
597
|
+
"insufficient-free-for-stocks-buy": "Not enough free cash for this buy. Check `cash.availableToTrade` in `account.summary()`; cash reserved for pending orders or held in pies does not count.",
|
|
598
|
+
"selling-equity-not-owned": "You are selling more than is available. Shares held in pies or reserved by pending orders do not count: check `quantityAvailableForTrading` on the position, or use `orders.sellAll(ticker)`.",
|
|
599
|
+
"market-closed": "The market for this instrument is closed. `exchanges.status(ticker)` shows its current session and when it next opens.",
|
|
600
|
+
"quantity-precision-mismatch": "Quantities allow at most 4 decimal places. Use `truncateQuantity(quantity)` to round down safely."
|
|
601
|
+
};
|
|
602
|
+
var NOT_FOUND_HINTS = {
|
|
603
|
+
getOrder: "The order is not pending any more: it may have filled, been cancelled or never existed. Filled and cancelled orders are in `history.orders.list()`.",
|
|
604
|
+
cancelOrder: "The order is not pending any more, so there is nothing to cancel: it may already have filled or been cancelled.",
|
|
605
|
+
getPie: "No pie has that ID. `pies.list()` shows the ones that exist.",
|
|
606
|
+
deletePie: "No pie has that ID. `pies.list()` shows the ones that exist.",
|
|
607
|
+
updatePie: "No pie has that ID. `pies.list()` shows the ones that exist.",
|
|
608
|
+
duplicatePie: "No pie has that ID. `pies.list()` shows the ones that exist."
|
|
609
|
+
};
|
|
610
|
+
function notFoundHint(endpoint) {
|
|
611
|
+
return (endpoint && NOT_FOUND_HINTS[endpoint]) ?? "Trading 212 has nothing at that address.";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/errors/fromResponse.ts
|
|
615
|
+
var MAX_MESSAGE = 500;
|
|
616
|
+
var JAVA_OPTIONAL = /\bOptional\[([^\]]*)\]/g;
|
|
617
|
+
function lastSegment(type) {
|
|
618
|
+
if (type === "about:blank") return void 0;
|
|
619
|
+
return type.split(/[/#]/).filter(Boolean).pop();
|
|
620
|
+
}
|
|
621
|
+
function parseErrorBody(text) {
|
|
622
|
+
const trimmed = text.trim();
|
|
623
|
+
if (!trimmed) return {};
|
|
624
|
+
let json;
|
|
625
|
+
try {
|
|
626
|
+
json = JSON.parse(trimmed);
|
|
627
|
+
} catch {
|
|
628
|
+
return { message: trimmed.slice(0, MAX_MESSAGE), body: trimmed };
|
|
629
|
+
}
|
|
630
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
631
|
+
return typeof json === "string" ? { message: json.slice(0, MAX_MESSAGE), body: json } : { body: json };
|
|
632
|
+
}
|
|
633
|
+
const record = json;
|
|
634
|
+
const stringField = (key) => {
|
|
635
|
+
const value = record[key];
|
|
636
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
637
|
+
};
|
|
638
|
+
const type = stringField("type");
|
|
639
|
+
const traceId = stringField("traceId");
|
|
640
|
+
const code = stringField("code");
|
|
641
|
+
const context = record.context;
|
|
642
|
+
const contextType = typeof context === "object" && context !== null ? context.type : void 0;
|
|
643
|
+
const apiCode = (code === "BusinessException" && typeof contextType === "string" && contextType.trim() ? contextType.trim() : code) ?? stringField("errorCode") ?? (type ? lastSegment(type) : void 0);
|
|
644
|
+
const message = stringField("detail") ?? stringField("clarification") ?? stringField("message") ?? stringField("errorMessage") ?? stringField("error") ?? stringField("title");
|
|
645
|
+
return {
|
|
646
|
+
...apiCode !== void 0 && { apiCode },
|
|
647
|
+
...message !== void 0 && { message: message.replace(JAVA_OPTIONAL, "$1").slice(0, MAX_MESSAGE) },
|
|
648
|
+
...traceId !== void 0 && { traceId },
|
|
649
|
+
body: json
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
var SCOPE = /Scope\(\s*([\w:.-]+)\s*\)/i;
|
|
653
|
+
var normalise = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
654
|
+
var DEMO_ONLY = normalise("not-available-in-demo-account");
|
|
655
|
+
var KNOWN_REJECTIONS = Object.keys(ORDER_REJECTION_HINTS).map(
|
|
656
|
+
(reason) => [reason, normalise(reason)]
|
|
657
|
+
);
|
|
658
|
+
function findRejection(haystack) {
|
|
659
|
+
return KNOWN_REJECTIONS.find(([, needle]) => haystack.includes(needle))?.[0];
|
|
660
|
+
}
|
|
661
|
+
var FACTS = {
|
|
662
|
+
"min-quantity-exceeded": ["minQuantity", /at least\s+(\d+(?:\.\d+)?)/i],
|
|
663
|
+
"max-quantity-exceeded": ["maxQuantity", /\bis\s+(\d+(?:\.\d+)?)\s*$/i],
|
|
664
|
+
"quantity-precision-mismatch": ["maxDecimals", /precision\s+(\d+)/i],
|
|
665
|
+
"selling-equity-not-owned": ["ownedQuantity", /owned:\s*(\d+(?:\.\d+)?)/i]
|
|
666
|
+
};
|
|
667
|
+
function factsOf(reason, message) {
|
|
668
|
+
const fact = FACTS[reason];
|
|
669
|
+
const match = fact && message ? fact[1].exec(message) : null;
|
|
670
|
+
return fact && match?.[1] ? { [fact[0]]: Number(match[1]) } : {};
|
|
671
|
+
}
|
|
672
|
+
function sentOrder(sent) {
|
|
673
|
+
if (typeof sent !== "object" || sent === null) return {};
|
|
674
|
+
const { ticker, quantity, limitPrice, stopPrice } = sent;
|
|
675
|
+
const price = typeof limitPrice === "number" ? limitPrice : typeof stopPrice === "number" ? stopPrice : void 0;
|
|
676
|
+
return {
|
|
677
|
+
...typeof ticker === "string" && { ticker },
|
|
678
|
+
...typeof quantity === "number" && Number.isFinite(quantity) && { quantity: Math.abs(quantity) },
|
|
679
|
+
...price !== void 0 && { price }
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function rejectionSentence(reason, facts, order) {
|
|
683
|
+
const quantity = order.quantity === void 0 ? "the quantity" : `a quantity of ${String(order.quantity)}`;
|
|
684
|
+
const instrument = order.ticker ?? "this instrument";
|
|
685
|
+
const shares = order.quantity === void 0 ? instrument : `${String(order.quantity)} ${instrument}`;
|
|
686
|
+
const named = (value, words) => value === void 0 ? `the ${words}` : `the ${words}, ${String(value)}`;
|
|
687
|
+
switch (reason) {
|
|
688
|
+
case "min-quantity-exceeded":
|
|
689
|
+
return `${quantity} is below ${named(facts.minQuantity, "smallest Trading 212 accepts for this order")}.`;
|
|
690
|
+
case "max-quantity-exceeded":
|
|
691
|
+
return `${quantity} is above ${named(facts.maxQuantity, "largest Trading 212 accepts in one order")}.`;
|
|
692
|
+
case "quantity-precision-mismatch":
|
|
693
|
+
return `${quantity} has more decimal places than the ${String(facts.maxDecimals ?? QUANTITY_DECIMALS)} Trading 212 allows.`;
|
|
694
|
+
case "selling-equity-not-owned":
|
|
695
|
+
return facts.ownedQuantity === void 0 ? `it sells ${shares}, more than the account owns.` : `it sells ${shares}, but the account owns ${String(facts.ownedQuantity)}.`;
|
|
696
|
+
case "insufficient-free-for-stocks-buy":
|
|
697
|
+
return `there is not enough free cash to buy ${shares}${order.price === void 0 ? "" : ` at ${String(order.price)} each`}.`;
|
|
698
|
+
case "market-closed":
|
|
699
|
+
return `the market for ${instrument} is closed.`;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
var sentence = (text) => /[.!?]$/.test(text) ? text : `${text}.`;
|
|
703
|
+
function errorFromResponse(context) {
|
|
704
|
+
const { status, request, kind, environment, rateLimit } = context;
|
|
705
|
+
const parsed = parseErrorBody(context.text);
|
|
706
|
+
const where = `${request.method} ${request.path}`;
|
|
707
|
+
const answered = `${status}${context.statusText ? ` ${context.statusText}` : ""}`;
|
|
708
|
+
const said = parsed.message ? `: ${sentence(parsed.message)}` : ".";
|
|
709
|
+
const searchable = normalise(`${parsed.apiCode ?? ""} ${parsed.message ?? ""} ${context.text}`);
|
|
710
|
+
const details = {
|
|
711
|
+
status,
|
|
712
|
+
request,
|
|
713
|
+
...rateLimit && { rateLimit },
|
|
714
|
+
...parsed.body !== void 0 && { body: parsed.body },
|
|
715
|
+
...parsed.apiCode !== void 0 && { apiCode: parsed.apiCode },
|
|
716
|
+
...parsed.traceId !== void 0 && { traceId: parsed.traceId }
|
|
717
|
+
};
|
|
718
|
+
if (kind === "trade" && (status === 408 || status >= 500)) {
|
|
719
|
+
return new OrderOutcomeUnknownError(
|
|
720
|
+
`${where} may or may not have gone through: Trading 212 answered ${answered}${parsed.message ? `: ${parsed.message}` : ""}.`,
|
|
721
|
+
{ ...details, hint: HINTS.outcomeUnknown, sent: context.sent }
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
switch (status) {
|
|
725
|
+
case 400: {
|
|
726
|
+
const rejection = kind === "trade" ? findRejection(searchable) : void 0;
|
|
727
|
+
if (rejection) {
|
|
728
|
+
const facts = factsOf(rejection, parsed.message);
|
|
729
|
+
return new OrderRejectedError(
|
|
730
|
+
`${where} was refused: ${rejectionSentence(rejection, facts, sentOrder(context.sent))}`,
|
|
731
|
+
{ ...details, ...facts, reason: rejection, hint: ORDER_REJECTION_HINTS[rejection] }
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (parsed.apiCode && kind === "trade" && parsed.apiCode !== "invalid-request") {
|
|
735
|
+
return new OrderRejectedError(`${where} was refused (${parsed.apiCode})${said}`, {
|
|
736
|
+
...details,
|
|
737
|
+
reason: parsed.apiCode,
|
|
738
|
+
hint: HINTS.validation
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const invalid = parsed.message === "Invalid payload" ? `: Trading 212 could not accept the fields sent ("Invalid payload").` : said;
|
|
742
|
+
return new ValidationError(`${where} was refused as invalid${invalid}`, {
|
|
743
|
+
...details,
|
|
744
|
+
hint: kind === "trade" ? HINTS.invalidOrder : HINTS.validation
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
case 401:
|
|
748
|
+
return new AuthenticationError(`${where} was refused: Trading 212 did not accept the API key and secret.`, {
|
|
749
|
+
...details,
|
|
750
|
+
environment,
|
|
751
|
+
hint: HINTS.authentication(environment)
|
|
752
|
+
});
|
|
753
|
+
case 403: {
|
|
754
|
+
const scope = SCOPE.exec(`${parsed.message ?? ""} ${context.text}`)?.[1];
|
|
755
|
+
if (scope) {
|
|
756
|
+
return new MissingScopeError(`${where} was refused: the API key does not have the "${scope}" permission.`, {
|
|
757
|
+
...details,
|
|
758
|
+
scope,
|
|
759
|
+
hint: HINTS.missingScope(scope)
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
if (searchable.includes(DEMO_ONLY)) {
|
|
763
|
+
return new NotAvailableInDemoError(`${where} was refused: Trading 212 does not allow this on a demo account.`, {
|
|
764
|
+
...details,
|
|
765
|
+
hint: HINTS.notAvailableInDemo
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return new PermissionError(`${where} was refused${said}`, { ...details, hint: HINTS.permission });
|
|
769
|
+
}
|
|
770
|
+
case 404:
|
|
771
|
+
return new NotFoundError(
|
|
772
|
+
`${where} failed: ${parsed.message ? sentence(parsed.message) : "Trading 212 has nothing at that address."}`,
|
|
773
|
+
{
|
|
774
|
+
...details,
|
|
775
|
+
hint: notFoundHint(request.endpoint)
|
|
776
|
+
}
|
|
777
|
+
);
|
|
778
|
+
case 408:
|
|
779
|
+
return new TimeoutError(`${where} failed with ${answered}${said}`, { ...details, hint: HINTS.timeout(kind) });
|
|
780
|
+
case 429: {
|
|
781
|
+
const resetAt = rateLimit?.resetAt;
|
|
782
|
+
const retryAfterMs = resetAt === void 0 ? void 0 : Math.max(0, resetAt - context.now);
|
|
783
|
+
const budget = rateLimit?.limit !== void 0 && rateLimit.periodMs !== void 0 ? ` (the limit is ${String(rateLimit.limit)} per ${String(rateLimit.periodMs / 1e3)} s)` : "";
|
|
784
|
+
return new RateLimitError(`${where} was refused: too many requests${budget}.`, {
|
|
785
|
+
...details,
|
|
786
|
+
...resetAt !== void 0 && { resetAt },
|
|
787
|
+
...retryAfterMs !== void 0 && { retryAfterMs },
|
|
788
|
+
hint: HINTS.rateLimited(request.endpoint, resetAt)
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
default: {
|
|
792
|
+
const message = `${where} failed with ${answered}${said}`;
|
|
793
|
+
if (status >= 500) return new ServerError(message, { ...details, hint: HINTS.server(kind) });
|
|
794
|
+
return new HttpError(message, details);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/logging/logger.ts
|
|
800
|
+
var LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
801
|
+
var noop = () => void 0;
|
|
802
|
+
var silentLogger = { debug: noop, info: noop, warn: noop, error: noop };
|
|
803
|
+
function formatFields(fields) {
|
|
804
|
+
return Object.entries(fields).filter(([, value]) => value !== void 0).map(([key, value]) => {
|
|
805
|
+
const bare = typeof value === "string" && /^[^\s"=]+$/.test(value);
|
|
806
|
+
return `${key}=${bare ? value : JSON.stringify(value)}`;
|
|
807
|
+
}).join(" ");
|
|
808
|
+
}
|
|
809
|
+
function consoleLogger(options = {}) {
|
|
810
|
+
const threshold = LEVELS[options.level ?? "info"];
|
|
811
|
+
const target = options.console ?? console;
|
|
812
|
+
const write = (level) => (message, fields) => {
|
|
813
|
+
if (LEVELS[level] < threshold) return;
|
|
814
|
+
const extra = fields ? formatFields(fields) : "";
|
|
815
|
+
target[level](`[trading212] ${level.toUpperCase().padEnd(5)} ${message}${extra ? ` ${extra}` : ""}`);
|
|
816
|
+
};
|
|
817
|
+
return { debug: write("debug"), info: write("info"), warn: write("warn"), error: write("error") };
|
|
818
|
+
}
|
|
819
|
+
function fieldsFirst(target) {
|
|
820
|
+
return {
|
|
821
|
+
debug: (message, fields = {}) => {
|
|
822
|
+
target.debug(fields, message);
|
|
823
|
+
},
|
|
824
|
+
info: (message, fields = {}) => {
|
|
825
|
+
target.info(fields, message);
|
|
826
|
+
},
|
|
827
|
+
warn: (message, fields = {}) => {
|
|
828
|
+
target.warn(fields, message);
|
|
829
|
+
},
|
|
830
|
+
error: (message, fields = {}) => {
|
|
831
|
+
target.error(fields, message);
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function safeLogger(logger, redact) {
|
|
836
|
+
const call = (level) => (message, fields) => {
|
|
837
|
+
try {
|
|
838
|
+
logger[level](redact(message), fields ? redact(fields) : void 0);
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
return { debug: call("debug"), info: call("info"), warn: call("warn"), error: call("error") };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/resources/exchanges.ts
|
|
846
|
+
var SESSION_AFTER = {
|
|
847
|
+
OPEN: "regular",
|
|
848
|
+
BREAK_END: "regular",
|
|
849
|
+
PRE_MARKET_OPEN: "preMarket",
|
|
850
|
+
AFTER_HOURS_OPEN: "afterHours",
|
|
851
|
+
OVERNIGHT_OPEN: "overnight",
|
|
852
|
+
CLOSE: "closed",
|
|
853
|
+
BREAK_START: "closed",
|
|
854
|
+
AFTER_HOURS_CLOSE: "closed"
|
|
855
|
+
};
|
|
856
|
+
var eventOrder = (type) => SESSION_AFTER[type] === "closed" ? 0 : 1;
|
|
857
|
+
function sortedEvents(events) {
|
|
858
|
+
return events.flatMap((event) => {
|
|
859
|
+
const at = event.date ? new Date(event.date) : void 0;
|
|
860
|
+
return event.type && at && !Number.isNaN(at.getTime()) ? [{ type: event.type, at }] : [];
|
|
861
|
+
}).sort((a, b) => a.at.getTime() - b.at.getTime() || eventOrder(a.type) - eventOrder(b.type));
|
|
862
|
+
}
|
|
863
|
+
function sessionAt(events, at) {
|
|
864
|
+
const sorted = sortedEvents(events);
|
|
865
|
+
const time = at.getTime();
|
|
866
|
+
const previous = sorted.filter((event) => event.at.getTime() <= time).pop();
|
|
867
|
+
const upcoming = sorted.filter((event) => event.at.getTime() > time);
|
|
868
|
+
const next = upcoming[0];
|
|
869
|
+
return {
|
|
870
|
+
session: previous ? SESSION_AFTER[previous.type] ?? "unknown" : "unknown",
|
|
871
|
+
since: previous?.at,
|
|
872
|
+
next: next && { event: next.type, at: next.at },
|
|
873
|
+
nextOpen: upcoming.find((event) => SESSION_AFTER[event.type] === "regular")?.at
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
var ExchangesResource = class {
|
|
877
|
+
#context;
|
|
878
|
+
#instruments;
|
|
879
|
+
constructor(context, instruments) {
|
|
880
|
+
this.#context = context;
|
|
881
|
+
this.#instruments = instruments;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Every exchange and its working schedules, cached like the instrument list.
|
|
885
|
+
*
|
|
886
|
+
* Scope `metadata` · 1 request / 30 s.
|
|
887
|
+
*/
|
|
888
|
+
async list(options = {}) {
|
|
889
|
+
const { refresh = false, signal } = options;
|
|
890
|
+
const { cache, cacheNamespace, metadataTtlMs, transport } = this.#context;
|
|
891
|
+
return cache.load(
|
|
892
|
+
`${cacheNamespace}:exchanges`,
|
|
893
|
+
{ ttlMs: metadataTtlMs, refresh, signal, accept: isList },
|
|
894
|
+
async () => {
|
|
895
|
+
const { data } = await transport.request(ENDPOINTS.listExchanges);
|
|
896
|
+
return Array.isArray(data) ? data : [];
|
|
897
|
+
}
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Whether an instrument's market is open, read from Trading 212's working schedules, so you can check before an
|
|
902
|
+
* order is refused with `market-closed`. The schedules cover a limited window ahead, so `nextOpen` can be undefined.
|
|
903
|
+
*
|
|
904
|
+
* @example
|
|
905
|
+
* const { open, next } = await t212.exchanges.status("AAPL_US_EQ");
|
|
906
|
+
*/
|
|
907
|
+
async status(ticker, at = /* @__PURE__ */ new Date(), options = {}) {
|
|
908
|
+
const instrument = typeof ticker === "string" ? await this.#instruments.find(ticker, options) : ticker;
|
|
909
|
+
const name = typeof ticker === "string" ? ticker : ticker.ticker ?? "";
|
|
910
|
+
if (!instrument) {
|
|
911
|
+
throw new InstrumentNotFoundError(`No instrument has the ticker "${name}".`, {
|
|
912
|
+
query: name,
|
|
913
|
+
suggestions: [],
|
|
914
|
+
hint: "Use instruments.resolve() to turn a symbol or name into a Trading 212 ticker."
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
const exchanges = await this.list(options);
|
|
918
|
+
for (const exchange of exchanges) {
|
|
919
|
+
const schedule = exchange.workingSchedules?.find((candidate) => candidate.id === instrument.workingScheduleId);
|
|
920
|
+
if (schedule) {
|
|
921
|
+
const state = sessionAt(schedule.timeEvents ?? [], at);
|
|
922
|
+
return {
|
|
923
|
+
ticker: name,
|
|
924
|
+
exchange: exchange.name ?? void 0,
|
|
925
|
+
...state,
|
|
926
|
+
open: state.session === "regular",
|
|
927
|
+
extendedHours: ["preMarket", "afterHours", "overnight"].includes(state.session)
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
ticker: name,
|
|
933
|
+
exchange: void 0,
|
|
934
|
+
session: "unknown",
|
|
935
|
+
open: false,
|
|
936
|
+
extendedHours: false,
|
|
937
|
+
since: void 0,
|
|
938
|
+
next: void 0,
|
|
939
|
+
nextOpen: void 0
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// src/orders/validate.ts
|
|
945
|
+
var TIME_VALIDITIES = ["DAY", "GOOD_TILL_CANCEL"];
|
|
946
|
+
var REQUIRED = {
|
|
947
|
+
market: [],
|
|
948
|
+
limit: ["limitPrice", "timeValidity"],
|
|
949
|
+
stop: ["stopPrice", "timeValidity"],
|
|
950
|
+
stopLimit: ["stopPrice", "limitPrice", "timeValidity"]
|
|
951
|
+
};
|
|
952
|
+
var ALLOWED = {
|
|
953
|
+
market: /* @__PURE__ */ new Set(["ticker", "quantity", "extendedHours"]),
|
|
954
|
+
limit: /* @__PURE__ */ new Set(["ticker", "quantity", "limitPrice", "timeValidity"]),
|
|
955
|
+
stop: /* @__PURE__ */ new Set(["ticker", "quantity", "stopPrice", "timeValidity"]),
|
|
956
|
+
stopLimit: /* @__PURE__ */ new Set(["ticker", "quantity", "stopPrice", "limitPrice", "timeValidity"])
|
|
957
|
+
};
|
|
958
|
+
function issuesFor(type, fields, signed) {
|
|
959
|
+
const issues = [];
|
|
960
|
+
const add = (field, message) => {
|
|
961
|
+
issues.push({ field, message });
|
|
962
|
+
};
|
|
963
|
+
if (typeof fields.ticker !== "string" || fields.ticker.trim() === "") add("ticker", "is required");
|
|
964
|
+
else if (/\s/.test(fields.ticker))
|
|
965
|
+
add("ticker", "must not contain spaces; use the Trading 212 ticker, such as AAPL_US_EQ");
|
|
966
|
+
const quantity = fields.quantity;
|
|
967
|
+
if (typeof quantity !== "number" || !Number.isFinite(quantity)) add("quantity", "must be a finite number");
|
|
968
|
+
else if (quantity === 0) add("quantity", "must not be zero");
|
|
969
|
+
else if (signed === "positive" && quantity < 0)
|
|
970
|
+
add("quantity", "must be positive; buy() and sell() set the sign themselves");
|
|
971
|
+
else if (decimalPlaces(quantity) > QUANTITY_DECIMALS) {
|
|
972
|
+
add("quantity", `has more than ${QUANTITY_DECIMALS} decimal places; use truncateQuantity()`);
|
|
973
|
+
}
|
|
974
|
+
for (const field of REQUIRED[type]) {
|
|
975
|
+
if (fields[field] === void 0) add(field, `is required for a ${type} order`);
|
|
976
|
+
}
|
|
977
|
+
for (const field of ["limitPrice", "stopPrice"]) {
|
|
978
|
+
const price = fields[field];
|
|
979
|
+
if (price !== void 0 && (typeof price !== "number" || !Number.isFinite(price) || price <= 0)) {
|
|
980
|
+
add(field, "must be a positive number");
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (fields.timeValidity !== void 0 && !TIME_VALIDITIES.includes(fields.timeValidity)) {
|
|
984
|
+
add("timeValidity", 'must be "DAY" or "GOOD_TILL_CANCEL"');
|
|
985
|
+
}
|
|
986
|
+
if (fields.extendedHours !== void 0 && typeof fields.extendedHours !== "boolean") {
|
|
987
|
+
add("extendedHours", "must be true or false");
|
|
988
|
+
}
|
|
989
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
990
|
+
if (value !== void 0 && !ALLOWED[type].has(field)) {
|
|
991
|
+
add(field, field === "extendedHours" ? "is only allowed on market orders" : `is not used by a ${type} order`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return issues;
|
|
995
|
+
}
|
|
996
|
+
var ORDER_TYPES = /* @__PURE__ */ new Set(["market", "limit", "stop", "stopLimit"]);
|
|
997
|
+
function validateOrder(type, fields, signed = "any") {
|
|
998
|
+
if (!ORDER_TYPES.has(type)) {
|
|
999
|
+
throw new ValidationError(`"${type}" is not an order type.`, {
|
|
1000
|
+
issues: [{ field: "type", message: 'must be "market", "limit", "stop" or "stopLimit"' }],
|
|
1001
|
+
hint: "Nothing was sent."
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
const issues = issuesFor(type, fields, signed);
|
|
1005
|
+
if (issues.length === 0) return;
|
|
1006
|
+
const summary = issues.map((issue) => `${issue.field} ${issue.message}`).join("; ");
|
|
1007
|
+
throw new ValidationError(`The ${type} order is invalid: ${summary}.`, {
|
|
1008
|
+
issues,
|
|
1009
|
+
hint: "Nothing was sent. Fix the fields listed in `error.issues`."
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// src/internal/duration.ts
|
|
1014
|
+
var MAX_TIMER_MS = 2147483647;
|
|
1015
|
+
function durationOption(value, field) {
|
|
1016
|
+
if (value === void 0) return void 0;
|
|
1017
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_TIMER_MS) {
|
|
1018
|
+
throw new ValidationError(`${field} must be a positive number of milliseconds.`, {
|
|
1019
|
+
issues: [{ field, message: "must be a positive number of milliseconds" }],
|
|
1020
|
+
hint: "Nothing was sent."
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
return Math.ceil(value);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/internal/sleep.ts
|
|
1027
|
+
function sleep(ms, signal) {
|
|
1028
|
+
return new Promise((resolve, reject) => {
|
|
1029
|
+
if (signal?.aborted) {
|
|
1030
|
+
reject(signal.reason);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (ms <= 0) {
|
|
1034
|
+
resolve();
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const onAbort = () => {
|
|
1038
|
+
clearTimeout(timer);
|
|
1039
|
+
reject(signal?.reason);
|
|
1040
|
+
};
|
|
1041
|
+
const timer = setTimeout(() => {
|
|
1042
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1043
|
+
resolve();
|
|
1044
|
+
}, ms);
|
|
1045
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/resources/orders.ts
|
|
1050
|
+
var ENDPOINT_FOR = {
|
|
1051
|
+
market: ENDPOINTS.placeMarketOrder,
|
|
1052
|
+
limit: ENDPOINTS.placeLimitOrder,
|
|
1053
|
+
stop: ENDPOINTS.placeStopOrder,
|
|
1054
|
+
stopLimit: ENDPOINTS.placeStopLimitOrder
|
|
1055
|
+
};
|
|
1056
|
+
var FINAL_ORDER_STATUSES = /* @__PURE__ */ new Set(["FILLED", "CANCELLED", "REJECTED", "REPLACED"]);
|
|
1057
|
+
function checkOrderId(id) {
|
|
1058
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) {
|
|
1059
|
+
throw new ValidationError("An order ID must be a positive whole number.", {
|
|
1060
|
+
issues: [{ field: "id", message: "is not a valid order ID" }]
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return id;
|
|
1064
|
+
}
|
|
1065
|
+
function requestBody(input) {
|
|
1066
|
+
const body = {};
|
|
1067
|
+
for (const [key, value] of Object.entries(input)) {
|
|
1068
|
+
if (key !== "type" && value !== void 0) body[key] = value;
|
|
1069
|
+
}
|
|
1070
|
+
return body;
|
|
1071
|
+
}
|
|
1072
|
+
var OrdersResource = class {
|
|
1073
|
+
#context;
|
|
1074
|
+
#positions;
|
|
1075
|
+
#history;
|
|
1076
|
+
constructor(context, positions, history) {
|
|
1077
|
+
this.#context = context;
|
|
1078
|
+
this.#positions = positions;
|
|
1079
|
+
this.#history = history;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Orders that are still working: not yet filled, cancelled or expired.
|
|
1083
|
+
*
|
|
1084
|
+
* Scope `orders:read` · 1 request / 5 s.
|
|
1085
|
+
*/
|
|
1086
|
+
async list(options = {}) {
|
|
1087
|
+
const { data } = await this.#context.transport.request(ENDPOINTS.listOrders, options);
|
|
1088
|
+
return Array.isArray(data) ? data : [];
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* One pending order. Throws `NotFoundError` once it has filled or been cancelled.
|
|
1092
|
+
*
|
|
1093
|
+
* Scope `orders:read` · 1 request / 1 s.
|
|
1094
|
+
*/
|
|
1095
|
+
async get(id, options = {}) {
|
|
1096
|
+
const { data } = await this.#context.transport.request(ENDPOINTS.getOrder, {
|
|
1097
|
+
...options,
|
|
1098
|
+
params: { id: checkOrderId(id) }
|
|
1099
|
+
});
|
|
1100
|
+
return data;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Asks Trading 212 to cancel a pending order. Success means the request was accepted; the order may still fill
|
|
1104
|
+
* first. Never retried.
|
|
1105
|
+
*
|
|
1106
|
+
* Scope `orders:execute` · 50 requests / minute.
|
|
1107
|
+
*/
|
|
1108
|
+
async cancel(id, options = {}) {
|
|
1109
|
+
await this.#context.transport.request(ENDPOINTS.cancelOrder, { ...options, params: { id: checkOrderId(id) } });
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Trades at the next available price. A negative quantity sells. Never retried.
|
|
1113
|
+
*
|
|
1114
|
+
* Scope `orders:execute` · 50 requests / minute.
|
|
1115
|
+
*/
|
|
1116
|
+
async market(request, options) {
|
|
1117
|
+
return this.#place("market", request, options);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Trades at `limitPrice` or better. A negative quantity sells. Never retried.
|
|
1121
|
+
*
|
|
1122
|
+
* Scope `orders:execute` · 1 request / 2 s.
|
|
1123
|
+
*/
|
|
1124
|
+
async limit(request, options) {
|
|
1125
|
+
return this.#place("limit", request, options);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Places a market order once the price reaches `stopPrice`: a negative quantity makes a stop-loss. Never retried.
|
|
1129
|
+
*
|
|
1130
|
+
* Scope `orders:execute` · 1 request / 2 s.
|
|
1131
|
+
*/
|
|
1132
|
+
async stop(request, options) {
|
|
1133
|
+
return this.#place("stop", request, options);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Places a limit order at `limitPrice` once the price reaches `stopPrice`. Never retried.
|
|
1137
|
+
*
|
|
1138
|
+
* Scope `orders:execute` · 1 request / 2 s.
|
|
1139
|
+
*/
|
|
1140
|
+
async stopLimit(request, options) {
|
|
1141
|
+
return this.#place("stopLimit", request, options);
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Buys. The quantity is positive; `type` defaults to `market`.
|
|
1145
|
+
*
|
|
1146
|
+
* @example
|
|
1147
|
+
* await t212.orders.buy({ ticker: "AAPL_US_EQ", quantity: 0.5 });
|
|
1148
|
+
* await t212.orders.buy({ type: "limit", ticker: "AAPL_US_EQ", quantity: 1, limitPrice: 180, timeValidity: "DAY" });
|
|
1149
|
+
*/
|
|
1150
|
+
async buy(input, options) {
|
|
1151
|
+
const { type = "market", ...fields } = input;
|
|
1152
|
+
validateOrder(type, fields, "positive");
|
|
1153
|
+
return this.#place(type, fields, options);
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Sells. The quantity is positive and the sign is set for you, so a slip cannot turn a sale into a purchase.
|
|
1157
|
+
*
|
|
1158
|
+
* @example
|
|
1159
|
+
* await t212.orders.sell({ type: "stop", ticker: "AAPL_US_EQ", quantity: 1, stopPrice: 150, timeValidity: "GOOD_TILL_CANCEL" });
|
|
1160
|
+
*/
|
|
1161
|
+
async sell(input, options) {
|
|
1162
|
+
const { type = "market", ...fields } = input;
|
|
1163
|
+
validateOrder(type, fields, "positive");
|
|
1164
|
+
return this.#place(type, { ...fields, quantity: -fields.quantity }, options);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Sells everything you can of one holding at market: `quantityAvailableForTrading`, cut to 4 decimal places so it
|
|
1168
|
+
* never exceeds what you own. Shares held in pies or reserved by pending orders are left alone.
|
|
1169
|
+
*/
|
|
1170
|
+
async sellAll(ticker, options) {
|
|
1171
|
+
const { extendedHours, dryRun, ...call } = options ?? {};
|
|
1172
|
+
const position = await this.#positions.get(ticker, call);
|
|
1173
|
+
if (!position) {
|
|
1174
|
+
throw new ValidationError(`You hold no ${ticker}, so there is nothing to sell.`, {
|
|
1175
|
+
issues: [{ field: "ticker", message: "not held" }]
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
const available = position.quantityAvailableForTrading;
|
|
1179
|
+
if (typeof available !== "number") {
|
|
1180
|
+
throw new ValidationError(`Trading 212 did not say how much of your ${ticker} is available to sell.`, {
|
|
1181
|
+
issues: [{ field: "quantityAvailableForTrading", message: "is missing from the position" }],
|
|
1182
|
+
hint: "Sell an explicit quantity with orders.sell() instead."
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
const quantity = truncateQuantity(available);
|
|
1186
|
+
if (quantity <= 0) {
|
|
1187
|
+
throw new ValidationError(`None of your ${ticker} is available to sell.`, {
|
|
1188
|
+
issues: [{ field: "quantity", message: `${available} available` }],
|
|
1189
|
+
hint: "Shares held in pies or reserved by pending orders cannot be sold until they are released."
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
const request = {
|
|
1193
|
+
ticker,
|
|
1194
|
+
quantity: -quantity,
|
|
1195
|
+
...extendedHours !== void 0 && { extendedHours }
|
|
1196
|
+
};
|
|
1197
|
+
return this.#place("market", request, { ...call, ...dryRun !== void 0 && { dryRun } });
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Waits until an order is filled, cancelled or rejected, and returns it in its final state. It polls the pending
|
|
1201
|
+
* order, and once that is gone looks the order up in the history, so the key needs `history:orders` as well.
|
|
1202
|
+
* `timeoutMs` bounds the whole wait, including any wait for rate-limit budget.
|
|
1203
|
+
*/
|
|
1204
|
+
async waitFor(order, options = {}) {
|
|
1205
|
+
const { signal } = options;
|
|
1206
|
+
const timeoutMs = durationOption(options.timeoutMs, "timeoutMs") ?? 6e4;
|
|
1207
|
+
const intervalMs = durationOption(options.intervalMs, "intervalMs") ?? 1e3;
|
|
1208
|
+
const id = checkOrderId(typeof order === "number" ? order : order.id);
|
|
1209
|
+
let ticker = typeof order === "number" ? void 0 : order.ticker ?? order.instrument?.ticker ?? void 0;
|
|
1210
|
+
let last = typeof order === "number" ? void 0 : order;
|
|
1211
|
+
let gone = false;
|
|
1212
|
+
const deadline = Date.now() + timeoutMs;
|
|
1213
|
+
const expiry = AbortSignal.timeout(timeoutMs);
|
|
1214
|
+
const call = { signal: signal ? AbortSignal.any([signal, expiry]) : expiry };
|
|
1215
|
+
const timedOut = () => new TimeoutError(`Order ${id} had not settled after ${timeoutMs} ms (last seen ${last?.status ?? "pending"}).`, {
|
|
1216
|
+
hint: "It may still fill. Check it again with orders.get() or history.orders.list()."
|
|
1217
|
+
});
|
|
1218
|
+
try {
|
|
1219
|
+
for (; ; ) {
|
|
1220
|
+
if (!gone) {
|
|
1221
|
+
try {
|
|
1222
|
+
const current = await this.get(id, call);
|
|
1223
|
+
last = current;
|
|
1224
|
+
ticker ??= current.ticker ?? current.instrument?.ticker ?? void 0;
|
|
1225
|
+
if (current.status && FINAL_ORDER_STATUSES.has(current.status)) return current;
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
if (!(error instanceof NotFoundError)) throw error;
|
|
1228
|
+
gone = true;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (gone) {
|
|
1232
|
+
const settled = await this.#fromHistory(id, ticker, call);
|
|
1233
|
+
if (settled) return settled;
|
|
1234
|
+
}
|
|
1235
|
+
if (Date.now() + intervalMs > deadline) throw timedOut();
|
|
1236
|
+
await sleep(intervalMs, call.signal);
|
|
1237
|
+
}
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
if (expiry.aborted && !signal?.aborted) throw timedOut();
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
async #fromHistory(id, ticker, call) {
|
|
1244
|
+
const page = await this.#history.list({ ...call, limit: 50, ...ticker && { ticker } });
|
|
1245
|
+
return page.items.find((item) => item.order?.id === id)?.order ?? void 0;
|
|
1246
|
+
}
|
|
1247
|
+
async #place(type, request, options) {
|
|
1248
|
+
const { dryRun = false, ...call } = options ?? {};
|
|
1249
|
+
const body = requestBody(request);
|
|
1250
|
+
validateOrder(type, body);
|
|
1251
|
+
const endpoint = ENDPOINT_FOR[type];
|
|
1252
|
+
const { transport } = this.#context;
|
|
1253
|
+
if (dryRun) {
|
|
1254
|
+
const { url } = transport.describe(endpoint);
|
|
1255
|
+
const result = {
|
|
1256
|
+
dryRun: true,
|
|
1257
|
+
method: "POST",
|
|
1258
|
+
url,
|
|
1259
|
+
endpoint: endpoint.id,
|
|
1260
|
+
body,
|
|
1261
|
+
wouldSend: transport.tradingEnabled
|
|
1262
|
+
};
|
|
1263
|
+
return result;
|
|
1264
|
+
}
|
|
1265
|
+
const { data } = await transport.request(endpoint, { ...call, body, expectBody: true });
|
|
1266
|
+
return data;
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
// src/resources/portfolio.ts
|
|
1271
|
+
var numberOrZero = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1272
|
+
function buildPortfolioSnapshot(summary, positions, takenAt = /* @__PURE__ */ new Date()) {
|
|
1273
|
+
const holdings = positions.map((position) => {
|
|
1274
|
+
const value = numberOrZero(position.walletImpact?.currentValue);
|
|
1275
|
+
const cost = numberOrZero(position.walletImpact?.totalCost);
|
|
1276
|
+
const profitLoss = numberOrZero(position.walletImpact?.unrealizedProfitLoss);
|
|
1277
|
+
return {
|
|
1278
|
+
ticker: position.instrument?.ticker ?? "",
|
|
1279
|
+
name: position.instrument?.name ?? void 0,
|
|
1280
|
+
quantity: numberOrZero(position.quantity),
|
|
1281
|
+
value,
|
|
1282
|
+
cost,
|
|
1283
|
+
profitLoss,
|
|
1284
|
+
profitLossRatio: cost === 0 ? void 0 : profitLoss / cost
|
|
1285
|
+
};
|
|
1286
|
+
});
|
|
1287
|
+
const invested = holdings.reduce((total, holding) => total + holding.value, 0);
|
|
1288
|
+
return {
|
|
1289
|
+
currency: summary.currency ?? void 0,
|
|
1290
|
+
totalValue: numberOrZero(summary.totalValue),
|
|
1291
|
+
cash: numberOrZero(summary.cash?.availableToTrade),
|
|
1292
|
+
invested,
|
|
1293
|
+
unrealisedProfitLoss: numberOrZero(summary.investments?.unrealizedProfitLoss),
|
|
1294
|
+
realisedProfitLoss: numberOrZero(summary.investments?.realizedProfitLoss),
|
|
1295
|
+
holdings: holdings.map((holding) => ({ ...holding, weight: invested === 0 ? 0 : holding.value / invested })).sort((a, b) => b.value - a.value),
|
|
1296
|
+
takenAt
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
var PortfolioResource = class {
|
|
1300
|
+
#account;
|
|
1301
|
+
#positions;
|
|
1302
|
+
constructor(account, positions) {
|
|
1303
|
+
this.#account = account;
|
|
1304
|
+
this.#positions = positions;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* The account's totals and every holding's value, profit and weight, from two requests.
|
|
1308
|
+
*
|
|
1309
|
+
* @example
|
|
1310
|
+
* const { totalValue, holdings } = await t212.portfolio.snapshot();
|
|
1311
|
+
*/
|
|
1312
|
+
async snapshot(options = {}) {
|
|
1313
|
+
const [summary, positions] = await Promise.all([this.#account.summary(options), this.#positions.list(options)]);
|
|
1314
|
+
return buildPortfolioSnapshot(summary, positions);
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
// src/version.ts
|
|
1319
|
+
var VERSION = "0.1.2" ;
|
|
1320
|
+
|
|
1321
|
+
// src/http/retry.ts
|
|
1322
|
+
var DEFAULT_RETRY = { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 1e4 };
|
|
1323
|
+
function isRetryable(error) {
|
|
1324
|
+
return error instanceof TimeoutError || error instanceof ServerError || error instanceof RateLimitError || error instanceof NetworkError;
|
|
1325
|
+
}
|
|
1326
|
+
function retryDelay(attempt, retry, error, random = Math.random) {
|
|
1327
|
+
if (error instanceof RateLimitError && error.retryAfterMs !== void 0) {
|
|
1328
|
+
return error.retryAfterMs + Math.floor(random() * 250);
|
|
1329
|
+
}
|
|
1330
|
+
const ceiling = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
1331
|
+
return Math.floor(random() * ceiling);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/config.ts
|
|
1335
|
+
var ONE_HOUR = 60 * 60 * 1e3;
|
|
1336
|
+
function fail(field, message, hint) {
|
|
1337
|
+
throw new ConfigurationError(message, { field, ...hint && { hint } });
|
|
1338
|
+
}
|
|
1339
|
+
function checkCredential(field, value) {
|
|
1340
|
+
const label = field === "apiKey" ? "API key" : "API secret";
|
|
1341
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1342
|
+
fail(field, `The ${label} is missing.`, "Create a key in the Trading 212 app under Settings > API (Beta).");
|
|
1343
|
+
}
|
|
1344
|
+
if (value.trim() !== value) {
|
|
1345
|
+
fail(field, `The ${label} starts or ends with whitespace.`, "This is usually a copy-paste slip: trim it.");
|
|
1346
|
+
}
|
|
1347
|
+
if (/\s/.test(value)) fail(field, `The ${label} contains whitespace.`, "Copy it again from the Trading 212 app.");
|
|
1348
|
+
return value;
|
|
1349
|
+
}
|
|
1350
|
+
function positiveMs(field, value, fallback, max) {
|
|
1351
|
+
if (value === void 0) return fallback;
|
|
1352
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > max) {
|
|
1353
|
+
fail(field, `${field} must be a positive number of milliseconds no greater than ${max}.`);
|
|
1354
|
+
}
|
|
1355
|
+
return Math.ceil(value);
|
|
1356
|
+
}
|
|
1357
|
+
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1358
|
+
function resolveOrigin(baseUrl, environment) {
|
|
1359
|
+
const official = BASE_URLS[environment];
|
|
1360
|
+
if (baseUrl === void 0) return { origin: official, official: true };
|
|
1361
|
+
let url;
|
|
1362
|
+
try {
|
|
1363
|
+
url = new URL(baseUrl);
|
|
1364
|
+
} catch {
|
|
1365
|
+
fail("baseUrl", "baseUrl is not a valid URL.");
|
|
1366
|
+
}
|
|
1367
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname))) {
|
|
1368
|
+
fail(
|
|
1369
|
+
"baseUrl",
|
|
1370
|
+
"baseUrl must use https://, so your key is never sent in the clear.",
|
|
1371
|
+
"Only http://localhost is allowed without TLS."
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
if (url.username || url.password) fail("baseUrl", "baseUrl must not contain a username or password.");
|
|
1375
|
+
if (url.search || url.hash || url.pathname !== "/" && url.pathname !== "") {
|
|
1376
|
+
fail("baseUrl", "baseUrl must be an origin only, such as https://demo.trading212.com, with no path.");
|
|
1377
|
+
}
|
|
1378
|
+
const other = environment === "demo" ? "live" : "demo";
|
|
1379
|
+
if (url.origin === BASE_URLS[other]) {
|
|
1380
|
+
fail(
|
|
1381
|
+
"baseUrl",
|
|
1382
|
+
`baseUrl is Trading 212's ${other} host, but environment is "${environment}".`,
|
|
1383
|
+
`Set environment to "${other}" instead of pointing baseUrl at it.`
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
return { origin: url.origin, official: url.origin === official };
|
|
1387
|
+
}
|
|
1388
|
+
var ENVIRONMENT_ALIASES = /* @__PURE__ */ new Map([
|
|
1389
|
+
["prod", "live"],
|
|
1390
|
+
["production", "live"],
|
|
1391
|
+
["real", "live"],
|
|
1392
|
+
["paper", "demo"],
|
|
1393
|
+
["practice", "demo"],
|
|
1394
|
+
["sandbox", "demo"],
|
|
1395
|
+
["test", "demo"]
|
|
1396
|
+
]);
|
|
1397
|
+
function environmentHint(value, field) {
|
|
1398
|
+
const alias = typeof value === "string" ? ENVIRONMENT_ALIASES.get(value.trim().toLowerCase()) : void 0;
|
|
1399
|
+
if (alias) return `Trading 212 calls that environment "${alias}": set ${field} to ${alias}.`;
|
|
1400
|
+
return `Set ${field} to demo or live. There is deliberately no default, because live trades real money.`;
|
|
1401
|
+
}
|
|
1402
|
+
function isBrowser() {
|
|
1403
|
+
const scope = globalThis;
|
|
1404
|
+
return typeof scope.document === "object" && typeof scope.window === "object";
|
|
1405
|
+
}
|
|
1406
|
+
function resolveOptions(options) {
|
|
1407
|
+
if (typeof options !== "object" || options === null) fail("options", "Pass an options object.");
|
|
1408
|
+
if (!isEnvironment(options.environment)) {
|
|
1409
|
+
fail("environment", 'environment must be "demo" or "live".', environmentHint(options.environment, "environment"));
|
|
1410
|
+
}
|
|
1411
|
+
if (isBrowser() && !options.dangerouslyAllowBrowser) {
|
|
1412
|
+
fail(
|
|
1413
|
+
"dangerouslyAllowBrowser",
|
|
1414
|
+
"trading212.js refuses to run in a browser.",
|
|
1415
|
+
"Trading 212 rejects requests from web pages, and any key you ship to a browser is public. Call the API from a server instead."
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
const apiKey = checkCredential("apiKey", options.apiKey);
|
|
1419
|
+
const apiSecret = checkCredential("apiSecret", options.apiSecret);
|
|
1420
|
+
if (apiKey === apiSecret) {
|
|
1421
|
+
fail("apiSecret", "The API secret is the same as the API key.", "Paste the secret, not the key, into apiSecret.");
|
|
1422
|
+
}
|
|
1423
|
+
if (apiKey.includes(":")) fail("apiKey", "The API key contains a colon, which no Trading 212 key has.");
|
|
1424
|
+
const trading = options.trading;
|
|
1425
|
+
if (trading !== void 0 && trading !== "enabled" && trading !== "disabled") {
|
|
1426
|
+
fail("trading", 'trading must be "enabled" or "disabled".');
|
|
1427
|
+
}
|
|
1428
|
+
if (options.rateLimit !== void 0 && !["pace", "burst", "off"].includes(options.rateLimit)) {
|
|
1429
|
+
fail("rateLimit", 'rateLimit must be "pace", "burst" or "off".');
|
|
1430
|
+
}
|
|
1431
|
+
let retry = DEFAULT_RETRY;
|
|
1432
|
+
if (options.retry === false) retry = null;
|
|
1433
|
+
else if (options.retry) {
|
|
1434
|
+
const maxAttempts = options.retry.maxAttempts ?? DEFAULT_RETRY.maxAttempts;
|
|
1435
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10) {
|
|
1436
|
+
fail("retry.maxAttempts", "retry.maxAttempts must be a whole number from 1 to 10.");
|
|
1437
|
+
}
|
|
1438
|
+
retry = {
|
|
1439
|
+
maxAttempts,
|
|
1440
|
+
baseDelayMs: positiveMs("retry.baseDelayMs", options.retry.baseDelayMs, DEFAULT_RETRY.baseDelayMs, 6e4),
|
|
1441
|
+
maxDelayMs: positiveMs("retry.maxDelayMs", options.retry.maxDelayMs, DEFAULT_RETRY.maxDelayMs, 3e5)
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
const { origin: resolvedOrigin, official } = resolveOrigin(options.baseUrl, options.environment);
|
|
1445
|
+
return {
|
|
1446
|
+
environment: options.environment,
|
|
1447
|
+
apiKey,
|
|
1448
|
+
apiSecret,
|
|
1449
|
+
// Only Trading 212's own demo host may trade by default: a proxy could forward anywhere, including live.
|
|
1450
|
+
trading: options.trading ?? (options.environment === "demo" && official ? "enabled" : "disabled"),
|
|
1451
|
+
timeoutMs: positiveMs("timeoutMs", options.timeoutMs, 15e3, 6e5),
|
|
1452
|
+
retry,
|
|
1453
|
+
rateLimit: options.rateLimit ?? "pace",
|
|
1454
|
+
maxRateLimitWaitMs: positiveMs("maxRateLimitWaitMs", options.maxRateLimitWaitMs, 6e4, 36e5),
|
|
1455
|
+
metadataTtlMs: positiveMs("metadataTtlMs", options.metadataTtlMs, ONE_HOUR, 7 * 24 * ONE_HOUR),
|
|
1456
|
+
origin: resolvedOrigin,
|
|
1457
|
+
officialOrigin: official
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function optionsFromEnv(env) {
|
|
1461
|
+
const source = env ?? globalThis.process?.env ?? {};
|
|
1462
|
+
const environment = source.T212_ENV?.trim().toLowerCase();
|
|
1463
|
+
if (!isEnvironment(environment)) {
|
|
1464
|
+
fail(
|
|
1465
|
+
"T212_ENV",
|
|
1466
|
+
environment ? `T212_ENV is "${environment}", which is neither demo nor live.` : "T212_ENV is not set.",
|
|
1467
|
+
environmentHint(environment, "T212_ENV")
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
const prefix = environment === "demo" ? "T212_DEMO" : "T212_LIVE";
|
|
1471
|
+
const read = (name) => source[name] === "" ? void 0 : source[name];
|
|
1472
|
+
const apiKey = read("T212_API_KEY") ?? read(`${prefix}_API_KEY`);
|
|
1473
|
+
const apiSecret = read("T212_API_SECRET") ?? read(`${prefix}_API_SECRET`);
|
|
1474
|
+
if (!apiKey) fail("T212_API_KEY", `Neither T212_API_KEY nor ${prefix}_API_KEY is set.`);
|
|
1475
|
+
if (!apiSecret) fail("T212_API_SECRET", `Neither T212_API_SECRET nor ${prefix}_API_SECRET is set.`);
|
|
1476
|
+
return { environment, apiKey, apiSecret };
|
|
1477
|
+
}
|
|
1478
|
+
function basicAuthorization(apiKey, apiSecret) {
|
|
1479
|
+
const bytes = new TextEncoder().encode(`${apiKey}:${apiSecret}`);
|
|
1480
|
+
let binary = "";
|
|
1481
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
1482
|
+
return `Basic ${btoa(binary)}`;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// src/http/rateLimiter.ts
|
|
1486
|
+
function planPaced(bucket, now) {
|
|
1487
|
+
const slot = Math.max(now, bucket.nextSlotAt, bucket.blockedUntil);
|
|
1488
|
+
return { waitMs: slot - now, next: { nextSlotAt: slot + bucket.periodMs / bucket.limit } };
|
|
1489
|
+
}
|
|
1490
|
+
function planBurst(bucket, now) {
|
|
1491
|
+
let { windowStartsAt, windowEndsAt, remaining } = bucket;
|
|
1492
|
+
if (bucket.blockedUntil > now && windowStartsAt < bucket.blockedUntil) {
|
|
1493
|
+
windowStartsAt = bucket.blockedUntil;
|
|
1494
|
+
windowEndsAt = bucket.blockedUntil + bucket.periodMs;
|
|
1495
|
+
remaining = bucket.limit;
|
|
1496
|
+
}
|
|
1497
|
+
if (now >= windowEndsAt) {
|
|
1498
|
+
windowStartsAt = now;
|
|
1499
|
+
windowEndsAt = now + bucket.periodMs;
|
|
1500
|
+
remaining = bucket.limit;
|
|
1501
|
+
}
|
|
1502
|
+
if (remaining > 0) {
|
|
1503
|
+
return {
|
|
1504
|
+
waitMs: Math.max(0, windowStartsAt - now),
|
|
1505
|
+
next: { windowStartsAt, windowEndsAt, remaining: remaining - 1 }
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
return {
|
|
1509
|
+
waitMs: windowEndsAt - now,
|
|
1510
|
+
next: { windowStartsAt: windowEndsAt, windowEndsAt: windowEndsAt + bucket.periodMs, remaining: bucket.limit - 1 }
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("trading212.js/rate-limits");
|
|
1514
|
+
function registry() {
|
|
1515
|
+
const holder = globalThis;
|
|
1516
|
+
holder[REGISTRY] ??= /* @__PURE__ */ new Map();
|
|
1517
|
+
return holder[REGISTRY];
|
|
1518
|
+
}
|
|
1519
|
+
var RateLimiter = class {
|
|
1520
|
+
#buckets;
|
|
1521
|
+
#mode;
|
|
1522
|
+
constructor(accountKey, mode) {
|
|
1523
|
+
const shared = registry();
|
|
1524
|
+
let buckets = shared.get(accountKey);
|
|
1525
|
+
if (!buckets) {
|
|
1526
|
+
buckets = /* @__PURE__ */ new Map();
|
|
1527
|
+
shared.set(accountKey, buckets);
|
|
1528
|
+
}
|
|
1529
|
+
this.#buckets = buckets;
|
|
1530
|
+
this.#mode = mode;
|
|
1531
|
+
}
|
|
1532
|
+
#bucket(endpoint) {
|
|
1533
|
+
let bucket = this.#buckets.get(endpoint.id);
|
|
1534
|
+
if (!bucket) {
|
|
1535
|
+
bucket = {
|
|
1536
|
+
limit: endpoint.rateLimit.requests,
|
|
1537
|
+
periodMs: endpoint.rateLimit.periodMs,
|
|
1538
|
+
nextSlotAt: 0,
|
|
1539
|
+
remaining: endpoint.rateLimit.requests,
|
|
1540
|
+
windowStartsAt: 0,
|
|
1541
|
+
windowEndsAt: 0,
|
|
1542
|
+
blockedUntil: 0,
|
|
1543
|
+
lastSeen: void 0
|
|
1544
|
+
};
|
|
1545
|
+
this.#buckets.set(endpoint.id, bucket);
|
|
1546
|
+
}
|
|
1547
|
+
return bucket;
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Books the next slot for a call. When the wait would be longer than `maxWaitMs`, nothing is booked, so a refused
|
|
1551
|
+
* call never delays the ones after it.
|
|
1552
|
+
*/
|
|
1553
|
+
reserve(endpoint, now, maxWaitMs = Number.POSITIVE_INFINITY) {
|
|
1554
|
+
const bucket = this.#bucket(endpoint);
|
|
1555
|
+
if (this.#mode === "off") return { waitMs: 0, booked: true };
|
|
1556
|
+
const plan = this.#mode === "pace" ? planPaced(bucket, now) : planBurst(bucket, now);
|
|
1557
|
+
if (plan.waitMs > maxWaitMs) return { waitMs: plan.waitMs, booked: false };
|
|
1558
|
+
Object.assign(bucket, plan.next);
|
|
1559
|
+
return { waitMs: plan.waitMs, booked: true };
|
|
1560
|
+
}
|
|
1561
|
+
/** Corrects the budget from what Trading 212's headers said, which beats the documented figures. */
|
|
1562
|
+
update(endpoint, info, now) {
|
|
1563
|
+
const bucket = this.#bucket(endpoint);
|
|
1564
|
+
bucket.lastSeen = info;
|
|
1565
|
+
if (info.limit !== void 0 && info.limit > 0) bucket.limit = info.limit;
|
|
1566
|
+
if (info.periodMs !== void 0 && info.periodMs > 0) bucket.periodMs = info.periodMs;
|
|
1567
|
+
if (info.resetAt !== void 0 && info.resetAt > now) {
|
|
1568
|
+
if (info.remaining === 0) bucket.blockedUntil = Math.max(bucket.blockedUntil, info.resetAt);
|
|
1569
|
+
if (info.remaining !== void 0 && bucket.windowStartsAt <= now && now < bucket.windowEndsAt) {
|
|
1570
|
+
bucket.remaining = Math.min(bucket.remaining, info.remaining);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/** How long the endpoint is closed for after a 429 or an empty budget, in milliseconds. */
|
|
1575
|
+
blockedFor(endpoint, now) {
|
|
1576
|
+
if (this.#mode === "off") return 0;
|
|
1577
|
+
return Math.max(0, this.#bucket(endpoint).blockedUntil - now);
|
|
1578
|
+
}
|
|
1579
|
+
/** Records a 429: nothing more is sent to the endpoint until the reset. */
|
|
1580
|
+
markLimited(endpoint, resetAt, now) {
|
|
1581
|
+
const bucket = this.#bucket(endpoint);
|
|
1582
|
+
bucket.blockedUntil = Math.max(bucket.blockedUntil, resetAt ?? now + bucket.periodMs);
|
|
1583
|
+
}
|
|
1584
|
+
snapshot(endpoints) {
|
|
1585
|
+
const out = {};
|
|
1586
|
+
for (const endpoint of endpoints) {
|
|
1587
|
+
const bucket = this.#bucket(endpoint);
|
|
1588
|
+
out[endpoint.id] = {
|
|
1589
|
+
limit: bucket.limit,
|
|
1590
|
+
periodMs: bucket.periodMs,
|
|
1591
|
+
remaining: bucket.lastSeen?.remaining,
|
|
1592
|
+
resetAt: bucket.lastSeen?.resetAt === void 0 ? void 0 : new Date(bucket.lastSeen.resetAt)
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
return out;
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
// src/http/rateLimitHeaders.ts
|
|
1600
|
+
function numberHeader(headers, name) {
|
|
1601
|
+
const raw = headers.get(name);
|
|
1602
|
+
if (raw === null || raw.trim() === "") return void 0;
|
|
1603
|
+
const value = Number(raw);
|
|
1604
|
+
return Number.isFinite(value) ? value : void 0;
|
|
1605
|
+
}
|
|
1606
|
+
function retryAfter(headers, now) {
|
|
1607
|
+
const raw = headers.get("retry-after")?.trim();
|
|
1608
|
+
if (!raw) return void 0;
|
|
1609
|
+
if (/^\d+$/.test(raw)) return now + Number(raw) * 1e3;
|
|
1610
|
+
const date = Date.parse(raw);
|
|
1611
|
+
return Number.isNaN(date) ? void 0 : date;
|
|
1612
|
+
}
|
|
1613
|
+
function parseRateLimitHeaders(headers, now = Date.now()) {
|
|
1614
|
+
const limit = numberHeader(headers, "x-ratelimit-limit");
|
|
1615
|
+
const period = numberHeader(headers, "x-ratelimit-period");
|
|
1616
|
+
const remaining = numberHeader(headers, "x-ratelimit-remaining");
|
|
1617
|
+
const used = numberHeader(headers, "x-ratelimit-used");
|
|
1618
|
+
const resetHeader = numberHeader(headers, "x-ratelimit-reset");
|
|
1619
|
+
const reset = resetHeader === void 0 ? retryAfter(headers, now) : resetHeader > 1e12 ? resetHeader : resetHeader * 1e3;
|
|
1620
|
+
if ([limit, period, remaining, used, reset].every((value) => value === void 0)) return void 0;
|
|
1621
|
+
return {
|
|
1622
|
+
...limit !== void 0 && { limit },
|
|
1623
|
+
...period !== void 0 && { periodMs: period * 1e3 },
|
|
1624
|
+
...remaining !== void 0 && { remaining },
|
|
1625
|
+
...used !== void 0 && { used },
|
|
1626
|
+
...reset !== void 0 && { resetAt: reset }
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/http/transport.ts
|
|
1631
|
+
var MAX_ERROR_BODY = 64 * 1024;
|
|
1632
|
+
var NOT_SENT = /* @__PURE__ */ new Set([
|
|
1633
|
+
"ENOTFOUND",
|
|
1634
|
+
"EAI_AGAIN",
|
|
1635
|
+
"ECONNREFUSED",
|
|
1636
|
+
"ENETUNREACH",
|
|
1637
|
+
"EHOSTUNREACH",
|
|
1638
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
1639
|
+
"CERT_HAS_EXPIRED",
|
|
1640
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
1641
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
1642
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
1643
|
+
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
1644
|
+
]);
|
|
1645
|
+
function errorCode(error) {
|
|
1646
|
+
let current = error;
|
|
1647
|
+
for (let depth = 0; depth < 4 && current; depth++) {
|
|
1648
|
+
const code = current.code;
|
|
1649
|
+
if (typeof code === "string") return code;
|
|
1650
|
+
current = current.cause;
|
|
1651
|
+
}
|
|
1652
|
+
return void 0;
|
|
1653
|
+
}
|
|
1654
|
+
function buildPath(template, params = {}) {
|
|
1655
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => {
|
|
1656
|
+
const value = params[name];
|
|
1657
|
+
if (value === void 0 || value === "") {
|
|
1658
|
+
throw new ValidationError(`Missing the "${name}" for ${template}.`, {
|
|
1659
|
+
issues: [{ field: name, message: "is required" }]
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
return encodeURIComponent(String(value));
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
function encodeQuery(query) {
|
|
1666
|
+
if (!query) return "";
|
|
1667
|
+
const search = new URLSearchParams();
|
|
1668
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1669
|
+
if (value !== void 0) search.set(key, String(value));
|
|
1670
|
+
}
|
|
1671
|
+
return search.toString();
|
|
1672
|
+
}
|
|
1673
|
+
var Transport = class {
|
|
1674
|
+
#config;
|
|
1675
|
+
#warnedDeprecated = /* @__PURE__ */ new Set();
|
|
1676
|
+
constructor(config) {
|
|
1677
|
+
this.#config = config;
|
|
1678
|
+
}
|
|
1679
|
+
get origin() {
|
|
1680
|
+
return this.#config.origin;
|
|
1681
|
+
}
|
|
1682
|
+
get tradingEnabled() {
|
|
1683
|
+
return this.#config.trading === "enabled";
|
|
1684
|
+
}
|
|
1685
|
+
rateLimitSnapshot() {
|
|
1686
|
+
return this.#config.rateLimiter.snapshot(Object.values(ENDPOINTS));
|
|
1687
|
+
}
|
|
1688
|
+
/** Builds a request's URL and description without sending it. */
|
|
1689
|
+
describe(endpoint, options = {}) {
|
|
1690
|
+
const path = buildPath(endpoint.path, options.params);
|
|
1691
|
+
const query = options.rawQuery ?? encodeQuery(options.query);
|
|
1692
|
+
return {
|
|
1693
|
+
method: endpoint.method,
|
|
1694
|
+
path,
|
|
1695
|
+
endpoint: endpoint.id,
|
|
1696
|
+
url: `${this.#config.origin}${path}${query ? `?${query}` : ""}`
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
async request(endpoint, given = {}) {
|
|
1700
|
+
const { url, ...description } = this.describe(endpoint, given);
|
|
1701
|
+
const config = this.#config;
|
|
1702
|
+
const options = { ...given, timeoutMs: durationOption(given.timeoutMs, "timeoutMs") ?? config.timeoutMs };
|
|
1703
|
+
if (endpoint.kind === "trade" && !this.tradingEnabled) {
|
|
1704
|
+
throw new TradingDisabledError(`Refused to send ${description.method} ${description.path}: trading is off.`, {
|
|
1705
|
+
request: description,
|
|
1706
|
+
hint: HINTS.tradingDisabled
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
if (endpoint.deprecated && !this.#warnedDeprecated.has(endpoint.id)) {
|
|
1710
|
+
this.#warnedDeprecated.add(endpoint.id);
|
|
1711
|
+
config.logger.warn(`${endpoint.id} uses a deprecated Trading 212 endpoint that may be removed.`, {
|
|
1712
|
+
endpoint: endpoint.id
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
const maxAttempts = endpoint.kind === "read" && config.retry ? config.retry.maxAttempts : 1;
|
|
1716
|
+
for (let attempt = 1; ; attempt++) {
|
|
1717
|
+
try {
|
|
1718
|
+
return await this.#attempt(endpoint, url, description, options, attempt);
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
const { retry } = config;
|
|
1721
|
+
const delayMs = retry && attempt < maxAttempts && isRetryable(error) ? retryDelay(attempt, retry, error) : Number.POSITIVE_INFINITY;
|
|
1722
|
+
if (delayMs > config.maxRateLimitWaitMs) {
|
|
1723
|
+
this.#logFailure(endpoint, description, error);
|
|
1724
|
+
throw error;
|
|
1725
|
+
}
|
|
1726
|
+
const event = { ...this.#event(description, attempt), delayMs, error };
|
|
1727
|
+
config.logger.warn(`Retrying ${description.method} ${description.path} in ${delayMs} ms.`, {
|
|
1728
|
+
endpoint: endpoint.id,
|
|
1729
|
+
attempt,
|
|
1730
|
+
delayMs,
|
|
1731
|
+
code: error.code
|
|
1732
|
+
});
|
|
1733
|
+
this.#hook(() => config.hooks.onRetry?.(event));
|
|
1734
|
+
await sleep(delayMs, options.signal);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
// Anything that changes the account is logged at info, so a default logger keeps a record of every order.
|
|
1739
|
+
#logFailure(endpoint, description, error) {
|
|
1740
|
+
const where = `${description.method} ${description.path}`;
|
|
1741
|
+
const known = isTrading212Error(error);
|
|
1742
|
+
const fields = {
|
|
1743
|
+
endpoint: endpoint.id,
|
|
1744
|
+
...known && {
|
|
1745
|
+
code: error.code,
|
|
1746
|
+
status: error.status,
|
|
1747
|
+
apiCode: error.apiCode,
|
|
1748
|
+
traceId: error.traceId,
|
|
1749
|
+
hint: error.hint
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
if (error instanceof OrderOutcomeUnknownError) {
|
|
1753
|
+
this.#config.logger.warn(
|
|
1754
|
+
`${where} may or may not have gone through. Check orders.list() before sending it again.`,
|
|
1755
|
+
fields
|
|
1756
|
+
);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const message = known ? error.message : `${where} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1760
|
+
this.#config.logger[endpoint.kind === "read" ? "debug" : "info"](message, fields);
|
|
1761
|
+
}
|
|
1762
|
+
#event(description, attempt) {
|
|
1763
|
+
return {
|
|
1764
|
+
endpoint: description.endpoint ?? "",
|
|
1765
|
+
method: description.method,
|
|
1766
|
+
path: description.path,
|
|
1767
|
+
attempt
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
#hook(call) {
|
|
1771
|
+
try {
|
|
1772
|
+
call();
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
async #waitForBudget(endpoint, description, signal) {
|
|
1777
|
+
const config = this.#config;
|
|
1778
|
+
const deadline = Date.now() + config.maxRateLimitWaitMs;
|
|
1779
|
+
for (; ; ) {
|
|
1780
|
+
if (signal?.aborted) throw signal.reason;
|
|
1781
|
+
const now = Date.now();
|
|
1782
|
+
const { waitMs, booked } = config.rateLimiter.reserve(endpoint, now, deadline - now);
|
|
1783
|
+
if (!booked) {
|
|
1784
|
+
throw new RateLimitError(
|
|
1785
|
+
`${description.method} ${description.path} would have to wait ${Math.ceil(waitMs / 1e3)} s for rate-limit budget, longer than maxRateLimitWaitMs.`,
|
|
1786
|
+
{
|
|
1787
|
+
request: description,
|
|
1788
|
+
retryAfterMs: waitMs,
|
|
1789
|
+
resetAt: now + waitMs,
|
|
1790
|
+
hint: HINTS.rateLimited(endpoint.id, now + waitMs)
|
|
1791
|
+
}
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
if (waitMs > 0) {
|
|
1795
|
+
const message = `Waiting ${waitMs} ms for ${endpoint.id}'s rate limit.`;
|
|
1796
|
+
if (waitMs >= 5e3) config.logger.warn(message, { endpoint: endpoint.id, waitMs });
|
|
1797
|
+
else config.logger.debug(message, { endpoint: endpoint.id, waitMs });
|
|
1798
|
+
this.#hook(() => config.hooks.onRateLimitWait?.({ endpoint: endpoint.id, waitMs }));
|
|
1799
|
+
await sleep(waitMs, signal);
|
|
1800
|
+
}
|
|
1801
|
+
if (config.rateLimiter.blockedFor(endpoint, Date.now()) <= 0) return;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
async #attempt(endpoint, url, description, options, attempt) {
|
|
1805
|
+
const config = this.#config;
|
|
1806
|
+
await this.#waitForBudget(endpoint, description, options.signal);
|
|
1807
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
1808
|
+
const headers = { Authorization: config.authorization, Accept: "application/json" };
|
|
1809
|
+
if (options.body !== void 0) headers["Content-Type"] = "application/json";
|
|
1810
|
+
if (config.userAgent) headers["User-Agent"] = config.userAgent;
|
|
1811
|
+
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
|
|
1812
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
1813
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
1814
|
+
const event = this.#event(description, attempt);
|
|
1815
|
+
const level = endpoint.kind === "read" ? "debug" : "info";
|
|
1816
|
+
config.logger[level](`Sending ${description.method} ${description.path}`, { endpoint: endpoint.id, attempt });
|
|
1817
|
+
this.#hook(() => config.hooks.onRequest?.(event));
|
|
1818
|
+
const started = Date.now();
|
|
1819
|
+
let response;
|
|
1820
|
+
let text;
|
|
1821
|
+
try {
|
|
1822
|
+
response = await config.fetch(url, {
|
|
1823
|
+
method: endpoint.method,
|
|
1824
|
+
headers,
|
|
1825
|
+
...options.body !== void 0 && { body: JSON.stringify(options.body) },
|
|
1826
|
+
signal,
|
|
1827
|
+
redirect: "manual"
|
|
1828
|
+
});
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
throw this.#requestFailure(error, endpoint, description, options, timeout, timeoutMs, true);
|
|
1831
|
+
}
|
|
1832
|
+
const rateLimit = parseRateLimitHeaders(response.headers);
|
|
1833
|
+
if (rateLimit) config.rateLimiter.update(endpoint, rateLimit, Date.now());
|
|
1834
|
+
try {
|
|
1835
|
+
const raw = await response.text();
|
|
1836
|
+
text = response.ok ? config.removeSecrets(raw) : config.redact(raw);
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
const mayHaveRun = response.status < 400 || response.status === 408 || response.status >= 500;
|
|
1839
|
+
throw this.#requestFailure(error, endpoint, description, options, timeout, timeoutMs, mayHaveRun);
|
|
1840
|
+
}
|
|
1841
|
+
const durationMs = Date.now() - started;
|
|
1842
|
+
config.logger[level](`${description.method} ${description.path} returned ${response.status} in ${durationMs} ms`, {
|
|
1843
|
+
endpoint: endpoint.id,
|
|
1844
|
+
status: response.status,
|
|
1845
|
+
durationMs,
|
|
1846
|
+
rateLimitRemaining: rateLimit?.remaining
|
|
1847
|
+
});
|
|
1848
|
+
this.#hook(() => config.hooks.onResponse?.({ ...event, status: response.status, durationMs, rateLimit }));
|
|
1849
|
+
if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
|
|
1850
|
+
const message = `${description.method} ${description.path} answered with a redirect, which is never followed with your credentials.`;
|
|
1851
|
+
const details = { status: response.status, request: description, ...rateLimit && { rateLimit } };
|
|
1852
|
+
if (endpoint.kind === "trade") {
|
|
1853
|
+
throw new OrderOutcomeUnknownError(message, { ...details, sent: options.body, hint: HINTS.outcomeUnknown });
|
|
1854
|
+
}
|
|
1855
|
+
throw new UnexpectedResponseError(message, details);
|
|
1856
|
+
}
|
|
1857
|
+
if (!response.ok) {
|
|
1858
|
+
if (response.status === 429) config.rateLimiter.markLimited(endpoint, rateLimit?.resetAt, Date.now());
|
|
1859
|
+
throw errorFromResponse({
|
|
1860
|
+
status: response.status,
|
|
1861
|
+
statusText: response.statusText,
|
|
1862
|
+
text: text.slice(0, MAX_ERROR_BODY),
|
|
1863
|
+
request: description,
|
|
1864
|
+
kind: endpoint.kind,
|
|
1865
|
+
environment: config.environment,
|
|
1866
|
+
rateLimit,
|
|
1867
|
+
sent: options.body,
|
|
1868
|
+
now: Date.now()
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
return {
|
|
1872
|
+
data: this.#parse(text, endpoint, description, options, response.status),
|
|
1873
|
+
status: response.status,
|
|
1874
|
+
rateLimit
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
#parse(text, endpoint, description, options, status) {
|
|
1878
|
+
const where = `${description.method} ${description.path}`;
|
|
1879
|
+
const unreadable = (message, cause) => endpoint.kind === "trade" ? new OrderOutcomeUnknownError(`${where} succeeded (${status}) but ${message}.`, {
|
|
1880
|
+
status,
|
|
1881
|
+
request: description,
|
|
1882
|
+
sent: options.body,
|
|
1883
|
+
body: this.#config.redact(text.slice(0, 2e3)),
|
|
1884
|
+
hint: HINTS.outcomeUnknown,
|
|
1885
|
+
cause
|
|
1886
|
+
}) : new UnexpectedResponseError(`${where} ${message}.`, {
|
|
1887
|
+
status,
|
|
1888
|
+
request: description,
|
|
1889
|
+
body: this.#config.redact(text.slice(0, 2e3)),
|
|
1890
|
+
cause
|
|
1891
|
+
});
|
|
1892
|
+
if (!text.trim()) {
|
|
1893
|
+
if (options.expectBody) throw unreadable("returned an empty body");
|
|
1894
|
+
return void 0;
|
|
1895
|
+
}
|
|
1896
|
+
try {
|
|
1897
|
+
return JSON.parse(text);
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
throw unreadable("returned a body that is not JSON", error);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
#requestFailure(error, endpoint, description, options, timeout, timeoutMs, mayHaveRun) {
|
|
1903
|
+
const trade = endpoint.kind === "trade";
|
|
1904
|
+
const where = `${description.method} ${description.path}`;
|
|
1905
|
+
if (options.signal?.aborted) {
|
|
1906
|
+
if (trade && mayHaveRun) {
|
|
1907
|
+
return new OrderOutcomeUnknownError(`${where} was aborted after it was sent.`, {
|
|
1908
|
+
request: description,
|
|
1909
|
+
sent: options.body,
|
|
1910
|
+
hint: HINTS.outcomeUnknown,
|
|
1911
|
+
cause: options.signal.reason
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
return options.signal.reason;
|
|
1915
|
+
}
|
|
1916
|
+
if (timeout.aborted) {
|
|
1917
|
+
if (trade && mayHaveRun) {
|
|
1918
|
+
return new OrderOutcomeUnknownError(`${where} did not answer within ${timeoutMs} ms.`, {
|
|
1919
|
+
request: description,
|
|
1920
|
+
sent: options.body,
|
|
1921
|
+
hint: HINTS.outcomeUnknown
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
return new TimeoutError(`${where} did not answer within ${timeoutMs} ms.`, {
|
|
1925
|
+
request: description,
|
|
1926
|
+
hint: HINTS.timeout(endpoint.kind)
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
const code = errorCode(error);
|
|
1930
|
+
const sent = !(code !== void 0 && NOT_SENT.has(code));
|
|
1931
|
+
if (trade && sent && mayHaveRun) {
|
|
1932
|
+
return new OrderOutcomeUnknownError(`${where} lost its connection${code ? ` (${code})` : ""}.`, {
|
|
1933
|
+
request: description,
|
|
1934
|
+
sent: options.body,
|
|
1935
|
+
hint: HINTS.outcomeUnknown,
|
|
1936
|
+
cause: error
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
const failure = sent ? "the connection broke" : "could not connect";
|
|
1940
|
+
return new NetworkError(`${where} failed: ${failure}${code ? ` (${code})` : ""}.`, {
|
|
1941
|
+
request: description,
|
|
1942
|
+
sent,
|
|
1943
|
+
hint: HINTS.network(sent, endpoint.kind),
|
|
1944
|
+
cause: error
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
// src/internal/hash.ts
|
|
1950
|
+
function fingerprint(value) {
|
|
1951
|
+
let a = 2166136261;
|
|
1952
|
+
let b = 16777619;
|
|
1953
|
+
for (let index = 0; index < value.length; index++) {
|
|
1954
|
+
const code = value.charCodeAt(index);
|
|
1955
|
+
a = Math.imul(a ^ code, 16777619);
|
|
1956
|
+
b = Math.imul(b ^ code, 1540483477) ^ b >>> 15;
|
|
1957
|
+
}
|
|
1958
|
+
return (a >>> 0).toString(16).padStart(8, "0") + (b >>> 0).toString(16).padStart(8, "0");
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// src/logging/redact.ts
|
|
1962
|
+
var REDACTED = "[REDACTED]";
|
|
1963
|
+
var SENSITIVE_KEY = /authorization|secret|password|api[-_]?key|token|cookie|credential/i;
|
|
1964
|
+
var BASIC_HEADER = /Basic\s+[A-Za-z0-9+/=]{8,}/g;
|
|
1965
|
+
var MAX_DEPTH = 8;
|
|
1966
|
+
function createRedactor(secrets) {
|
|
1967
|
+
const removeSecrets = createSecretFilter(secrets);
|
|
1968
|
+
const redactString = (text) => removeSecrets(text.replace(BASIC_HEADER, `Basic ${REDACTED}`));
|
|
1969
|
+
const visit = (value, depth, seen) => {
|
|
1970
|
+
if (typeof value === "string") return redactString(value);
|
|
1971
|
+
if (value === null || typeof value !== "object") return value;
|
|
1972
|
+
if (seen.has(value)) return "[Circular]";
|
|
1973
|
+
if (depth >= MAX_DEPTH) return "[Truncated]";
|
|
1974
|
+
seen.add(value);
|
|
1975
|
+
if (Array.isArray(value)) return value.map((item) => visit(item, depth + 1, seen));
|
|
1976
|
+
const out = {};
|
|
1977
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
1978
|
+
let safe;
|
|
1979
|
+
if (SENSITIVE_KEY.test(key)) safe = REDACTED;
|
|
1980
|
+
else if (key === "downloadLink" && typeof inner === "string") safe = stripQuery(redactString(inner));
|
|
1981
|
+
else safe = visit(inner, depth + 1, seen);
|
|
1982
|
+
Object.defineProperty(out, key, { value: safe, enumerable: true, writable: true, configurable: true });
|
|
1983
|
+
}
|
|
1984
|
+
return out;
|
|
1985
|
+
};
|
|
1986
|
+
return (value) => visit(value, 0, /* @__PURE__ */ new WeakSet());
|
|
1987
|
+
}
|
|
1988
|
+
function createSecretFilter(secrets) {
|
|
1989
|
+
const needles = [...new Set(secrets.filter((secret) => secret.length >= 4))].sort((a, b) => b.length - a.length);
|
|
1990
|
+
return (text) => {
|
|
1991
|
+
let out = text;
|
|
1992
|
+
for (const needle of needles) out = out.split(needle).join(REDACTED);
|
|
1993
|
+
return out;
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
function stripQuery(url) {
|
|
1997
|
+
const index = url.search(/[?#]/);
|
|
1998
|
+
return index === -1 ? url : `${url.slice(0, index)}?${REDACTED}`;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// src/resources/account.ts
|
|
2002
|
+
var AccountResource = class {
|
|
2003
|
+
#context;
|
|
2004
|
+
constructor(context) {
|
|
2005
|
+
this.#context = context;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Cash, invested capital, profit and loss, and total value, in the account's primary currency.
|
|
2009
|
+
*
|
|
2010
|
+
* Scope `account` · 1 request / 5 s.
|
|
2011
|
+
*
|
|
2012
|
+
* @example
|
|
2013
|
+
* const { totalValue, currency } = await t212.account.summary();
|
|
2014
|
+
*/
|
|
2015
|
+
async summary(options = {}) {
|
|
2016
|
+
const { data } = await this.#context.transport.request(ENDPOINTS.getAccountSummary, options);
|
|
2017
|
+
return data;
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
// src/pagination.ts
|
|
2022
|
+
var MAX_LIMIT = 50;
|
|
2023
|
+
function checkLimit(limit, field = "limit") {
|
|
2024
|
+
if (limit === void 0) return;
|
|
2025
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
2026
|
+
throw new ValidationError(`${field} must be a whole number from 1 to ${MAX_LIMIT}.`, {
|
|
2027
|
+
issues: [{ field, message: `must be 1 to ${MAX_LIMIT}` }]
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
function nextPageQuery(nextPagePath, endpoint, origin) {
|
|
2032
|
+
if (nextPagePath === null || nextPagePath === void 0) return null;
|
|
2033
|
+
const raw = nextPagePath.trim();
|
|
2034
|
+
if (raw === "" || raw === "null" || raw.startsWith("null&") || raw.startsWith("null?")) return null;
|
|
2035
|
+
const absolute = raw.startsWith("/") || /^[a-z][a-z\d+.-]*:/i.test(raw);
|
|
2036
|
+
const relative = absolute ? raw : `${endpoint.path}?${raw.replace(/^\?/, "")}`;
|
|
2037
|
+
let url;
|
|
2038
|
+
try {
|
|
2039
|
+
url = new URL(relative, origin);
|
|
2040
|
+
} catch {
|
|
2041
|
+
throw unsafeNextPage(endpoint);
|
|
2042
|
+
}
|
|
2043
|
+
if (url.origin !== origin || url.pathname !== endpoint.path || raw.startsWith("//") || raw.includes("\\")) {
|
|
2044
|
+
throw unsafeNextPage(endpoint);
|
|
2045
|
+
}
|
|
2046
|
+
const query = url.search.replace(/^\?/, "");
|
|
2047
|
+
return query === "" ? null : query;
|
|
2048
|
+
}
|
|
2049
|
+
function unsafeNextPage(endpoint) {
|
|
2050
|
+
return new UnexpectedResponseError(
|
|
2051
|
+
`Trading 212 returned a nextPagePath for ${endpoint.id} that points somewhere else; it was not followed.`,
|
|
2052
|
+
{ request: { method: endpoint.method, path: endpoint.path, endpoint: endpoint.id } }
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
function cursorQuery(cursor, endpoint, origin) {
|
|
2056
|
+
const query = nextPageQuery(cursor, endpoint, origin);
|
|
2057
|
+
if (query === null || cursor.startsWith("/")) {
|
|
2058
|
+
throw new ValidationError("cursor must be a nextCursor returned by an earlier page.", {
|
|
2059
|
+
issues: [{ field: "cursor", message: "is not a cursor this library produced" }]
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
return query;
|
|
2063
|
+
}
|
|
2064
|
+
var PagedList = class {
|
|
2065
|
+
#transport;
|
|
2066
|
+
#endpoint;
|
|
2067
|
+
constructor(transport, endpoint) {
|
|
2068
|
+
this.#transport = transport;
|
|
2069
|
+
this.#endpoint = endpoint;
|
|
2070
|
+
}
|
|
2071
|
+
async list(options = {}) {
|
|
2072
|
+
const { limit, cursor, signal, timeoutMs, ...filters } = options;
|
|
2073
|
+
checkLimit(limit);
|
|
2074
|
+
const call = { ...signal && { signal }, ...timeoutMs !== void 0 && { timeoutMs } };
|
|
2075
|
+
if (cursor !== void 0) {
|
|
2076
|
+
return this.#fetch(cursorQuery(cursor, this.#endpoint, this.#transport.origin), call);
|
|
2077
|
+
}
|
|
2078
|
+
const query = new URLSearchParams();
|
|
2079
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
2080
|
+
if (value instanceof Date) query.set(key, value.toISOString());
|
|
2081
|
+
else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
2082
|
+
query.set(key, String(value));
|
|
2083
|
+
}
|
|
2084
|
+
if (limit !== void 0) query.set("limit", String(limit));
|
|
2085
|
+
return this.#fetch(query.toString(), call);
|
|
2086
|
+
}
|
|
2087
|
+
async #fetch(rawQuery, call) {
|
|
2088
|
+
const { data } = await this.#transport.request(this.#endpoint, { ...call, rawQuery });
|
|
2089
|
+
const items = Array.isArray(data?.items) ? data.items : [];
|
|
2090
|
+
const following = nextPageQuery(data?.nextPagePath, this.#endpoint, this.#transport.origin);
|
|
2091
|
+
return {
|
|
2092
|
+
items,
|
|
2093
|
+
nextCursor: following,
|
|
2094
|
+
hasMore: following !== null,
|
|
2095
|
+
next: (options = {}) => following === null ? Promise.resolve(null) : this.#fetch(following, options)
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Every item, newest first, fetching pages as the loop asks for them. If a request fails part-way, the error's
|
|
2100
|
+
* `resumeCursor` says where to carry on.
|
|
2101
|
+
*/
|
|
2102
|
+
async *all(options = {}) {
|
|
2103
|
+
const { pageSize = MAX_LIMIT, maxPages, maxItems, ...rest } = options;
|
|
2104
|
+
checkLimit(pageSize, "pageSize");
|
|
2105
|
+
let pages = 0;
|
|
2106
|
+
let yielded = 0;
|
|
2107
|
+
let cursor = rest.cursor;
|
|
2108
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2109
|
+
const call = {
|
|
2110
|
+
...rest.signal && { signal: rest.signal },
|
|
2111
|
+
...rest.timeoutMs !== void 0 && { timeoutMs: rest.timeoutMs }
|
|
2112
|
+
};
|
|
2113
|
+
let page;
|
|
2114
|
+
try {
|
|
2115
|
+
page = await this.list({ ...rest, limit: pageSize });
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
throw attachCursor(error, cursor);
|
|
2118
|
+
}
|
|
2119
|
+
while (page) {
|
|
2120
|
+
pages++;
|
|
2121
|
+
for (const item of page.items) {
|
|
2122
|
+
if (maxItems !== void 0 && yielded >= maxItems) return;
|
|
2123
|
+
yield item;
|
|
2124
|
+
yielded++;
|
|
2125
|
+
}
|
|
2126
|
+
const full = maxPages !== void 0 && pages >= maxPages || maxItems !== void 0 && yielded >= maxItems;
|
|
2127
|
+
if (!page.hasMore || full) return;
|
|
2128
|
+
cursor = page.nextCursor ?? void 0;
|
|
2129
|
+
if (cursor !== void 0) {
|
|
2130
|
+
if (seen.has(cursor)) {
|
|
2131
|
+
throw new UnexpectedResponseError(
|
|
2132
|
+
`Trading 212 returned the same next page of ${this.#endpoint.id} twice; stopped rather than loop.`,
|
|
2133
|
+
{ request: { method: this.#endpoint.method, path: this.#endpoint.path, endpoint: this.#endpoint.id } }
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
seen.add(cursor);
|
|
2137
|
+
}
|
|
2138
|
+
try {
|
|
2139
|
+
page = await page.next(call);
|
|
2140
|
+
} catch (error) {
|
|
2141
|
+
throw attachCursor(error, cursor);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
};
|
|
2146
|
+
function attachCursor(error, cursor) {
|
|
2147
|
+
if (error instanceof Trading212Error && cursor !== void 0) error.resumeCursor = cursor;
|
|
2148
|
+
return error;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// src/resources/history.ts
|
|
2152
|
+
var HistoryResource = class {
|
|
2153
|
+
/**
|
|
2154
|
+
* Orders that have finished, with their fills.
|
|
2155
|
+
*
|
|
2156
|
+
* Scope `history:orders` · 20 requests / minute.
|
|
2157
|
+
*
|
|
2158
|
+
* @example
|
|
2159
|
+
* for await (const { order, fill } of t212.history.orders.all({ ticker: "AAPL_US_EQ" })) console.log(order?.status, fill?.price);
|
|
2160
|
+
*/
|
|
2161
|
+
orders;
|
|
2162
|
+
/**
|
|
2163
|
+
* Dividends paid out.
|
|
2164
|
+
*
|
|
2165
|
+
* Scope `history:dividends` · 20 requests / minute.
|
|
2166
|
+
*/
|
|
2167
|
+
dividends;
|
|
2168
|
+
/**
|
|
2169
|
+
* Deposits, withdrawals, fees, transfers and interest. `time` starts the list from that moment.
|
|
2170
|
+
*
|
|
2171
|
+
* Scope `history:transactions` · 20 requests / minute.
|
|
2172
|
+
*/
|
|
2173
|
+
transactions;
|
|
2174
|
+
constructor(context) {
|
|
2175
|
+
this.orders = new PagedList(context.transport, ENDPOINTS.listHistoricalOrders);
|
|
2176
|
+
this.dividends = new PagedList(context.transport, ENDPOINTS.listDividends);
|
|
2177
|
+
this.transactions = new PagedList(context.transport, ENDPOINTS.listTransactions);
|
|
2178
|
+
}
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
// src/resources/instruments.ts
|
|
2182
|
+
function matchScore(instrument, query) {
|
|
2183
|
+
const q = query.toLowerCase();
|
|
2184
|
+
const ticker = instrument.ticker.toLowerCase();
|
|
2185
|
+
const short = instrument.shortName?.toLowerCase() ?? "";
|
|
2186
|
+
const name = instrument.name?.toLowerCase() ?? "";
|
|
2187
|
+
const isin = instrument.isin?.toLowerCase() ?? "";
|
|
2188
|
+
if (ticker === q) return 100;
|
|
2189
|
+
if (isin === q) return 95;
|
|
2190
|
+
if (short === q) return 90;
|
|
2191
|
+
if (ticker.startsWith(`${q}_`)) return 85;
|
|
2192
|
+
if (name === q) return 80;
|
|
2193
|
+
if (name.startsWith(q)) return 60;
|
|
2194
|
+
if (new RegExp(`\\b${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(name)) return 50;
|
|
2195
|
+
if (short.startsWith(q) || ticker.startsWith(q)) return 40;
|
|
2196
|
+
if (name.includes(q)) return 30;
|
|
2197
|
+
return 0;
|
|
2198
|
+
}
|
|
2199
|
+
function hasTicker(instrument) {
|
|
2200
|
+
return typeof instrument.ticker === "string" && instrument.ticker !== "";
|
|
2201
|
+
}
|
|
2202
|
+
function passesFilters(instrument, options) {
|
|
2203
|
+
if (options.type && instrument.type?.toUpperCase() !== options.type.toUpperCase()) return false;
|
|
2204
|
+
if (options.currency && instrument.currencyCode?.toUpperCase() !== options.currency.toUpperCase()) return false;
|
|
2205
|
+
return true;
|
|
2206
|
+
}
|
|
2207
|
+
var InstrumentsResource = class {
|
|
2208
|
+
#context;
|
|
2209
|
+
constructor(context) {
|
|
2210
|
+
this.#context = context;
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Every instrument the account can trade: about 5 MB, so it is cached (one hour by default) and concurrent callers
|
|
2214
|
+
* share one download.
|
|
2215
|
+
*
|
|
2216
|
+
* Scope `metadata` · 1 request / 50 s.
|
|
2217
|
+
*/
|
|
2218
|
+
async list(options = {}) {
|
|
2219
|
+
const { refresh = false, signal } = options;
|
|
2220
|
+
const { cache, cacheNamespace, metadataTtlMs, transport } = this.#context;
|
|
2221
|
+
return cache.load(
|
|
2222
|
+
`${cacheNamespace}:instruments`,
|
|
2223
|
+
{ ttlMs: metadataTtlMs, refresh, signal, accept: isList },
|
|
2224
|
+
async () => {
|
|
2225
|
+
const { data } = await transport.request(ENDPOINTS.listInstruments);
|
|
2226
|
+
return Array.isArray(data) ? data.filter(hasTicker) : [];
|
|
2227
|
+
}
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Finds instruments by ticker, ISIN, short name or name, best match first. Searches the cached list, so it costs
|
|
2232
|
+
* no request after the first.
|
|
2233
|
+
*
|
|
2234
|
+
* @example
|
|
2235
|
+
* const [apple] = await t212.instruments.search("Apple", { currency: "USD" });
|
|
2236
|
+
*/
|
|
2237
|
+
async search(query, options = {}) {
|
|
2238
|
+
const { limit = 10, type, currency, ...list } = options;
|
|
2239
|
+
const trimmed = query.trim();
|
|
2240
|
+
if (!trimmed)
|
|
2241
|
+
throw new ValidationError("Search for something: the query is empty.", {
|
|
2242
|
+
issues: [{ field: "query", message: "is empty" }]
|
|
2243
|
+
});
|
|
2244
|
+
const filters = { ...type && { type }, ...currency && { currency } };
|
|
2245
|
+
return (await this.list(list)).filter((instrument) => passesFilters(instrument, filters)).map((instrument) => ({ instrument, score: matchScore(instrument, trimmed) })).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score || a.instrument.ticker.localeCompare(b.instrument.ticker)).slice(0, limit).map((entry) => entry.instrument);
|
|
2246
|
+
}
|
|
2247
|
+
/** The instrument with exactly this Trading 212 ticker, or `undefined`. */
|
|
2248
|
+
async find(ticker, options = {}) {
|
|
2249
|
+
return (await this.list(options)).find((instrument) => instrument.ticker === ticker);
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Turns what a person would type (`AAPL`, `Apple`, an ISIN) into exactly one instrument, or throws
|
|
2253
|
+
* `InstrumentNotFoundError` listing the closest matches when none or several fit.
|
|
2254
|
+
*
|
|
2255
|
+
* @example
|
|
2256
|
+
* const apple = await t212.instruments.resolve("AAPL", { currency: "USD" }); // AAPL_US_EQ
|
|
2257
|
+
*/
|
|
2258
|
+
async resolve(query, options = {}) {
|
|
2259
|
+
const { type, currency, ...list } = options;
|
|
2260
|
+
const ranked = await this.search(query, {
|
|
2261
|
+
...list,
|
|
2262
|
+
limit: 10,
|
|
2263
|
+
...type && { type },
|
|
2264
|
+
...currency && { currency }
|
|
2265
|
+
});
|
|
2266
|
+
const best = ranked[0];
|
|
2267
|
+
const top = best ? matchScore(best, query.trim()) : 0;
|
|
2268
|
+
const tied = ranked.filter((instrument) => matchScore(instrument, query.trim()) === top);
|
|
2269
|
+
if (best && top >= 80 && tied.length === 1) return best;
|
|
2270
|
+
const suggestions = ranked.slice(0, 5).map((instrument) => ({ ticker: instrument.ticker, name: instrument.name ?? void 0 }));
|
|
2271
|
+
const why = ranked.length === 0 ? "nothing matched" : tied.length > 1 ? `${tied.length} instruments match equally well` : "no instrument matched exactly";
|
|
2272
|
+
throw new InstrumentNotFoundError(`Could not resolve "${query}" to one instrument: ${why}.`, {
|
|
2273
|
+
query,
|
|
2274
|
+
suggestions,
|
|
2275
|
+
hint: suggestions.length > 0 ? `Did you mean ${suggestions.map((suggestion) => suggestion.ticker).join(", ")}? Narrow it with { currency } or { type }, or pass the exact ticker.` : "Check the spelling, or search with instruments.search()."
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
|
|
2280
|
+
// src/resources/pies.ts
|
|
2281
|
+
function checkPieId(id) {
|
|
2282
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) {
|
|
2283
|
+
throw new ValidationError("A pie ID must be a positive whole number.", {
|
|
2284
|
+
issues: [{ field: "id", message: "is not a valid pie ID" }]
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
return id;
|
|
2288
|
+
}
|
|
2289
|
+
var PiesResource = class {
|
|
2290
|
+
#context;
|
|
2291
|
+
constructor(context) {
|
|
2292
|
+
this.#context = context;
|
|
2293
|
+
}
|
|
2294
|
+
/** Every pie, with its progress. Scope `pies:read` · 1 request / 30 s. */
|
|
2295
|
+
async list(options = {}) {
|
|
2296
|
+
const { data } = await this.#context.transport.request(
|
|
2297
|
+
ENDPOINTS.listPies,
|
|
2298
|
+
options
|
|
2299
|
+
);
|
|
2300
|
+
return Array.isArray(data) ? data : [];
|
|
2301
|
+
}
|
|
2302
|
+
/** One pie with its instruments and settings. Scope `pies:read` · 1 request / 5 s. */
|
|
2303
|
+
async get(id, options = {}) {
|
|
2304
|
+
const { data } = await this.#context.transport.request(ENDPOINTS.getPie, {
|
|
2305
|
+
...options,
|
|
2306
|
+
params: { id: checkPieId(id) }
|
|
2307
|
+
});
|
|
2308
|
+
return data;
|
|
2309
|
+
}
|
|
2310
|
+
/** Creates a pie. Scope `pies:write` · 1 request / 5 s. */
|
|
2311
|
+
async create(pie, options = {}) {
|
|
2312
|
+
const { data } = await this.#context.transport.request(
|
|
2313
|
+
ENDPOINTS.createPie,
|
|
2314
|
+
{
|
|
2315
|
+
...options,
|
|
2316
|
+
body: pie
|
|
2317
|
+
}
|
|
2318
|
+
);
|
|
2319
|
+
return data;
|
|
2320
|
+
}
|
|
2321
|
+
/** Changes a pie's settings or instruments. Scope `pies:write` · 1 request / 5 s. */
|
|
2322
|
+
async update(id, pie, options = {}) {
|
|
2323
|
+
const { data } = await this.#context.transport.request(
|
|
2324
|
+
ENDPOINTS.updatePie,
|
|
2325
|
+
{
|
|
2326
|
+
...options,
|
|
2327
|
+
params: { id: checkPieId(id) },
|
|
2328
|
+
body: pie
|
|
2329
|
+
}
|
|
2330
|
+
);
|
|
2331
|
+
return data;
|
|
2332
|
+
}
|
|
2333
|
+
/** Deletes a pie. Scope `pies:write` · 1 request / 5 s. */
|
|
2334
|
+
async delete(id, options = {}) {
|
|
2335
|
+
await this.#context.transport.request(ENDPOINTS.deletePie, { ...options, params: { id: checkPieId(id) } });
|
|
2336
|
+
}
|
|
2337
|
+
/** Copies a pie under a new name. Scope `pies:write` · 1 request / 5 s. */
|
|
2338
|
+
async duplicate(id, copy = {}, options = {}) {
|
|
2339
|
+
const { data } = await this.#context.transport.request(
|
|
2340
|
+
ENDPOINTS.duplicatePie,
|
|
2341
|
+
{
|
|
2342
|
+
...options,
|
|
2343
|
+
params: { id: checkPieId(id) },
|
|
2344
|
+
body: copy
|
|
2345
|
+
}
|
|
2346
|
+
);
|
|
2347
|
+
return data;
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2351
|
+
// src/resources/positions.ts
|
|
2352
|
+
var PositionsResource = class {
|
|
2353
|
+
#context;
|
|
2354
|
+
constructor(context) {
|
|
2355
|
+
this.#context = context;
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Every open position, or just one ticker's.
|
|
2359
|
+
*
|
|
2360
|
+
* Scope `portfolio` · 1 request / 1 s.
|
|
2361
|
+
*/
|
|
2362
|
+
async list(options = {}) {
|
|
2363
|
+
const { ticker, ...call } = options;
|
|
2364
|
+
const { data } = await this.#context.transport.request(ENDPOINTS.listPositions, {
|
|
2365
|
+
...call,
|
|
2366
|
+
query: { ticker }
|
|
2367
|
+
});
|
|
2368
|
+
return Array.isArray(data) ? data : [];
|
|
2369
|
+
}
|
|
2370
|
+
/** One ticker's position, or `undefined` when you hold none. */
|
|
2371
|
+
async get(ticker, options = {}) {
|
|
2372
|
+
const positions = await this.list({ ...options, ticker });
|
|
2373
|
+
return positions.find((position) => position.instrument?.ticker === ticker);
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2377
|
+
// src/resources/reports.ts
|
|
2378
|
+
var FAILED = /* @__PURE__ */ new Set(["Failed", "Canceled"]);
|
|
2379
|
+
function isoDate(value, field) {
|
|
2380
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
2381
|
+
if (Number.isNaN(date.getTime())) {
|
|
2382
|
+
throw new ValidationError(`${field} is not a valid date.`, { issues: [{ field, message: "is not a valid date" }] });
|
|
2383
|
+
}
|
|
2384
|
+
return date.toISOString();
|
|
2385
|
+
}
|
|
2386
|
+
var ReportsResource = class {
|
|
2387
|
+
#context;
|
|
2388
|
+
constructor(context) {
|
|
2389
|
+
this.#context = context;
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Every report you have asked for, with its status and, once finished, its download link.
|
|
2393
|
+
*
|
|
2394
|
+
* 1 request / minute.
|
|
2395
|
+
*/
|
|
2396
|
+
async list(options = {}) {
|
|
2397
|
+
const { data } = await this.#context.transport.request(
|
|
2398
|
+
ENDPOINTS.listReports,
|
|
2399
|
+
options
|
|
2400
|
+
);
|
|
2401
|
+
return Array.isArray(data) ? data : [];
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Asks Trading 212 to build a CSV report, and returns its ID.
|
|
2405
|
+
*
|
|
2406
|
+
* 1 request / 30 s.
|
|
2407
|
+
*/
|
|
2408
|
+
async request(request, options = {}) {
|
|
2409
|
+
const timeFrom = isoDate(request.from, "from");
|
|
2410
|
+
const timeTo = isoDate(request.to, "to");
|
|
2411
|
+
if (timeFrom >= timeTo) {
|
|
2412
|
+
throw new ValidationError("from must be before to.", {
|
|
2413
|
+
issues: [{ field: "from", message: "is not before to" }]
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
const include = request.include ?? {};
|
|
2417
|
+
const body = {
|
|
2418
|
+
timeFrom,
|
|
2419
|
+
timeTo,
|
|
2420
|
+
dataIncluded: {
|
|
2421
|
+
includeDividends: include.dividends ?? true,
|
|
2422
|
+
includeInterest: include.interest ?? true,
|
|
2423
|
+
includeOrders: include.orders ?? true,
|
|
2424
|
+
includeTransactions: include.transactions ?? true
|
|
2425
|
+
}
|
|
2426
|
+
};
|
|
2427
|
+
const { data } = await this.#context.transport.request(
|
|
2428
|
+
ENDPOINTS.requestReport,
|
|
2429
|
+
{
|
|
2430
|
+
...options,
|
|
2431
|
+
body
|
|
2432
|
+
}
|
|
2433
|
+
);
|
|
2434
|
+
if (typeof data?.reportId !== "number") {
|
|
2435
|
+
throw new UnexpectedResponseError("Trading 212 accepted the report request but returned no reportId.", {
|
|
2436
|
+
body: data
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
return data.reportId;
|
|
2440
|
+
}
|
|
2441
|
+
/** Checks a report until it is finished, and returns it. Throws if it failed or was cancelled. */
|
|
2442
|
+
async waitFor(reportId, options = {}) {
|
|
2443
|
+
const { timeoutMs: wait, intervalMs: interval, onStatus, ...call } = options;
|
|
2444
|
+
const timeoutMs = durationOption(wait, "timeoutMs") ?? 10 * 6e4;
|
|
2445
|
+
const intervalMs = durationOption(interval, "intervalMs") ?? 6e4;
|
|
2446
|
+
const deadline = Date.now() + timeoutMs;
|
|
2447
|
+
for (; ; ) {
|
|
2448
|
+
const report = (await this.list(call)).find((candidate) => candidate.reportId === reportId);
|
|
2449
|
+
if (report) {
|
|
2450
|
+
onStatus?.(report);
|
|
2451
|
+
if (report.status === "Finished" && report.downloadLink) return report;
|
|
2452
|
+
if (report.status && FAILED.has(report.status)) {
|
|
2453
|
+
throw new UnexpectedResponseError(
|
|
2454
|
+
`Report ${reportId} ${report.status === "Failed" ? "failed" : "was cancelled"}.`,
|
|
2455
|
+
{
|
|
2456
|
+
body: report,
|
|
2457
|
+
hint: "Request it again with reports.request()."
|
|
2458
|
+
}
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (Date.now() + intervalMs > deadline) {
|
|
2463
|
+
throw new TimeoutError(`Report ${reportId} was not ready after ${Math.round(timeoutMs / 1e3)} s.`, {
|
|
2464
|
+
hint: "Large reports take a while. Call reports.waitFor() again later; the report keeps building."
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
await sleep(intervalMs, call.signal);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Downloads a finished report's CSV. The link is signed, so your API credentials are never sent with it.
|
|
2472
|
+
*/
|
|
2473
|
+
async download(report, options = {}) {
|
|
2474
|
+
const link = typeof report === "string" ? report : report.downloadLink;
|
|
2475
|
+
if (!link)
|
|
2476
|
+
throw new ValidationError("The report has no download link yet.", {
|
|
2477
|
+
issues: [{ field: "downloadLink", message: "is missing" }]
|
|
2478
|
+
});
|
|
2479
|
+
let url;
|
|
2480
|
+
try {
|
|
2481
|
+
url = new URL(link);
|
|
2482
|
+
} catch {
|
|
2483
|
+
throw new ValidationError("The download link is not a valid URL.", {
|
|
2484
|
+
issues: [{ field: "downloadLink", message: "is invalid" }]
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
if (url.protocol !== "https:") {
|
|
2488
|
+
throw new ValidationError("The download link is not https.", {
|
|
2489
|
+
issues: [{ field: "downloadLink", message: "must be https" }]
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
const timeoutMs = durationOption(options.timeoutMs, "timeoutMs") ?? Math.max(this.#context.timeoutMs, 12e4);
|
|
2493
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
2494
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
2495
|
+
const where = stripQuery(url.toString());
|
|
2496
|
+
const failed = (error) => {
|
|
2497
|
+
if (options.signal?.aborted) return options.signal.reason;
|
|
2498
|
+
const name = error instanceof Error ? error.name : "Error";
|
|
2499
|
+
if (timeout.aborted)
|
|
2500
|
+
return new TimeoutError(`Downloading the report from ${where} took longer than ${timeoutMs} ms.`);
|
|
2501
|
+
return new NetworkError(`Could not download the report from ${where} (${name}).`, {
|
|
2502
|
+
sent: true,
|
|
2503
|
+
hint: "Try again: downloading does not change anything."
|
|
2504
|
+
});
|
|
2505
|
+
};
|
|
2506
|
+
let response;
|
|
2507
|
+
try {
|
|
2508
|
+
response = await this.#context.fetch(url.toString(), { method: "GET", signal });
|
|
2509
|
+
} catch (error) {
|
|
2510
|
+
throw failed(error);
|
|
2511
|
+
}
|
|
2512
|
+
if (!response.ok) {
|
|
2513
|
+
throw new UnexpectedResponseError(`Downloading the report from ${where} failed with ${response.status}.`, {
|
|
2514
|
+
status: response.status,
|
|
2515
|
+
hint: "Signed links expire. Fetch a fresh one with reports.list()."
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
try {
|
|
2519
|
+
return await response.text();
|
|
2520
|
+
} catch (error) {
|
|
2521
|
+
throw failed(error);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Requests a report, waits for it and downloads it, in one call. Waiting respects the one-request-a-minute limit,
|
|
2526
|
+
* so expect at least a minute.
|
|
2527
|
+
*
|
|
2528
|
+
* @example
|
|
2529
|
+
* const { csv } = await t212.reports.export({ from: "2026-01-01", to: "2026-06-30" });
|
|
2530
|
+
*/
|
|
2531
|
+
async export(request, options = {}) {
|
|
2532
|
+
const call = options.signal ? { signal: options.signal } : {};
|
|
2533
|
+
const reportId = await this.request(request, call);
|
|
2534
|
+
const report = await this.waitFor(reportId, options);
|
|
2535
|
+
return { report, csv: await this.download(report, call) };
|
|
2536
|
+
}
|
|
2537
|
+
};
|
|
2538
|
+
|
|
2539
|
+
// src/client.ts
|
|
2540
|
+
var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
|
|
2541
|
+
var Trading212 = class _Trading212 {
|
|
2542
|
+
environment;
|
|
2543
|
+
/** Cash and totals. */
|
|
2544
|
+
account;
|
|
2545
|
+
/** Open positions. */
|
|
2546
|
+
positions;
|
|
2547
|
+
/** Everything you can trade, cached, with search and ticker lookup. */
|
|
2548
|
+
instruments;
|
|
2549
|
+
/** Exchanges, their schedules, and whether a market is open. */
|
|
2550
|
+
exchanges;
|
|
2551
|
+
/** Place, inspect, cancel and wait for orders. */
|
|
2552
|
+
orders;
|
|
2553
|
+
/** Past orders, dividends and transactions, paginated. */
|
|
2554
|
+
history;
|
|
2555
|
+
/** CSV exports. */
|
|
2556
|
+
reports;
|
|
2557
|
+
/** The account summary and positions combined. */
|
|
2558
|
+
portfolio;
|
|
2559
|
+
/**
|
|
2560
|
+
* Pies.
|
|
2561
|
+
*
|
|
2562
|
+
* @deprecated Trading 212 has deprecated the pies endpoints; they may be removed.
|
|
2563
|
+
*/
|
|
2564
|
+
pies;
|
|
2565
|
+
#transport;
|
|
2566
|
+
constructor(options) {
|
|
2567
|
+
const resolved = resolveOptions(options);
|
|
2568
|
+
const authorization = basicAuthorization(resolved.apiKey, resolved.apiSecret);
|
|
2569
|
+
const secrets = [resolved.apiKey, resolved.apiSecret, authorization.slice("Basic ".length)];
|
|
2570
|
+
const redact = createRedactor(secrets);
|
|
2571
|
+
const logger = safeLogger(options.logger ?? silentLogger, redact);
|
|
2572
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
2573
|
+
if (!fetchImpl) {
|
|
2574
|
+
throw new ConfigurationError("No fetch implementation is available.", {
|
|
2575
|
+
field: "fetch",
|
|
2576
|
+
hint: "Use Node 22 or later, Bun or Deno, or pass one as the fetch option."
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
if (!resolved.officialOrigin) {
|
|
2580
|
+
logger.warn(`Requests go to ${resolved.origin}, which is not Trading 212's ${resolved.environment} host.`, {
|
|
2581
|
+
origin: resolved.origin
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
const accountKey = fingerprint(`${resolved.environment}\0${resolved.origin}\0${resolved.apiKey}`);
|
|
2585
|
+
this.environment = resolved.environment;
|
|
2586
|
+
this.#transport = new Transport({
|
|
2587
|
+
environment: resolved.environment,
|
|
2588
|
+
origin: resolved.origin,
|
|
2589
|
+
authorization,
|
|
2590
|
+
fetch: fetchImpl,
|
|
2591
|
+
timeoutMs: resolved.timeoutMs,
|
|
2592
|
+
retry: resolved.retry,
|
|
2593
|
+
rateLimiter: new RateLimiter(accountKey, resolved.rateLimit),
|
|
2594
|
+
maxRateLimitWaitMs: resolved.maxRateLimitWaitMs,
|
|
2595
|
+
logger,
|
|
2596
|
+
hooks: options.hooks ?? {},
|
|
2597
|
+
redact,
|
|
2598
|
+
removeSecrets: createSecretFilter(secrets),
|
|
2599
|
+
userAgent: `trading212.js/${VERSION}${options.userAgent ? ` ${options.userAgent}` : ""}`,
|
|
2600
|
+
trading: resolved.trading
|
|
2601
|
+
});
|
|
2602
|
+
const context = {
|
|
2603
|
+
transport: this.#transport,
|
|
2604
|
+
cache: new CachedLoader(options.cache ?? new MemoryCache(), (error, key) => {
|
|
2605
|
+
logger.warn(`The cache store failed for the ${key.split(":").pop() ?? "metadata"} list, so it was bypassed.`, {
|
|
2606
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2607
|
+
});
|
|
2608
|
+
}),
|
|
2609
|
+
cacheNamespace: accountKey,
|
|
2610
|
+
metadataTtlMs: resolved.metadataTtlMs,
|
|
2611
|
+
fetch: fetchImpl,
|
|
2612
|
+
timeoutMs: resolved.timeoutMs
|
|
2613
|
+
};
|
|
2614
|
+
this.account = new AccountResource(context);
|
|
2615
|
+
this.positions = new PositionsResource(context);
|
|
2616
|
+
this.instruments = new InstrumentsResource(context);
|
|
2617
|
+
this.exchanges = new ExchangesResource(context, this.instruments);
|
|
2618
|
+
this.history = new HistoryResource(context);
|
|
2619
|
+
this.orders = new OrdersResource(context, this.positions, this.history.orders);
|
|
2620
|
+
this.reports = new ReportsResource(context);
|
|
2621
|
+
this.portfolio = new PortfolioResource(this.account, this.positions);
|
|
2622
|
+
this.pies = new PiesResource(context);
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Builds a client from `T212_ENV`, `T212_API_KEY` and `T212_API_SECRET` (or `T212_DEMO_*` / `T212_LIVE_*`).
|
|
2626
|
+
* `overrides` sets any other option.
|
|
2627
|
+
*
|
|
2628
|
+
* @example
|
|
2629
|
+
* const t212 = Trading212.fromEnv({ logger: consoleLogger() });
|
|
2630
|
+
*/
|
|
2631
|
+
static fromEnv(overrides = {}, env) {
|
|
2632
|
+
return new _Trading212({ ...optionsFromEnv(env), ...overrides });
|
|
2633
|
+
}
|
|
2634
|
+
/** Whether this client may place or cancel orders and change pies. */
|
|
2635
|
+
get trading() {
|
|
2636
|
+
return this.#transport.tradingEnabled ? "enabled" : "disabled";
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Checks the key works, with one request, and says which account it belongs to. A wrong key or environment fails
|
|
2640
|
+
* here with an `AuthenticationError` that explains which.
|
|
2641
|
+
*/
|
|
2642
|
+
async verify(options = {}) {
|
|
2643
|
+
const summary = await this.account.summary(options);
|
|
2644
|
+
return {
|
|
2645
|
+
environment: this.environment,
|
|
2646
|
+
accountId: summary.id ?? void 0,
|
|
2647
|
+
currency: summary.currency ?? void 0,
|
|
2648
|
+
trading: this.trading
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
/** What is known about every endpoint's rate-limit budget, shared by every client using this key. */
|
|
2652
|
+
rateLimits() {
|
|
2653
|
+
return this.#transport.rateLimitSnapshot();
|
|
2654
|
+
}
|
|
2655
|
+
toJSON() {
|
|
2656
|
+
return { environment: this.environment, trading: this.trading, origin: this.#transport.origin };
|
|
2657
|
+
}
|
|
2658
|
+
[INSPECT]() {
|
|
2659
|
+
return `Trading212 { environment: '${this.environment}', trading: '${this.trading}' }`;
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
|
|
2663
|
+
exports.AuthenticationError = AuthenticationError;
|
|
2664
|
+
exports.BASE_URLS = BASE_URLS;
|
|
2665
|
+
exports.ConfigurationError = ConfigurationError;
|
|
2666
|
+
exports.ENDPOINTS = ENDPOINTS;
|
|
2667
|
+
exports.FINAL_ORDER_STATUSES = FINAL_ORDER_STATUSES;
|
|
2668
|
+
exports.HttpError = HttpError;
|
|
2669
|
+
exports.InstrumentNotFoundError = InstrumentNotFoundError;
|
|
2670
|
+
exports.MemoryCache = MemoryCache;
|
|
2671
|
+
exports.MissingScopeError = MissingScopeError;
|
|
2672
|
+
exports.NetworkError = NetworkError;
|
|
2673
|
+
exports.NotAvailableInDemoError = NotAvailableInDemoError;
|
|
2674
|
+
exports.NotFoundError = NotFoundError;
|
|
2675
|
+
exports.OrderOutcomeUnknownError = OrderOutcomeUnknownError;
|
|
2676
|
+
exports.OrderRejectedError = OrderRejectedError;
|
|
2677
|
+
exports.PermissionError = PermissionError;
|
|
2678
|
+
exports.QUANTITY_DECIMALS = QUANTITY_DECIMALS;
|
|
2679
|
+
exports.REDACTED = REDACTED;
|
|
2680
|
+
exports.RateLimitError = RateLimitError;
|
|
2681
|
+
exports.SPEC_VERSION = SPEC_VERSION;
|
|
2682
|
+
exports.ServerError = ServerError;
|
|
2683
|
+
exports.TIME_VALIDITIES = TIME_VALIDITIES;
|
|
2684
|
+
exports.TimeoutError = TimeoutError;
|
|
2685
|
+
exports.Trading212 = Trading212;
|
|
2686
|
+
exports.Trading212Error = Trading212Error;
|
|
2687
|
+
exports.TradingDisabledError = TradingDisabledError;
|
|
2688
|
+
exports.UnexpectedResponseError = UnexpectedResponseError;
|
|
2689
|
+
exports.VERSION = VERSION;
|
|
2690
|
+
exports.ValidationError = ValidationError;
|
|
2691
|
+
exports.buildPortfolioSnapshot = buildPortfolioSnapshot;
|
|
2692
|
+
exports.consoleLogger = consoleLogger;
|
|
2693
|
+
exports.decimalPlaces = decimalPlaces;
|
|
2694
|
+
exports.fieldsFirst = fieldsFirst;
|
|
2695
|
+
exports.isTrading212Error = isTrading212Error;
|
|
2696
|
+
exports.isValidQuantity = isValidQuantity;
|
|
2697
|
+
exports.parseErrorBody = parseErrorBody;
|
|
2698
|
+
exports.sessionAt = sessionAt;
|
|
2699
|
+
exports.silentLogger = silentLogger;
|
|
2700
|
+
exports.truncateQuantity = truncateQuantity;
|