@alfe.ai/ctrader-mcp 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +408 -154
- package/package.json +3 -3
- package/proto/ctrader.proto +32 -24
package/dist/server.js
CHANGED
|
@@ -6,9 +6,9 @@ import { resolveConfig } from "@alfe.ai/config";
|
|
|
6
6
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import * as tls from "node:tls";
|
|
9
|
+
import protobuf from "protobufjs";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
|
-
import protobuf from "protobufjs";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
//#region src/config.ts
|
|
14
14
|
/**
|
|
@@ -41,6 +41,15 @@ var ConfigError = class extends Error {
|
|
|
41
41
|
this.name = "ConfigError";
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
|
+
const MAX_INT64 = 9223372036854775807n;
|
|
45
|
+
function parseAccountId(raw) {
|
|
46
|
+
if (!/^[1-9][0-9]*$/.test(raw)) return null;
|
|
47
|
+
try {
|
|
48
|
+
return BigInt(raw) <= MAX_INT64 ? raw : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
44
53
|
/**
|
|
45
54
|
* Resolve the TLS host from a live/demo hint.
|
|
46
55
|
*
|
|
@@ -84,18 +93,22 @@ function buildRegistry(creds) {
|
|
|
84
93
|
const registry = /* @__PURE__ */ new Map();
|
|
85
94
|
for (const account of creds.accounts) {
|
|
86
95
|
const idRaw = account.ctidTraderAccountId.trim();
|
|
87
|
-
const accountId =
|
|
88
|
-
if (
|
|
96
|
+
const accountId = parseAccountId(idRaw);
|
|
97
|
+
if (accountId == null) continue;
|
|
89
98
|
const accessToken = account.accessToken.trim();
|
|
90
99
|
if (!accessToken) continue;
|
|
91
100
|
const host = resolveHost(account.host);
|
|
92
|
-
|
|
101
|
+
const isLive = host === CTRADER_LIVE_HOST;
|
|
102
|
+
if (typeof account.isLive !== "boolean" || account.isLive !== isLive) throw new ConfigError(`cTrader account ${idRaw} has conflicting host/live metadata; refusing ambiguous real-money routing.`);
|
|
103
|
+
const canonicalId = accountId;
|
|
104
|
+
if (registry.has(canonicalId)) throw new ConfigError(`Duplicate cTrader account ${canonicalId} was returned; refusing ambiguous credential/host routing.`);
|
|
105
|
+
registry.set(canonicalId, {
|
|
93
106
|
clientId,
|
|
94
107
|
clientSecret,
|
|
95
108
|
accessToken,
|
|
96
109
|
accountId,
|
|
97
110
|
host,
|
|
98
|
-
isLive
|
|
111
|
+
isLive,
|
|
99
112
|
...account.brokerName != null ? { brokerName: account.brokerName } : {},
|
|
100
113
|
...account.accountNumber != null ? { accountNumber: account.accountNumber } : {}
|
|
101
114
|
});
|
|
@@ -222,6 +235,36 @@ const RESPONSE_MESSAGE = {
|
|
|
222
235
|
[PayloadType.OA_ORDER_ERROR_EVENT]: "ctrader.ProtoOAOrderErrorEvent",
|
|
223
236
|
[PayloadType.OA_ERROR_RES]: "ctrader.ProtoOAErrorRes"
|
|
224
237
|
};
|
|
238
|
+
/**
|
|
239
|
+
* The only successful correlated reply accepted for each request. cTrader can
|
|
240
|
+
* also answer any request with one of the error payloads handled by the
|
|
241
|
+
* client. Keeping the success contract explicit prevents an unrelated frame
|
|
242
|
+
* that happens to reuse a clientMsgId from being reported as success.
|
|
243
|
+
*/
|
|
244
|
+
const EXPECTED_RESPONSE = {
|
|
245
|
+
[PayloadType.OA_APPLICATION_AUTH_REQ]: PayloadType.OA_APPLICATION_AUTH_RES,
|
|
246
|
+
[PayloadType.OA_ACCOUNT_AUTH_REQ]: PayloadType.OA_ACCOUNT_AUTH_RES,
|
|
247
|
+
[PayloadType.OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_REQ]: PayloadType.OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_RES,
|
|
248
|
+
[PayloadType.OA_TRADER_REQ]: PayloadType.OA_TRADER_RES,
|
|
249
|
+
[PayloadType.OA_RECONCILE_REQ]: PayloadType.OA_RECONCILE_RES,
|
|
250
|
+
[PayloadType.OA_SYMBOLS_LIST_REQ]: PayloadType.OA_SYMBOLS_LIST_RES,
|
|
251
|
+
[PayloadType.OA_SYMBOL_BY_ID_REQ]: PayloadType.OA_SYMBOL_BY_ID_RES,
|
|
252
|
+
[PayloadType.OA_GET_TRENDBARS_REQ]: PayloadType.OA_GET_TRENDBARS_RES,
|
|
253
|
+
[PayloadType.OA_SUBSCRIBE_SPOTS_REQ]: PayloadType.OA_SUBSCRIBE_SPOTS_RES,
|
|
254
|
+
[PayloadType.OA_UNSUBSCRIBE_SPOTS_REQ]: PayloadType.OA_UNSUBSCRIBE_SPOTS_RES,
|
|
255
|
+
[PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_REQ]: PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_RES,
|
|
256
|
+
[PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_REQ]: PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_RES,
|
|
257
|
+
[PayloadType.OA_DEAL_LIST_REQ]: PayloadType.OA_DEAL_LIST_RES,
|
|
258
|
+
[PayloadType.OA_GET_TICKDATA_REQ]: PayloadType.OA_GET_TICKDATA_RES,
|
|
259
|
+
[PayloadType.OA_EXPECTED_MARGIN_REQ]: PayloadType.OA_EXPECTED_MARGIN_RES,
|
|
260
|
+
[PayloadType.OA_CASH_FLOW_HISTORY_LIST_REQ]: PayloadType.OA_CASH_FLOW_HISTORY_LIST_RES,
|
|
261
|
+
[PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ]: PayloadType.OA_GET_POSITION_UNREALIZED_PNL_RES,
|
|
262
|
+
[PayloadType.OA_NEW_ORDER_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
263
|
+
[PayloadType.OA_AMEND_ORDER_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
264
|
+
[PayloadType.OA_AMEND_POSITION_SLTP_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
265
|
+
[PayloadType.OA_CLOSE_POSITION_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
266
|
+
[PayloadType.OA_CANCEL_ORDER_REQ]: PayloadType.OA_EXECUTION_EVENT
|
|
267
|
+
};
|
|
225
268
|
//#endregion
|
|
226
269
|
//#region src/proto.ts
|
|
227
270
|
/**
|
|
@@ -270,6 +313,45 @@ function loadRoot(protoPath = resolveProtoPath()) {
|
|
|
270
313
|
cachedRoot ??= protobuf.loadSync(protoPath);
|
|
271
314
|
return cachedRoot;
|
|
272
315
|
}
|
|
316
|
+
/** Hard ceiling for one cTrader envelope and the parser's retained buffer. */
|
|
317
|
+
const MAX_FRAME_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
318
|
+
const INT64_FIELD_TYPES = new Set([
|
|
319
|
+
"int64",
|
|
320
|
+
"uint64",
|
|
321
|
+
"sint64",
|
|
322
|
+
"fixed64",
|
|
323
|
+
"sfixed64"
|
|
324
|
+
]);
|
|
325
|
+
function normalizeFieldValue(field, value) {
|
|
326
|
+
if (field.repeated) {
|
|
327
|
+
if (!Array.isArray(value)) return value;
|
|
328
|
+
return value.map((entry) => normalizeSingularFieldValue(field, entry));
|
|
329
|
+
}
|
|
330
|
+
if (field.map) {
|
|
331
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
332
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeSingularFieldValue(field, entry)]));
|
|
333
|
+
}
|
|
334
|
+
return normalizeSingularFieldValue(field, value);
|
|
335
|
+
}
|
|
336
|
+
function normalizeSingularFieldValue(field, value) {
|
|
337
|
+
if (field.resolvedType instanceof protobuf.Type && typeof value === "object" && value !== null && !Array.isArray(value)) return normalizeInt64Strings(field.resolvedType, value);
|
|
338
|
+
if (INT64_FIELD_TYPES.has(field.type) && typeof value === "string") {
|
|
339
|
+
const unsigned = field.type === "uint64" || field.type === "fixed64";
|
|
340
|
+
return protobuf.util.LongBits.from(value).toLong(unsigned);
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Convert only protobuf int64 decimal strings to exact Long values before
|
|
346
|
+
* validation. Unlike `fromObject`, this preserves missing required fields so
|
|
347
|
+
* `verify` still rejects malformed payloads instead of filling defaults.
|
|
348
|
+
*/
|
|
349
|
+
function normalizeInt64Strings(type, payload) {
|
|
350
|
+
return Object.fromEntries(Object.entries(payload).map(([name, value]) => {
|
|
351
|
+
if (!Object.hasOwn(type.fields, name)) return [name, value];
|
|
352
|
+
return [name, normalizeFieldValue(type.fields[name], value)];
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
273
355
|
/**
|
|
274
356
|
* Encode an outbound request into a fully-framed buffer:
|
|
275
357
|
* length prefix + ProtoMessage(payloadType, payload, clientMsgId).
|
|
@@ -278,9 +360,16 @@ function encodeRequest(root, payloadType, payload, clientMsgId) {
|
|
|
278
360
|
const messageName = REQUEST_MESSAGE[payloadType];
|
|
279
361
|
if (!messageName) throw new Error(`No request message registered for payloadType ${String(payloadType)}`);
|
|
280
362
|
const InnerType = root.lookupType(messageName);
|
|
281
|
-
|
|
363
|
+
let normalizedPayload;
|
|
364
|
+
try {
|
|
365
|
+
normalizedPayload = normalizeInt64Strings(InnerType, payload);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw new Error(`Invalid ${messageName} payload: ${err instanceof Error ? err.message : String(err)}`);
|
|
368
|
+
}
|
|
369
|
+
const innerErr = InnerType.verify(normalizedPayload);
|
|
282
370
|
if (innerErr) throw new Error(`Invalid ${messageName} payload: ${innerErr}`);
|
|
283
|
-
const
|
|
371
|
+
const innerMessage = InnerType.create(normalizedPayload);
|
|
372
|
+
const innerBytes = InnerType.encode(innerMessage).finish();
|
|
284
373
|
const Envelope = root.lookupType("ctrader.ProtoMessage");
|
|
285
374
|
const envelopeBytes = Envelope.encode(Envelope.create({
|
|
286
375
|
payloadType,
|
|
@@ -306,11 +395,19 @@ function decodeEnvelope(root, envelopeBytes) {
|
|
|
306
395
|
var FrameParser = class {
|
|
307
396
|
buffer = Buffer.alloc(0);
|
|
308
397
|
push(chunk) {
|
|
398
|
+
if (this.buffer.length + chunk.byteLength > 16777220) {
|
|
399
|
+
this.buffer = Buffer.alloc(0);
|
|
400
|
+
throw new Error(`cTrader frame buffer exceeds ${String(MAX_FRAME_LENGTH_BYTES)} bytes`);
|
|
401
|
+
}
|
|
309
402
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
310
403
|
const frames = [];
|
|
311
404
|
for (;;) {
|
|
312
405
|
if (this.buffer.length < 4) break;
|
|
313
406
|
const length = this.buffer.readUInt32BE(0);
|
|
407
|
+
if (length === 0 || length > 16777216) {
|
|
408
|
+
this.buffer = Buffer.alloc(0);
|
|
409
|
+
throw new Error(`Invalid cTrader frame length ${String(length)}`);
|
|
410
|
+
}
|
|
314
411
|
if (this.buffer.length < 4 + length) break;
|
|
315
412
|
frames.push(this.buffer.subarray(4, 4 + length));
|
|
316
413
|
this.buffer = this.buffer.subarray(4 + length);
|
|
@@ -374,9 +471,13 @@ function volumeToLots(volume, lotSize) {
|
|
|
374
471
|
* are in the same centi-unit space. Returns an error string, or null if valid.
|
|
375
472
|
*/
|
|
376
473
|
function validateVolume(volume, minVolume, maxVolume, stepVolume) {
|
|
474
|
+
if (!Number.isSafeInteger(volume) || volume <= 0) return `volume must be a positive safe integer, got ${String(volume)}`;
|
|
475
|
+
if (!Number.isSafeInteger(minVolume) || minVolume <= 0) return `symbol minimum volume is invalid: ${String(minVolume)}`;
|
|
476
|
+
if (!Number.isSafeInteger(maxVolume) || maxVolume < minVolume) return `symbol maximum volume is invalid: ${String(maxVolume)}`;
|
|
477
|
+
if (!Number.isSafeInteger(stepVolume) || stepVolume <= 0) return `symbol volume step is invalid: ${String(stepVolume)}`;
|
|
377
478
|
if (volume < minVolume) return `volume ${String(volume)} is below the symbol minimum ${String(minVolume)}`;
|
|
378
|
-
if (
|
|
379
|
-
if (
|
|
479
|
+
if (volume > maxVolume) return `volume ${String(volume)} exceeds the symbol maximum ${String(maxVolume)}`;
|
|
480
|
+
if ((volume - minVolume) % stepVolume !== 0) return `volume ${String(volume)} does not align to the symbol step ${String(stepVolume)} (offset from min ${String(minVolume)})`;
|
|
380
481
|
return null;
|
|
381
482
|
}
|
|
382
483
|
/** cTrader trendbar/spot integer prices are scaled by 10^5. */
|
|
@@ -490,25 +591,48 @@ var CTraderError = class extends Error {
|
|
|
490
591
|
* execution events for other sessions, …).
|
|
491
592
|
*/
|
|
492
593
|
const LISTENABLE_EVENTS = new Set([PayloadType.OA_DEPTH_EVENT, PayloadType.OA_SPOT_EVENT]);
|
|
493
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Halved from 10s so the read-idle watchdog can detect a wedged socket before
|
|
596
|
+
* `REQUEST_TIMEOUT_MS` fires, while still tolerating two consecutive missed
|
|
597
|
+
* echoes (see `READ_IDLE_TIMEOUT_MS`). cTrader expects a heartbeat at least
|
|
598
|
+
* every 10s and drops an idle connection at ~30s, so 5s is well within
|
|
599
|
+
* protocol expectations. Heartbeats are protocol keepalives, not part of the
|
|
600
|
+
* historical-data payload class that gets rate limited.
|
|
601
|
+
*/
|
|
602
|
+
const HEARTBEAT_INTERVAL_MS = 5e3;
|
|
494
603
|
const REQUEST_TIMEOUT_MS = 2e4;
|
|
495
604
|
const RECONNECT_BASE_MS = 1e3;
|
|
496
605
|
const RECONNECT_MAX_MS = 3e4;
|
|
606
|
+
const MAX_PENDING_REQUESTS = 256;
|
|
497
607
|
/**
|
|
498
|
-
* Read-idle watchdog: cTrader echoes our
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* heartbeat echo doesn't false-positive.
|
|
608
|
+
* Read-idle watchdog: cTrader echoes our heartbeats and pushes its own traffic,
|
|
609
|
+
* so a *healthy* socket is never silent for long. If NO inbound byte arrives
|
|
610
|
+
* for this long we treat the socket as silently half-dead (a TCP half-open with
|
|
611
|
+
* no FIN — the OS never fires `close`, so `handleDrop` never runs and every
|
|
612
|
+
* request would otherwise time out indefinitely) and force a reconnect. Set to
|
|
613
|
+
* 3× the heartbeat interval so two dropped heartbeat echoes don't false-positive.
|
|
505
614
|
*/
|
|
506
615
|
const READ_IDLE_TIMEOUT_MS = 3 * HEARTBEAT_INTERVAL_MS;
|
|
507
616
|
/** How often the watchdog checks the read-idle clock. */
|
|
508
|
-
const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS;
|
|
617
|
+
const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS / 2;
|
|
618
|
+
/**
|
|
619
|
+
* Detection must beat the request timeout, or the watchdog is useless to the
|
|
620
|
+
* caller that trips it: a wedged socket surfaces an ambiguous `REQUEST_TIMEOUT`
|
|
621
|
+
* (indistinguishable from a slow server) instead of the fast, honest,
|
|
622
|
+
* retryable `CONNECTION_DROPPED` the reconnect path exists to produce.
|
|
623
|
+
*
|
|
624
|
+
* That was the shipped state — 30s idle + 10s poll = up to 40s to detect, vs a
|
|
625
|
+
* 20s request timeout — so the 0.3.1 watchdog never spared the first caller.
|
|
626
|
+
* Worst-case detection is READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS.
|
|
627
|
+
*/
|
|
628
|
+
if (READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS >= REQUEST_TIMEOUT_MS) throw new Error(`ctrader-mcp timing misconfigured: read-idle detection (${String(READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS)}ms) must be faster than REQUEST_TIMEOUT_MS (${String(REQUEST_TIMEOUT_MS)}ms).`);
|
|
509
629
|
function log$1(msg) {
|
|
510
630
|
process.stderr.write(`[ctrader-mcp] ${msg}\n`);
|
|
511
631
|
}
|
|
632
|
+
/** Convert a validated decimal account id to an exact protobuf int64 value. */
|
|
633
|
+
function accountIdLong(accountId) {
|
|
634
|
+
return protobuf.util.LongBits.from(accountId).toLong(false);
|
|
635
|
+
}
|
|
512
636
|
/** Production TLS transport. */
|
|
513
637
|
const tlsConnect = (host, port) => new Promise((resolve, reject) => {
|
|
514
638
|
const socket = tls.connect({
|
|
@@ -548,6 +672,7 @@ var HostSocket = class {
|
|
|
548
672
|
reconnectScheduled = false;
|
|
549
673
|
closing = false;
|
|
550
674
|
connectPromise = null;
|
|
675
|
+
reconnectPromise = null;
|
|
551
676
|
/** ctidTraderAccountId → accessToken used to account-auth it on this socket. */
|
|
552
677
|
authedAccounts = /* @__PURE__ */ new Map();
|
|
553
678
|
/** Serializes account-auth so concurrent tool calls don't double-auth. */
|
|
@@ -563,17 +688,44 @@ var HostSocket = class {
|
|
|
563
688
|
}
|
|
564
689
|
/** Connect the socket and run the app-auth handshake. Idempotent. */
|
|
565
690
|
async start() {
|
|
566
|
-
this.
|
|
567
|
-
|
|
691
|
+
if (this.closing) throw new CTraderError("CLIENT_CLOSED", "Client is shutting down");
|
|
692
|
+
if (this.conn) return;
|
|
693
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
694
|
+
if (this.connectPromise) return this.connectPromise;
|
|
695
|
+
const attempt = this.establishConnection();
|
|
696
|
+
this.connectPromise = attempt;
|
|
697
|
+
try {
|
|
698
|
+
await attempt;
|
|
699
|
+
} finally {
|
|
700
|
+
if (this.connectPromise === attempt) this.connectPromise = null;
|
|
701
|
+
}
|
|
568
702
|
}
|
|
569
|
-
async
|
|
570
|
-
|
|
703
|
+
async establishConnection() {
|
|
704
|
+
const conn = await this.connectFn(this.host, CTRADER_PORT);
|
|
705
|
+
if (this.closing) {
|
|
706
|
+
conn.destroy();
|
|
707
|
+
throw new CTraderError("CLIENT_CLOSED", "Client closed while connecting");
|
|
708
|
+
}
|
|
709
|
+
this.parser = new FrameParser();
|
|
710
|
+
this.conn = conn;
|
|
571
711
|
this.lastInboundAt = Date.now();
|
|
572
|
-
this.wireConnection(
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
712
|
+
this.wireConnection(conn);
|
|
713
|
+
try {
|
|
714
|
+
await this.appAuth();
|
|
715
|
+
if (this.conn !== conn) throw new CTraderError("CONNECTION_DROPPED", "Socket dropped during application authentication");
|
|
716
|
+
this.startHeartbeat();
|
|
717
|
+
this.startWatchdog();
|
|
718
|
+
log$1(`Connected + app-authenticated to ${this.host}`);
|
|
719
|
+
} catch (err) {
|
|
720
|
+
if (this.conn === conn) {
|
|
721
|
+
this.conn = null;
|
|
722
|
+
this.parser = new FrameParser();
|
|
723
|
+
try {
|
|
724
|
+
conn.destroy();
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
577
729
|
}
|
|
578
730
|
/**
|
|
579
731
|
* Ensure `accountId` is account-authed on this socket. Deduped: an account is
|
|
@@ -586,7 +738,7 @@ var HostSocket = class {
|
|
|
586
738
|
let inFlight = this.accountAuthPromises.get(accountId);
|
|
587
739
|
if (!inFlight) {
|
|
588
740
|
inFlight = this.request(PayloadType.OA_ACCOUNT_AUTH_REQ, {
|
|
589
|
-
ctidTraderAccountId: accountId,
|
|
741
|
+
ctidTraderAccountId: accountIdLong(accountId),
|
|
590
742
|
accessToken
|
|
591
743
|
}).then(() => {
|
|
592
744
|
this.authedAccounts.set(accountId, accessToken);
|
|
@@ -612,14 +764,24 @@ var HostSocket = class {
|
|
|
612
764
|
}
|
|
613
765
|
wireConnection(conn) {
|
|
614
766
|
conn.on("data", (chunk) => {
|
|
767
|
+
if (this.conn !== conn || this.closing) return;
|
|
615
768
|
this.lastInboundAt = Date.now();
|
|
616
|
-
|
|
769
|
+
try {
|
|
770
|
+
for (const frame of this.parser.push(chunk)) this.dispatch(frame);
|
|
771
|
+
} catch (err) {
|
|
772
|
+
log$1(`Invalid frame stream (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
773
|
+
this.handleDrop(conn);
|
|
774
|
+
try {
|
|
775
|
+
conn.destroy();
|
|
776
|
+
} catch {}
|
|
777
|
+
}
|
|
617
778
|
});
|
|
618
779
|
conn.on("error", (err) => {
|
|
780
|
+
if (this.conn !== conn || this.closing) return;
|
|
619
781
|
log$1(`Socket error (${this.host}): ${err.message}`);
|
|
620
782
|
});
|
|
621
783
|
conn.on("close", () => {
|
|
622
|
-
if (!this.closing) this.handleDrop();
|
|
784
|
+
if (!this.closing) this.handleDrop(conn);
|
|
623
785
|
});
|
|
624
786
|
}
|
|
625
787
|
async appAuth() {
|
|
@@ -680,18 +842,17 @@ var HostSocket = class {
|
|
|
680
842
|
*/
|
|
681
843
|
forceReconnect() {
|
|
682
844
|
const dead = this.conn;
|
|
683
|
-
|
|
845
|
+
if (!dead) return;
|
|
846
|
+
this.handleDrop(dead);
|
|
684
847
|
try {
|
|
685
|
-
dead
|
|
848
|
+
dead.destroy();
|
|
686
849
|
} catch {}
|
|
687
|
-
this.handleDrop();
|
|
688
850
|
}
|
|
689
|
-
handleDrop() {
|
|
690
|
-
if (
|
|
851
|
+
handleDrop(dropped) {
|
|
852
|
+
if (this.conn !== dropped) return;
|
|
691
853
|
log$1(`Socket dropped (${this.host}) — attempting reconnect`);
|
|
692
854
|
this.stopHeartbeat();
|
|
693
855
|
this.stopWatchdog();
|
|
694
|
-
this.reconnectScheduled = true;
|
|
695
856
|
this.conn = null;
|
|
696
857
|
this.parser = new FrameParser();
|
|
697
858
|
this.authedAccounts.clear();
|
|
@@ -700,27 +861,32 @@ var HostSocket = class {
|
|
|
700
861
|
req.reject(new CTraderError("CONNECTION_DROPPED", "Socket closed before a response arrived"));
|
|
701
862
|
this.pending.delete(id);
|
|
702
863
|
}
|
|
703
|
-
this.
|
|
864
|
+
if (!this.reconnectScheduled && !this.closing) {
|
|
865
|
+
this.reconnectScheduled = true;
|
|
866
|
+
const attempt = this.reconnect();
|
|
867
|
+
this.reconnectPromise = attempt;
|
|
868
|
+
attempt.then(() => {
|
|
869
|
+
if (this.reconnectPromise === attempt) this.reconnectPromise = null;
|
|
870
|
+
}, () => {
|
|
871
|
+
if (this.reconnectPromise === attempt) this.reconnectPromise = null;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
704
874
|
}
|
|
705
875
|
async reconnect() {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
log$1(`Reconnected + re-app-authenticated (${this.host})`);
|
|
721
|
-
} catch (err) {
|
|
722
|
-
log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
723
|
-
this.reconnect();
|
|
876
|
+
while (!this.closing) {
|
|
877
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
|
|
878
|
+
this.reconnectAttempts += 1;
|
|
879
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
880
|
+
if (this.closing) return;
|
|
881
|
+
try {
|
|
882
|
+
await this.establishConnection();
|
|
883
|
+
this.reconnectAttempts = 0;
|
|
884
|
+
this.reconnectScheduled = false;
|
|
885
|
+
log$1(`Reconnected + re-app-authenticated (${this.host})`);
|
|
886
|
+
return;
|
|
887
|
+
} catch (err) {
|
|
888
|
+
log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
889
|
+
}
|
|
724
890
|
}
|
|
725
891
|
}
|
|
726
892
|
dispatch(frame) {
|
|
@@ -754,6 +920,10 @@ var HostSocket = class {
|
|
|
754
920
|
waiter.reject(err);
|
|
755
921
|
return;
|
|
756
922
|
}
|
|
923
|
+
if (decoded.payloadType !== waiter.expectedPayloadType) {
|
|
924
|
+
waiter.reject(new CTraderError("UNEXPECTED_RESPONSE", `Request payloadType ${String(waiter.requestPayloadType)} expected ${String(waiter.expectedPayloadType)} but received ${String(decoded.payloadType)}`));
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
757
927
|
waiter.resolve({
|
|
758
928
|
payloadType: decoded.payloadType,
|
|
759
929
|
message: decoded.message
|
|
@@ -783,6 +953,10 @@ var HostSocket = class {
|
|
|
783
953
|
*/
|
|
784
954
|
async request(payloadType, payload) {
|
|
785
955
|
if (!this.conn) throw new CTraderError("NOT_CONNECTED", `The cTrader socket to ${this.host} is not connected`);
|
|
956
|
+
if (this.pending.size >= MAX_PENDING_REQUESTS) throw new CTraderError("TOO_MANY_PENDING_REQUESTS", `The cTrader socket already has ${String(MAX_PENDING_REQUESTS)} requests awaiting replies`);
|
|
957
|
+
const expectedPayloadType = EXPECTED_RESPONSE[payloadType];
|
|
958
|
+
if (expectedPayloadType == null) throw new CTraderError("UNSUPPORTED_REQUEST", `No successful response contract is registered for payloadType ${String(payloadType)}`);
|
|
959
|
+
const conn = this.conn;
|
|
786
960
|
const clientMsgId = randomUUID();
|
|
787
961
|
const frame = encodeRequest(this.root, payloadType, payload, clientMsgId);
|
|
788
962
|
return new Promise((resolve, reject) => {
|
|
@@ -792,11 +966,29 @@ var HostSocket = class {
|
|
|
792
966
|
}, REQUEST_TIMEOUT_MS);
|
|
793
967
|
timer.unref();
|
|
794
968
|
this.pending.set(clientMsgId, {
|
|
969
|
+
requestPayloadType: payloadType,
|
|
970
|
+
expectedPayloadType,
|
|
795
971
|
resolve,
|
|
796
972
|
reject,
|
|
797
973
|
timer
|
|
798
974
|
});
|
|
799
|
-
this.conn
|
|
975
|
+
if (this.conn !== conn) {
|
|
976
|
+
clearTimeout(timer);
|
|
977
|
+
this.pending.delete(clientMsgId);
|
|
978
|
+
reject(new CTraderError("CONNECTION_DROPPED", "Socket changed before the request was written"));
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
conn.write(frame);
|
|
983
|
+
} catch (err) {
|
|
984
|
+
clearTimeout(timer);
|
|
985
|
+
this.pending.delete(clientMsgId);
|
|
986
|
+
reject(new CTraderError("REQUEST_WRITE_FAILED", err instanceof Error ? err.message : "The socket rejected the request write"));
|
|
987
|
+
this.handleDrop(conn);
|
|
988
|
+
try {
|
|
989
|
+
conn.destroy();
|
|
990
|
+
} catch {}
|
|
991
|
+
}
|
|
800
992
|
});
|
|
801
993
|
}
|
|
802
994
|
/** Clean shutdown: stop heartbeat, fail waiters, destroy the socket. */
|
|
@@ -811,6 +1003,7 @@ var HostSocket = class {
|
|
|
811
1003
|
}
|
|
812
1004
|
this.conn?.destroy();
|
|
813
1005
|
this.conn = null;
|
|
1006
|
+
this.connectPromise = null;
|
|
814
1007
|
this.authedAccounts.clear();
|
|
815
1008
|
this.eventListeners.clear();
|
|
816
1009
|
}
|
|
@@ -864,10 +1057,10 @@ var CTraderPool = class {
|
|
|
864
1057
|
* Route a request to a specific account. Resolves the account → its host
|
|
865
1058
|
* socket, ensures the socket is connected + app-authed and the account is
|
|
866
1059
|
* account-authed, then sends the request. Every account-scoped request must
|
|
867
|
-
* go through here so it lands on the RIGHT host socket. The
|
|
868
|
-
* `ctidTraderAccountId` is
|
|
869
|
-
*
|
|
870
|
-
*
|
|
1060
|
+
* go through here so it lands on the RIGHT host socket. The routed registry
|
|
1061
|
+
* entry is authoritative: its `ctidTraderAccountId` is injected after the
|
|
1062
|
+
* caller payload, so host/auth selection and protobuf account identity can
|
|
1063
|
+
* never diverge.
|
|
871
1064
|
*
|
|
872
1065
|
* Throws `CTraderError("UNKNOWN_ACCOUNT")` if the id isn't in the registry
|
|
873
1066
|
* (fail closed — never fall back to another account).
|
|
@@ -877,7 +1070,10 @@ var CTraderPool = class {
|
|
|
877
1070
|
if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
|
|
878
1071
|
const socket = this.socketFor(config);
|
|
879
1072
|
await socket.authenticateAccount(config.accountId, config.accessToken);
|
|
880
|
-
return socket.request(payloadType,
|
|
1073
|
+
return socket.request(payloadType, {
|
|
1074
|
+
...payload,
|
|
1075
|
+
ctidTraderAccountId: accountIdLong(config.accountId)
|
|
1076
|
+
});
|
|
881
1077
|
}
|
|
882
1078
|
/**
|
|
883
1079
|
* Register an unsolicited-event listener on the socket that serves
|
|
@@ -988,13 +1184,32 @@ const EXECUTION_TYPE_NAME = {
|
|
|
988
1184
|
11: "ORDER_PARTIAL_FILL",
|
|
989
1185
|
12: "BONUS_DEPOSIT_WITHDRAW"
|
|
990
1186
|
};
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1187
|
+
const PLACE_ORDER_SUCCESS_TYPES = new Set([
|
|
1188
|
+
2,
|
|
1189
|
+
3,
|
|
1190
|
+
11
|
|
1191
|
+
]);
|
|
1192
|
+
const AMEND_SUCCESS_TYPES = new Set([4]);
|
|
1193
|
+
const CLOSE_SUCCESS_TYPES = new Set([3, 11]);
|
|
1194
|
+
const CANCEL_SUCCESS_TYPES = new Set([5]);
|
|
1195
|
+
const WRITE_TOOL_NAMES = new Set([
|
|
1196
|
+
"place_order",
|
|
1197
|
+
"modify_order",
|
|
1198
|
+
"close_position",
|
|
1199
|
+
"cancel_order"
|
|
997
1200
|
]);
|
|
1201
|
+
const READ_ANNOTATIONS = {
|
|
1202
|
+
readOnlyHint: true,
|
|
1203
|
+
destructiveHint: false,
|
|
1204
|
+
idempotentHint: true,
|
|
1205
|
+
openWorldHint: true
|
|
1206
|
+
};
|
|
1207
|
+
const WRITE_ANNOTATIONS = {
|
|
1208
|
+
readOnlyHint: false,
|
|
1209
|
+
destructiveHint: true,
|
|
1210
|
+
idempotentHint: false,
|
|
1211
|
+
openWorldHint: true
|
|
1212
|
+
};
|
|
998
1213
|
const TRENDBAR_PERIOD = {
|
|
999
1214
|
M1: 1,
|
|
1000
1215
|
M2: 2,
|
|
@@ -1037,7 +1252,7 @@ function num(v) {
|
|
|
1037
1252
|
/** Serialize an account for a listing (both the registry and error payloads). */
|
|
1038
1253
|
function describeAccount(config) {
|
|
1039
1254
|
return {
|
|
1040
|
-
accountId:
|
|
1255
|
+
accountId: config.accountId,
|
|
1041
1256
|
isLive: config.isLive,
|
|
1042
1257
|
host: config.host,
|
|
1043
1258
|
broker: config.brokerName ?? null,
|
|
@@ -1094,12 +1309,12 @@ function isToolError(v) {
|
|
|
1094
1309
|
return "content" in v;
|
|
1095
1310
|
}
|
|
1096
1311
|
/** Fetch a symbol's full detail (for lotSize / volume rules / digits). */
|
|
1097
|
-
async function getSymbolDetail(pool, accountId,
|
|
1312
|
+
async function getSymbolDetail(pool, accountId, ctidTraderAccountId, symbolId) {
|
|
1098
1313
|
const symbols = (await pool.request(accountId, PayloadType.OA_SYMBOL_BY_ID_REQ, {
|
|
1099
|
-
ctidTraderAccountId
|
|
1314
|
+
ctidTraderAccountId,
|
|
1100
1315
|
symbolId: [symbolId]
|
|
1101
1316
|
})).message.symbol ?? [];
|
|
1102
|
-
if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${
|
|
1317
|
+
if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${symbolId} on this account`);
|
|
1103
1318
|
return symbols[0];
|
|
1104
1319
|
}
|
|
1105
1320
|
/** Unref'd sleep so a pending collection window never keeps the process alive. */
|
|
@@ -1146,25 +1361,42 @@ const LIVE_SUBSCRIPTION = {
|
|
|
1146
1361
|
event: PayloadType.OA_SPOT_EVENT
|
|
1147
1362
|
}
|
|
1148
1363
|
};
|
|
1364
|
+
const MAX_SIGNED_INT64 = 9223372036854775807n;
|
|
1365
|
+
function isCanonicalInt64(value) {
|
|
1366
|
+
if (!/^[1-9][0-9]*$/.test(value)) return false;
|
|
1367
|
+
try {
|
|
1368
|
+
return BigInt(value) <= MAX_SIGNED_INT64;
|
|
1369
|
+
} catch {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* cTrader identifiers are int64 values and are returned to tools as decimal
|
|
1375
|
+
* strings. Accept safe JSON numbers for compatibility, but never round an
|
|
1376
|
+
* unsafe number or exponent/decimal string through JavaScript Number.
|
|
1377
|
+
*/
|
|
1378
|
+
function int64IdField(description) {
|
|
1379
|
+
return z.union([z.string(), z.number().int().positive()]).transform((value) => String(value)).refine(isCanonicalInt64, "Expected a canonical positive signed-int64 decimal string").describe(description);
|
|
1380
|
+
}
|
|
1149
1381
|
/**
|
|
1150
1382
|
* Subscribe → run `collect` while `handler` receives this symbol's events →
|
|
1151
1383
|
* ALWAYS unsubscribe + unregister (finally). The unsubscribe is best-effort:
|
|
1152
1384
|
* its failure is logged, never masks the result, and a truly leaked
|
|
1153
1385
|
* subscription dies with the socket.
|
|
1154
1386
|
*/
|
|
1155
|
-
async function withLiveSubscription(pool,
|
|
1156
|
-
const accountId =
|
|
1387
|
+
async function withLiveSubscription(pool, ctidTraderAccountId, symbolId, kind, handler, collect) {
|
|
1388
|
+
const accountId = ctidTraderAccountId;
|
|
1157
1389
|
const sub = LIVE_SUBSCRIPTION[kind];
|
|
1158
|
-
return withLiveDataLock(`${accountId}:${
|
|
1390
|
+
return withLiveDataLock(`${accountId}:${symbolId}:${kind}`, async () => {
|
|
1159
1391
|
const unregister = pool.onAccountEvent(accountId, (event) => {
|
|
1160
1392
|
if (event.payloadType !== sub.event) return;
|
|
1161
1393
|
if (str(event.message.ctidTraderAccountId) !== accountId) return;
|
|
1162
|
-
if (str(event.message.symbolId) !==
|
|
1394
|
+
if (str(event.message.symbolId) !== symbolId) return;
|
|
1163
1395
|
handler(event.message);
|
|
1164
1396
|
});
|
|
1165
1397
|
try {
|
|
1166
1398
|
await pool.request(accountId, sub.subscribe, {
|
|
1167
|
-
ctidTraderAccountId
|
|
1399
|
+
ctidTraderAccountId,
|
|
1168
1400
|
symbolId: [symbolId]
|
|
1169
1401
|
});
|
|
1170
1402
|
return await collect();
|
|
@@ -1172,11 +1404,11 @@ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, hand
|
|
|
1172
1404
|
unregister();
|
|
1173
1405
|
try {
|
|
1174
1406
|
await pool.request(accountId, sub.unsubscribe, {
|
|
1175
|
-
ctidTraderAccountId
|
|
1407
|
+
ctidTraderAccountId,
|
|
1176
1408
|
symbolId: [symbolId]
|
|
1177
1409
|
});
|
|
1178
1410
|
} catch (err) {
|
|
1179
|
-
process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${
|
|
1411
|
+
process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${symbolId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1180
1412
|
}
|
|
1181
1413
|
}
|
|
1182
1414
|
});
|
|
@@ -1187,9 +1419,13 @@ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, hand
|
|
|
1187
1419
|
* required-when-multiple rule at runtime so the model gets a helpful listing
|
|
1188
1420
|
* instead of a bare validation error.
|
|
1189
1421
|
*/
|
|
1190
|
-
const accountIdField =
|
|
1422
|
+
const accountIdField = int64IdField("ctidTraderAccountId of the account to act on (from get_accounts). Optional when exactly one account is connected; REQUIRED when several are — this tool never defaults to an arbitrary account.").optional();
|
|
1191
1423
|
function registerTools(server, pool) {
|
|
1192
|
-
const
|
|
1424
|
+
const rawRegister = server.registerTool.bind(server);
|
|
1425
|
+
const register = (name, definition, handler) => rawRegister(name, {
|
|
1426
|
+
...definition,
|
|
1427
|
+
annotations: WRITE_TOOL_NAMES.has(name) ? WRITE_ANNOTATIONS : READ_ANNOTATIONS
|
|
1428
|
+
}, handler);
|
|
1193
1429
|
register("get_accounts", {
|
|
1194
1430
|
description: "List the cTrader trading accounts connected for this agent. Returns each account's ctidTraderAccountId, live/demo flag, host, broker, and number. Use an accountId here as the `accountId` argument on the other tools.",
|
|
1195
1431
|
inputSchema: {}
|
|
@@ -1207,10 +1443,10 @@ function registerTools(server, pool) {
|
|
|
1207
1443
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1208
1444
|
if (isToolError(resolved)) return resolved;
|
|
1209
1445
|
try {
|
|
1210
|
-
const trader = (await pool.request(
|
|
1446
|
+
const trader = (await pool.request(resolved.accountId, PayloadType.OA_TRADER_REQ, { ctidTraderAccountId: resolved.accountId })).message.trader ?? {};
|
|
1211
1447
|
const moneyDigits = trader.moneyDigits != null ? num(trader.moneyDigits) : 2;
|
|
1212
1448
|
return ok({
|
|
1213
|
-
ctidTraderAccountId: str(trader.ctidTraderAccountId) ||
|
|
1449
|
+
ctidTraderAccountId: str(trader.ctidTraderAccountId) || resolved.accountId,
|
|
1214
1450
|
balance: moneyToDecimal(num(trader.balance), moneyDigits),
|
|
1215
1451
|
balanceRaw: str(trader.balance) || "0",
|
|
1216
1452
|
moneyDigits,
|
|
@@ -1230,12 +1466,12 @@ function registerTools(server, pool) {
|
|
|
1230
1466
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1231
1467
|
if (isToolError(resolved)) return resolved;
|
|
1232
1468
|
try {
|
|
1233
|
-
const positions = (await pool.request(
|
|
1469
|
+
const positions = (await pool.request(resolved.accountId, PayloadType.OA_RECONCILE_REQ, {
|
|
1234
1470
|
ctidTraderAccountId: resolved.accountId,
|
|
1235
1471
|
returnProtectionOrders: true
|
|
1236
1472
|
})).message.position ?? [];
|
|
1237
1473
|
return ok({
|
|
1238
|
-
accountId:
|
|
1474
|
+
accountId: resolved.accountId,
|
|
1239
1475
|
positions: positions.map((p) => {
|
|
1240
1476
|
const td = p.tradeData ?? {};
|
|
1241
1477
|
return {
|
|
@@ -1261,13 +1497,13 @@ function registerTools(server, pool) {
|
|
|
1261
1497
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1262
1498
|
if (isToolError(resolved)) return resolved;
|
|
1263
1499
|
try {
|
|
1264
|
-
const orders = (await pool.request(
|
|
1500
|
+
const orders = (await pool.request(resolved.accountId, PayloadType.OA_RECONCILE_REQ, {
|
|
1265
1501
|
ctidTraderAccountId: resolved.accountId,
|
|
1266
1502
|
returnProtectionOrders: true
|
|
1267
1503
|
})).message.order ?? [];
|
|
1268
1504
|
const typeName = (v) => v === ORDER_TYPE.LIMIT ? "LIMIT" : v === ORDER_TYPE.STOP ? "STOP" : v === ORDER_TYPE.STOP_LIMIT ? "STOP_LIMIT" : v === ORDER_TYPE.MARKET ? "MARKET" : String(v);
|
|
1269
1505
|
return ok({
|
|
1270
|
-
accountId:
|
|
1506
|
+
accountId: resolved.accountId,
|
|
1271
1507
|
orders: orders.map((o) => {
|
|
1272
1508
|
const td = o.tradeData ?? {};
|
|
1273
1509
|
return {
|
|
@@ -1291,13 +1527,13 @@ function registerTools(server, pool) {
|
|
|
1291
1527
|
description: "List tradable symbols on a connected cTrader account (symbol lists are per-account). Optionally filter by a name substring (case-insensitive, e.g. \"EURUSD\"). Returns symbolId + name — use symbolId on order and market-data tools. Pass `accountId` when several accounts are connected.",
|
|
1292
1528
|
inputSchema: {
|
|
1293
1529
|
accountId: accountIdField,
|
|
1294
|
-
nameFilter: z.string().optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
|
|
1530
|
+
nameFilter: z.string().max(100).optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
|
|
1295
1531
|
}
|
|
1296
1532
|
}, async (args) => {
|
|
1297
1533
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1298
1534
|
if (isToolError(resolved)) return resolved;
|
|
1299
1535
|
try {
|
|
1300
|
-
let symbols = (await pool.request(
|
|
1536
|
+
let symbols = (await pool.request(resolved.accountId, PayloadType.OA_SYMBOLS_LIST_REQ, {
|
|
1301
1537
|
ctidTraderAccountId: resolved.accountId,
|
|
1302
1538
|
includeArchivedSymbols: false
|
|
1303
1539
|
})).message.symbol ?? [];
|
|
@@ -1306,7 +1542,7 @@ function registerTools(server, pool) {
|
|
|
1306
1542
|
symbols = symbols.filter((s) => str(s.symbolName).toLowerCase().includes(needle));
|
|
1307
1543
|
}
|
|
1308
1544
|
return ok({
|
|
1309
|
-
accountId:
|
|
1545
|
+
accountId: resolved.accountId,
|
|
1310
1546
|
count: symbols.length,
|
|
1311
1547
|
symbols: symbols.map((s) => ({
|
|
1312
1548
|
symbolId: str(s.symbolId),
|
|
@@ -1323,7 +1559,7 @@ function registerTools(server, pool) {
|
|
|
1323
1559
|
description: "Get recent OHLC candles (trendbars) for a symbol on a connected cTrader account. Specify symbolId (from get_symbols), a period, and how many bars. Prices are returned as real decimal prices. Pass `accountId` when several accounts are connected.",
|
|
1324
1560
|
inputSchema: {
|
|
1325
1561
|
accountId: accountIdField,
|
|
1326
|
-
symbolId:
|
|
1562
|
+
symbolId: int64IdField("The symbol id from get_symbols"),
|
|
1327
1563
|
period: z.enum(Object.keys(TRENDBAR_PERIOD)).default("H1").describe("Candle period: M1, M5, M15, M30, H1, H4, D1, W1, MN1, etc."),
|
|
1328
1564
|
count: z.coerce.number().int().min(1).max(1e3).default(50).describe("Number of most-recent bars to return (1-1000)")
|
|
1329
1565
|
}
|
|
@@ -1332,15 +1568,15 @@ function registerTools(server, pool) {
|
|
|
1332
1568
|
if (isToolError(resolved)) return resolved;
|
|
1333
1569
|
try {
|
|
1334
1570
|
const period = TRENDBAR_PERIOD[args.period];
|
|
1335
|
-
const bars = (await pool.request(
|
|
1571
|
+
const bars = (await pool.request(resolved.accountId, PayloadType.OA_GET_TRENDBARS_REQ, {
|
|
1336
1572
|
ctidTraderAccountId: resolved.accountId,
|
|
1337
1573
|
symbolId: args.symbolId,
|
|
1338
1574
|
period,
|
|
1339
1575
|
count: args.count
|
|
1340
1576
|
})).message.trendbar ?? [];
|
|
1341
1577
|
return ok({
|
|
1342
|
-
accountId:
|
|
1343
|
-
symbolId:
|
|
1578
|
+
accountId: resolved.accountId,
|
|
1579
|
+
symbolId: args.symbolId,
|
|
1344
1580
|
period: args.period,
|
|
1345
1581
|
bars: bars.map((b) => decodeTrendbar(b))
|
|
1346
1582
|
});
|
|
@@ -1352,19 +1588,19 @@ function registerTools(server, pool) {
|
|
|
1352
1588
|
description: "Get full trading details for one symbol on a connected cTrader account: price digits, pip position, lot size, min/max/step volume (in lots), swap rates, and short-selling availability. Pass `accountId` when several accounts are connected.",
|
|
1353
1589
|
inputSchema: {
|
|
1354
1590
|
accountId: accountIdField,
|
|
1355
|
-
symbolId:
|
|
1591
|
+
symbolId: int64IdField("Symbol id from get_symbols")
|
|
1356
1592
|
}
|
|
1357
1593
|
}, async (args) => {
|
|
1358
1594
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1359
1595
|
if (isToolError(resolved)) return resolved;
|
|
1360
|
-
const accountId =
|
|
1596
|
+
const accountId = resolved.accountId;
|
|
1361
1597
|
try {
|
|
1362
1598
|
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
|
|
1363
1599
|
const lotSize = num(symbol.lotSize);
|
|
1364
1600
|
const toLots = (v) => lotSize > 0 ? volumeToLots(num(v), lotSize) : null;
|
|
1365
1601
|
return ok({
|
|
1366
1602
|
accountId,
|
|
1367
|
-
symbolId: str(symbol.symbolId) ||
|
|
1603
|
+
symbolId: str(symbol.symbolId) || args.symbolId,
|
|
1368
1604
|
digits: num(symbol.digits),
|
|
1369
1605
|
pipPosition: num(symbol.pipPosition),
|
|
1370
1606
|
lotSize,
|
|
@@ -1385,13 +1621,13 @@ function registerTools(server, pool) {
|
|
|
1385
1621
|
description: "Get the LIVE bid/ask for a symbol on a connected cTrader account via a brief spot subscription (ticks merge until both sides are seen, up to waitMs). Returns bid, ask, and spread as real decimal prices. Pass `accountId` when several accounts are connected.",
|
|
1386
1622
|
inputSchema: {
|
|
1387
1623
|
accountId: accountIdField,
|
|
1388
|
-
symbolId:
|
|
1624
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1389
1625
|
waitMs: z.coerce.number().int().min(200).max(1e4).default(3e3).describe("Max milliseconds to wait for both bid and ask ticks (returns early once both are seen)")
|
|
1390
1626
|
}
|
|
1391
1627
|
}, async (args) => {
|
|
1392
1628
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1393
1629
|
if (isToolError(resolved)) return resolved;
|
|
1394
|
-
const accountId =
|
|
1630
|
+
const accountId = resolved.accountId;
|
|
1395
1631
|
try {
|
|
1396
1632
|
const digits = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).digits);
|
|
1397
1633
|
const quote = {
|
|
@@ -1409,10 +1645,10 @@ function registerTools(server, pool) {
|
|
|
1409
1645
|
if (message.ask != null) quote.ask = roundToDigits(priceToDecimal(num(message.ask)), digits);
|
|
1410
1646
|
if (quote.bid != null && quote.ask != null) signalBothSeen();
|
|
1411
1647
|
}, () => Promise.race([bothSeen, sleep(args.waitMs)]));
|
|
1412
|
-
if (quote.bid == null && quote.ask == null) return fail(new CTraderError("NO_QUOTE_DATA", `No live ticks for symbol ${
|
|
1648
|
+
if (quote.bid == null && quote.ask == null) return fail(new CTraderError("NO_QUOTE_DATA", `No live ticks for symbol ${args.symbolId} within ${String(args.waitMs)}ms — the market may be closed or the symbol not quoted on this account`));
|
|
1413
1649
|
return ok({
|
|
1414
1650
|
accountId,
|
|
1415
|
-
symbolId:
|
|
1651
|
+
symbolId: args.symbolId,
|
|
1416
1652
|
digits,
|
|
1417
1653
|
bid: quote.bid,
|
|
1418
1654
|
ask: quote.ask,
|
|
@@ -1428,14 +1664,14 @@ function registerTools(server, pool) {
|
|
|
1428
1664
|
description: "Get the LIVE Level 2 order book (depth of market) for a symbol on a connected cTrader account. Subscribes briefly, assembles the bid/ask ladder (sizes aggregated per price level), then unsubscribes. Returns bids (descending) and asks (ascending) with sizes in base-asset units and lots, plus best bid/ask and spread. Not every broker/symbol publishes depth — a NO_DEPTH_DATA error means none arrived. Pass `accountId` when several accounts are connected.",
|
|
1429
1665
|
inputSchema: {
|
|
1430
1666
|
accountId: accountIdField,
|
|
1431
|
-
symbolId:
|
|
1667
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1432
1668
|
levels: z.coerce.number().int().min(1).max(50).default(10).describe("Max price levels per side to return"),
|
|
1433
1669
|
collectMs: z.coerce.number().int().min(200).max(5e3).default(1e3).describe("How long to collect depth events before snapshotting (the full book arrives on subscribe; longer windows fold in more updates)")
|
|
1434
1670
|
}
|
|
1435
1671
|
}, async (args) => {
|
|
1436
1672
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1437
1673
|
if (isToolError(resolved)) return resolved;
|
|
1438
|
-
const accountId =
|
|
1674
|
+
const accountId = resolved.accountId;
|
|
1439
1675
|
try {
|
|
1440
1676
|
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
|
|
1441
1677
|
const digits = num(symbol.digits);
|
|
@@ -1461,7 +1697,7 @@ function registerTools(server, pool) {
|
|
|
1461
1697
|
}
|
|
1462
1698
|
for (const deleted of message.deletedQuotes ?? []) book.delete(str(deleted));
|
|
1463
1699
|
}, () => sleep(args.collectMs));
|
|
1464
|
-
if (eventsReceived === 0) return fail(new CTraderError("NO_DEPTH_DATA", `No depth events for symbol ${
|
|
1700
|
+
if (eventsReceived === 0) return fail(new CTraderError("NO_DEPTH_DATA", `No depth events for symbol ${args.symbolId} within ${String(args.collectMs)}ms — this broker/symbol may not publish Level 2 via the Open API, or the market is closed`));
|
|
1465
1701
|
const ladder = (side) => {
|
|
1466
1702
|
const byPrice = /* @__PURE__ */ new Map();
|
|
1467
1703
|
for (const quote of book.values()) {
|
|
@@ -1482,7 +1718,7 @@ function registerTools(server, pool) {
|
|
|
1482
1718
|
const bestAsk = asks.at(0)?.price ?? null;
|
|
1483
1719
|
return ok({
|
|
1484
1720
|
accountId,
|
|
1485
|
-
symbolId:
|
|
1721
|
+
symbolId: args.symbolId,
|
|
1486
1722
|
digits,
|
|
1487
1723
|
bestBid,
|
|
1488
1724
|
bestAsk,
|
|
@@ -1503,15 +1739,15 @@ function registerTools(server, pool) {
|
|
|
1503
1739
|
description: "Get historical tick-by-tick prices (BID or ASK side) for a symbol on a connected cTrader account. Defaults to the last 5 minutes — keep ranges short, tick volumes are large; `hasMore` signals truncation. Ticks are returned newest-first with real decimal prices.",
|
|
1504
1740
|
inputSchema: {
|
|
1505
1741
|
accountId: accountIdField,
|
|
1506
|
-
symbolId:
|
|
1742
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1507
1743
|
type: z.enum(["BID", "ASK"]).default("BID").describe("Which side's ticks to fetch"),
|
|
1508
|
-
from: z.string().optional().describe("ISO 8601 range start (default: 5 minutes before `to`)"),
|
|
1509
|
-
to: z.string().optional().describe("ISO 8601 range end (default: now)")
|
|
1744
|
+
from: z.string().max(100).optional().describe("ISO 8601 range start (default: 5 minutes before `to`)"),
|
|
1745
|
+
to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)")
|
|
1510
1746
|
}
|
|
1511
1747
|
}, async (args) => {
|
|
1512
1748
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1513
1749
|
if (isToolError(resolved)) return resolved;
|
|
1514
|
-
const accountId =
|
|
1750
|
+
const accountId = resolved.accountId;
|
|
1515
1751
|
try {
|
|
1516
1752
|
const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
|
|
1517
1753
|
const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 5 * 6e4;
|
|
@@ -1526,7 +1762,7 @@ function registerTools(server, pool) {
|
|
|
1526
1762
|
const raw = res.message.tickData ?? [];
|
|
1527
1763
|
return ok({
|
|
1528
1764
|
accountId,
|
|
1529
|
-
symbolId:
|
|
1765
|
+
symbolId: args.symbolId,
|
|
1530
1766
|
type: args.type,
|
|
1531
1767
|
count: raw.length,
|
|
1532
1768
|
hasMore: Boolean(res.message.hasMore),
|
|
@@ -1540,14 +1776,14 @@ function registerTools(server, pool) {
|
|
|
1540
1776
|
description: "Get executed deal (fill) history for a connected cTrader account: entry/exit fills with price, volume, commission, and — for closing deals — realized PnL. Defaults to the last 7 days; cTrader caps the from/to span (about a week per request).",
|
|
1541
1777
|
inputSchema: {
|
|
1542
1778
|
accountId: accountIdField,
|
|
1543
|
-
from: z.string().optional().describe("ISO 8601 range start (default: 7 days before `to`)"),
|
|
1544
|
-
to: z.string().optional().describe("ISO 8601 range end (default: now)"),
|
|
1779
|
+
from: z.string().max(100).optional().describe("ISO 8601 range start (default: 7 days before `to`)"),
|
|
1780
|
+
to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)"),
|
|
1545
1781
|
maxRows: z.coerce.number().int().min(1).max(1e3).default(100).describe("Max deals to return")
|
|
1546
1782
|
}
|
|
1547
1783
|
}, async (args) => {
|
|
1548
1784
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1549
1785
|
if (isToolError(resolved)) return resolved;
|
|
1550
|
-
const accountId =
|
|
1786
|
+
const accountId = resolved.accountId;
|
|
1551
1787
|
try {
|
|
1552
1788
|
const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
|
|
1553
1789
|
const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 7 * 864e5;
|
|
@@ -1599,7 +1835,7 @@ function registerTools(server, pool) {
|
|
|
1599
1835
|
}, async (args) => {
|
|
1600
1836
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1601
1837
|
if (isToolError(resolved)) return resolved;
|
|
1602
|
-
const accountId =
|
|
1838
|
+
const accountId = resolved.accountId;
|
|
1603
1839
|
try {
|
|
1604
1840
|
const res = await pool.request(accountId, PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ, { ctidTraderAccountId: resolved.accountId });
|
|
1605
1841
|
const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
|
|
@@ -1620,16 +1856,16 @@ function registerTools(server, pool) {
|
|
|
1620
1856
|
description: "Get the margin that would be required to open BUY/SELL positions of given sizes (in lots) on a symbol, in the account's deposit currency. Useful before place_order to check affordability. Pass `accountId` when several accounts are connected.",
|
|
1621
1857
|
inputSchema: {
|
|
1622
1858
|
accountId: accountIdField,
|
|
1623
|
-
symbolId:
|
|
1859
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1624
1860
|
volumesLots: z.array(z.coerce.number().positive()).min(1).max(10).describe("Position sizes in lots to quote margin for, e.g. [0.1, 0.5, 1]")
|
|
1625
1861
|
}
|
|
1626
1862
|
}, async (args) => {
|
|
1627
1863
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1628
1864
|
if (isToolError(resolved)) return resolved;
|
|
1629
|
-
const accountId =
|
|
1865
|
+
const accountId = resolved.accountId;
|
|
1630
1866
|
try {
|
|
1631
1867
|
const lotSize = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).lotSize);
|
|
1632
|
-
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${
|
|
1868
|
+
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${args.symbolId} has no lotSize; cannot size the margin quote`));
|
|
1633
1869
|
const res = await pool.request(accountId, PayloadType.OA_EXPECTED_MARGIN_REQ, {
|
|
1634
1870
|
ctidTraderAccountId: resolved.accountId,
|
|
1635
1871
|
symbolId: args.symbolId,
|
|
@@ -1639,7 +1875,7 @@ function registerTools(server, pool) {
|
|
|
1639
1875
|
const margins = res.message.margin ?? [];
|
|
1640
1876
|
return ok({
|
|
1641
1877
|
accountId,
|
|
1642
|
-
symbolId:
|
|
1878
|
+
symbolId: args.symbolId,
|
|
1643
1879
|
margins: margins.map((m) => ({
|
|
1644
1880
|
volumeLots: volumeToLots(num(m.volume), lotSize),
|
|
1645
1881
|
buyMargin: moneyToDecimal(num(m.buyMargin), moneyDigits),
|
|
@@ -1654,13 +1890,13 @@ function registerTools(server, pool) {
|
|
|
1654
1890
|
description: "Get deposit/withdrawal history for a connected cTrader account over a date range (default: last 30 days). Amounts are real currency figures; positive delta = deposit, negative = withdrawal.",
|
|
1655
1891
|
inputSchema: {
|
|
1656
1892
|
accountId: accountIdField,
|
|
1657
|
-
from: z.string().optional().describe("ISO 8601 range start (default: 30 days before `to`)"),
|
|
1658
|
-
to: z.string().optional().describe("ISO 8601 range end (default: now)")
|
|
1893
|
+
from: z.string().max(100).optional().describe("ISO 8601 range start (default: 30 days before `to`)"),
|
|
1894
|
+
to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)")
|
|
1659
1895
|
}
|
|
1660
1896
|
}, async (args) => {
|
|
1661
1897
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1662
1898
|
if (isToolError(resolved)) return resolved;
|
|
1663
|
-
const accountId =
|
|
1899
|
+
const accountId = resolved.accountId;
|
|
1664
1900
|
try {
|
|
1665
1901
|
const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
|
|
1666
1902
|
const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 30 * 864e5;
|
|
@@ -1692,7 +1928,7 @@ function registerTools(server, pool) {
|
|
|
1692
1928
|
description: "Place a MARKET, LIMIT, STOP, or STOP_LIMIT order on a connected cTrader account. Volume is in LOTS and is converted to the symbol's protocol volume (validated against min/max/step). LIMIT requires limitPrice; STOP requires stopPrice; STOP_LIMIT requires stopPrice + slippageInPoints. SL/TP: pass ABSOLUTE prices via stopLoss/takeProfit, or price DISTANCES via stopLossDistance/takeProfitDistance (required for a trailing stop). timeInForce defaults to GOOD_TILL_CANCEL; GOOD_TILL_DATE requires expiresAt. Pass `accountId` when several accounts are connected. WARNING: on a live account this moves real money.",
|
|
1693
1929
|
inputSchema: {
|
|
1694
1930
|
accountId: accountIdField,
|
|
1695
|
-
symbolId:
|
|
1931
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1696
1932
|
side: z.enum(["BUY", "SELL"]).describe("Trade side"),
|
|
1697
1933
|
orderType: z.enum([
|
|
1698
1934
|
"MARKET",
|
|
@@ -1715,17 +1951,19 @@ function registerTools(server, pool) {
|
|
|
1715
1951
|
"IMMEDIATE_OR_CANCEL",
|
|
1716
1952
|
"FILL_OR_KILL"
|
|
1717
1953
|
]).optional().describe("Order lifetime (default GOOD_TILL_CANCEL). GOOD_TILL_DATE requires expiresAt."),
|
|
1718
|
-
expiresAt: z.string().optional().describe("ISO 8601 expiry — required with (and only valid with) timeInForce GOOD_TILL_DATE"),
|
|
1719
|
-
label: z.string().optional().describe("Optional client label for the order")
|
|
1954
|
+
expiresAt: z.string().max(100).optional().describe("ISO 8601 expiry — required with (and only valid with) timeInForce GOOD_TILL_DATE"),
|
|
1955
|
+
label: z.string().max(50).optional().describe("Optional client label for the order (max 50 characters)")
|
|
1720
1956
|
}
|
|
1721
1957
|
}, async (args) => {
|
|
1722
1958
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1723
1959
|
if (isToolError(resolved)) return resolved;
|
|
1724
|
-
const accountId =
|
|
1960
|
+
const accountId = resolved.accountId;
|
|
1725
1961
|
try {
|
|
1726
1962
|
if (args.orderType === "LIMIT" && args.limitPrice == null) return fail(new CTraderError("LIMIT_PRICE_REQUIRED", "LIMIT orders require limitPrice"));
|
|
1727
1963
|
if ((args.orderType === "STOP" || args.orderType === "STOP_LIMIT") && args.stopPrice == null) return fail(new CTraderError("STOP_PRICE_REQUIRED", `${args.orderType} orders require stopPrice`));
|
|
1728
1964
|
if (args.orderType === "STOP_LIMIT" && args.slippageInPoints == null) return fail(new CTraderError("SLIPPAGE_REQUIRED", "STOP_LIMIT orders require slippageInPoints"));
|
|
1965
|
+
if (args.orderType === "STOP_LIMIT" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP_LIMIT orders use stopPrice + slippageInPoints, not limitPrice"));
|
|
1966
|
+
if (args.orderType !== "STOP_LIMIT" && args.slippageInPoints != null) return fail(new CTraderError("SLIPPAGE_NOT_ALLOWED", "slippageInPoints is only valid for STOP_LIMIT orders"));
|
|
1729
1967
|
if (args.orderType === "MARKET" && (args.limitPrice != null || args.stopPrice != null)) return fail(new CTraderError("PRICE_NOT_ALLOWED", "MARKET orders take no limitPrice/stopPrice — use LIMIT or STOP"));
|
|
1730
1968
|
if (args.orderType === "LIMIT" && args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "LIMIT orders take no stopPrice"));
|
|
1731
1969
|
if (args.orderType === "STOP" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP orders take no limitPrice"));
|
|
@@ -1737,9 +1975,18 @@ function registerTools(server, pool) {
|
|
|
1737
1975
|
}
|
|
1738
1976
|
if (args.timeInForce === "GOOD_TILL_DATE" && args.expiresAt == null) return fail(new CTraderError("EXPIRATION_REQUIRED", "timeInForce GOOD_TILL_DATE requires expiresAt"));
|
|
1739
1977
|
if (args.expiresAt != null && args.timeInForce !== "GOOD_TILL_DATE") return fail(new CTraderError("EXPIRATION_NOT_ALLOWED", "expiresAt is only valid with timeInForce GOOD_TILL_DATE"));
|
|
1978
|
+
let expirationTimestamp;
|
|
1979
|
+
if (args.expiresAt != null) {
|
|
1980
|
+
expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
|
|
1981
|
+
if (expirationTimestamp <= Date.now()) return fail(new CTraderError("EXPIRATION_IN_PAST", "expiresAt must be in the future"));
|
|
1982
|
+
}
|
|
1983
|
+
const relativeStopLoss = args.stopLossDistance != null ? Math.round(args.stopLossDistance * PRICE_SCALE) : void 0;
|
|
1984
|
+
const relativeTakeProfit = args.takeProfitDistance != null ? Math.round(args.takeProfitDistance * PRICE_SCALE) : void 0;
|
|
1985
|
+
if (relativeStopLoss != null && (!Number.isSafeInteger(relativeStopLoss) || relativeStopLoss <= 0)) return fail(new CTraderError("INVALID_STOP_LOSS_DISTANCE", "stopLossDistance is too small or too large for cTrader's relative-price units"));
|
|
1986
|
+
if (relativeTakeProfit != null && (!Number.isSafeInteger(relativeTakeProfit) || relativeTakeProfit <= 0)) return fail(new CTraderError("INVALID_TAKE_PROFIT_DISTANCE", "takeProfitDistance is too small or too large for cTrader's relative-price units"));
|
|
1740
1987
|
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
|
|
1741
1988
|
const lotSize = num(symbol.lotSize);
|
|
1742
|
-
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${
|
|
1989
|
+
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${args.symbolId} has no lotSize; cannot size the order`));
|
|
1743
1990
|
const volume = lotsToVolume(args.volumeLots, lotSize);
|
|
1744
1991
|
const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
|
|
1745
1992
|
if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
|
|
@@ -1755,13 +2002,13 @@ function registerTools(server, pool) {
|
|
|
1755
2002
|
if (args.slippageInPoints != null) payload.slippageInPoints = args.slippageInPoints;
|
|
1756
2003
|
if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
|
|
1757
2004
|
if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
|
|
1758
|
-
if (
|
|
1759
|
-
if (
|
|
2005
|
+
if (relativeStopLoss != null) payload.relativeStopLoss = relativeStopLoss;
|
|
2006
|
+
if (relativeTakeProfit != null) payload.relativeTakeProfit = relativeTakeProfit;
|
|
1760
2007
|
if (args.trailingStopLoss != null) payload.trailingStopLoss = args.trailingStopLoss;
|
|
1761
2008
|
if (args.timeInForce != null) payload.timeInForce = TIME_IN_FORCE[args.timeInForce];
|
|
1762
|
-
if (
|
|
2009
|
+
if (expirationTimestamp != null) payload.expirationTimestamp = expirationTimestamp;
|
|
1763
2010
|
if (args.label != null) payload.label = args.label;
|
|
1764
|
-
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload));
|
|
2011
|
+
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload), PLACE_ORDER_SUCCESS_TYPES);
|
|
1765
2012
|
return ok({
|
|
1766
2013
|
accountId,
|
|
1767
2014
|
executionType,
|
|
@@ -1777,8 +2024,8 @@ function registerTools(server, pool) {
|
|
|
1777
2024
|
description: "Modify an existing order on a connected cTrader account. For a PENDING order (by orderId) you can change limitPrice/stopPrice/SL/TP. For an OPEN position's protection, pass positionId to set stopLoss/takeProfit. Provide exactly one of orderId or positionId. Pass `accountId` when several accounts are connected.",
|
|
1778
2025
|
inputSchema: {
|
|
1779
2026
|
accountId: accountIdField,
|
|
1780
|
-
orderId:
|
|
1781
|
-
positionId:
|
|
2027
|
+
orderId: int64IdField("Pending order id (from get_orders) to amend").optional(),
|
|
2028
|
+
positionId: int64IdField("Open position id (from get_positions) to set SL/TP on").optional(),
|
|
1782
2029
|
limitPrice: z.coerce.number().positive().optional().describe("New limit price (pending order only)"),
|
|
1783
2030
|
stopPrice: z.coerce.number().positive().optional().describe("New stop price (pending order only)"),
|
|
1784
2031
|
stopLoss: z.coerce.number().positive().optional().describe("New absolute stop-loss price"),
|
|
@@ -1787,21 +2034,23 @@ function registerTools(server, pool) {
|
|
|
1787
2034
|
}, async (args) => {
|
|
1788
2035
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1789
2036
|
if (isToolError(resolved)) return resolved;
|
|
1790
|
-
const accountId =
|
|
2037
|
+
const accountId = resolved.accountId;
|
|
1791
2038
|
try {
|
|
1792
2039
|
if (args.orderId == null === (args.positionId == null)) return fail(new CTraderError("INVALID_TARGET", "Provide exactly one of orderId or positionId"));
|
|
2040
|
+
if (args.limitPrice == null && args.stopPrice == null && args.stopLoss == null && args.takeProfit == null) return fail(new CTraderError("NO_CHANGES", "Provide at least one price or protection change"));
|
|
1793
2041
|
if (args.positionId != null) {
|
|
2042
|
+
if (args.limitPrice != null || args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "Position protection changes accept only stopLoss/takeProfit"));
|
|
1794
2043
|
const payload = {
|
|
1795
2044
|
ctidTraderAccountId: resolved.accountId,
|
|
1796
2045
|
positionId: args.positionId
|
|
1797
2046
|
};
|
|
1798
2047
|
if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
|
|
1799
2048
|
if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
|
|
1800
|
-
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload));
|
|
2049
|
+
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload), AMEND_SUCCESS_TYPES);
|
|
1801
2050
|
return ok({
|
|
1802
2051
|
accountId,
|
|
1803
2052
|
executionType,
|
|
1804
|
-
positionId:
|
|
2053
|
+
positionId: args.positionId,
|
|
1805
2054
|
execution: summary
|
|
1806
2055
|
});
|
|
1807
2056
|
}
|
|
@@ -1813,11 +2062,11 @@ function registerTools(server, pool) {
|
|
|
1813
2062
|
if (args.stopPrice != null) payload.stopPrice = args.stopPrice;
|
|
1814
2063
|
if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
|
|
1815
2064
|
if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
|
|
1816
|
-
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload));
|
|
2065
|
+
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload), AMEND_SUCCESS_TYPES);
|
|
1817
2066
|
return ok({
|
|
1818
2067
|
accountId,
|
|
1819
2068
|
executionType,
|
|
1820
|
-
orderId:
|
|
2069
|
+
orderId: args.orderId,
|
|
1821
2070
|
execution: summary
|
|
1822
2071
|
});
|
|
1823
2072
|
} catch (err) {
|
|
@@ -1828,36 +2077,41 @@ function registerTools(server, pool) {
|
|
|
1828
2077
|
description: "Close an open position (fully or partially) on a connected cTrader account. Pass the positionId from get_positions. volumeLots defaults to the full position size when omitted. Pass `accountId` when several accounts are connected.",
|
|
1829
2078
|
inputSchema: {
|
|
1830
2079
|
accountId: accountIdField,
|
|
1831
|
-
positionId:
|
|
2080
|
+
positionId: int64IdField("Position id from get_positions"),
|
|
1832
2081
|
volumeLots: z.coerce.number().positive().optional().describe("Lots to close; omit to close the whole position")
|
|
1833
2082
|
}
|
|
1834
2083
|
}, async (args) => {
|
|
1835
2084
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1836
2085
|
if (isToolError(resolved)) return resolved;
|
|
1837
|
-
const accountId =
|
|
2086
|
+
const accountId = resolved.accountId;
|
|
1838
2087
|
try {
|
|
1839
2088
|
const pos = ((await pool.request(accountId, PayloadType.OA_RECONCILE_REQ, {
|
|
1840
2089
|
ctidTraderAccountId: resolved.accountId,
|
|
1841
2090
|
returnProtectionOrders: true
|
|
1842
|
-
})).message.position ?? []).find((p) => str(p.positionId) ===
|
|
1843
|
-
if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${
|
|
2091
|
+
})).message.position ?? []).find((p) => str(p.positionId) === args.positionId);
|
|
2092
|
+
if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${args.positionId}`));
|
|
1844
2093
|
const td = pos.tradeData ?? {};
|
|
1845
2094
|
const fullVolume = num(td.volume);
|
|
2095
|
+
if (!Number.isSafeInteger(fullVolume) || fullVolume <= 0) return fail(new CTraderError("INVALID_POSITION_VOLUME", `Position ${args.positionId} has invalid broker volume ${String(fullVolume)}`));
|
|
1846
2096
|
let volume = fullVolume;
|
|
1847
2097
|
if (args.volumeLots != null) {
|
|
1848
|
-
const
|
|
2098
|
+
const positionSymbolId = str(td.symbolId);
|
|
2099
|
+
if (!isCanonicalInt64(positionSymbolId)) return fail(new CTraderError("INVALID_POSITION_SYMBOL", `Position ${args.positionId} has an invalid symbol id`));
|
|
2100
|
+
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, positionSymbolId);
|
|
1849
2101
|
volume = lotsToVolume(args.volumeLots, num(symbol.lotSize));
|
|
2102
|
+
const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
|
|
2103
|
+
if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
|
|
1850
2104
|
if (volume > fullVolume) return fail(new CTraderError("VOLUME_EXCEEDS_POSITION", `Requested ${String(volume)} exceeds position volume ${String(fullVolume)}`));
|
|
1851
2105
|
}
|
|
1852
2106
|
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CLOSE_POSITION_REQ, {
|
|
1853
2107
|
ctidTraderAccountId: resolved.accountId,
|
|
1854
2108
|
positionId: args.positionId,
|
|
1855
2109
|
volume
|
|
1856
|
-
}));
|
|
2110
|
+
}), CLOSE_SUCCESS_TYPES);
|
|
1857
2111
|
return ok({
|
|
1858
2112
|
accountId,
|
|
1859
2113
|
executionType,
|
|
1860
|
-
positionId:
|
|
2114
|
+
positionId: args.positionId,
|
|
1861
2115
|
volume,
|
|
1862
2116
|
execution: summary
|
|
1863
2117
|
});
|
|
@@ -1869,21 +2123,21 @@ function registerTools(server, pool) {
|
|
|
1869
2123
|
description: "Cancel a pending (not-yet-filled) order on a connected cTrader account. Pass the orderId from get_orders. Pass `accountId` when several accounts are connected.",
|
|
1870
2124
|
inputSchema: {
|
|
1871
2125
|
accountId: accountIdField,
|
|
1872
|
-
orderId:
|
|
2126
|
+
orderId: int64IdField("Pending order id from get_orders")
|
|
1873
2127
|
}
|
|
1874
2128
|
}, async (args) => {
|
|
1875
2129
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1876
2130
|
if (isToolError(resolved)) return resolved;
|
|
1877
|
-
const accountId =
|
|
2131
|
+
const accountId = resolved.accountId;
|
|
1878
2132
|
try {
|
|
1879
2133
|
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CANCEL_ORDER_REQ, {
|
|
1880
2134
|
ctidTraderAccountId: resolved.accountId,
|
|
1881
2135
|
orderId: args.orderId
|
|
1882
|
-
}),
|
|
2136
|
+
}), CANCEL_SUCCESS_TYPES);
|
|
1883
2137
|
return ok({
|
|
1884
2138
|
accountId,
|
|
1885
2139
|
executionType,
|
|
1886
|
-
orderId:
|
|
2140
|
+
orderId: args.orderId,
|
|
1887
2141
|
execution: summary
|
|
1888
2142
|
});
|
|
1889
2143
|
} catch (err) {
|
|
@@ -1907,17 +2161,16 @@ function summariseExecution(message) {
|
|
|
1907
2161
|
/**
|
|
1908
2162
|
* Turn a write-tool reply into a definite outcome. A cTrader order op is
|
|
1909
2163
|
* expected to come back as a ProtoOAExecutionEvent (2126); anything else means
|
|
1910
|
-
* we can't confirm the write landed
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
* path and must fail closed.
|
|
2164
|
+
* we can't confirm the write landed. Each operation passes an explicit
|
|
2165
|
+
* allowlist of execution types that prove its intended outcome; missing,
|
|
2166
|
+
* unknown, or merely unrelated execution events fail closed.
|
|
1914
2167
|
*/
|
|
1915
|
-
function assertExecution(res,
|
|
2168
|
+
function assertExecution(res, allowedTypes) {
|
|
1916
2169
|
if (res.payloadType !== PayloadType.OA_EXECUTION_EVENT) throw new CTraderError("UNEXPECTED_EXECUTION_REPLY", `Expected an execution event (${String(PayloadType.OA_EXECUTION_EVENT)}) but got payloadType ${String(res.payloadType)}; cannot confirm the order`);
|
|
1917
2170
|
const summary = summariseExecution(res.message);
|
|
1918
2171
|
const code = res.message.executionType != null ? num(res.message.executionType) : null;
|
|
1919
2172
|
const executionType = typeof summary.executionType === "string" ? summary.executionType : "UNKNOWN";
|
|
1920
|
-
if (code
|
|
2173
|
+
if (code == null || !allowedTypes.has(code)) throw new CTraderError(code == null ? "MISSING_EXECUTION_TYPE" : executionType, `cTrader returned an execution that does not confirm this operation (${executionType})`, {
|
|
1921
2174
|
orderId: str(summary.orderId) || void 0,
|
|
1922
2175
|
positionId: str(summary.positionId) || void 0
|
|
1923
2176
|
});
|
|
@@ -1955,6 +2208,7 @@ function assertExecution(res, extraOkTypes = /* @__PURE__ */ new Set()) {
|
|
|
1955
2208
|
* account it names (see client.ts). No account is ever chosen implicitly when
|
|
1956
2209
|
* several are connected — the tools require an explicit `accountId` in that case.
|
|
1957
2210
|
*/
|
|
2211
|
+
const packageVersion = createRequire(import.meta.url)("../package.json").version;
|
|
1958
2212
|
function log(msg) {
|
|
1959
2213
|
process.stderr.write(`[ctrader-mcp] ${msg}\n`);
|
|
1960
2214
|
}
|
|
@@ -1978,7 +2232,7 @@ async function main() {
|
|
|
1978
2232
|
const pool = new CTraderPool(registry);
|
|
1979
2233
|
const server = new McpServer({
|
|
1980
2234
|
name: "ctrader-mcp-server",
|
|
1981
|
-
version:
|
|
2235
|
+
version: packageVersion
|
|
1982
2236
|
});
|
|
1983
2237
|
registerTools(server, pool);
|
|
1984
2238
|
const shutdown = () => {
|