@alfe.ai/ctrader-mcp 0.2.1 → 0.3.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/dist/server.js CHANGED
@@ -140,13 +140,30 @@ const PayloadType = {
140
140
  OA_EXECUTION_EVENT: 2126,
141
141
  OA_SUBSCRIBE_SPOTS_REQ: 2127,
142
142
  OA_SUBSCRIBE_SPOTS_RES: 2128,
143
+ OA_UNSUBSCRIBE_SPOTS_REQ: 2129,
144
+ OA_UNSUBSCRIBE_SPOTS_RES: 2130,
143
145
  OA_SPOT_EVENT: 2131,
144
146
  OA_ORDER_ERROR_EVENT: 2132,
147
+ OA_DEAL_LIST_REQ: 2133,
148
+ OA_DEAL_LIST_RES: 2134,
145
149
  OA_GET_TRENDBARS_REQ: 2137,
146
150
  OA_GET_TRENDBARS_RES: 2138,
151
+ OA_EXPECTED_MARGIN_REQ: 2139,
152
+ OA_EXPECTED_MARGIN_RES: 2140,
147
153
  OA_ERROR_RES: 2142,
154
+ OA_CASH_FLOW_HISTORY_LIST_REQ: 2143,
155
+ OA_CASH_FLOW_HISTORY_LIST_RES: 2144,
156
+ OA_GET_TICKDATA_REQ: 2145,
157
+ OA_GET_TICKDATA_RES: 2146,
148
158
  OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_REQ: 2149,
149
- OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_RES: 2150
159
+ OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_RES: 2150,
160
+ OA_DEPTH_EVENT: 2155,
161
+ OA_SUBSCRIBE_DEPTH_QUOTES_REQ: 2156,
162
+ OA_SUBSCRIBE_DEPTH_QUOTES_RES: 2157,
163
+ OA_UNSUBSCRIBE_DEPTH_QUOTES_REQ: 2158,
164
+ OA_UNSUBSCRIBE_DEPTH_QUOTES_RES: 2159,
165
+ OA_GET_POSITION_UNREALIZED_PNL_REQ: 2187,
166
+ OA_GET_POSITION_UNREALIZED_PNL_RES: 2188
150
167
  };
151
168
  /**
152
169
  * Map each request payloadType to the fully-qualified proto message name used
@@ -164,6 +181,14 @@ const REQUEST_MESSAGE = {
164
181
  [PayloadType.OA_SYMBOL_BY_ID_REQ]: "ctrader.ProtoOASymbolByIdReq",
165
182
  [PayloadType.OA_GET_TRENDBARS_REQ]: "ctrader.ProtoOAGetTrendbarsReq",
166
183
  [PayloadType.OA_SUBSCRIBE_SPOTS_REQ]: "ctrader.ProtoOASubscribeSpotsReq",
184
+ [PayloadType.OA_UNSUBSCRIBE_SPOTS_REQ]: "ctrader.ProtoOAUnsubscribeSpotsReq",
185
+ [PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_REQ]: "ctrader.ProtoOASubscribeDepthQuotesReq",
186
+ [PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_REQ]: "ctrader.ProtoOAUnsubscribeDepthQuotesReq",
187
+ [PayloadType.OA_DEAL_LIST_REQ]: "ctrader.ProtoOADealListReq",
188
+ [PayloadType.OA_GET_TICKDATA_REQ]: "ctrader.ProtoOAGetTickDataReq",
189
+ [PayloadType.OA_EXPECTED_MARGIN_REQ]: "ctrader.ProtoOAExpectedMarginReq",
190
+ [PayloadType.OA_CASH_FLOW_HISTORY_LIST_REQ]: "ctrader.ProtoOACashFlowHistoryListReq",
191
+ [PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ]: "ctrader.ProtoOAGetPositionUnrealizedPnLReq",
167
192
  [PayloadType.OA_NEW_ORDER_REQ]: "ctrader.ProtoOANewOrderReq",
168
193
  [PayloadType.OA_AMEND_ORDER_REQ]: "ctrader.ProtoOAAmendOrderReq",
169
194
  [PayloadType.OA_AMEND_POSITION_SLTP_REQ]: "ctrader.ProtoOAAmendPositionSLTPReq",
@@ -183,7 +208,16 @@ const RESPONSE_MESSAGE = {
183
208
  [PayloadType.OA_SYMBOL_BY_ID_RES]: "ctrader.ProtoOASymbolByIdRes",
184
209
  [PayloadType.OA_GET_TRENDBARS_RES]: "ctrader.ProtoOAGetTrendbarsRes",
185
210
  [PayloadType.OA_SUBSCRIBE_SPOTS_RES]: "ctrader.ProtoOASubscribeSpotsRes",
211
+ [PayloadType.OA_UNSUBSCRIBE_SPOTS_RES]: "ctrader.ProtoOAUnsubscribeSpotsRes",
186
212
  [PayloadType.OA_SPOT_EVENT]: "ctrader.ProtoOASpotEvent",
213
+ [PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_RES]: "ctrader.ProtoOASubscribeDepthQuotesRes",
214
+ [PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_RES]: "ctrader.ProtoOAUnsubscribeDepthQuotesRes",
215
+ [PayloadType.OA_DEPTH_EVENT]: "ctrader.ProtoOADepthEvent",
216
+ [PayloadType.OA_DEAL_LIST_RES]: "ctrader.ProtoOADealListRes",
217
+ [PayloadType.OA_GET_TICKDATA_RES]: "ctrader.ProtoOAGetTickDataRes",
218
+ [PayloadType.OA_EXPECTED_MARGIN_RES]: "ctrader.ProtoOAExpectedMarginRes",
219
+ [PayloadType.OA_CASH_FLOW_HISTORY_LIST_RES]: "ctrader.ProtoOACashFlowHistoryListRes",
220
+ [PayloadType.OA_GET_POSITION_UNREALIZED_PNL_RES]: "ctrader.ProtoOAGetPositionUnrealizedPnLRes",
187
221
  [PayloadType.OA_EXECUTION_EVENT]: "ctrader.ProtoOAExecutionEvent",
188
222
  [PayloadType.OA_ORDER_ERROR_EVENT]: "ctrader.ProtoOAOrderErrorEvent",
189
223
  [PayloadType.OA_ERROR_RES]: "ctrader.ProtoOAErrorRes"
@@ -352,6 +386,39 @@ function priceToDecimal(raw) {
352
386
  return raw / PRICE_SCALE;
353
387
  }
354
388
  /**
389
+ * Convert a depth-quote `size` to base-asset units. Depth sizes are in CENTS
390
+ * of a unit on the wire (NOT the centi-unit volume space used by orders):
391
+ * units = size / 100.
392
+ */
393
+ function depthSizeToUnits(raw) {
394
+ return raw / 100;
395
+ }
396
+ /**
397
+ * Reconstruct absolute {timestamp, price} ticks from cTrader's delta encoding.
398
+ * Tick data arrives NEWEST-FIRST: the first entry's `timestamp`/`tick` are
399
+ * absolute, every later entry carries the (usually negative) DELTA from the
400
+ * entry before it. Prices come out ÷100000 like every other price.
401
+ */
402
+ function decodeTickData(ticks) {
403
+ const out = [];
404
+ let ts = 0;
405
+ let price = 0;
406
+ for (const [i, t] of ticks.entries()) {
407
+ if (i === 0) {
408
+ ts = Number(t.timestamp ?? 0);
409
+ price = Number(t.tick ?? 0);
410
+ } else {
411
+ ts += Number(t.timestamp ?? 0);
412
+ price += Number(t.tick ?? 0);
413
+ }
414
+ out.push({
415
+ timestamp: new Date(ts).toISOString(),
416
+ price: priceToDecimal(price)
417
+ });
418
+ }
419
+ return out;
420
+ }
421
+ /**
355
422
  * Reconstruct a trendbar's OHLC from its delta encoding. cTrader stores the
356
423
  * bar `low` absolutely and open/high/close as unsigned deltas above the low.
357
424
  */
@@ -417,10 +484,28 @@ var CTraderError = class extends Error {
417
484
  if (refs?.positionId != null) this.positionId = refs.positionId;
418
485
  }
419
486
  };
487
+ /**
488
+ * The unsolicited payload types fanned out to `onEvent` listeners. Everything
489
+ * else without a clientMsgId waiter is still silently dropped (heartbeats,
490
+ * execution events for other sessions, …).
491
+ */
492
+ const LISTENABLE_EVENTS = new Set([PayloadType.OA_DEPTH_EVENT, PayloadType.OA_SPOT_EVENT]);
420
493
  const HEARTBEAT_INTERVAL_MS = 1e4;
421
494
  const REQUEST_TIMEOUT_MS = 2e4;
422
495
  const RECONNECT_BASE_MS = 1e3;
423
496
  const RECONNECT_MAX_MS = 3e4;
497
+ /**
498
+ * Read-idle watchdog: cTrader echoes our 10s heartbeats and pushes its own
499
+ * traffic, so a *healthy* socket is never silent for long. If NO inbound byte
500
+ * arrives for this long we treat the socket as silently half-dead (a TCP
501
+ * half-open with no FIN — the OS never fires `close`, so `handleDrop` never
502
+ * runs and every request would otherwise time out at 20s indefinitely) and
503
+ * force a reconnect. Set to 3× the heartbeat interval so a single dropped
504
+ * heartbeat echo doesn't false-positive.
505
+ */
506
+ const READ_IDLE_TIMEOUT_MS = 3 * HEARTBEAT_INTERVAL_MS;
507
+ /** How often the watchdog checks the read-idle clock. */
508
+ const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS;
424
509
  function log$1(msg) {
425
510
  process.stderr.write(`[ctrader-mcp] ${msg}\n`);
426
511
  }
@@ -455,13 +540,20 @@ var HostSocket = class {
455
540
  parser = new FrameParser();
456
541
  pending = /* @__PURE__ */ new Map();
457
542
  heartbeatTimer = null;
543
+ watchdogTimer = null;
544
+ /** Timestamp (ms) of the last inbound byte — drives the read-idle watchdog. */
545
+ lastInboundAt = 0;
458
546
  reconnectAttempts = 0;
547
+ /** True from the moment a drop is handled until the reconnect succeeds. */
548
+ reconnectScheduled = false;
459
549
  closing = false;
460
550
  connectPromise = null;
461
551
  /** ctidTraderAccountId → accessToken used to account-auth it on this socket. */
462
552
  authedAccounts = /* @__PURE__ */ new Map();
463
553
  /** Serializes account-auth so concurrent tool calls don't double-auth. */
464
554
  accountAuthPromises = /* @__PURE__ */ new Map();
555
+ /** Listeners for unsolicited events (depth / spot ticks). */
556
+ eventListeners = /* @__PURE__ */ new Set();
465
557
  constructor(host, clientId, clientSecret, connectFn = tlsConnect, root = loadRoot()) {
466
558
  this.host = host;
467
559
  this.clientId = clientId;
@@ -476,9 +568,11 @@ var HostSocket = class {
476
568
  }
477
569
  async doStart() {
478
570
  this.conn = await this.connectFn(this.host, CTRADER_PORT);
571
+ this.lastInboundAt = Date.now();
479
572
  this.wireConnection(this.conn);
480
573
  await this.appAuth();
481
574
  this.startHeartbeat();
575
+ this.startWatchdog();
482
576
  log$1(`Connected + app-authenticated to ${this.host}`);
483
577
  }
484
578
  /**
@@ -503,8 +597,22 @@ var HostSocket = class {
503
597
  }
504
598
  await inFlight;
505
599
  }
600
+ /**
601
+ * Register a listener for unsolicited events (depth / spot ticks) on this
602
+ * socket. Returns an unregister function — callers MUST unregister when
603
+ * their collection window ends or the listener leaks for the socket's
604
+ * lifetime. Listeners receive every listenable event on the socket;
605
+ * account/symbol filtering is the caller's job.
606
+ */
607
+ onEvent(listener) {
608
+ this.eventListeners.add(listener);
609
+ return () => {
610
+ this.eventListeners.delete(listener);
611
+ };
612
+ }
506
613
  wireConnection(conn) {
507
614
  conn.on("data", (chunk) => {
615
+ this.lastInboundAt = Date.now();
508
616
  for (const frame of this.parser.push(chunk)) this.dispatch(frame);
509
617
  });
510
618
  conn.on("error", (err) => {
@@ -539,9 +647,51 @@ var HostSocket = class {
539
647
  this.heartbeatTimer = null;
540
648
  }
541
649
  }
650
+ /**
651
+ * Start the read-idle watchdog. On a *silent* socket (no inbound byte for
652
+ * `READ_IDLE_TIMEOUT_MS`) the OS may never fire `close` (a TCP half-open with
653
+ * no FIN), so `handleDrop` never runs and every request just times out at 20s
654
+ * forever. The watchdog detects that silence and forces a teardown +
655
+ * reconnect, converting a wedged socket into a fast recovery.
656
+ */
657
+ startWatchdog() {
658
+ this.stopWatchdog();
659
+ this.watchdogTimer = setInterval(() => {
660
+ if (!this.conn || this.closing) return;
661
+ const idleFor = Date.now() - this.lastInboundAt;
662
+ if (idleFor >= READ_IDLE_TIMEOUT_MS) {
663
+ log$1(`No inbound data from ${this.host} for ${String(idleFor)}ms — forcing reconnect`);
664
+ this.forceReconnect();
665
+ }
666
+ }, WATCHDOG_INTERVAL_MS);
667
+ this.watchdogTimer.unref();
668
+ }
669
+ stopWatchdog() {
670
+ if (this.watchdogTimer) {
671
+ clearInterval(this.watchdogTimer);
672
+ this.watchdogTimer = null;
673
+ }
674
+ }
675
+ /**
676
+ * Tear down a silently-wedged socket and reconnect. Destroying the socket
677
+ * normally fires `close`, but we don't rely on it — we drive `handleDrop`
678
+ * directly (which fails in-flight waiters and schedules the reconnect) and
679
+ * suppress the redundant `close` callback via the same `closing`-free path.
680
+ */
681
+ forceReconnect() {
682
+ const dead = this.conn;
683
+ this.conn = null;
684
+ try {
685
+ dead?.destroy();
686
+ } catch {}
687
+ this.handleDrop();
688
+ }
542
689
  handleDrop() {
690
+ if (!this.conn && this.heartbeatTimer === null && this.reconnectScheduled) return;
543
691
  log$1(`Socket dropped (${this.host}) — attempting reconnect`);
544
692
  this.stopHeartbeat();
693
+ this.stopWatchdog();
694
+ this.reconnectScheduled = true;
545
695
  this.conn = null;
546
696
  this.parser = new FrameParser();
547
697
  this.authedAccounts.clear();
@@ -560,10 +710,13 @@ var HostSocket = class {
560
710
  if (this.closing) return;
561
711
  try {
562
712
  this.conn = await this.connectFn(this.host, CTRADER_PORT);
713
+ this.lastInboundAt = Date.now();
563
714
  this.wireConnection(this.conn);
564
715
  await this.appAuth();
565
716
  this.startHeartbeat();
717
+ this.startWatchdog();
566
718
  this.reconnectAttempts = 0;
719
+ this.reconnectScheduled = false;
567
720
  log$1(`Reconnected + re-app-authenticated (${this.host})`);
568
721
  } catch (err) {
569
722
  log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
@@ -579,6 +732,17 @@ var HostSocket = class {
579
732
  return;
580
733
  }
581
734
  if (decoded.payloadType === PayloadType.HEARTBEAT_EVENT) return;
735
+ if (LISTENABLE_EVENTS.has(decoded.payloadType)) {
736
+ for (const listener of this.eventListeners) try {
737
+ listener({
738
+ payloadType: decoded.payloadType,
739
+ message: decoded.message
740
+ });
741
+ } catch (err) {
742
+ log$1(`Event listener threw (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
743
+ }
744
+ return;
745
+ }
582
746
  const id = decoded.clientMsgId;
583
747
  if (!id) return;
584
748
  const waiter = this.pending.get(id);
@@ -639,6 +803,7 @@ var HostSocket = class {
639
803
  close() {
640
804
  this.closing = true;
641
805
  this.stopHeartbeat();
806
+ this.stopWatchdog();
642
807
  for (const [id, req] of this.pending) {
643
808
  clearTimeout(req.timer);
644
809
  req.reject(new CTraderError("CLIENT_CLOSED", "Client is shutting down"));
@@ -647,6 +812,7 @@ var HostSocket = class {
647
812
  this.conn?.destroy();
648
813
  this.conn = null;
649
814
  this.authedAccounts.clear();
815
+ this.eventListeners.clear();
650
816
  }
651
817
  };
652
818
  /**
@@ -713,6 +879,20 @@ var CTraderPool = class {
713
879
  await socket.authenticateAccount(config.accountId, config.accessToken);
714
880
  return socket.request(payloadType, payload);
715
881
  }
882
+ /**
883
+ * Register an unsolicited-event listener on the socket that serves
884
+ * `accountId`'s host. Returns the unregister function. The listener sees
885
+ * every listenable event on that socket — filter by
886
+ * `message.ctidTraderAccountId` / `message.symbolId` in the caller.
887
+ *
888
+ * Throws `CTraderError("UNKNOWN_ACCOUNT")` for an unregistered account
889
+ * (fail closed, same as request()).
890
+ */
891
+ onAccountEvent(accountId, listener) {
892
+ const config = this.resolve(accountId);
893
+ if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
894
+ return this.socketFor(config).onEvent(listener);
895
+ }
716
896
  /** Close every host socket. */
717
897
  close() {
718
898
  for (const socket of this.sockets.values()) socket.close();
@@ -725,9 +905,19 @@ var CTraderPool = class {
725
905
  * MCP tool registration for the cTrader MCP server.
726
906
  *
727
907
  * Read tools: get_accounts, get_account_details, get_positions, get_orders,
728
- * get_symbols, get_market_data
908
+ * get_symbols, get_symbol_details, get_market_data, get_quote,
909
+ * get_depth, get_tick_data, get_trade_history, get_position_pnl,
910
+ * get_expected_margin, get_cash_flow_history
729
911
  * Write tools: place_order, modify_order, close_position, cancel_order
730
912
  *
913
+ * ── Live-data tools (get_quote / get_depth) ──
914
+ * MCP tools are request/response, so live subscriptions are snapshotted:
915
+ * subscribe → collect events for a short window → ALWAYS unsubscribe
916
+ * (finally) → return the assembled snapshot. Calls are serialized per
917
+ * (account, symbol, kind): cTrader only sends the initial full book / first
918
+ * tick on a FRESH subscribe, and double-subscribing errors — so overlapping
919
+ * calls take turns instead of sharing a subscription.
920
+ *
731
921
  * ── Account routing (money-safety) ──
732
922
  * Every tool that acts on an account takes an OPTIONAL `accountId`
733
923
  * (= ctidTraderAccountId):
@@ -748,12 +938,38 @@ var CTraderPool = class {
748
938
  const ORDER_TYPE = {
749
939
  MARKET: 1,
750
940
  LIMIT: 2,
751
- STOP: 3
941
+ STOP: 3,
942
+ STOP_LIMIT: 6
752
943
  };
753
944
  const TRADE_SIDE = {
754
945
  BUY: 1,
755
946
  SELL: 2
756
947
  };
948
+ const QUOTE_TYPE = {
949
+ BID: 1,
950
+ ASK: 2
951
+ };
952
+ /** ProtoOATimeInForce (OpenApiModelMessages.proto), transcribed verbatim. */
953
+ const TIME_IN_FORCE = {
954
+ GOOD_TILL_DATE: 1,
955
+ GOOD_TILL_CANCEL: 2,
956
+ IMMEDIATE_OR_CANCEL: 3,
957
+ FILL_OR_KILL: 4
958
+ };
959
+ /** ProtoOADealStatus (OpenApiModelMessages.proto), transcribed verbatim. */
960
+ const DEAL_STATUS_NAME = {
961
+ 2: "FILLED",
962
+ 3: "PARTIALLY_FILLED",
963
+ 4: "REJECTED",
964
+ 5: "INTERNALLY_REJECTED",
965
+ 6: "ERROR",
966
+ 7: "MISSED"
967
+ };
968
+ /** ProtoOAChangeBalanceType — only the two operations agents care about. */
969
+ const CHANGE_BALANCE_TYPE_NAME = {
970
+ 0: "DEPOSIT",
971
+ 1: "WITHDRAW"
972
+ };
757
973
  /**
758
974
  * ProtoOAExecutionType (OpenApiModelMessages.proto). The number the broker
759
975
  * returns on a ProtoOAExecutionEvent tells us the real outcome — a bare
@@ -886,6 +1102,85 @@ async function getSymbolDetail(pool, accountId, numericAccountId, symbolId) {
886
1102
  if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${String(symbolId)} on this account`);
887
1103
  return symbols[0];
888
1104
  }
1105
+ /** Unref'd sleep so a pending collection window never keeps the process alive. */
1106
+ function sleep(ms) {
1107
+ return new Promise((resolve) => {
1108
+ setTimeout(resolve, ms).unref();
1109
+ });
1110
+ }
1111
+ /** Round a decoded price to the symbol's quoted digits. */
1112
+ function roundToDigits(price, digits) {
1113
+ return Number(price.toFixed(Math.min(Math.max(digits, 0), 10)));
1114
+ }
1115
+ /** Parse an ISO 8601 input into epoch ms, failing closed on garbage. */
1116
+ function parseTimestamp(value, field) {
1117
+ const ms = Date.parse(value);
1118
+ if (Number.isNaN(ms)) throw new CTraderError("INVALID_TIMESTAMP", `${field} is not a valid ISO 8601 datetime: ${value}`);
1119
+ return ms;
1120
+ }
1121
+ /**
1122
+ * Serializes live-data snapshots per (account, symbol, kind). cTrader only
1123
+ * sends the initial full depth book / current spot on a FRESH subscribe and
1124
+ * rejects a second subscribe on an already-subscribed symbol — so overlapping
1125
+ * tool calls must take turns rather than share one subscription.
1126
+ */
1127
+ const liveDataLocks = /* @__PURE__ */ new Map();
1128
+ function withLiveDataLock(key, fn) {
1129
+ const run = (liveDataLocks.get(key) ?? Promise.resolve()).then(fn, fn);
1130
+ const tail = run.then(() => void 0, () => void 0);
1131
+ liveDataLocks.set(key, tail);
1132
+ tail.then(() => {
1133
+ if (liveDataLocks.get(key) === tail) liveDataLocks.delete(key);
1134
+ });
1135
+ return run;
1136
+ }
1137
+ const LIVE_SUBSCRIPTION = {
1138
+ depth: {
1139
+ subscribe: PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_REQ,
1140
+ unsubscribe: PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_REQ,
1141
+ event: PayloadType.OA_DEPTH_EVENT
1142
+ },
1143
+ spot: {
1144
+ subscribe: PayloadType.OA_SUBSCRIBE_SPOTS_REQ,
1145
+ unsubscribe: PayloadType.OA_UNSUBSCRIBE_SPOTS_REQ,
1146
+ event: PayloadType.OA_SPOT_EVENT
1147
+ }
1148
+ };
1149
+ /**
1150
+ * Subscribe → run `collect` while `handler` receives this symbol's events →
1151
+ * ALWAYS unsubscribe + unregister (finally). The unsubscribe is best-effort:
1152
+ * its failure is logged, never masks the result, and a truly leaked
1153
+ * subscription dies with the socket.
1154
+ */
1155
+ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, handler, collect) {
1156
+ const accountId = String(numericAccountId);
1157
+ const sub = LIVE_SUBSCRIPTION[kind];
1158
+ return withLiveDataLock(`${accountId}:${String(symbolId)}:${kind}`, async () => {
1159
+ const unregister = pool.onAccountEvent(accountId, (event) => {
1160
+ if (event.payloadType !== sub.event) return;
1161
+ if (str(event.message.ctidTraderAccountId) !== accountId) return;
1162
+ if (str(event.message.symbolId) !== String(symbolId)) return;
1163
+ handler(event.message);
1164
+ });
1165
+ try {
1166
+ await pool.request(accountId, sub.subscribe, {
1167
+ ctidTraderAccountId: numericAccountId,
1168
+ symbolId: [symbolId]
1169
+ });
1170
+ return await collect();
1171
+ } finally {
1172
+ unregister();
1173
+ try {
1174
+ await pool.request(accountId, sub.unsubscribe, {
1175
+ ctidTraderAccountId: numericAccountId,
1176
+ symbolId: [symbolId]
1177
+ });
1178
+ } catch (err) {
1179
+ process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${String(symbolId)}: ${err instanceof Error ? err.message : String(err)}\n`);
1180
+ }
1181
+ }
1182
+ });
1183
+ }
889
1184
  /**
890
1185
  * The shared `accountId` selector for account-scoped tools. Optional at the
891
1186
  * schema level (single-account convenience); `resolveAccount` enforces the
@@ -970,7 +1265,7 @@ function registerTools(server, pool) {
970
1265
  ctidTraderAccountId: resolved.accountId,
971
1266
  returnProtectionOrders: true
972
1267
  })).message.order ?? [];
973
- const typeName = (v) => v === ORDER_TYPE.LIMIT ? "LIMIT" : v === ORDER_TYPE.STOP ? "STOP" : v === ORDER_TYPE.MARKET ? "MARKET" : String(v);
1268
+ 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);
974
1269
  return ok({
975
1270
  accountId: String(resolved.accountId),
976
1271
  orders: orders.map((o) => {
@@ -1053,17 +1348,374 @@ function registerTools(server, pool) {
1053
1348
  return fail(err);
1054
1349
  }
1055
1350
  });
1351
+ register("get_symbol_details", {
1352
+ 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
+ inputSchema: {
1354
+ accountId: accountIdField,
1355
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols")
1356
+ }
1357
+ }, async (args) => {
1358
+ const resolved = resolveAccount(pool, args.accountId);
1359
+ if (isToolError(resolved)) return resolved;
1360
+ const accountId = String(resolved.accountId);
1361
+ try {
1362
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1363
+ const lotSize = num(symbol.lotSize);
1364
+ const toLots = (v) => lotSize > 0 ? volumeToLots(num(v), lotSize) : null;
1365
+ return ok({
1366
+ accountId,
1367
+ symbolId: str(symbol.symbolId) || String(args.symbolId),
1368
+ digits: num(symbol.digits),
1369
+ pipPosition: num(symbol.pipPosition),
1370
+ lotSize,
1371
+ minVolumeLots: toLots(symbol.minVolume),
1372
+ maxVolumeLots: toLots(symbol.maxVolume),
1373
+ stepVolumeLots: toLots(symbol.stepVolume),
1374
+ swapLong: symbol.swapLong ?? null,
1375
+ swapShort: symbol.swapShort ?? null,
1376
+ swapRollover3Days: symbol.swapRollover3Days ?? null,
1377
+ enableShortSelling: symbol.enableShortSelling != null ? Boolean(symbol.enableShortSelling) : null,
1378
+ guaranteedStopLossAvailable: symbol.guaranteedStopLoss != null ? Boolean(symbol.guaranteedStopLoss) : null
1379
+ });
1380
+ } catch (err) {
1381
+ return fail(err);
1382
+ }
1383
+ });
1384
+ register("get_quote", {
1385
+ 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
+ inputSchema: {
1387
+ accountId: accountIdField,
1388
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1389
+ waitMs: z.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
+ }
1391
+ }, async (args) => {
1392
+ const resolved = resolveAccount(pool, args.accountId);
1393
+ if (isToolError(resolved)) return resolved;
1394
+ const accountId = String(resolved.accountId);
1395
+ try {
1396
+ const digits = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).digits);
1397
+ const quote = {
1398
+ bid: null,
1399
+ ask: null,
1400
+ ticks: 0
1401
+ };
1402
+ let signalBothSeen = () => void 0;
1403
+ const bothSeen = new Promise((resolve) => {
1404
+ signalBothSeen = resolve;
1405
+ });
1406
+ await withLiveSubscription(pool, resolved.accountId, args.symbolId, "spot", (message) => {
1407
+ quote.ticks += 1;
1408
+ if (message.bid != null) quote.bid = roundToDigits(priceToDecimal(num(message.bid)), digits);
1409
+ if (message.ask != null) quote.ask = roundToDigits(priceToDecimal(num(message.ask)), digits);
1410
+ if (quote.bid != null && quote.ask != null) signalBothSeen();
1411
+ }, () => 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 ${String(args.symbolId)} within ${String(args.waitMs)}ms — the market may be closed or the symbol not quoted on this account`));
1413
+ return ok({
1414
+ accountId,
1415
+ symbolId: String(args.symbolId),
1416
+ digits,
1417
+ bid: quote.bid,
1418
+ ask: quote.ask,
1419
+ spread: quote.bid != null && quote.ask != null ? roundToDigits(quote.ask - quote.bid, digits) : null,
1420
+ ticksReceived: quote.ticks,
1421
+ ...quote.bid == null || quote.ask == null ? { note: "Only one side ticked within the window; the missing side is null." } : {}
1422
+ });
1423
+ } catch (err) {
1424
+ return fail(err);
1425
+ }
1426
+ });
1427
+ register("get_depth", {
1428
+ 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
+ inputSchema: {
1430
+ accountId: accountIdField,
1431
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1432
+ levels: z.number().int().min(1).max(50).default(10).describe("Max price levels per side to return"),
1433
+ collectMs: z.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
+ }
1435
+ }, async (args) => {
1436
+ const resolved = resolveAccount(pool, args.accountId);
1437
+ if (isToolError(resolved)) return resolved;
1438
+ const accountId = String(resolved.accountId);
1439
+ try {
1440
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1441
+ const digits = num(symbol.digits);
1442
+ const lotSize = num(symbol.lotSize);
1443
+ const book = /* @__PURE__ */ new Map();
1444
+ let eventsReceived = 0;
1445
+ await withLiveSubscription(pool, resolved.accountId, args.symbolId, "depth", (message) => {
1446
+ eventsReceived += 1;
1447
+ const newQuotes = message.newQuotes ?? [];
1448
+ for (const quote of newQuotes) {
1449
+ const id = str(quote.id);
1450
+ const sizeUnits = depthSizeToUnits(num(quote.size));
1451
+ if (quote.bid != null) book.set(id, {
1452
+ side: "bid",
1453
+ price: priceToDecimal(num(quote.bid)),
1454
+ sizeUnits
1455
+ });
1456
+ else if (quote.ask != null) book.set(id, {
1457
+ side: "ask",
1458
+ price: priceToDecimal(num(quote.ask)),
1459
+ sizeUnits
1460
+ });
1461
+ }
1462
+ for (const deleted of message.deletedQuotes ?? []) book.delete(str(deleted));
1463
+ }, () => sleep(args.collectMs));
1464
+ if (eventsReceived === 0) return fail(new CTraderError("NO_DEPTH_DATA", `No depth events for symbol ${String(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
+ const ladder = (side) => {
1466
+ const byPrice = /* @__PURE__ */ new Map();
1467
+ for (const quote of book.values()) {
1468
+ if (quote.side !== side) continue;
1469
+ byPrice.set(quote.price, (byPrice.get(quote.price) ?? 0) + quote.sizeUnits);
1470
+ }
1471
+ const rows = [...byPrice.entries()].map(([price, sizeUnits]) => ({
1472
+ price: roundToDigits(price, digits),
1473
+ sizeUnits,
1474
+ sizeLots: lotSize > 0 ? volumeToLots(sizeUnits * 100, lotSize) : null
1475
+ }));
1476
+ rows.sort((a, b) => side === "bid" ? b.price - a.price : a.price - b.price);
1477
+ return rows;
1478
+ };
1479
+ const bids = ladder("bid");
1480
+ const asks = ladder("ask");
1481
+ const bestBid = bids.at(0)?.price ?? null;
1482
+ const bestAsk = asks.at(0)?.price ?? null;
1483
+ return ok({
1484
+ accountId,
1485
+ symbolId: String(args.symbolId),
1486
+ digits,
1487
+ bestBid,
1488
+ bestAsk,
1489
+ spread: bestBid != null && bestAsk != null ? roundToDigits(bestAsk - bestBid, digits) : null,
1490
+ bids: bids.slice(0, args.levels),
1491
+ asks: asks.slice(0, args.levels),
1492
+ levelsAvailable: {
1493
+ bid: bids.length,
1494
+ ask: asks.length
1495
+ },
1496
+ eventsReceived
1497
+ });
1498
+ } catch (err) {
1499
+ return fail(err);
1500
+ }
1501
+ });
1502
+ register("get_tick_data", {
1503
+ 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
+ inputSchema: {
1505
+ accountId: accountIdField,
1506
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1507
+ 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)")
1510
+ }
1511
+ }, async (args) => {
1512
+ const resolved = resolveAccount(pool, args.accountId);
1513
+ if (isToolError(resolved)) return resolved;
1514
+ const accountId = String(resolved.accountId);
1515
+ try {
1516
+ const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1517
+ const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 5 * 6e4;
1518
+ if (fromMs >= toMs) return fail(new CTraderError("INVALID_RANGE", "`from` must be before `to`"));
1519
+ const res = await pool.request(accountId, PayloadType.OA_GET_TICKDATA_REQ, {
1520
+ ctidTraderAccountId: resolved.accountId,
1521
+ symbolId: args.symbolId,
1522
+ type: QUOTE_TYPE[args.type],
1523
+ fromTimestamp: fromMs,
1524
+ toTimestamp: toMs
1525
+ });
1526
+ const raw = res.message.tickData ?? [];
1527
+ return ok({
1528
+ accountId,
1529
+ symbolId: String(args.symbolId),
1530
+ type: args.type,
1531
+ count: raw.length,
1532
+ hasMore: Boolean(res.message.hasMore),
1533
+ ticks: decodeTickData(raw)
1534
+ });
1535
+ } catch (err) {
1536
+ return fail(err);
1537
+ }
1538
+ });
1539
+ register("get_trade_history", {
1540
+ 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
+ inputSchema: {
1542
+ 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)"),
1545
+ maxRows: z.number().int().min(1).max(1e3).default(100).describe("Max deals to return")
1546
+ }
1547
+ }, async (args) => {
1548
+ const resolved = resolveAccount(pool, args.accountId);
1549
+ if (isToolError(resolved)) return resolved;
1550
+ const accountId = String(resolved.accountId);
1551
+ try {
1552
+ const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1553
+ const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 7 * 864e5;
1554
+ if (fromMs >= toMs) return fail(new CTraderError("INVALID_RANGE", "`from` must be before `to`"));
1555
+ const res = await pool.request(accountId, PayloadType.OA_DEAL_LIST_REQ, {
1556
+ ctidTraderAccountId: resolved.accountId,
1557
+ fromTimestamp: fromMs,
1558
+ toTimestamp: toMs,
1559
+ maxRows: args.maxRows
1560
+ });
1561
+ const deals = res.message.deal ?? [];
1562
+ return ok({
1563
+ accountId,
1564
+ count: deals.length,
1565
+ hasMore: Boolean(res.message.hasMore),
1566
+ deals: deals.map((d) => {
1567
+ const moneyDigits = d.moneyDigits != null ? num(d.moneyDigits) : 2;
1568
+ const close = d.closePositionDetail;
1569
+ const closeMoneyDigits = close?.moneyDigits != null ? num(close.moneyDigits) : moneyDigits;
1570
+ return {
1571
+ dealId: str(d.dealId),
1572
+ orderId: str(d.orderId),
1573
+ positionId: str(d.positionId),
1574
+ symbolId: str(d.symbolId),
1575
+ side: num(d.tradeSide) === TRADE_SIDE.SELL ? "SELL" : "BUY",
1576
+ status: DEAL_STATUS_NAME[num(d.dealStatus)] ?? str(d.dealStatus),
1577
+ volume: str(d.volume) || "0",
1578
+ filledVolume: str(d.filledVolume) || "0",
1579
+ executionPrice: d.executionPrice ?? null,
1580
+ commission: d.commission != null ? moneyToDecimal(num(d.commission), moneyDigits) : null,
1581
+ executedAt: new Date(num(d.executionTimestamp)).toISOString(),
1582
+ closedPosition: close ? {
1583
+ entryPrice: close.entryPrice ?? null,
1584
+ grossProfit: moneyToDecimal(num(close.grossProfit), closeMoneyDigits),
1585
+ swap: moneyToDecimal(num(close.swap), closeMoneyDigits),
1586
+ commission: moneyToDecimal(num(close.commission), closeMoneyDigits),
1587
+ balanceAfter: moneyToDecimal(num(close.balance), closeMoneyDigits)
1588
+ } : null
1589
+ };
1590
+ })
1591
+ });
1592
+ } catch (err) {
1593
+ return fail(err);
1594
+ }
1595
+ });
1596
+ register("get_position_pnl", {
1597
+ description: "Get the current UNREALIZED profit/loss (gross and net) for every open position on a connected cTrader account, in the account's deposit currency. Pass `accountId` when several accounts are connected.",
1598
+ inputSchema: { accountId: accountIdField }
1599
+ }, async (args) => {
1600
+ const resolved = resolveAccount(pool, args.accountId);
1601
+ if (isToolError(resolved)) return resolved;
1602
+ const accountId = String(resolved.accountId);
1603
+ try {
1604
+ const res = await pool.request(accountId, PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ, { ctidTraderAccountId: resolved.accountId });
1605
+ const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
1606
+ return ok({
1607
+ accountId,
1608
+ moneyDigits,
1609
+ positions: (res.message.positionUnrealizedPnL ?? []).map((r) => ({
1610
+ positionId: str(r.positionId),
1611
+ grossUnrealizedPnL: moneyToDecimal(num(r.grossUnrealizedPnL), moneyDigits),
1612
+ netUnrealizedPnL: moneyToDecimal(num(r.netUnrealizedPnL), moneyDigits)
1613
+ }))
1614
+ });
1615
+ } catch (err) {
1616
+ return fail(err);
1617
+ }
1618
+ });
1619
+ register("get_expected_margin", {
1620
+ 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
+ inputSchema: {
1622
+ accountId: accountIdField,
1623
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1624
+ volumesLots: z.array(z.number().positive()).min(1).max(10).describe("Position sizes in lots to quote margin for, e.g. [0.1, 0.5, 1]")
1625
+ }
1626
+ }, async (args) => {
1627
+ const resolved = resolveAccount(pool, args.accountId);
1628
+ if (isToolError(resolved)) return resolved;
1629
+ const accountId = String(resolved.accountId);
1630
+ try {
1631
+ const lotSize = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).lotSize);
1632
+ if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${String(args.symbolId)} has no lotSize; cannot size the margin quote`));
1633
+ const res = await pool.request(accountId, PayloadType.OA_EXPECTED_MARGIN_REQ, {
1634
+ ctidTraderAccountId: resolved.accountId,
1635
+ symbolId: args.symbolId,
1636
+ volume: args.volumesLots.map((lots) => lotsToVolume(lots, lotSize))
1637
+ });
1638
+ const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
1639
+ const margins = res.message.margin ?? [];
1640
+ return ok({
1641
+ accountId,
1642
+ symbolId: String(args.symbolId),
1643
+ margins: margins.map((m) => ({
1644
+ volumeLots: volumeToLots(num(m.volume), lotSize),
1645
+ buyMargin: moneyToDecimal(num(m.buyMargin), moneyDigits),
1646
+ sellMargin: moneyToDecimal(num(m.sellMargin), moneyDigits)
1647
+ }))
1648
+ });
1649
+ } catch (err) {
1650
+ return fail(err);
1651
+ }
1652
+ });
1653
+ register("get_cash_flow_history", {
1654
+ 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
+ inputSchema: {
1656
+ 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)")
1659
+ }
1660
+ }, async (args) => {
1661
+ const resolved = resolveAccount(pool, args.accountId);
1662
+ if (isToolError(resolved)) return resolved;
1663
+ const accountId = String(resolved.accountId);
1664
+ try {
1665
+ const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1666
+ const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 30 * 864e5;
1667
+ if (fromMs >= toMs) return fail(new CTraderError("INVALID_RANGE", "`from` must be before `to`"));
1668
+ const entries = (await pool.request(accountId, PayloadType.OA_CASH_FLOW_HISTORY_LIST_REQ, {
1669
+ ctidTraderAccountId: resolved.accountId,
1670
+ fromTimestamp: fromMs,
1671
+ toTimestamp: toMs
1672
+ })).message.depositWithdraw ?? [];
1673
+ return ok({
1674
+ accountId,
1675
+ count: entries.length,
1676
+ entries: entries.map((e) => {
1677
+ const moneyDigits = e.moneyDigits != null ? num(e.moneyDigits) : 2;
1678
+ return {
1679
+ type: CHANGE_BALANCE_TYPE_NAME[num(e.operationType)] ?? str(e.operationType),
1680
+ amount: moneyToDecimal(num(e.delta), moneyDigits),
1681
+ balanceAfter: moneyToDecimal(num(e.balance), moneyDigits),
1682
+ at: new Date(num(e.changeBalanceTimestamp)).toISOString(),
1683
+ note: e.externalNote ?? null
1684
+ };
1685
+ })
1686
+ });
1687
+ } catch (err) {
1688
+ return fail(err);
1689
+ }
1690
+ });
1056
1691
  register("place_order", {
1057
- description: "Place a MARKET or 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). For LIMIT you must pass limitPrice. SL/TP are absolute prices. Pass `accountId` when several accounts are connected. WARNING: on a live account this moves real money.",
1692
+ 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.",
1058
1693
  inputSchema: {
1059
1694
  accountId: accountIdField,
1060
1695
  symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1061
1696
  side: z.enum(["BUY", "SELL"]).describe("Trade side"),
1062
- orderType: z.enum(["MARKET", "LIMIT"]).default("MARKET").describe("MARKET fills now; LIMIT rests at limitPrice"),
1697
+ orderType: z.enum([
1698
+ "MARKET",
1699
+ "LIMIT",
1700
+ "STOP",
1701
+ "STOP_LIMIT"
1702
+ ]).default("MARKET").describe("MARKET fills now; LIMIT rests at limitPrice; STOP triggers at stopPrice; STOP_LIMIT triggers at stopPrice with a max slippage of slippageInPoints"),
1063
1703
  volumeLots: z.number().positive().describe("Order size in lots (e.g. 0.1). Converted to protocol volume via the symbol lotSize."),
1064
1704
  limitPrice: z.number().positive().optional().describe("Required for LIMIT orders — the price to rest the order at"),
1065
- stopLoss: z.number().positive().optional().describe("Absolute stop-loss price"),
1066
- takeProfit: z.number().positive().optional().describe("Absolute take-profit price"),
1705
+ stopPrice: z.number().positive().optional().describe("Required for STOP and STOP_LIMIT orders — the trigger price"),
1706
+ slippageInPoints: z.number().int().min(0).optional().describe("Required for STOP_LIMIT — max slippage from stopPrice, in points"),
1707
+ stopLoss: z.number().positive().optional().describe("Absolute stop-loss price (mutually exclusive with stopLossDistance)"),
1708
+ takeProfit: z.number().positive().optional().describe("Absolute take-profit price (mutually exclusive with takeProfitDistance)"),
1709
+ stopLossDistance: z.number().positive().optional().describe("Stop-loss as a price DISTANCE from entry (e.g. 0.0010 = 10 pips on a 5-digit FX pair). Required when trailingStopLoss is true."),
1710
+ takeProfitDistance: z.number().positive().optional().describe("Take-profit as a price DISTANCE from entry"),
1711
+ trailingStopLoss: z.boolean().optional().describe("Trail the stop-loss as price moves in your favour. Requires stopLossDistance (not an absolute stopLoss)."),
1712
+ timeInForce: z.enum([
1713
+ "GOOD_TILL_CANCEL",
1714
+ "GOOD_TILL_DATE",
1715
+ "IMMEDIATE_OR_CANCEL",
1716
+ "FILL_OR_KILL"
1717
+ ]).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"),
1067
1719
  label: z.string().optional().describe("Optional client label for the order")
1068
1720
  }
1069
1721
  }, async (args) => {
@@ -1072,6 +1724,19 @@ function registerTools(server, pool) {
1072
1724
  const accountId = String(resolved.accountId);
1073
1725
  try {
1074
1726
  if (args.orderType === "LIMIT" && args.limitPrice == null) return fail(new CTraderError("LIMIT_PRICE_REQUIRED", "LIMIT orders require limitPrice"));
1727
+ if ((args.orderType === "STOP" || args.orderType === "STOP_LIMIT") && args.stopPrice == null) return fail(new CTraderError("STOP_PRICE_REQUIRED", `${args.orderType} orders require stopPrice`));
1728
+ if (args.orderType === "STOP_LIMIT" && args.slippageInPoints == null) return fail(new CTraderError("SLIPPAGE_REQUIRED", "STOP_LIMIT orders require slippageInPoints"));
1729
+ 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
+ if (args.orderType === "LIMIT" && args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "LIMIT orders take no stopPrice"));
1731
+ if (args.orderType === "STOP" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP orders take no limitPrice"));
1732
+ if (args.stopLoss != null && args.stopLossDistance != null) return fail(new CTraderError("CONFLICTING_STOP_LOSS", "Pass stopLoss (absolute) OR stopLossDistance, not both"));
1733
+ if (args.takeProfit != null && args.takeProfitDistance != null) return fail(new CTraderError("CONFLICTING_TAKE_PROFIT", "Pass takeProfit (absolute) OR takeProfitDistance, not both"));
1734
+ if (args.trailingStopLoss) {
1735
+ if (args.stopLoss != null) return fail(new CTraderError("TRAILING_NEEDS_DISTANCE", "A trailing stop uses stopLossDistance, not an absolute stopLoss"));
1736
+ if (args.stopLossDistance == null) return fail(new CTraderError("TRAILING_NEEDS_DISTANCE", "trailingStopLoss requires stopLossDistance"));
1737
+ }
1738
+ if (args.timeInForce === "GOOD_TILL_DATE" && args.expiresAt == null) return fail(new CTraderError("EXPIRATION_REQUIRED", "timeInForce GOOD_TILL_DATE requires expiresAt"));
1739
+ 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"));
1075
1740
  const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1076
1741
  const lotSize = num(symbol.lotSize);
1077
1742
  if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${String(args.symbolId)} has no lotSize; cannot size the order`));
@@ -1086,8 +1751,15 @@ function registerTools(server, pool) {
1086
1751
  volume
1087
1752
  };
1088
1753
  if (args.limitPrice != null) payload.limitPrice = args.limitPrice;
1754
+ if (args.stopPrice != null) payload.stopPrice = args.stopPrice;
1755
+ if (args.slippageInPoints != null) payload.slippageInPoints = args.slippageInPoints;
1089
1756
  if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1090
1757
  if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1758
+ if (args.stopLossDistance != null) payload.relativeStopLoss = Math.round(args.stopLossDistance * PRICE_SCALE);
1759
+ if (args.takeProfitDistance != null) payload.relativeTakeProfit = Math.round(args.takeProfitDistance * PRICE_SCALE);
1760
+ if (args.trailingStopLoss != null) payload.trailingStopLoss = args.trailingStopLoss;
1761
+ if (args.timeInForce != null) payload.timeInForce = TIME_IN_FORCE[args.timeInForce];
1762
+ if (args.expiresAt != null) payload.expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
1091
1763
  if (args.label != null) payload.label = args.label;
1092
1764
  const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload));
1093
1765
  return ok({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alfe.ai/ctrader-mcp",
3
- "version": "0.2.1",
4
- "description": "cTrader MCP server — full trading (place/modify/close orders + read) over the cTrader Open API (protobuf/TLS)",
3
+ "version": "0.3.1",
4
+ "description": "cTrader MCP server — full trading (market/limit/stop/stop-limit orders, trailing stops), Level 2 depth, live quotes, trade history, PnL & margin over the cTrader Open API (protobuf/TLS)",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
7
7
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "protobufjs": "^8.7.0",
23
23
  "zod": "^4.0.5",
24
- "@alfe.ai/agent-api-client": "0.11.0",
24
+ "@alfe.ai/agent-api-client": "0.11.1",
25
25
  "@alfe.ai/config": "0.3.0"
26
26
  },
27
27
  "license": "UNLICENSED",
@@ -53,6 +53,11 @@ enum ProtoOATradeSide {
53
53
  SELL = 2;
54
54
  }
55
55
 
56
+ enum ProtoOAQuoteType {
57
+ BID = 1;
58
+ ASK = 2;
59
+ }
60
+
56
61
  enum ProtoOATrendbarPeriod {
57
62
  M1 = 1;
58
63
  M2 = 2;
@@ -280,8 +285,11 @@ message ProtoOASymbol {
280
285
  optional bool enableShortSelling = 4;
281
286
  optional bool guaranteedStopLoss = 5;
282
287
  optional uint32 swapRollover3Days = 6;
283
- optional bool swapLong = 7;
284
- optional bool swapShort = 8;
288
+ // swapLong/swapShort are canonical DOUBLE (fixed64 on the wire) — declaring
289
+ // them bool (varint) mis-consumes bytes and corrupts every field decoded
290
+ // after them whenever the broker sets swap rates.
291
+ optional double swapLong = 7;
292
+ optional double swapShort = 8;
285
293
  optional int64 maxVolume = 9;
286
294
  optional int64 minVolume = 10;
287
295
  optional int64 stepVolume = 11;
@@ -361,6 +369,213 @@ message ProtoOASpotEvent {
361
369
  optional int64 timestamp = 8;
362
370
  }
363
371
 
372
+ message ProtoOAUnsubscribeSpotsReq {
373
+ optional uint32 payloadType = 1;
374
+ required int64 ctidTraderAccountId = 2;
375
+ repeated int64 symbolId = 3;
376
+ }
377
+
378
+ message ProtoOAUnsubscribeSpotsRes {
379
+ optional uint32 payloadType = 1;
380
+ required int64 ctidTraderAccountId = 2;
381
+ }
382
+
383
+ // ── Depth of market (Level 2) subscription ──────────────────────────────
384
+ //
385
+ // The first ProtoOADepthEvent after a subscribe carries the FULL current book
386
+ // as `newQuotes`; later events are deltas — upsert `newQuotes` by quote `id`,
387
+ // then remove `deletedQuotes` ids. A quote is one side only: `bid` OR `ask`
388
+ // is set, never both. Prices are ×100000 like spot/trendbar prices; `size`
389
+ // is in CENTS of a unit (÷100 = base-asset units).
390
+
391
+ message ProtoOADepthQuote {
392
+ required uint64 id = 1;
393
+ required uint64 size = 3;
394
+ optional uint64 bid = 4;
395
+ optional uint64 ask = 5;
396
+ }
397
+
398
+ message ProtoOASubscribeDepthQuotesReq {
399
+ optional uint32 payloadType = 1;
400
+ required int64 ctidTraderAccountId = 2;
401
+ repeated int64 symbolId = 3;
402
+ }
403
+
404
+ message ProtoOASubscribeDepthQuotesRes {
405
+ optional uint32 payloadType = 1;
406
+ required int64 ctidTraderAccountId = 2;
407
+ }
408
+
409
+ message ProtoOAUnsubscribeDepthQuotesReq {
410
+ optional uint32 payloadType = 1;
411
+ required int64 ctidTraderAccountId = 2;
412
+ repeated int64 symbolId = 3;
413
+ }
414
+
415
+ message ProtoOAUnsubscribeDepthQuotesRes {
416
+ optional uint32 payloadType = 1;
417
+ required int64 ctidTraderAccountId = 2;
418
+ }
419
+
420
+ message ProtoOADepthEvent {
421
+ optional uint32 payloadType = 1;
422
+ required int64 ctidTraderAccountId = 2;
423
+ required uint64 symbolId = 3;
424
+ repeated ProtoOADepthQuote newQuotes = 4;
425
+ repeated uint64 deletedQuotes = 5 [packed = true];
426
+ }
427
+
428
+ // ── Historical tick data ────────────────────────────────────────────────
429
+ //
430
+ // Tick data comes back NEWEST-FIRST and delta-encoded: the first entry's
431
+ // `timestamp`/`tick` are absolute, every subsequent entry carries the DELTA
432
+ // from the previous entry (negative for time going backwards). Decode by
433
+ // running sums. Tick prices are ×100000 like all other prices.
434
+
435
+ message ProtoOATickData {
436
+ required int64 timestamp = 1;
437
+ required int64 tick = 2;
438
+ }
439
+
440
+ message ProtoOAGetTickDataReq {
441
+ optional uint32 payloadType = 1;
442
+ required int64 ctidTraderAccountId = 2;
443
+ required int64 symbolId = 3;
444
+ required ProtoOAQuoteType type = 4;
445
+ optional int64 fromTimestamp = 5;
446
+ optional int64 toTimestamp = 6;
447
+ }
448
+
449
+ message ProtoOAGetTickDataRes {
450
+ optional uint32 payloadType = 1;
451
+ required int64 ctidTraderAccountId = 2;
452
+ repeated ProtoOATickData tickData = 3;
453
+ required bool hasMore = 4;
454
+ }
455
+
456
+ // ── Deal (trade) history ────────────────────────────────────────────────
457
+ // dealStatus is upstream enum ProtoOADealStatus (FILLED=2, PARTIALLY_FILLED=3,
458
+ // REJECTED=4, INTERNALLY_REJECTED=5, ERROR=6, MISSED=7); kept as a scalar like
459
+ // orderStatus. operationType on deposits is ProtoOAChangeBalanceType
460
+ // (BALANCE_DEPOSIT=0, BALANCE_WITHDRAW=1, …), also scalar.
461
+
462
+ message ProtoOAClosePositionDetail {
463
+ required double entryPrice = 1;
464
+ required int64 grossProfit = 2;
465
+ required int64 swap = 3;
466
+ required int64 commission = 4;
467
+ required int64 balance = 5;
468
+ optional double quoteToDepositConversionRate = 6;
469
+ optional int64 closedVolume = 7;
470
+ optional int64 balanceVersion = 8;
471
+ optional uint32 moneyDigits = 9;
472
+ optional int64 pnlConversionFee = 10;
473
+ }
474
+
475
+ message ProtoOADeal {
476
+ required int64 dealId = 1;
477
+ required int64 orderId = 2;
478
+ required int64 positionId = 3;
479
+ required int64 volume = 4;
480
+ required int64 filledVolume = 5;
481
+ required int64 symbolId = 6;
482
+ required int64 createTimestamp = 7;
483
+ required int64 executionTimestamp = 8;
484
+ optional int64 utcLastUpdateTimestamp = 9;
485
+ optional double executionPrice = 10;
486
+ required ProtoOATradeSide tradeSide = 11;
487
+ required uint32 dealStatus = 12;
488
+ optional double marginRate = 13;
489
+ optional int64 commission = 14;
490
+ optional double baseToUsdConversionRate = 15;
491
+ optional ProtoOAClosePositionDetail closePositionDetail = 16;
492
+ optional uint32 moneyDigits = 17;
493
+ }
494
+
495
+ message ProtoOADealListReq {
496
+ optional uint32 payloadType = 1;
497
+ required int64 ctidTraderAccountId = 2;
498
+ optional int64 fromTimestamp = 3;
499
+ optional int64 toTimestamp = 4;
500
+ optional int32 maxRows = 5;
501
+ }
502
+
503
+ message ProtoOADealListRes {
504
+ optional uint32 payloadType = 1;
505
+ required int64 ctidTraderAccountId = 2;
506
+ repeated ProtoOADeal deal = 3;
507
+ required bool hasMore = 4;
508
+ }
509
+
510
+ // ── Expected margin ─────────────────────────────────────────────────────
511
+
512
+ message ProtoOAExpectedMargin {
513
+ required int64 volume = 1;
514
+ required int64 buyMargin = 2;
515
+ required int64 sellMargin = 3;
516
+ }
517
+
518
+ message ProtoOAExpectedMarginReq {
519
+ optional uint32 payloadType = 1;
520
+ required int64 ctidTraderAccountId = 2;
521
+ required int64 symbolId = 3;
522
+ repeated int64 volume = 4;
523
+ }
524
+
525
+ message ProtoOAExpectedMarginRes {
526
+ optional uint32 payloadType = 1;
527
+ required int64 ctidTraderAccountId = 2;
528
+ repeated ProtoOAExpectedMargin margin = 3;
529
+ optional uint32 moneyDigits = 4;
530
+ }
531
+
532
+ // ── Cash flow (deposit / withdrawal) history ────────────────────────────
533
+
534
+ message ProtoOADepositWithdraw {
535
+ required uint32 operationType = 1;
536
+ required int64 balanceHistoryId = 2;
537
+ required int64 balance = 3;
538
+ required int64 delta = 4;
539
+ required int64 changeBalanceTimestamp = 5;
540
+ optional string externalNote = 6;
541
+ optional int64 balanceVersion = 7;
542
+ optional int64 equity = 8;
543
+ optional uint32 moneyDigits = 9;
544
+ }
545
+
546
+ message ProtoOACashFlowHistoryListReq {
547
+ optional uint32 payloadType = 1;
548
+ required int64 ctidTraderAccountId = 2;
549
+ required int64 fromTimestamp = 3;
550
+ required int64 toTimestamp = 4;
551
+ }
552
+
553
+ message ProtoOACashFlowHistoryListRes {
554
+ optional uint32 payloadType = 1;
555
+ required int64 ctidTraderAccountId = 2;
556
+ repeated ProtoOADepositWithdraw depositWithdraw = 3;
557
+ }
558
+
559
+ // ── Unrealized PnL per open position ────────────────────────────────────
560
+
561
+ message ProtoOAPositionUnrealizedPnL {
562
+ required int64 positionId = 1;
563
+ required int64 grossUnrealizedPnL = 2;
564
+ required int64 netUnrealizedPnL = 3;
565
+ }
566
+
567
+ message ProtoOAGetPositionUnrealizedPnLReq {
568
+ optional uint32 payloadType = 1;
569
+ required int64 ctidTraderAccountId = 2;
570
+ }
571
+
572
+ message ProtoOAGetPositionUnrealizedPnLRes {
573
+ optional uint32 payloadType = 1;
574
+ required int64 ctidTraderAccountId = 2;
575
+ repeated ProtoOAPositionUnrealizedPnL positionUnrealizedPnL = 3;
576
+ required uint32 moneyDigits = 4;
577
+ }
578
+
364
579
  // ── Order mutations (all respond via ProtoOAExecutionEvent, 2126) ───────
365
580
 
366
581
  message ProtoOANewOrderReq {