@mautriz/mt5 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,477 @@
1
+ // ── Response types ──────────────────────────────────────────────────
2
+
3
+ export interface AccountInfo {
4
+ balance: number;
5
+ equity: number;
6
+ margin: number;
7
+ free_margin: number;
8
+ profit: number;
9
+ leverage: number;
10
+ currency: string;
11
+ name: string;
12
+ number: number;
13
+ server: string;
14
+ company: string;
15
+ }
16
+
17
+ export interface Position {
18
+ ticket: number;
19
+ symbol: string;
20
+ type: "buy" | "sell";
21
+ volume: number;
22
+ open_price: number;
23
+ sl: number;
24
+ tp: number;
25
+ profit: number;
26
+ open_time: string;
27
+ }
28
+
29
+ export type PendingOrderType =
30
+ | "buy_limit"
31
+ | "sell_limit"
32
+ | "buy_stop"
33
+ | "sell_stop"
34
+ | "unknown";
35
+
36
+ export interface PendingOrder {
37
+ ticket: number;
38
+ symbol: string;
39
+ type: PendingOrderType;
40
+ volume: number;
41
+ price: number;
42
+ sl: number;
43
+ tp: number;
44
+ }
45
+
46
+ export type DealType = "buy" | "sell" | "other";
47
+ export type DealEntry = "in" | "out" | "inout" | "unknown";
48
+
49
+ export interface Deal {
50
+ ticket: number;
51
+ symbol: string;
52
+ type: DealType;
53
+ entry: DealEntry;
54
+ volume: number;
55
+ price: number;
56
+ profit: number;
57
+ commission: number;
58
+ swap: number;
59
+ time: string;
60
+ }
61
+
62
+ export interface SymbolTick {
63
+ symbol: string;
64
+ bid: number;
65
+ ask: number;
66
+ last: number;
67
+ volume: number;
68
+ time: string;
69
+ }
70
+
71
+ export type TradeMode =
72
+ | "disabled"
73
+ | "longonly"
74
+ | "shortonly"
75
+ | "closeonly"
76
+ | "full"
77
+ | "unknown";
78
+
79
+ export interface TimeOffset {
80
+ offset_seconds: number;
81
+ server_time: string;
82
+ gmt_time: string;
83
+ }
84
+
85
+ export interface SymbolDetails {
86
+ symbol: string;
87
+ description: string;
88
+ digits: number;
89
+ point: number;
90
+ spread: number;
91
+ trade_mode: TradeMode;
92
+ volume_min: number;
93
+ volume_max: number;
94
+ volume_step: number;
95
+ trade_contract_size: number;
96
+ filling_mode: number;
97
+ bid: number;
98
+ ask: number;
99
+ }
100
+
101
+ // ── Request parameter types ─────────────────────────────────────────
102
+
103
+ export type OrderType =
104
+ | "buy"
105
+ | "sell"
106
+ | "buy_limit"
107
+ | "sell_limit"
108
+ | "buy_stop"
109
+ | "sell_stop";
110
+
111
+ export interface OpenOrderParams {
112
+ type: OrderType;
113
+ symbol: string;
114
+ volume: number;
115
+ price?: number;
116
+ sl?: number;
117
+ tp?: number;
118
+ }
119
+
120
+ export interface CloseOrderParams {
121
+ ticket: number;
122
+ volume?: number;
123
+ }
124
+
125
+ export interface ModifyOrderParams {
126
+ ticket: number;
127
+ sl?: number;
128
+ tp?: number;
129
+ price?: number;
130
+ }
131
+
132
+ // ── Error type ──────────────────────────────────────────────────────
133
+
134
+ export class MT5Error extends Error {
135
+ public readonly retcode: number | undefined;
136
+
137
+ constructor(message: string, retcode?: number) {
138
+ super(message);
139
+ this.name = "MT5Error";
140
+ this.retcode = retcode;
141
+ }
142
+ }
143
+
144
+ // ── Push message types ──────────────────────────────────────────────
145
+
146
+ export interface HelloMessage {
147
+ type: "hello";
148
+ ea: string;
149
+ symbol: string;
150
+ [key: string]: unknown;
151
+ }
152
+
153
+ export interface TickMessage {
154
+ type: "tick";
155
+ symbol: string;
156
+ bid: number;
157
+ ask: number;
158
+ [key: string]: unknown;
159
+ }
160
+
161
+ export interface PushMessage {
162
+ type: string;
163
+ [key: string]: unknown;
164
+ }
165
+
166
+ type PushEventMap = {
167
+ hello: HelloMessage;
168
+ tick: TickMessage;
169
+ message: PushMessage;
170
+ connected: undefined;
171
+ disconnected: { code: number; reason: string };
172
+ error: Event;
173
+ };
174
+
175
+ type EventCallback<T> = T extends undefined ? () => void : (data: T) => void;
176
+
177
+ // ── Internal types ──────────────────────────────────────────────────
178
+
179
+ interface PendingRequest {
180
+ resolve: (value: any) => void;
181
+ reject: (reason: any) => void;
182
+ timer: ReturnType<typeof setTimeout>;
183
+ }
184
+
185
+ // ── Client ──────────────────────────────────────────────────────────
186
+
187
+ export class MT5Client {
188
+ private readonly url: string;
189
+ private ws: WebSocket | null = null;
190
+ private reqCounter = 0;
191
+ private pending = new Map<string, PendingRequest>();
192
+ private listeners = new Map<string, Set<Function>>();
193
+ private timeoutMs: number;
194
+
195
+ constructor(url: string, options?: { timeout?: number }) {
196
+ this.url = url;
197
+ this.timeoutMs = options?.timeout ?? 30_000;
198
+ }
199
+
200
+ // ── Connection ──────────────────────────────────────────────────
201
+
202
+ connect(): Promise<void> {
203
+ return new Promise((resolve, reject) => {
204
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
205
+ resolve();
206
+ return;
207
+ }
208
+
209
+ const ws = new WebSocket(this.url);
210
+ this.ws = ws;
211
+
212
+ const onOpen = () => {
213
+ cleanup();
214
+ this.setupHandlers(ws);
215
+ this.emit("connected", undefined);
216
+ resolve();
217
+ };
218
+
219
+ const onError = (ev: Event) => {
220
+ cleanup();
221
+ reject(new MT5Error("WebSocket connection failed"));
222
+ };
223
+
224
+ const onClose = () => {
225
+ cleanup();
226
+ reject(new MT5Error("WebSocket closed before connection was established"));
227
+ };
228
+
229
+ const cleanup = () => {
230
+ ws.removeEventListener("open", onOpen);
231
+ ws.removeEventListener("error", onError);
232
+ ws.removeEventListener("close", onClose);
233
+ };
234
+
235
+ ws.addEventListener("open", onOpen);
236
+ ws.addEventListener("error", onError);
237
+ ws.addEventListener("close", onClose);
238
+ });
239
+ }
240
+
241
+ disconnect(): void {
242
+ if (this.ws) {
243
+ this.ws.close();
244
+ this.ws = null;
245
+ }
246
+ for (const [, req] of this.pending) {
247
+ clearTimeout(req.timer);
248
+ req.reject(new MT5Error("Disconnected"));
249
+ }
250
+ this.pending.clear();
251
+ }
252
+
253
+ get connected(): boolean {
254
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
255
+ }
256
+
257
+ // ── Event emitter ───────────────────────────────────────────────
258
+
259
+ on<K extends keyof PushEventMap>(
260
+ event: K,
261
+ callback: EventCallback<PushEventMap[K]>
262
+ ): this {
263
+ let set = this.listeners.get(event);
264
+ if (!set) {
265
+ set = new Set();
266
+ this.listeners.set(event, set);
267
+ }
268
+ set.add(callback);
269
+ return this;
270
+ }
271
+
272
+ off<K extends keyof PushEventMap>(
273
+ event: K,
274
+ callback: EventCallback<PushEventMap[K]>
275
+ ): this {
276
+ const set = this.listeners.get(event);
277
+ if (set) {
278
+ set.delete(callback);
279
+ if (set.size === 0) this.listeners.delete(event);
280
+ }
281
+ return this;
282
+ }
283
+
284
+ private emit<K extends keyof PushEventMap>(
285
+ event: K,
286
+ data: PushEventMap[K]
287
+ ): void {
288
+ const set = this.listeners.get(event);
289
+ if (set) {
290
+ for (const cb of set) {
291
+ (cb as Function)(data);
292
+ }
293
+ }
294
+ }
295
+
296
+ // ── API methods ─────────────────────────────────────────────────
297
+
298
+ async getAccount(): Promise<AccountInfo> {
299
+ const res = await this.request({ action: "get_account" });
300
+ return {
301
+ balance: res.balance,
302
+ equity: res.equity,
303
+ margin: res.margin,
304
+ free_margin: res.free_margin,
305
+ profit: res.profit,
306
+ leverage: res.leverage,
307
+ currency: res.currency,
308
+ name: res.name,
309
+ number: res.number,
310
+ server: res.server,
311
+ company: res.company,
312
+ };
313
+ }
314
+
315
+ async openOrder(params: OpenOrderParams): Promise<{ ticket: number }> {
316
+ const res = await this.request({
317
+ action: "open_order",
318
+ type: params.type,
319
+ symbol: params.symbol,
320
+ volume: params.volume,
321
+ ...(params.price !== undefined && { price: params.price }),
322
+ ...(params.sl !== undefined && { sl: params.sl }),
323
+ ...(params.tp !== undefined && { tp: params.tp }),
324
+ });
325
+ return { ticket: res.ticket };
326
+ }
327
+
328
+ async closeOrder(params: CloseOrderParams): Promise<{ ticket: number }> {
329
+ const res = await this.request({
330
+ action: "close_order",
331
+ ticket: params.ticket,
332
+ ...(params.volume !== undefined && { volume: params.volume }),
333
+ });
334
+ return { ticket: res.ticket };
335
+ }
336
+
337
+ async modifyOrder(params: ModifyOrderParams): Promise<void> {
338
+ await this.request({
339
+ action: "modify_order",
340
+ ticket: params.ticket,
341
+ ...(params.sl !== undefined && { sl: params.sl }),
342
+ ...(params.tp !== undefined && { tp: params.tp }),
343
+ ...(params.price !== undefined && { price: params.price }),
344
+ });
345
+ }
346
+
347
+ async getPositions(): Promise<Position[]> {
348
+ const res = await this.request({ action: "get_positions" });
349
+ return res.positions;
350
+ }
351
+
352
+ async getOrders(): Promise<PendingOrder[]> {
353
+ const res = await this.request({ action: "get_orders" });
354
+ return res.orders;
355
+ }
356
+
357
+ async getDeals(days?: number): Promise<Deal[]> {
358
+ const res = await this.request({
359
+ action: "get_deals",
360
+ ...(days !== undefined && { days }),
361
+ });
362
+ return res.deals;
363
+ }
364
+
365
+ async getSymbolTick(symbol: string): Promise<SymbolTick> {
366
+ const res = await this.request({ action: "get_symbol_tick", symbol });
367
+ return {
368
+ symbol: res.symbol,
369
+ bid: res.bid,
370
+ ask: res.ask,
371
+ last: res.last,
372
+ volume: res.volume,
373
+ time: res.time,
374
+ };
375
+ }
376
+
377
+ async getSymbolDetails(symbol: string): Promise<SymbolDetails> {
378
+ const res = await this.request({ action: "get_symbol_details", symbol });
379
+ return {
380
+ symbol: res.symbol,
381
+ description: res.description,
382
+ digits: res.digits,
383
+ point: res.point,
384
+ spread: res.spread,
385
+ trade_mode: res.trade_mode,
386
+ volume_min: res.volume_min,
387
+ volume_max: res.volume_max,
388
+ volume_step: res.volume_step,
389
+ trade_contract_size: res.trade_contract_size,
390
+ filling_mode: res.filling_mode,
391
+ bid: res.bid,
392
+ ask: res.ask,
393
+ };
394
+ }
395
+
396
+ async getTimeOffset(): Promise<TimeOffset> {
397
+ const res = await this.request({ action: "get_time_offset" });
398
+ return {
399
+ offset_seconds: res.offset_seconds,
400
+ server_time: res.server_time,
401
+ gmt_time: res.gmt_time,
402
+ };
403
+ }
404
+
405
+ // ── Internals ───────────────────────────────────────────────────
406
+
407
+ private nextId(): string {
408
+ return `req-${++this.reqCounter}`;
409
+ }
410
+
411
+ private request(payload: Record<string, unknown>): Promise<any> {
412
+ return new Promise((resolve, reject) => {
413
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
414
+ reject(new MT5Error("Not connected"));
415
+ return;
416
+ }
417
+
418
+ const id = this.nextId();
419
+ const timer = setTimeout(() => {
420
+ this.pending.delete(id);
421
+ reject(new MT5Error(`Request ${id} timed out after ${this.timeoutMs}ms`));
422
+ }, this.timeoutMs);
423
+
424
+ this.pending.set(id, { resolve, reject, timer });
425
+ this.ws.send(JSON.stringify({ ...payload, id }));
426
+ });
427
+ }
428
+
429
+ private setupHandlers(ws: WebSocket): void {
430
+ ws.addEventListener("message", (ev: MessageEvent) => {
431
+ let msg: any;
432
+ try {
433
+ msg = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
434
+ } catch {
435
+ return;
436
+ }
437
+
438
+ // Response to a request (has an "id" field matching a pending request)
439
+ if (msg.id && this.pending.has(msg.id)) {
440
+ const req = this.pending.get(msg.id)!;
441
+ this.pending.delete(msg.id);
442
+ clearTimeout(req.timer);
443
+
444
+ if (msg.status === "ok") {
445
+ req.resolve(msg);
446
+ } else {
447
+ req.reject(new MT5Error(msg.error || "Unknown error", msg.retcode));
448
+ }
449
+ return;
450
+ }
451
+
452
+ // Push message from EA (no matching request id)
453
+ if (msg.type) {
454
+ this.emit(msg.type as keyof PushEventMap, msg as any);
455
+ // Also emit on the generic "message" event
456
+ this.emit("message", msg as PushMessage);
457
+ }
458
+ });
459
+
460
+ ws.addEventListener("close", (ev: CloseEvent) => {
461
+ this.emit("disconnected", {
462
+ code: ev.code,
463
+ reason: ev.reason,
464
+ });
465
+ for (const [, req] of this.pending) {
466
+ clearTimeout(req.timer);
467
+ req.reject(new MT5Error("Connection closed"));
468
+ }
469
+ this.pending.clear();
470
+ this.ws = null;
471
+ });
472
+
473
+ ws.addEventListener("error", (ev: Event) => {
474
+ this.emit("error", ev);
475
+ });
476
+ }
477
+ }
package/mt5.test.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { MT5Client } from "./index";
3
+
4
+ const SYMBOL = process.env.SYMBOL || "BTCUSD";
5
+ const URL = process.env.WS_URL || "ws://localhost:8080";
6
+
7
+ const mt5 = new MT5Client(URL, { timeout: 300_000 });
8
+
9
+ let buyTicket: number;
10
+ let sellTicket: number;
11
+
12
+ beforeAll(async () => {
13
+ await mt5.connect();
14
+ });
15
+
16
+ afterAll(() => {
17
+ mt5.disconnect();
18
+ });
19
+
20
+ describe("Account", () => {
21
+ it("should return account info", async () => {
22
+ const account = await mt5.getAccount();
23
+ expect(account.balance).toBeTypeOf("number");
24
+ expect(account.equity).toBeTypeOf("number");
25
+ expect(account.leverage).toBeGreaterThan(0);
26
+ console.log(
27
+ ` balance=${account.balance} equity=${account.equity} leverage=${account.leverage}`,
28
+ );
29
+ });
30
+ });
31
+
32
+ describe("Symbol", () => {
33
+ it("should return symbol details", async () => {
34
+ const details = await mt5.getSymbolDetails(SYMBOL);
35
+ expect(details.symbol).toBe(SYMBOL);
36
+ expect(details.trade_mode).toBe("full");
37
+ expect(details.volume_min).toBeGreaterThan(0);
38
+ console.log(
39
+ ` ${details.symbol}: digits=${details.digits} spread=${details.spread} filling_mode=${details.filling_mode}`,
40
+ );
41
+ console.log(
42
+ ` volume: min=${details.volume_min} max=${details.volume_max} step=${details.volume_step}`,
43
+ );
44
+ });
45
+
46
+ it("should return symbol tick with bid/ask", async () => {
47
+ let tick;
48
+ for (let attempt = 1; attempt <= 5; attempt++) {
49
+ tick = await mt5.getSymbolTick(SYMBOL);
50
+ if (tick.bid > 0) break;
51
+ console.log(` attempt ${attempt}/5 — no quotes yet, waiting 1s...`);
52
+ await new Promise((r) => setTimeout(r, 1000));
53
+ }
54
+ expect(tick!.bid).toBeGreaterThan(0);
55
+ expect(tick!.ask).toBeGreaterThan(0);
56
+ console.log(` bid=${tick!.bid} ask=${tick!.ask} last=${tick!.last}`);
57
+ });
58
+ });
59
+
60
+ describe("Orders", () => {
61
+ it("should open a buy order", async () => {
62
+ const result = await mt5.openOrder({
63
+ type: "buy",
64
+ symbol: SYMBOL,
65
+ volume: 0.1,
66
+ });
67
+ expect(result.ticket).toBeGreaterThan(0);
68
+ buyTicket = result.ticket;
69
+ console.log(` ticket=${buyTicket}`);
70
+ });
71
+
72
+ it("should show the buy in positions", async () => {
73
+ const positions = await mt5.getPositions();
74
+ const our = positions.find((p) => p.ticket === buyTicket);
75
+ expect(our).toBeDefined();
76
+ expect(our!.type).toBe("buy");
77
+ expect(our!.symbol).toBe(SYMBOL);
78
+ console.log(
79
+ ` found ${positions.length} position(s), ours: ticket=${our!.ticket} ${our!.type} ${our!.volume} ${our!.symbol}`,
80
+ );
81
+ });
82
+
83
+ it("should open a sell order", async () => {
84
+ const result = await mt5.openOrder({
85
+ type: "sell",
86
+ symbol: SYMBOL,
87
+ volume: 0.01,
88
+ });
89
+ expect(result.ticket).toBeGreaterThan(0);
90
+ sellTicket = result.ticket;
91
+ console.log(` ticket=${sellTicket}`);
92
+ });
93
+
94
+ it("should close the buy position", async () => {
95
+ const result = await mt5.closeOrder({ ticket: buyTicket });
96
+ expect(result.ticket).toBeGreaterThan(0);
97
+ });
98
+
99
+ it("should close the sell position", async () => {
100
+ const result = await mt5.closeOrder({ ticket: sellTicket });
101
+ expect(result.ticket).toBeGreaterThan(0);
102
+ });
103
+
104
+ it("should have no remaining positions for our tickets", async () => {
105
+ const positions = await mt5.getPositions();
106
+ const ours = positions.filter(
107
+ (p) => p.ticket === buyTicket || p.ticket === sellTicket,
108
+ );
109
+ expect(ours).toHaveLength(0);
110
+ console.log(
111
+ ` ${positions.length} position(s) remaining (none are ours)`,
112
+ );
113
+ });
114
+ });
115
+
116
+ describe("Concurrent - Four Clients", () => {
117
+ it("should open and close orders simultaneously from 4 clients", async () => {
118
+ const clients = await Promise.all(
119
+ Array.from({ length: 3 }, () => {
120
+ const c = new MT5Client(URL, { timeout: 300_000 });
121
+ return c.connect().then(() => c);
122
+ }),
123
+ );
124
+ const allClients = [mt5, ...clients];
125
+
126
+ // Open 4 orders simultaneously from different clients
127
+ const opens = await Promise.all(
128
+ allClients.map((c, i) =>
129
+ c.openOrder({
130
+ type: i % 2 === 0 ? "buy" : "sell",
131
+ symbol: SYMBOL,
132
+ volume: 0.01,
133
+ }),
134
+ ),
135
+ );
136
+
137
+ for (const o of opens) {
138
+ expect(o.ticket).toBeGreaterThan(0);
139
+ }
140
+ console.log(` tickets: ${opens.map((o) => o.ticket).join(", ")}`);
141
+
142
+ // Close all 4 simultaneously
143
+ const closes = await Promise.all(
144
+ allClients.map((c, i) => c.closeOrder({ ticket: opens[i].ticket })),
145
+ );
146
+
147
+ for (const c of closes) {
148
+ expect(c.ticket).toBeGreaterThan(0);
149
+ }
150
+ console.log(` all 4 closed simultaneously`);
151
+
152
+ for (const c of clients) c.disconnect();
153
+ });
154
+ });
155
+
156
+ describe("Concurrent - Same Client", () => {
157
+ it("should open and close 4 positions simultaneously from one client", async () => {
158
+ // Open 4 orders simultaneously
159
+ const opens = await Promise.all(
160
+ Array.from({ length: 4 }, (_, i) =>
161
+ mt5.openOrder({
162
+ type: i % 2 === 0 ? "buy" : "sell",
163
+ symbol: SYMBOL,
164
+ volume: 0.01,
165
+ }),
166
+ ),
167
+ );
168
+
169
+ for (const o of opens) {
170
+ expect(o.ticket).toBeGreaterThan(0);
171
+ }
172
+ console.log(` opened: ${opens.map((o) => o.ticket).join(", ")}`);
173
+
174
+ // Close all 4 simultaneously
175
+ const closes = await Promise.all(
176
+ opens.map((o) => mt5.closeOrder({ ticket: o.ticket })),
177
+ );
178
+
179
+ for (const c of closes) {
180
+ expect(c.ticket).toBeGreaterThan(0);
181
+ }
182
+ console.log(` all 4 closed simultaneously`);
183
+
184
+ // Verify all gone
185
+ const positions = await mt5.getPositions();
186
+ const ours = positions.filter((p) =>
187
+ opens.some((o) => o.ticket === p.ticket),
188
+ );
189
+ expect(ours).toHaveLength(0);
190
+ console.log(` verified: ${positions.length} position(s) remaining`);
191
+ });
192
+ });
193
+
194
+ describe("Deals", () => {
195
+ it("should contain our trades in history", async () => {
196
+ const deals = await mt5.getDeals(1);
197
+ expect(deals.length).toBeGreaterThan(0);
198
+ console.log(` ${deals.length} deal(s) in last 24h`);
199
+
200
+ const ourDeals = deals.filter((d) => d.symbol === SYMBOL);
201
+ const entries = ourDeals.filter((d) => d.entry === "in");
202
+ const exits = ourDeals.filter((d) => d.entry === "out");
203
+ console.log(
204
+ ` ${SYMBOL}: ${entries.length} entry deal(s), ${exits.length} exit deal(s)`,
205
+ );
206
+ expect(entries.length).toBeGreaterThanOrEqual(2);
207
+ expect(exits.length).toBeGreaterThanOrEqual(2);
208
+ });
209
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@mautriz/mt5",
3
+ "version": "1.0.0",
4
+ "description": "TypeScript client library for the Mautriz MetaTrader 5 WebSocket API",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "vitest run",
10
+ "test:watch": "vitest",
11
+ "script": "npx tsx script.ts",
12
+ "bench": "npx tsx bench.ts",
13
+ "prepublishOnly": "tsc",
14
+ "publish:npm": "tsc && npm publish --access public"
15
+ },
16
+ "license": "ISC",
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0",
19
+ "vitest": "^3.0.0",
20
+ "@types/node": "^25.5.2"
21
+ },
22
+ "dependencies": {}
23
+ }