@kkkermit/trading212.js 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/chunk-2Z6CWW7T.cjs +2700 -0
- package/dist/chunk-HQVGPC64.js +2661 -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 +3 -3
package/dist/testing.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { BASE_URLS, ENDPOINTS, decimalPlaces, Trading212, REDACTED } from './chunk-HQVGPC64.js';
|
|
2
|
+
|
|
3
|
+
// src/testing/scriptedFetch.ts
|
|
4
|
+
var STATUS_TEXT = {
|
|
5
|
+
200: "OK",
|
|
6
|
+
204: "No Content",
|
|
7
|
+
400: "Bad Request",
|
|
8
|
+
401: "Unauthorized",
|
|
9
|
+
403: "Forbidden",
|
|
10
|
+
404: "Not Found",
|
|
11
|
+
408: "Request Timeout",
|
|
12
|
+
429: "Too Many Requests",
|
|
13
|
+
500: "Internal Server Error",
|
|
14
|
+
502: "Bad Gateway",
|
|
15
|
+
503: "Service Unavailable"
|
|
16
|
+
};
|
|
17
|
+
async function recordRequest(input, init) {
|
|
18
|
+
const url = new URL(input);
|
|
19
|
+
const headers = {};
|
|
20
|
+
let authorized = false;
|
|
21
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
22
|
+
if (key === "authorization") {
|
|
23
|
+
authorized = value.length > 0;
|
|
24
|
+
headers[key] = REDACTED;
|
|
25
|
+
} else headers[key] = value;
|
|
26
|
+
});
|
|
27
|
+
let body = void 0;
|
|
28
|
+
if (typeof init.body === "string") {
|
|
29
|
+
try {
|
|
30
|
+
body = JSON.parse(init.body);
|
|
31
|
+
} catch {
|
|
32
|
+
body = init.body;
|
|
33
|
+
}
|
|
34
|
+
} else if (init.body !== void 0 && init.body !== null) {
|
|
35
|
+
body = await new Response(init.body).text();
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
method: init.method ?? "GET",
|
|
39
|
+
url: url.toString(),
|
|
40
|
+
path: url.pathname,
|
|
41
|
+
query: Object.fromEntries(url.searchParams),
|
|
42
|
+
headers,
|
|
43
|
+
authorized,
|
|
44
|
+
body
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function toResponse(scripted) {
|
|
48
|
+
const status = scripted.status ?? 200;
|
|
49
|
+
const headers = new Headers(scripted.headers);
|
|
50
|
+
let text = null;
|
|
51
|
+
if (typeof scripted.body === "string") text = scripted.body;
|
|
52
|
+
else if (scripted.body !== void 0) {
|
|
53
|
+
text = JSON.stringify(scripted.body);
|
|
54
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
55
|
+
}
|
|
56
|
+
const nullBody = status === 204 || status === 205 || status === 304;
|
|
57
|
+
return new Response(nullBody ? null : text, { status, statusText: STATUS_TEXT[status] ?? "", headers });
|
|
58
|
+
}
|
|
59
|
+
function scriptedFetch(steps) {
|
|
60
|
+
const queue = [...steps];
|
|
61
|
+
const requests = [];
|
|
62
|
+
const fetch = async (input, init) => {
|
|
63
|
+
const request = await recordRequest(input, init);
|
|
64
|
+
requests.push(request);
|
|
65
|
+
if (init.signal?.aborted) throw init.signal.reason;
|
|
66
|
+
const step = queue.shift();
|
|
67
|
+
if (step === void 0) {
|
|
68
|
+
throw new Error(`scriptedFetch has no response left for ${request.method} ${request.path}.`);
|
|
69
|
+
}
|
|
70
|
+
const outcome = typeof step === "function" ? await step(request) : step;
|
|
71
|
+
if (outcome instanceof Error) throw outcome;
|
|
72
|
+
return toResponse(outcome);
|
|
73
|
+
};
|
|
74
|
+
return { fetch, requests, pending: () => queue.length };
|
|
75
|
+
}
|
|
76
|
+
function networkError(code = "ECONNRESET") {
|
|
77
|
+
return new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/testing/fakeServer.ts
|
|
81
|
+
var FAKE_API_KEY = "fake-trading212-key";
|
|
82
|
+
var FAKE_API_SECRET = "fake-trading212-secret";
|
|
83
|
+
var FAKE_AUTHORIZATION = `Basic ${btoa(`${FAKE_API_KEY}:${FAKE_API_SECRET}`)}`;
|
|
84
|
+
var REPORT_HOST = "https://reports.fake-trading212.invalid";
|
|
85
|
+
var DEFAULT_INSTRUMENTS = [
|
|
86
|
+
{
|
|
87
|
+
ticker: "AAPL_US_EQ",
|
|
88
|
+
name: "Apple",
|
|
89
|
+
shortName: "AAPL",
|
|
90
|
+
isin: "US0378331005",
|
|
91
|
+
currencyCode: "USD",
|
|
92
|
+
type: "STOCK",
|
|
93
|
+
workingScheduleId: 1,
|
|
94
|
+
extendedHours: true,
|
|
95
|
+
maxOpenQuantity: 1e4,
|
|
96
|
+
addedOn: "2018-07-12T08:00:00.000Z"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
ticker: "MSFT_US_EQ",
|
|
100
|
+
name: "Microsoft",
|
|
101
|
+
shortName: "MSFT",
|
|
102
|
+
isin: "US5949181045",
|
|
103
|
+
currencyCode: "USD",
|
|
104
|
+
type: "STOCK",
|
|
105
|
+
workingScheduleId: 1,
|
|
106
|
+
extendedHours: true,
|
|
107
|
+
maxOpenQuantity: 1e4,
|
|
108
|
+
addedOn: "2018-07-12T08:00:00.000Z"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
ticker: "TSLA_US_EQ",
|
|
112
|
+
name: "Tesla",
|
|
113
|
+
shortName: "TSLA",
|
|
114
|
+
isin: "US88160R1014",
|
|
115
|
+
currencyCode: "USD",
|
|
116
|
+
type: "STOCK",
|
|
117
|
+
workingScheduleId: 1,
|
|
118
|
+
extendedHours: true,
|
|
119
|
+
maxOpenQuantity: 1e4,
|
|
120
|
+
addedOn: "2018-07-12T08:00:00.000Z"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
ticker: "VUSAl_EQ",
|
|
124
|
+
name: "Vanguard S&P 500 UCITS ETF",
|
|
125
|
+
shortName: "VUSA",
|
|
126
|
+
isin: "IE00B3XXRP09",
|
|
127
|
+
currencyCode: "GBP",
|
|
128
|
+
type: "ETF",
|
|
129
|
+
workingScheduleId: 2,
|
|
130
|
+
extendedHours: false,
|
|
131
|
+
maxOpenQuantity: 1e4,
|
|
132
|
+
addedOn: "2019-02-01T08:00:00.000Z"
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
var DEFAULT_PRICES = {
|
|
136
|
+
AAPL_US_EQ: 230,
|
|
137
|
+
MSFT_US_EQ: 420,
|
|
138
|
+
TSLA_US_EQ: 250,
|
|
139
|
+
VUSAl_EQ: 95
|
|
140
|
+
};
|
|
141
|
+
function weekOfSessions(from, openUtc, closeUtc) {
|
|
142
|
+
const events = [];
|
|
143
|
+
const day = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()));
|
|
144
|
+
for (let offset = -1; offset < 8; offset++) {
|
|
145
|
+
const date = new Date(day.getTime() + offset * 864e5);
|
|
146
|
+
const weekday = date.getUTCDay();
|
|
147
|
+
if (weekday === 0 || weekday === 6) continue;
|
|
148
|
+
const stamp = date.toISOString().slice(0, 10);
|
|
149
|
+
events.push(
|
|
150
|
+
{ date: `${stamp}T${openUtc}:00.000Z`, type: "OPEN" },
|
|
151
|
+
{ date: `${stamp}T${closeUtc}:00.000Z`, type: "CLOSE" }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return events;
|
|
155
|
+
}
|
|
156
|
+
function defaultExchanges(now) {
|
|
157
|
+
return [
|
|
158
|
+
{ id: 1, name: "NASDAQ", workingSchedules: [{ id: 1, timeEvents: weekOfSessions(now, "14:30", "21:00") }] },
|
|
159
|
+
{
|
|
160
|
+
id: 2,
|
|
161
|
+
name: "London Stock Exchange",
|
|
162
|
+
workingSchedules: [{ id: 2, timeEvents: weekOfSessions(now, "08:00", "16:30") }]
|
|
163
|
+
}
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
var roundMoney = (value) => Math.round(value * 100) / 100;
|
|
167
|
+
var roundShares = (value) => Math.round(value * 1e8) / 1e8;
|
|
168
|
+
var INVALID = "The request is invalid";
|
|
169
|
+
var REFUSED = "Error while placing the order";
|
|
170
|
+
var NOT_FOUND = "Requested entity not found";
|
|
171
|
+
function apiError(status, code, title, detail) {
|
|
172
|
+
return { status, body: { type: `/api-errors/${code}`, title, status, ...detail !== void 0 && { detail } } };
|
|
173
|
+
}
|
|
174
|
+
var FakeTrading212Server = class {
|
|
175
|
+
environment;
|
|
176
|
+
/** Every request the client made, without credentials. */
|
|
177
|
+
requests = [];
|
|
178
|
+
/** When false, every order is refused with `market-closed`. */
|
|
179
|
+
marketOpen = true;
|
|
180
|
+
/** Trading 212 refuses CSV exports on demo; set this to let the fake offer them anyway. */
|
|
181
|
+
exportsOnDemo = false;
|
|
182
|
+
cash;
|
|
183
|
+
currency;
|
|
184
|
+
accountId;
|
|
185
|
+
instruments;
|
|
186
|
+
prices;
|
|
187
|
+
holdings = /* @__PURE__ */ new Map();
|
|
188
|
+
pending = /* @__PURE__ */ new Map();
|
|
189
|
+
orderHistory = [];
|
|
190
|
+
dividends;
|
|
191
|
+
transactions;
|
|
192
|
+
exchanges;
|
|
193
|
+
reports = [];
|
|
194
|
+
pies = /* @__PURE__ */ new Map();
|
|
195
|
+
realisedProfitLoss = 0;
|
|
196
|
+
#failures = /* @__PURE__ */ new Map();
|
|
197
|
+
#origin;
|
|
198
|
+
#nextId = 1e6;
|
|
199
|
+
#traces = 0;
|
|
200
|
+
constructor(state = {}, environment = "demo") {
|
|
201
|
+
this.environment = environment;
|
|
202
|
+
this.#origin = BASE_URLS[environment];
|
|
203
|
+
this.cash = state.cash ?? 1e4;
|
|
204
|
+
this.currency = state.currency ?? "GBP";
|
|
205
|
+
this.accountId = state.accountId ?? 12345678;
|
|
206
|
+
this.instruments = (state.instruments ?? DEFAULT_INSTRUMENTS).map((instrument) => ({ ...instrument }));
|
|
207
|
+
this.prices = new Map(Object.entries({ ...DEFAULT_PRICES, ...state.prices }));
|
|
208
|
+
this.dividends = [...state.dividends ?? []];
|
|
209
|
+
this.transactions = [...state.transactions ?? []];
|
|
210
|
+
this.exchanges = state.exchanges ?? defaultExchanges(/* @__PURE__ */ new Date());
|
|
211
|
+
for (const holding of state.positions ?? []) {
|
|
212
|
+
this.holdings.set(holding.ticker, {
|
|
213
|
+
ticker: holding.ticker,
|
|
214
|
+
quantity: holding.quantity,
|
|
215
|
+
averagePrice: holding.averagePrice ?? this.priceOf(holding.ticker),
|
|
216
|
+
inPies: holding.inPies ?? 0,
|
|
217
|
+
createdAt: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Pass this as the client's `fetch`. */
|
|
222
|
+
fetch = async (input, init) => {
|
|
223
|
+
if (init.signal?.aborted) throw init.signal.reason;
|
|
224
|
+
const url = new URL(input);
|
|
225
|
+
if (url.origin === REPORT_HOST) return this.#download(await recordRequest(input, init));
|
|
226
|
+
const request = await recordRequest(input, init);
|
|
227
|
+
this.requests.push(request);
|
|
228
|
+
const endpoint = this.#match(init.method ?? "GET", url.pathname);
|
|
229
|
+
if (url.origin !== this.#origin || !endpoint)
|
|
230
|
+
return toResponse(apiError(404, "not-found", "Not found", "Unknown route"));
|
|
231
|
+
const failure = this.#failures.get(endpoint.definition.id)?.shift();
|
|
232
|
+
if (failure && "network" in failure) throw networkError(failure.network);
|
|
233
|
+
const response = failure ?? this.#traced(this.#authorise(init) ?? this.#route(endpoint.definition.id, request, endpoint.id));
|
|
234
|
+
const limit = endpoint.definition.rateLimit;
|
|
235
|
+
return toResponse({
|
|
236
|
+
...response,
|
|
237
|
+
headers: {
|
|
238
|
+
"x-ratelimit-limit": String(limit.requests),
|
|
239
|
+
"x-ratelimit-period": String(limit.periodMs / 1e3),
|
|
240
|
+
"x-ratelimit-remaining": String(limit.requests),
|
|
241
|
+
"x-ratelimit-used": "0",
|
|
242
|
+
"x-ratelimit-reset": String(Math.ceil((Date.now() + limit.periodMs) / 1e3)),
|
|
243
|
+
...response.headers
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
/** Makes the next call to an endpoint fail. Queue several to fail several calls. */
|
|
248
|
+
failNext(endpoint, failure) {
|
|
249
|
+
const queue = this.#failures.get(endpoint) ?? [];
|
|
250
|
+
queue.push(failure);
|
|
251
|
+
this.#failures.set(endpoint, queue);
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
254
|
+
setPrice(ticker, price) {
|
|
255
|
+
this.prices.set(ticker, price);
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
priceOf(ticker) {
|
|
259
|
+
return this.prices.get(ticker) ?? 100;
|
|
260
|
+
}
|
|
261
|
+
/** Fills a pending limit or stop order, at its limit price, or `price` if given. */
|
|
262
|
+
fill(orderId, price) {
|
|
263
|
+
const pending = this.pending.get(orderId);
|
|
264
|
+
if (!pending) throw new Error(`The fake has no pending order ${orderId}.`);
|
|
265
|
+
this.pending.delete(orderId);
|
|
266
|
+
this.cash += pending.reservedCash;
|
|
267
|
+
const execution = price ?? pending.order.limitPrice ?? pending.order.stopPrice ?? this.priceOf(pending.order.ticker ?? "");
|
|
268
|
+
return this.#execute(pending.order, execution);
|
|
269
|
+
}
|
|
270
|
+
#traced(response) {
|
|
271
|
+
const { body } = response;
|
|
272
|
+
if ((response.status ?? 200) < 400 || typeof body !== "object" || body === null || !("type" in body))
|
|
273
|
+
return response;
|
|
274
|
+
return { ...response, body: { ...body, traceId: (this.#traces++).toString(16).padStart(32, "0") } };
|
|
275
|
+
}
|
|
276
|
+
#authorise(init) {
|
|
277
|
+
const authorization = new Headers(init.headers).get("authorization");
|
|
278
|
+
return authorization === FAKE_AUTHORIZATION ? void 0 : { status: 401, headers: { "content-type": "text/plain; charset=utf-8" }, body: "\n" };
|
|
279
|
+
}
|
|
280
|
+
#match(method, path) {
|
|
281
|
+
for (const definition of Object.values(ENDPOINTS)) {
|
|
282
|
+
if (definition.method !== method) continue;
|
|
283
|
+
const pattern = new RegExp(`^${definition.path.replace("{id}", "(\\d+)")}$`);
|
|
284
|
+
const match = pattern.exec(path);
|
|
285
|
+
if (match) return { definition, id: match[1] === void 0 ? void 0 : Number(match[1]) };
|
|
286
|
+
}
|
|
287
|
+
return void 0;
|
|
288
|
+
}
|
|
289
|
+
#instrument(ticker) {
|
|
290
|
+
return this.instruments.find((instrument) => instrument.ticker === ticker);
|
|
291
|
+
}
|
|
292
|
+
#instrumentSummary(ticker) {
|
|
293
|
+
const instrument = this.#instrument(ticker);
|
|
294
|
+
return {
|
|
295
|
+
ticker,
|
|
296
|
+
name: instrument?.name ?? null,
|
|
297
|
+
isin: instrument?.isin ?? null,
|
|
298
|
+
currency: instrument?.currencyCode ?? null
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
#reservedShares(ticker) {
|
|
302
|
+
let reserved = 0;
|
|
303
|
+
for (const pending of this.pending.values())
|
|
304
|
+
if (pending.order.ticker === ticker) reserved += pending.reservedShares;
|
|
305
|
+
return reserved;
|
|
306
|
+
}
|
|
307
|
+
position(ticker) {
|
|
308
|
+
const holding = this.holdings.get(ticker);
|
|
309
|
+
if (!holding) return void 0;
|
|
310
|
+
const price = this.priceOf(ticker);
|
|
311
|
+
const value = roundMoney(holding.quantity * price);
|
|
312
|
+
const cost = roundMoney(holding.quantity * holding.averagePrice);
|
|
313
|
+
return {
|
|
314
|
+
instrument: this.#instrumentSummary(ticker),
|
|
315
|
+
quantity: holding.quantity,
|
|
316
|
+
quantityInPies: holding.inPies,
|
|
317
|
+
quantityAvailableForTrading: roundShares(holding.quantity - holding.inPies - this.#reservedShares(ticker)),
|
|
318
|
+
averagePricePaid: holding.averagePrice,
|
|
319
|
+
currentPrice: price,
|
|
320
|
+
createdAt: holding.createdAt,
|
|
321
|
+
walletImpact: {
|
|
322
|
+
currency: this.currency,
|
|
323
|
+
currentValue: value,
|
|
324
|
+
totalCost: cost,
|
|
325
|
+
unrealizedProfitLoss: roundMoney(value - cost),
|
|
326
|
+
fxImpact: 0
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
#summary() {
|
|
331
|
+
const positions = [...this.holdings.keys()].flatMap((ticker) => this.position(ticker) ?? []);
|
|
332
|
+
const currentValue = roundMoney(
|
|
333
|
+
positions.reduce((total, position) => total + (position.walletImpact?.currentValue ?? 0), 0)
|
|
334
|
+
);
|
|
335
|
+
const totalCost = roundMoney(
|
|
336
|
+
positions.reduce((total, position) => total + (position.walletImpact?.totalCost ?? 0), 0)
|
|
337
|
+
);
|
|
338
|
+
const reserved = [...this.pending.values()].reduce((total, pending) => total + pending.reservedCash, 0);
|
|
339
|
+
return {
|
|
340
|
+
id: this.accountId,
|
|
341
|
+
currency: this.currency,
|
|
342
|
+
totalValue: roundMoney(this.cash + reserved + currentValue),
|
|
343
|
+
cash: { availableToTrade: roundMoney(this.cash), reservedForOrders: roundMoney(reserved), inPies: 0 },
|
|
344
|
+
investments: {
|
|
345
|
+
currentValue,
|
|
346
|
+
totalCost,
|
|
347
|
+
realizedProfitLoss: roundMoney(this.realisedProfitLoss),
|
|
348
|
+
unrealizedProfitLoss: roundMoney(currentValue - totalCost)
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
#route(id, request, pathId) {
|
|
353
|
+
const body = request.body ?? {};
|
|
354
|
+
switch (id) {
|
|
355
|
+
case "getAccountSummary":
|
|
356
|
+
return { body: this.#summary() };
|
|
357
|
+
case "listPositions": {
|
|
358
|
+
const tickers = request.query.ticker ? [request.query.ticker] : [...this.holdings.keys()];
|
|
359
|
+
return { body: tickers.flatMap((ticker) => this.position(ticker) ?? []) };
|
|
360
|
+
}
|
|
361
|
+
case "listInstruments":
|
|
362
|
+
return { body: this.instruments };
|
|
363
|
+
case "listExchanges":
|
|
364
|
+
return { body: this.exchanges };
|
|
365
|
+
case "listOrders":
|
|
366
|
+
return { body: [...this.pending.values()].map((pending) => pending.order) };
|
|
367
|
+
case "getOrder": {
|
|
368
|
+
const pending = this.pending.get(pathId ?? 0);
|
|
369
|
+
return pending ? { body: pending.order } : apiError(404, "entity-not-found", NOT_FOUND, "Order not found");
|
|
370
|
+
}
|
|
371
|
+
case "cancelOrder":
|
|
372
|
+
return this.#cancel(pathId ?? 0);
|
|
373
|
+
case "placeMarketOrder":
|
|
374
|
+
case "placeLimitOrder":
|
|
375
|
+
case "placeStopOrder":
|
|
376
|
+
case "placeStopLimitOrder":
|
|
377
|
+
return this.#place(id, body);
|
|
378
|
+
case "listHistoricalOrders":
|
|
379
|
+
return this.#page(request, this.orderHistory, (item) => item.order?.ticker ?? void 0);
|
|
380
|
+
case "listDividends":
|
|
381
|
+
return this.#page(request, this.dividends, (item) => item.ticker ?? void 0);
|
|
382
|
+
case "listTransactions":
|
|
383
|
+
return this.#page(request, this.transactions, () => void 0, true);
|
|
384
|
+
case "listReports":
|
|
385
|
+
return this.#listReports();
|
|
386
|
+
case "requestReport":
|
|
387
|
+
return this.#requestReport(body);
|
|
388
|
+
case "listPies":
|
|
389
|
+
return { body: [...this.pies.entries()].map(([pieId, pie]) => this.#pieSummary(pieId, pie.cash)) };
|
|
390
|
+
case "getPie":
|
|
391
|
+
return this.#pie(pathId ?? 0);
|
|
392
|
+
case "createPie": {
|
|
393
|
+
const pieId = this.#nextId++;
|
|
394
|
+
this.pies.set(pieId, { settings: body, cash: 0 });
|
|
395
|
+
return this.#pie(pieId);
|
|
396
|
+
}
|
|
397
|
+
case "updatePie": {
|
|
398
|
+
const pie = this.pies.get(pathId ?? 0);
|
|
399
|
+
if (!pie) return apiError(404, "entity-not-found", NOT_FOUND, "Pie not found");
|
|
400
|
+
pie.settings = { ...pie.settings, ...body };
|
|
401
|
+
return this.#pie(pathId ?? 0);
|
|
402
|
+
}
|
|
403
|
+
case "deletePie":
|
|
404
|
+
return this.pies.delete(pathId ?? 0) ? { status: 200 } : apiError(404, "entity-not-found", NOT_FOUND, "Pie not found");
|
|
405
|
+
case "duplicatePie": {
|
|
406
|
+
const pie = this.pies.get(pathId ?? 0);
|
|
407
|
+
if (!pie) return apiError(404, "entity-not-found", NOT_FOUND, "Pie not found");
|
|
408
|
+
const pieId = this.#nextId++;
|
|
409
|
+
this.pies.set(pieId, { settings: { ...pie.settings, ...body }, cash: 0 });
|
|
410
|
+
return this.#pie(pieId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
#pieSummary(id, cash) {
|
|
415
|
+
return { id, cash, progress: 0, status: "ON_TRACK", dividendDetails: { gained: 0, inCash: 0, reinvested: 0 } };
|
|
416
|
+
}
|
|
417
|
+
#pie(id) {
|
|
418
|
+
const pie = this.pies.get(id);
|
|
419
|
+
if (!pie) return apiError(404, "entity-not-found", NOT_FOUND, "Pie not found");
|
|
420
|
+
const details = {
|
|
421
|
+
settings: { ...pie.settings, id, creationDate: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
422
|
+
instruments: Object.entries(pie.settings.instrumentShares ?? {}).map(([ticker, share]) => ({
|
|
423
|
+
ticker,
|
|
424
|
+
expectedShare: share,
|
|
425
|
+
currentShare: 0,
|
|
426
|
+
ownedQuantity: 0
|
|
427
|
+
}))
|
|
428
|
+
};
|
|
429
|
+
return { body: details };
|
|
430
|
+
}
|
|
431
|
+
#cancel(orderId) {
|
|
432
|
+
const pending = this.pending.get(orderId);
|
|
433
|
+
if (!pending) return apiError(404, "entity-not-found", NOT_FOUND, "Order not found");
|
|
434
|
+
this.pending.delete(orderId);
|
|
435
|
+
this.cash += pending.reservedCash;
|
|
436
|
+
this.orderHistory.unshift({ order: { ...pending.order, status: "CANCELLED" } });
|
|
437
|
+
return { status: 200 };
|
|
438
|
+
}
|
|
439
|
+
#place(id, body) {
|
|
440
|
+
const ticker = typeof body.ticker === "string" ? body.ticker : "";
|
|
441
|
+
const quantity = typeof body.quantity === "number" ? body.quantity : Number.NaN;
|
|
442
|
+
if (!this.#instrument(ticker)) return apiError(400, "instrument-not-found", REFUSED, `No instrument ${ticker}`);
|
|
443
|
+
if (!Number.isFinite(quantity) || quantity === 0)
|
|
444
|
+
return apiError(400, "invalid-request", INVALID, "Invalid payload");
|
|
445
|
+
if (decimalPlaces(quantity) > 4)
|
|
446
|
+
return apiError(400, "quantity-precision-mismatch", REFUSED, "invalid quantity precision 4");
|
|
447
|
+
if (id !== "placeMarketOrder" && "extendedHours" in body)
|
|
448
|
+
return apiError(400, "invalid-request", INVALID, "Invalid payload");
|
|
449
|
+
if (!this.marketOpen) return apiError(400, "market-closed", REFUSED, "The market is closed");
|
|
450
|
+
const type = {
|
|
451
|
+
placeMarketOrder: "MARKET",
|
|
452
|
+
placeLimitOrder: "LIMIT",
|
|
453
|
+
placeStopOrder: "STOP",
|
|
454
|
+
placeStopLimitOrder: "STOP_LIMIT"
|
|
455
|
+
}[id] ?? "MARKET";
|
|
456
|
+
const limitPrice = typeof body.limitPrice === "number" ? body.limitPrice : void 0;
|
|
457
|
+
const stopPrice = typeof body.stopPrice === "number" ? body.stopPrice : void 0;
|
|
458
|
+
const price = limitPrice ?? stopPrice ?? this.priceOf(ticker);
|
|
459
|
+
const buying = quantity > 0;
|
|
460
|
+
const holding = this.holdings.get(ticker);
|
|
461
|
+
const available = holding ? holding.quantity - holding.inPies - this.#reservedShares(ticker) : 0;
|
|
462
|
+
if (buying && roundMoney(quantity * price) > roundMoney(this.cash)) {
|
|
463
|
+
return apiError(400, "insufficient-free-for-stocks-buy", REFUSED, "Insufficient funds");
|
|
464
|
+
}
|
|
465
|
+
if (!buying && -quantity > roundShares(available) + 1e-9)
|
|
466
|
+
return apiError(
|
|
467
|
+
400,
|
|
468
|
+
"selling-equity-not-owned",
|
|
469
|
+
REFUSED,
|
|
470
|
+
`Selling more equities than owned, owned: ${String(roundShares(available))}`
|
|
471
|
+
);
|
|
472
|
+
const order = {
|
|
473
|
+
id: this.#nextId++,
|
|
474
|
+
ticker,
|
|
475
|
+
instrument: this.#instrumentSummary(ticker),
|
|
476
|
+
type,
|
|
477
|
+
side: buying ? "BUY" : "SELL",
|
|
478
|
+
status: "NEW",
|
|
479
|
+
strategy: "QUANTITY",
|
|
480
|
+
quantity: Math.abs(quantity),
|
|
481
|
+
filledQuantity: 0,
|
|
482
|
+
currency: this.#instrument(ticker)?.currencyCode ?? this.currency,
|
|
483
|
+
initiatedFrom: "API",
|
|
484
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
485
|
+
extendedHours: body.extendedHours === true,
|
|
486
|
+
...limitPrice !== void 0 && { limitPrice },
|
|
487
|
+
...stopPrice !== void 0 && { stopPrice },
|
|
488
|
+
...typeof body.timeValidity === "string" && { timeInForce: body.timeValidity }
|
|
489
|
+
};
|
|
490
|
+
if (type === "MARKET") {
|
|
491
|
+
this.#execute(order, price);
|
|
492
|
+
return { body: order };
|
|
493
|
+
}
|
|
494
|
+
const reservedCash = buying ? roundMoney(quantity * price) : 0;
|
|
495
|
+
this.cash -= reservedCash;
|
|
496
|
+
this.pending.set(order.id ?? 0, { order, reservedCash, reservedShares: buying ? 0 : -quantity });
|
|
497
|
+
return { body: order };
|
|
498
|
+
}
|
|
499
|
+
#execute(order, price) {
|
|
500
|
+
const ticker = order.ticker ?? "";
|
|
501
|
+
const quantity = order.quantity ?? 0;
|
|
502
|
+
const buying = order.side === "BUY";
|
|
503
|
+
const value = roundMoney(quantity * price);
|
|
504
|
+
const holding = this.holdings.get(ticker);
|
|
505
|
+
let realised = 0;
|
|
506
|
+
if (buying) {
|
|
507
|
+
this.cash = roundMoney(this.cash - value);
|
|
508
|
+
if (holding) {
|
|
509
|
+
const total = holding.quantity + quantity;
|
|
510
|
+
holding.averagePrice = (holding.averagePrice * holding.quantity + price * quantity) / total;
|
|
511
|
+
holding.quantity = roundShares(total);
|
|
512
|
+
} else {
|
|
513
|
+
this.holdings.set(ticker, {
|
|
514
|
+
ticker,
|
|
515
|
+
quantity,
|
|
516
|
+
averagePrice: price,
|
|
517
|
+
inPies: 0,
|
|
518
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
} else if (holding) {
|
|
522
|
+
realised = roundMoney((price - holding.averagePrice) * quantity);
|
|
523
|
+
this.cash = roundMoney(this.cash + value);
|
|
524
|
+
holding.quantity = roundShares(holding.quantity - quantity);
|
|
525
|
+
if (holding.quantity <= 0) this.holdings.delete(ticker);
|
|
526
|
+
}
|
|
527
|
+
this.realisedProfitLoss += realised;
|
|
528
|
+
const filled = { ...order, status: "FILLED", filledQuantity: quantity, filledValue: value };
|
|
529
|
+
this.orderHistory.unshift({
|
|
530
|
+
order: filled,
|
|
531
|
+
fill: {
|
|
532
|
+
id: this.#nextId++,
|
|
533
|
+
price,
|
|
534
|
+
quantity,
|
|
535
|
+
filledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
536
|
+
type: "TRADE",
|
|
537
|
+
tradingMethod: "TOTV",
|
|
538
|
+
walletImpact: {
|
|
539
|
+
currency: this.currency,
|
|
540
|
+
fxRate: 1,
|
|
541
|
+
netValue: buying ? -value : value,
|
|
542
|
+
realisedProfitLoss: realised,
|
|
543
|
+
taxes: []
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
return filled;
|
|
548
|
+
}
|
|
549
|
+
#page(request, all, tickerOf, withTime = false) {
|
|
550
|
+
const limit = request.query.limit === void 0 ? 20 : Number(request.query.limit);
|
|
551
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50)
|
|
552
|
+
return apiError(
|
|
553
|
+
400,
|
|
554
|
+
"invalid-request",
|
|
555
|
+
INVALID,
|
|
556
|
+
limit > 50 ? "Limit cannot be greater than 50" : "Invalid limit"
|
|
557
|
+
);
|
|
558
|
+
const ticker = request.query.ticker;
|
|
559
|
+
const items = ticker ? all.filter((item) => tickerOf(item) === ticker) : all;
|
|
560
|
+
const start = request.query.cursor === void 0 ? 0 : Number(request.query.cursor);
|
|
561
|
+
const slice = items.slice(start, start + limit);
|
|
562
|
+
const end = start + limit;
|
|
563
|
+
let nextPagePath = null;
|
|
564
|
+
if (end < items.length) {
|
|
565
|
+
const query = new URLSearchParams({ limit: String(limit), cursor: String(end) });
|
|
566
|
+
if (ticker) query.set("ticker", ticker);
|
|
567
|
+
if (withTime) query.set("time", (/* @__PURE__ */ new Date(0)).toISOString());
|
|
568
|
+
nextPagePath = `${request.path}?${query.toString()}`;
|
|
569
|
+
}
|
|
570
|
+
return { body: { items: slice, nextPagePath } };
|
|
571
|
+
}
|
|
572
|
+
#exportsRefused() {
|
|
573
|
+
return this.environment === "demo" && !this.exportsOnDemo ? apiError(403, "not-available-in-demo-account", "The action is not allowed in demo account") : void 0;
|
|
574
|
+
}
|
|
575
|
+
#requestReport(body) {
|
|
576
|
+
const refused = this.#exportsRefused();
|
|
577
|
+
if (refused) return refused;
|
|
578
|
+
const reportId = this.#nextId++;
|
|
579
|
+
this.reports.push({
|
|
580
|
+
reportId,
|
|
581
|
+
status: "Queued",
|
|
582
|
+
timeFrom: typeof body.timeFrom === "string" ? body.timeFrom : null,
|
|
583
|
+
timeTo: typeof body.timeTo === "string" ? body.timeTo : null,
|
|
584
|
+
dataIncluded: body.dataIncluded ?? null
|
|
585
|
+
});
|
|
586
|
+
return { body: { reportId } };
|
|
587
|
+
}
|
|
588
|
+
#listReports() {
|
|
589
|
+
const refused = this.#exportsRefused();
|
|
590
|
+
if (refused) return refused;
|
|
591
|
+
const listed = this.reports.map((report) => ({ ...report }));
|
|
592
|
+
for (const report of this.reports) {
|
|
593
|
+
if (report.status === "Queued") report.status = "Finished";
|
|
594
|
+
if (report.status === "Finished")
|
|
595
|
+
report.downloadLink = `${REPORT_HOST}/${report.reportId ?? 0}.csv?signature=fake`;
|
|
596
|
+
}
|
|
597
|
+
return { body: listed };
|
|
598
|
+
}
|
|
599
|
+
#download(request) {
|
|
600
|
+
if (request.authorized) {
|
|
601
|
+
return toResponse({ status: 400, body: "The fake refuses report downloads that carry API credentials." });
|
|
602
|
+
}
|
|
603
|
+
const id = Number(request.path.replace(/\D/g, ""));
|
|
604
|
+
if (!this.reports.some((report) => report.reportId === id && report.status === "Finished"))
|
|
605
|
+
return toResponse({ status: 404, body: "No such report" });
|
|
606
|
+
const header = "Action,Time,ISIN,Ticker,Name,No. of shares,Price / share,Currency (Price / share),Total,Currency (Total)";
|
|
607
|
+
const rows = this.orderHistory.map(
|
|
608
|
+
({ order, fill }) => [
|
|
609
|
+
order?.side === "SELL" ? "Market sell" : "Market buy",
|
|
610
|
+
fill?.filledAt ?? "",
|
|
611
|
+
order?.instrument?.isin ?? "",
|
|
612
|
+
order?.ticker ?? "",
|
|
613
|
+
`"${order?.instrument?.name ?? ""}"`,
|
|
614
|
+
fill?.quantity ?? "",
|
|
615
|
+
fill?.price ?? "",
|
|
616
|
+
order?.currency ?? "",
|
|
617
|
+
Math.abs(fill?.walletImpact?.netValue ?? 0),
|
|
618
|
+
this.currency
|
|
619
|
+
].join(",")
|
|
620
|
+
);
|
|
621
|
+
return toResponse({ body: `${[header, ...rows].join("\n")}
|
|
622
|
+
`, headers: { "content-type": "text/csv" } });
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// src/testing/index.ts
|
|
627
|
+
function createFakeTrading212(options = {}) {
|
|
628
|
+
const environment = options.environment ?? "demo";
|
|
629
|
+
const server = new FakeTrading212Server(options.state, environment);
|
|
630
|
+
const client = new Trading212({
|
|
631
|
+
rateLimit: "off",
|
|
632
|
+
trading: "enabled",
|
|
633
|
+
...options.client,
|
|
634
|
+
environment,
|
|
635
|
+
apiKey: FAKE_API_KEY,
|
|
636
|
+
apiSecret: FAKE_API_SECRET,
|
|
637
|
+
fetch: server.fetch
|
|
638
|
+
});
|
|
639
|
+
return { client, server };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export { DEFAULT_INSTRUMENTS, FAKE_API_KEY, FAKE_API_SECRET, FakeTrading212Server, createFakeTrading212, networkError, scriptedFetch, weekOfSessions };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kkkermit/trading212.js",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Unofficial, zero-dependency TypeScript client for the Trading 212 Public API: typed, rate-limit aware, safe around live money, with errors that tell you how to fix them.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"trading212",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"./package.json": "./package.json"
|
|
68
68
|
},
|
|
69
69
|
"bin": {
|
|
70
|
-
"trading212": "
|
|
70
|
+
"trading212": "dist/cli.js"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"fixtures:capture": "tsx scripts/captureErrors.ts",
|
|
99
99
|
"secrets": "secretlint \"**/*\"",
|
|
100
100
|
"changeset": "changeset",
|
|
101
|
-
"
|
|
101
|
+
"prepack": "npm run build",
|
|
102
102
|
"prepare": "husky",
|
|
103
103
|
"smoke": "node scripts/smoke.mjs && node scripts/smoke.cjs",
|
|
104
104
|
"example": "tsx --tsconfig examples/tsconfig.json",
|