@alfe.ai/ctrader-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +484,12 @@ 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;
@@ -462,6 +535,8 @@ var HostSocket = class {
462
535
  authedAccounts = /* @__PURE__ */ new Map();
463
536
  /** Serializes account-auth so concurrent tool calls don't double-auth. */
464
537
  accountAuthPromises = /* @__PURE__ */ new Map();
538
+ /** Listeners for unsolicited events (depth / spot ticks). */
539
+ eventListeners = /* @__PURE__ */ new Set();
465
540
  constructor(host, clientId, clientSecret, connectFn = tlsConnect, root = loadRoot()) {
466
541
  this.host = host;
467
542
  this.clientId = clientId;
@@ -503,6 +578,19 @@ var HostSocket = class {
503
578
  }
504
579
  await inFlight;
505
580
  }
581
+ /**
582
+ * Register a listener for unsolicited events (depth / spot ticks) on this
583
+ * socket. Returns an unregister function — callers MUST unregister when
584
+ * their collection window ends or the listener leaks for the socket's
585
+ * lifetime. Listeners receive every listenable event on the socket;
586
+ * account/symbol filtering is the caller's job.
587
+ */
588
+ onEvent(listener) {
589
+ this.eventListeners.add(listener);
590
+ return () => {
591
+ this.eventListeners.delete(listener);
592
+ };
593
+ }
506
594
  wireConnection(conn) {
507
595
  conn.on("data", (chunk) => {
508
596
  for (const frame of this.parser.push(chunk)) this.dispatch(frame);
@@ -579,6 +667,17 @@ var HostSocket = class {
579
667
  return;
580
668
  }
581
669
  if (decoded.payloadType === PayloadType.HEARTBEAT_EVENT) return;
670
+ if (LISTENABLE_EVENTS.has(decoded.payloadType)) {
671
+ for (const listener of this.eventListeners) try {
672
+ listener({
673
+ payloadType: decoded.payloadType,
674
+ message: decoded.message
675
+ });
676
+ } catch (err) {
677
+ log$1(`Event listener threw (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
678
+ }
679
+ return;
680
+ }
582
681
  const id = decoded.clientMsgId;
583
682
  if (!id) return;
584
683
  const waiter = this.pending.get(id);
@@ -647,6 +746,7 @@ var HostSocket = class {
647
746
  this.conn?.destroy();
648
747
  this.conn = null;
649
748
  this.authedAccounts.clear();
749
+ this.eventListeners.clear();
650
750
  }
651
751
  };
652
752
  /**
@@ -713,6 +813,20 @@ var CTraderPool = class {
713
813
  await socket.authenticateAccount(config.accountId, config.accessToken);
714
814
  return socket.request(payloadType, payload);
715
815
  }
816
+ /**
817
+ * Register an unsolicited-event listener on the socket that serves
818
+ * `accountId`'s host. Returns the unregister function. The listener sees
819
+ * every listenable event on that socket — filter by
820
+ * `message.ctidTraderAccountId` / `message.symbolId` in the caller.
821
+ *
822
+ * Throws `CTraderError("UNKNOWN_ACCOUNT")` for an unregistered account
823
+ * (fail closed, same as request()).
824
+ */
825
+ onAccountEvent(accountId, listener) {
826
+ const config = this.resolve(accountId);
827
+ if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
828
+ return this.socketFor(config).onEvent(listener);
829
+ }
716
830
  /** Close every host socket. */
717
831
  close() {
718
832
  for (const socket of this.sockets.values()) socket.close();
@@ -725,9 +839,19 @@ var CTraderPool = class {
725
839
  * MCP tool registration for the cTrader MCP server.
726
840
  *
727
841
  * Read tools: get_accounts, get_account_details, get_positions, get_orders,
728
- * get_symbols, get_market_data
842
+ * get_symbols, get_symbol_details, get_market_data, get_quote,
843
+ * get_depth, get_tick_data, get_trade_history, get_position_pnl,
844
+ * get_expected_margin, get_cash_flow_history
729
845
  * Write tools: place_order, modify_order, close_position, cancel_order
730
846
  *
847
+ * ── Live-data tools (get_quote / get_depth) ──
848
+ * MCP tools are request/response, so live subscriptions are snapshotted:
849
+ * subscribe → collect events for a short window → ALWAYS unsubscribe
850
+ * (finally) → return the assembled snapshot. Calls are serialized per
851
+ * (account, symbol, kind): cTrader only sends the initial full book / first
852
+ * tick on a FRESH subscribe, and double-subscribing errors — so overlapping
853
+ * calls take turns instead of sharing a subscription.
854
+ *
731
855
  * ── Account routing (money-safety) ──
732
856
  * Every tool that acts on an account takes an OPTIONAL `accountId`
733
857
  * (= ctidTraderAccountId):
@@ -748,12 +872,38 @@ var CTraderPool = class {
748
872
  const ORDER_TYPE = {
749
873
  MARKET: 1,
750
874
  LIMIT: 2,
751
- STOP: 3
875
+ STOP: 3,
876
+ STOP_LIMIT: 6
752
877
  };
753
878
  const TRADE_SIDE = {
754
879
  BUY: 1,
755
880
  SELL: 2
756
881
  };
882
+ const QUOTE_TYPE = {
883
+ BID: 1,
884
+ ASK: 2
885
+ };
886
+ /** ProtoOATimeInForce (OpenApiModelMessages.proto), transcribed verbatim. */
887
+ const TIME_IN_FORCE = {
888
+ GOOD_TILL_DATE: 1,
889
+ GOOD_TILL_CANCEL: 2,
890
+ IMMEDIATE_OR_CANCEL: 3,
891
+ FILL_OR_KILL: 4
892
+ };
893
+ /** ProtoOADealStatus (OpenApiModelMessages.proto), transcribed verbatim. */
894
+ const DEAL_STATUS_NAME = {
895
+ 2: "FILLED",
896
+ 3: "PARTIALLY_FILLED",
897
+ 4: "REJECTED",
898
+ 5: "INTERNALLY_REJECTED",
899
+ 6: "ERROR",
900
+ 7: "MISSED"
901
+ };
902
+ /** ProtoOAChangeBalanceType — only the two operations agents care about. */
903
+ const CHANGE_BALANCE_TYPE_NAME = {
904
+ 0: "DEPOSIT",
905
+ 1: "WITHDRAW"
906
+ };
757
907
  /**
758
908
  * ProtoOAExecutionType (OpenApiModelMessages.proto). The number the broker
759
909
  * returns on a ProtoOAExecutionEvent tells us the real outcome — a bare
@@ -886,6 +1036,85 @@ async function getSymbolDetail(pool, accountId, numericAccountId, symbolId) {
886
1036
  if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${String(symbolId)} on this account`);
887
1037
  return symbols[0];
888
1038
  }
1039
+ /** Unref'd sleep so a pending collection window never keeps the process alive. */
1040
+ function sleep(ms) {
1041
+ return new Promise((resolve) => {
1042
+ setTimeout(resolve, ms).unref();
1043
+ });
1044
+ }
1045
+ /** Round a decoded price to the symbol's quoted digits. */
1046
+ function roundToDigits(price, digits) {
1047
+ return Number(price.toFixed(Math.min(Math.max(digits, 0), 10)));
1048
+ }
1049
+ /** Parse an ISO 8601 input into epoch ms, failing closed on garbage. */
1050
+ function parseTimestamp(value, field) {
1051
+ const ms = Date.parse(value);
1052
+ if (Number.isNaN(ms)) throw new CTraderError("INVALID_TIMESTAMP", `${field} is not a valid ISO 8601 datetime: ${value}`);
1053
+ return ms;
1054
+ }
1055
+ /**
1056
+ * Serializes live-data snapshots per (account, symbol, kind). cTrader only
1057
+ * sends the initial full depth book / current spot on a FRESH subscribe and
1058
+ * rejects a second subscribe on an already-subscribed symbol — so overlapping
1059
+ * tool calls must take turns rather than share one subscription.
1060
+ */
1061
+ const liveDataLocks = /* @__PURE__ */ new Map();
1062
+ function withLiveDataLock(key, fn) {
1063
+ const run = (liveDataLocks.get(key) ?? Promise.resolve()).then(fn, fn);
1064
+ const tail = run.then(() => void 0, () => void 0);
1065
+ liveDataLocks.set(key, tail);
1066
+ tail.then(() => {
1067
+ if (liveDataLocks.get(key) === tail) liveDataLocks.delete(key);
1068
+ });
1069
+ return run;
1070
+ }
1071
+ const LIVE_SUBSCRIPTION = {
1072
+ depth: {
1073
+ subscribe: PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_REQ,
1074
+ unsubscribe: PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_REQ,
1075
+ event: PayloadType.OA_DEPTH_EVENT
1076
+ },
1077
+ spot: {
1078
+ subscribe: PayloadType.OA_SUBSCRIBE_SPOTS_REQ,
1079
+ unsubscribe: PayloadType.OA_UNSUBSCRIBE_SPOTS_REQ,
1080
+ event: PayloadType.OA_SPOT_EVENT
1081
+ }
1082
+ };
1083
+ /**
1084
+ * Subscribe → run `collect` while `handler` receives this symbol's events →
1085
+ * ALWAYS unsubscribe + unregister (finally). The unsubscribe is best-effort:
1086
+ * its failure is logged, never masks the result, and a truly leaked
1087
+ * subscription dies with the socket.
1088
+ */
1089
+ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, handler, collect) {
1090
+ const accountId = String(numericAccountId);
1091
+ const sub = LIVE_SUBSCRIPTION[kind];
1092
+ return withLiveDataLock(`${accountId}:${String(symbolId)}:${kind}`, async () => {
1093
+ const unregister = pool.onAccountEvent(accountId, (event) => {
1094
+ if (event.payloadType !== sub.event) return;
1095
+ if (str(event.message.ctidTraderAccountId) !== accountId) return;
1096
+ if (str(event.message.symbolId) !== String(symbolId)) return;
1097
+ handler(event.message);
1098
+ });
1099
+ try {
1100
+ await pool.request(accountId, sub.subscribe, {
1101
+ ctidTraderAccountId: numericAccountId,
1102
+ symbolId: [symbolId]
1103
+ });
1104
+ return await collect();
1105
+ } finally {
1106
+ unregister();
1107
+ try {
1108
+ await pool.request(accountId, sub.unsubscribe, {
1109
+ ctidTraderAccountId: numericAccountId,
1110
+ symbolId: [symbolId]
1111
+ });
1112
+ } catch (err) {
1113
+ process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${String(symbolId)}: ${err instanceof Error ? err.message : String(err)}\n`);
1114
+ }
1115
+ }
1116
+ });
1117
+ }
889
1118
  /**
890
1119
  * The shared `accountId` selector for account-scoped tools. Optional at the
891
1120
  * schema level (single-account convenience); `resolveAccount` enforces the
@@ -970,7 +1199,7 @@ function registerTools(server, pool) {
970
1199
  ctidTraderAccountId: resolved.accountId,
971
1200
  returnProtectionOrders: true
972
1201
  })).message.order ?? [];
973
- const typeName = (v) => v === ORDER_TYPE.LIMIT ? "LIMIT" : v === ORDER_TYPE.STOP ? "STOP" : v === ORDER_TYPE.MARKET ? "MARKET" : String(v);
1202
+ 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
1203
  return ok({
975
1204
  accountId: String(resolved.accountId),
976
1205
  orders: orders.map((o) => {
@@ -1053,17 +1282,374 @@ function registerTools(server, pool) {
1053
1282
  return fail(err);
1054
1283
  }
1055
1284
  });
1285
+ register("get_symbol_details", {
1286
+ 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.",
1287
+ inputSchema: {
1288
+ accountId: accountIdField,
1289
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols")
1290
+ }
1291
+ }, async (args) => {
1292
+ const resolved = resolveAccount(pool, args.accountId);
1293
+ if (isToolError(resolved)) return resolved;
1294
+ const accountId = String(resolved.accountId);
1295
+ try {
1296
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1297
+ const lotSize = num(symbol.lotSize);
1298
+ const toLots = (v) => lotSize > 0 ? volumeToLots(num(v), lotSize) : null;
1299
+ return ok({
1300
+ accountId,
1301
+ symbolId: str(symbol.symbolId) || String(args.symbolId),
1302
+ digits: num(symbol.digits),
1303
+ pipPosition: num(symbol.pipPosition),
1304
+ lotSize,
1305
+ minVolumeLots: toLots(symbol.minVolume),
1306
+ maxVolumeLots: toLots(symbol.maxVolume),
1307
+ stepVolumeLots: toLots(symbol.stepVolume),
1308
+ swapLong: symbol.swapLong ?? null,
1309
+ swapShort: symbol.swapShort ?? null,
1310
+ swapRollover3Days: symbol.swapRollover3Days ?? null,
1311
+ enableShortSelling: symbol.enableShortSelling != null ? Boolean(symbol.enableShortSelling) : null,
1312
+ guaranteedStopLossAvailable: symbol.guaranteedStopLoss != null ? Boolean(symbol.guaranteedStopLoss) : null
1313
+ });
1314
+ } catch (err) {
1315
+ return fail(err);
1316
+ }
1317
+ });
1318
+ register("get_quote", {
1319
+ 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.",
1320
+ inputSchema: {
1321
+ accountId: accountIdField,
1322
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1323
+ 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)")
1324
+ }
1325
+ }, async (args) => {
1326
+ const resolved = resolveAccount(pool, args.accountId);
1327
+ if (isToolError(resolved)) return resolved;
1328
+ const accountId = String(resolved.accountId);
1329
+ try {
1330
+ const digits = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).digits);
1331
+ const quote = {
1332
+ bid: null,
1333
+ ask: null,
1334
+ ticks: 0
1335
+ };
1336
+ let signalBothSeen = () => void 0;
1337
+ const bothSeen = new Promise((resolve) => {
1338
+ signalBothSeen = resolve;
1339
+ });
1340
+ await withLiveSubscription(pool, resolved.accountId, args.symbolId, "spot", (message) => {
1341
+ quote.ticks += 1;
1342
+ if (message.bid != null) quote.bid = roundToDigits(priceToDecimal(num(message.bid)), digits);
1343
+ if (message.ask != null) quote.ask = roundToDigits(priceToDecimal(num(message.ask)), digits);
1344
+ if (quote.bid != null && quote.ask != null) signalBothSeen();
1345
+ }, () => Promise.race([bothSeen, sleep(args.waitMs)]));
1346
+ 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`));
1347
+ return ok({
1348
+ accountId,
1349
+ symbolId: String(args.symbolId),
1350
+ digits,
1351
+ bid: quote.bid,
1352
+ ask: quote.ask,
1353
+ spread: quote.bid != null && quote.ask != null ? roundToDigits(quote.ask - quote.bid, digits) : null,
1354
+ ticksReceived: quote.ticks,
1355
+ ...quote.bid == null || quote.ask == null ? { note: "Only one side ticked within the window; the missing side is null." } : {}
1356
+ });
1357
+ } catch (err) {
1358
+ return fail(err);
1359
+ }
1360
+ });
1361
+ register("get_depth", {
1362
+ 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.",
1363
+ inputSchema: {
1364
+ accountId: accountIdField,
1365
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1366
+ levels: z.number().int().min(1).max(50).default(10).describe("Max price levels per side to return"),
1367
+ 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)")
1368
+ }
1369
+ }, async (args) => {
1370
+ const resolved = resolveAccount(pool, args.accountId);
1371
+ if (isToolError(resolved)) return resolved;
1372
+ const accountId = String(resolved.accountId);
1373
+ try {
1374
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1375
+ const digits = num(symbol.digits);
1376
+ const lotSize = num(symbol.lotSize);
1377
+ const book = /* @__PURE__ */ new Map();
1378
+ let eventsReceived = 0;
1379
+ await withLiveSubscription(pool, resolved.accountId, args.symbolId, "depth", (message) => {
1380
+ eventsReceived += 1;
1381
+ const newQuotes = message.newQuotes ?? [];
1382
+ for (const quote of newQuotes) {
1383
+ const id = str(quote.id);
1384
+ const sizeUnits = depthSizeToUnits(num(quote.size));
1385
+ if (quote.bid != null) book.set(id, {
1386
+ side: "bid",
1387
+ price: priceToDecimal(num(quote.bid)),
1388
+ sizeUnits
1389
+ });
1390
+ else if (quote.ask != null) book.set(id, {
1391
+ side: "ask",
1392
+ price: priceToDecimal(num(quote.ask)),
1393
+ sizeUnits
1394
+ });
1395
+ }
1396
+ for (const deleted of message.deletedQuotes ?? []) book.delete(str(deleted));
1397
+ }, () => sleep(args.collectMs));
1398
+ 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`));
1399
+ const ladder = (side) => {
1400
+ const byPrice = /* @__PURE__ */ new Map();
1401
+ for (const quote of book.values()) {
1402
+ if (quote.side !== side) continue;
1403
+ byPrice.set(quote.price, (byPrice.get(quote.price) ?? 0) + quote.sizeUnits);
1404
+ }
1405
+ const rows = [...byPrice.entries()].map(([price, sizeUnits]) => ({
1406
+ price: roundToDigits(price, digits),
1407
+ sizeUnits,
1408
+ sizeLots: lotSize > 0 ? volumeToLots(sizeUnits * 100, lotSize) : null
1409
+ }));
1410
+ rows.sort((a, b) => side === "bid" ? b.price - a.price : a.price - b.price);
1411
+ return rows;
1412
+ };
1413
+ const bids = ladder("bid");
1414
+ const asks = ladder("ask");
1415
+ const bestBid = bids.at(0)?.price ?? null;
1416
+ const bestAsk = asks.at(0)?.price ?? null;
1417
+ return ok({
1418
+ accountId,
1419
+ symbolId: String(args.symbolId),
1420
+ digits,
1421
+ bestBid,
1422
+ bestAsk,
1423
+ spread: bestBid != null && bestAsk != null ? roundToDigits(bestAsk - bestBid, digits) : null,
1424
+ bids: bids.slice(0, args.levels),
1425
+ asks: asks.slice(0, args.levels),
1426
+ levelsAvailable: {
1427
+ bid: bids.length,
1428
+ ask: asks.length
1429
+ },
1430
+ eventsReceived
1431
+ });
1432
+ } catch (err) {
1433
+ return fail(err);
1434
+ }
1435
+ });
1436
+ register("get_tick_data", {
1437
+ 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.",
1438
+ inputSchema: {
1439
+ accountId: accountIdField,
1440
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1441
+ type: z.enum(["BID", "ASK"]).default("BID").describe("Which side's ticks to fetch"),
1442
+ from: z.string().optional().describe("ISO 8601 range start (default: 5 minutes before `to`)"),
1443
+ to: z.string().optional().describe("ISO 8601 range end (default: now)")
1444
+ }
1445
+ }, async (args) => {
1446
+ const resolved = resolveAccount(pool, args.accountId);
1447
+ if (isToolError(resolved)) return resolved;
1448
+ const accountId = String(resolved.accountId);
1449
+ try {
1450
+ const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1451
+ const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 5 * 6e4;
1452
+ if (fromMs >= toMs) return fail(new CTraderError("INVALID_RANGE", "`from` must be before `to`"));
1453
+ const res = await pool.request(accountId, PayloadType.OA_GET_TICKDATA_REQ, {
1454
+ ctidTraderAccountId: resolved.accountId,
1455
+ symbolId: args.symbolId,
1456
+ type: QUOTE_TYPE[args.type],
1457
+ fromTimestamp: fromMs,
1458
+ toTimestamp: toMs
1459
+ });
1460
+ const raw = res.message.tickData ?? [];
1461
+ return ok({
1462
+ accountId,
1463
+ symbolId: String(args.symbolId),
1464
+ type: args.type,
1465
+ count: raw.length,
1466
+ hasMore: Boolean(res.message.hasMore),
1467
+ ticks: decodeTickData(raw)
1468
+ });
1469
+ } catch (err) {
1470
+ return fail(err);
1471
+ }
1472
+ });
1473
+ register("get_trade_history", {
1474
+ 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).",
1475
+ inputSchema: {
1476
+ accountId: accountIdField,
1477
+ from: z.string().optional().describe("ISO 8601 range start (default: 7 days before `to`)"),
1478
+ to: z.string().optional().describe("ISO 8601 range end (default: now)"),
1479
+ maxRows: z.number().int().min(1).max(1e3).default(100).describe("Max deals to return")
1480
+ }
1481
+ }, async (args) => {
1482
+ const resolved = resolveAccount(pool, args.accountId);
1483
+ if (isToolError(resolved)) return resolved;
1484
+ const accountId = String(resolved.accountId);
1485
+ try {
1486
+ const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1487
+ const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 7 * 864e5;
1488
+ if (fromMs >= toMs) return fail(new CTraderError("INVALID_RANGE", "`from` must be before `to`"));
1489
+ const res = await pool.request(accountId, PayloadType.OA_DEAL_LIST_REQ, {
1490
+ ctidTraderAccountId: resolved.accountId,
1491
+ fromTimestamp: fromMs,
1492
+ toTimestamp: toMs,
1493
+ maxRows: args.maxRows
1494
+ });
1495
+ const deals = res.message.deal ?? [];
1496
+ return ok({
1497
+ accountId,
1498
+ count: deals.length,
1499
+ hasMore: Boolean(res.message.hasMore),
1500
+ deals: deals.map((d) => {
1501
+ const moneyDigits = d.moneyDigits != null ? num(d.moneyDigits) : 2;
1502
+ const close = d.closePositionDetail;
1503
+ const closeMoneyDigits = close?.moneyDigits != null ? num(close.moneyDigits) : moneyDigits;
1504
+ return {
1505
+ dealId: str(d.dealId),
1506
+ orderId: str(d.orderId),
1507
+ positionId: str(d.positionId),
1508
+ symbolId: str(d.symbolId),
1509
+ side: num(d.tradeSide) === TRADE_SIDE.SELL ? "SELL" : "BUY",
1510
+ status: DEAL_STATUS_NAME[num(d.dealStatus)] ?? str(d.dealStatus),
1511
+ volume: str(d.volume) || "0",
1512
+ filledVolume: str(d.filledVolume) || "0",
1513
+ executionPrice: d.executionPrice ?? null,
1514
+ commission: d.commission != null ? moneyToDecimal(num(d.commission), moneyDigits) : null,
1515
+ executedAt: new Date(num(d.executionTimestamp)).toISOString(),
1516
+ closedPosition: close ? {
1517
+ entryPrice: close.entryPrice ?? null,
1518
+ grossProfit: moneyToDecimal(num(close.grossProfit), closeMoneyDigits),
1519
+ swap: moneyToDecimal(num(close.swap), closeMoneyDigits),
1520
+ commission: moneyToDecimal(num(close.commission), closeMoneyDigits),
1521
+ balanceAfter: moneyToDecimal(num(close.balance), closeMoneyDigits)
1522
+ } : null
1523
+ };
1524
+ })
1525
+ });
1526
+ } catch (err) {
1527
+ return fail(err);
1528
+ }
1529
+ });
1530
+ register("get_position_pnl", {
1531
+ 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.",
1532
+ inputSchema: { accountId: accountIdField }
1533
+ }, async (args) => {
1534
+ const resolved = resolveAccount(pool, args.accountId);
1535
+ if (isToolError(resolved)) return resolved;
1536
+ const accountId = String(resolved.accountId);
1537
+ try {
1538
+ const res = await pool.request(accountId, PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ, { ctidTraderAccountId: resolved.accountId });
1539
+ const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
1540
+ return ok({
1541
+ accountId,
1542
+ moneyDigits,
1543
+ positions: (res.message.positionUnrealizedPnL ?? []).map((r) => ({
1544
+ positionId: str(r.positionId),
1545
+ grossUnrealizedPnL: moneyToDecimal(num(r.grossUnrealizedPnL), moneyDigits),
1546
+ netUnrealizedPnL: moneyToDecimal(num(r.netUnrealizedPnL), moneyDigits)
1547
+ }))
1548
+ });
1549
+ } catch (err) {
1550
+ return fail(err);
1551
+ }
1552
+ });
1553
+ register("get_expected_margin", {
1554
+ 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.",
1555
+ inputSchema: {
1556
+ accountId: accountIdField,
1557
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1558
+ 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]")
1559
+ }
1560
+ }, async (args) => {
1561
+ const resolved = resolveAccount(pool, args.accountId);
1562
+ if (isToolError(resolved)) return resolved;
1563
+ const accountId = String(resolved.accountId);
1564
+ try {
1565
+ const lotSize = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).lotSize);
1566
+ if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${String(args.symbolId)} has no lotSize; cannot size the margin quote`));
1567
+ const res = await pool.request(accountId, PayloadType.OA_EXPECTED_MARGIN_REQ, {
1568
+ ctidTraderAccountId: resolved.accountId,
1569
+ symbolId: args.symbolId,
1570
+ volume: args.volumesLots.map((lots) => lotsToVolume(lots, lotSize))
1571
+ });
1572
+ const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
1573
+ const margins = res.message.margin ?? [];
1574
+ return ok({
1575
+ accountId,
1576
+ symbolId: String(args.symbolId),
1577
+ margins: margins.map((m) => ({
1578
+ volumeLots: volumeToLots(num(m.volume), lotSize),
1579
+ buyMargin: moneyToDecimal(num(m.buyMargin), moneyDigits),
1580
+ sellMargin: moneyToDecimal(num(m.sellMargin), moneyDigits)
1581
+ }))
1582
+ });
1583
+ } catch (err) {
1584
+ return fail(err);
1585
+ }
1586
+ });
1587
+ register("get_cash_flow_history", {
1588
+ 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.",
1589
+ inputSchema: {
1590
+ accountId: accountIdField,
1591
+ from: z.string().optional().describe("ISO 8601 range start (default: 30 days before `to`)"),
1592
+ to: z.string().optional().describe("ISO 8601 range end (default: now)")
1593
+ }
1594
+ }, async (args) => {
1595
+ const resolved = resolveAccount(pool, args.accountId);
1596
+ if (isToolError(resolved)) return resolved;
1597
+ const accountId = String(resolved.accountId);
1598
+ try {
1599
+ const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1600
+ const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 30 * 864e5;
1601
+ if (fromMs >= toMs) return fail(new CTraderError("INVALID_RANGE", "`from` must be before `to`"));
1602
+ const entries = (await pool.request(accountId, PayloadType.OA_CASH_FLOW_HISTORY_LIST_REQ, {
1603
+ ctidTraderAccountId: resolved.accountId,
1604
+ fromTimestamp: fromMs,
1605
+ toTimestamp: toMs
1606
+ })).message.depositWithdraw ?? [];
1607
+ return ok({
1608
+ accountId,
1609
+ count: entries.length,
1610
+ entries: entries.map((e) => {
1611
+ const moneyDigits = e.moneyDigits != null ? num(e.moneyDigits) : 2;
1612
+ return {
1613
+ type: CHANGE_BALANCE_TYPE_NAME[num(e.operationType)] ?? str(e.operationType),
1614
+ amount: moneyToDecimal(num(e.delta), moneyDigits),
1615
+ balanceAfter: moneyToDecimal(num(e.balance), moneyDigits),
1616
+ at: new Date(num(e.changeBalanceTimestamp)).toISOString(),
1617
+ note: e.externalNote ?? null
1618
+ };
1619
+ })
1620
+ });
1621
+ } catch (err) {
1622
+ return fail(err);
1623
+ }
1624
+ });
1056
1625
  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.",
1626
+ 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
1627
  inputSchema: {
1059
1628
  accountId: accountIdField,
1060
1629
  symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1061
1630
  side: z.enum(["BUY", "SELL"]).describe("Trade side"),
1062
- orderType: z.enum(["MARKET", "LIMIT"]).default("MARKET").describe("MARKET fills now; LIMIT rests at limitPrice"),
1631
+ orderType: z.enum([
1632
+ "MARKET",
1633
+ "LIMIT",
1634
+ "STOP",
1635
+ "STOP_LIMIT"
1636
+ ]).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
1637
  volumeLots: z.number().positive().describe("Order size in lots (e.g. 0.1). Converted to protocol volume via the symbol lotSize."),
1064
1638
  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"),
1639
+ stopPrice: z.number().positive().optional().describe("Required for STOP and STOP_LIMIT orders — the trigger price"),
1640
+ slippageInPoints: z.number().int().min(0).optional().describe("Required for STOP_LIMIT — max slippage from stopPrice, in points"),
1641
+ stopLoss: z.number().positive().optional().describe("Absolute stop-loss price (mutually exclusive with stopLossDistance)"),
1642
+ takeProfit: z.number().positive().optional().describe("Absolute take-profit price (mutually exclusive with takeProfitDistance)"),
1643
+ 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."),
1644
+ takeProfitDistance: z.number().positive().optional().describe("Take-profit as a price DISTANCE from entry"),
1645
+ trailingStopLoss: z.boolean().optional().describe("Trail the stop-loss as price moves in your favour. Requires stopLossDistance (not an absolute stopLoss)."),
1646
+ timeInForce: z.enum([
1647
+ "GOOD_TILL_CANCEL",
1648
+ "GOOD_TILL_DATE",
1649
+ "IMMEDIATE_OR_CANCEL",
1650
+ "FILL_OR_KILL"
1651
+ ]).optional().describe("Order lifetime (default GOOD_TILL_CANCEL). GOOD_TILL_DATE requires expiresAt."),
1652
+ expiresAt: z.string().optional().describe("ISO 8601 expiry — required with (and only valid with) timeInForce GOOD_TILL_DATE"),
1067
1653
  label: z.string().optional().describe("Optional client label for the order")
1068
1654
  }
1069
1655
  }, async (args) => {
@@ -1072,6 +1658,19 @@ function registerTools(server, pool) {
1072
1658
  const accountId = String(resolved.accountId);
1073
1659
  try {
1074
1660
  if (args.orderType === "LIMIT" && args.limitPrice == null) return fail(new CTraderError("LIMIT_PRICE_REQUIRED", "LIMIT orders require limitPrice"));
1661
+ if ((args.orderType === "STOP" || args.orderType === "STOP_LIMIT") && args.stopPrice == null) return fail(new CTraderError("STOP_PRICE_REQUIRED", `${args.orderType} orders require stopPrice`));
1662
+ if (args.orderType === "STOP_LIMIT" && args.slippageInPoints == null) return fail(new CTraderError("SLIPPAGE_REQUIRED", "STOP_LIMIT orders require slippageInPoints"));
1663
+ 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"));
1664
+ if (args.orderType === "LIMIT" && args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "LIMIT orders take no stopPrice"));
1665
+ if (args.orderType === "STOP" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP orders take no limitPrice"));
1666
+ if (args.stopLoss != null && args.stopLossDistance != null) return fail(new CTraderError("CONFLICTING_STOP_LOSS", "Pass stopLoss (absolute) OR stopLossDistance, not both"));
1667
+ if (args.takeProfit != null && args.takeProfitDistance != null) return fail(new CTraderError("CONFLICTING_TAKE_PROFIT", "Pass takeProfit (absolute) OR takeProfitDistance, not both"));
1668
+ if (args.trailingStopLoss) {
1669
+ if (args.stopLoss != null) return fail(new CTraderError("TRAILING_NEEDS_DISTANCE", "A trailing stop uses stopLossDistance, not an absolute stopLoss"));
1670
+ if (args.stopLossDistance == null) return fail(new CTraderError("TRAILING_NEEDS_DISTANCE", "trailingStopLoss requires stopLossDistance"));
1671
+ }
1672
+ if (args.timeInForce === "GOOD_TILL_DATE" && args.expiresAt == null) return fail(new CTraderError("EXPIRATION_REQUIRED", "timeInForce GOOD_TILL_DATE requires expiresAt"));
1673
+ 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
1674
  const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1076
1675
  const lotSize = num(symbol.lotSize);
1077
1676
  if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${String(args.symbolId)} has no lotSize; cannot size the order`));
@@ -1086,8 +1685,15 @@ function registerTools(server, pool) {
1086
1685
  volume
1087
1686
  };
1088
1687
  if (args.limitPrice != null) payload.limitPrice = args.limitPrice;
1688
+ if (args.stopPrice != null) payload.stopPrice = args.stopPrice;
1689
+ if (args.slippageInPoints != null) payload.slippageInPoints = args.slippageInPoints;
1089
1690
  if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1090
1691
  if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1692
+ if (args.stopLossDistance != null) payload.relativeStopLoss = Math.round(args.stopLossDistance * PRICE_SCALE);
1693
+ if (args.takeProfitDistance != null) payload.relativeTakeProfit = Math.round(args.takeProfitDistance * PRICE_SCALE);
1694
+ if (args.trailingStopLoss != null) payload.trailingStopLoss = args.trailingStopLoss;
1695
+ if (args.timeInForce != null) payload.timeInForce = TIME_IN_FORCE[args.timeInForce];
1696
+ if (args.expiresAt != null) payload.expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
1091
1697
  if (args.label != null) payload.label = args.label;
1092
1698
  const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload));
1093
1699
  return ok({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alfe.ai/ctrader-mcp",
3
- "version": "0.2.0",
4
- "description": "cTrader MCP server — full trading (place/modify/close orders + read) over the cTrader Open API (protobuf/TLS)",
3
+ "version": "0.3.0",
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;
@@ -119,11 +124,16 @@ message ProtoOACtidTraderAccount {
119
124
  optional string brokerTitleShort = 6;
120
125
  }
121
126
 
127
+ // Canonical: permissionScope=3 (an enum upstream; we don't read it, kept as a
128
+ // scalar), ctidTraderAccount=4. This response is declared for completeness but
129
+ // is NOT on the live path — accounts come from AgentApiClient.getCTraderAccounts(),
130
+ // not this socket call. Field numbers corrected to match canonical anyway so a
131
+ // future decode of this message reads the account list at the right field.
122
132
  message ProtoOAGetAccountListByAccessTokenRes {
123
133
  optional uint32 payloadType = 1;
124
134
  required string accessToken = 2;
125
- repeated ProtoOACtidTraderAccount ctidTraderAccount = 3;
126
- optional bool permissionScope = 4;
135
+ optional uint32 permissionScope = 3;
136
+ repeated ProtoOACtidTraderAccount ctidTraderAccount = 4;
127
137
  }
128
138
 
129
139
  // ── Trader (balance / account details) ──────────────────────────────────
@@ -194,30 +204,33 @@ message ProtoOAPosition {
194
204
  optional bool trailingStopLoss = 16;
195
205
  }
196
206
 
207
+ // NOTE: canonical ProtoOAOrder has NO field 5 — it jumps orderStatus=4 → expirationTimestamp=6.
208
+ // Field numbers below are transcribed verbatim from Spotware's ProtoOAOrder
209
+ // (relativeStopLoss/relativeTakeProfit are int64, not double, upstream).
197
210
  message ProtoOAOrder {
198
211
  required int64 orderId = 1;
199
212
  required ProtoOATradeData tradeData = 2;
200
213
  required ProtoOAOrderType orderType = 3;
201
214
  required uint32 orderStatus = 4;
202
- optional int64 expirationTimestamp = 5;
203
- optional double executionPrice = 6;
204
- optional int64 executedVolume = 7;
205
- optional int64 utcLastUpdateTimestamp = 8;
206
- optional double baseSlippagePrice = 9;
207
- optional int64 slippageInPoints = 10;
208
- optional bool closingOrder = 11;
209
- optional double limitPrice = 12;
210
- optional double stopPrice = 13;
211
- optional double stopLoss = 14;
212
- optional double takeProfit = 15;
213
- optional string clientOrderId = 16;
214
- optional uint32 timeInForce = 17;
215
- optional int64 positionId = 18;
216
- optional double relativeStopLoss = 19;
217
- optional double relativeTakeProfit = 20;
218
- optional bool isStopOut = 21;
219
- optional bool trailingStopLoss = 22;
220
- optional uint32 stopTriggerMethod = 23;
215
+ optional int64 expirationTimestamp = 6;
216
+ optional double executionPrice = 7;
217
+ optional int64 executedVolume = 8;
218
+ optional int64 utcLastUpdateTimestamp = 9;
219
+ optional double baseSlippagePrice = 10;
220
+ optional int64 slippageInPoints = 11;
221
+ optional bool closingOrder = 12;
222
+ optional double limitPrice = 13;
223
+ optional double stopPrice = 14;
224
+ optional double stopLoss = 15;
225
+ optional double takeProfit = 16;
226
+ optional string clientOrderId = 17;
227
+ optional uint32 timeInForce = 18;
228
+ optional int64 positionId = 19;
229
+ optional int64 relativeStopLoss = 20;
230
+ optional int64 relativeTakeProfit = 21;
231
+ optional bool isStopOut = 22;
232
+ optional bool trailingStopLoss = 23;
233
+ optional uint32 stopTriggerMethod = 24;
221
234
  }
222
235
 
223
236
  message ProtoOAReconcileReq {
@@ -272,13 +285,19 @@ message ProtoOASymbol {
272
285
  optional bool enableShortSelling = 4;
273
286
  optional bool guaranteedStopLoss = 5;
274
287
  optional uint32 swapRollover3Days = 6;
275
- optional bool swapLong = 7;
276
- 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;
277
293
  optional int64 maxVolume = 9;
278
294
  optional int64 minVolume = 10;
279
295
  optional int64 stepVolume = 11;
280
296
  optional int64 maxExposure = 12;
281
- optional int64 lotSize = 21;
297
+ // lotSize is canonical field 30 (NOT 21 — field 21 is the deprecated
298
+ // minCommission). Reading it at 21 yields 0 for XAUUSD → SYMBOL_NO_LOTSIZE
299
+ // and mis-sizes volume for any symbol where field 21 is non-zero.
300
+ optional int64 lotSize = 30;
282
301
  }
283
302
 
284
303
  message ProtoOASymbolByIdReq {
@@ -350,6 +369,213 @@ message ProtoOASpotEvent {
350
369
  optional int64 timestamp = 8;
351
370
  }
352
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
+
353
579
  // ── Order mutations (all respond via ProtoOAExecutionEvent, 2126) ───────
354
580
 
355
581
  message ProtoOANewOrderReq {
@@ -421,14 +647,18 @@ message ProtoOACancelOrderReq {
421
647
  required int64 orderId = 3;
422
648
  }
423
649
 
650
+ // errorCode is canonical field 9 and isServerEvent field 10 (fields 6/7/8 are
651
+ // deal / bonusDepositWithdraw / depositWithdraw upstream, which we don't decode).
652
+ // An order-op rejection can ride here with errorCode set, so the field number
653
+ // is on the money path — reading errorCode at 8 misses the reject entirely.
424
654
  message ProtoOAExecutionEvent {
425
655
  optional uint32 payloadType = 1;
426
656
  required int64 ctidTraderAccountId = 2;
427
657
  required uint32 executionType = 3;
428
658
  optional ProtoOAPosition position = 4;
429
659
  optional ProtoOAOrder order = 5;
430
- optional string errorCode = 8;
431
- optional bool isServerEvent = 9;
660
+ optional string errorCode = 9;
661
+ optional bool isServerEvent = 10;
432
662
  }
433
663
 
434
664
  // ── Order-error event (payloadType 2132) ────────────────────────────────
@@ -437,11 +667,15 @@ message ProtoOAExecutionEvent {
437
667
  // hours, insufficient margin, etc.). Field numbers/types transcribed from
438
668
  // Spotware's ProtoOAOrderErrorEvent (OpenApiMessages.proto). This is a REJECT
439
669
  // on a money path — it MUST map to a CTraderError, never resolve as success.
670
+ // Canonical field numbers are NOT sequential: errorCode=2, orderId=3,
671
+ // ctidTraderAccountId=5, positionId=6, description=7. Getting these wrong
672
+ // means the reject decodes to an empty errorCode and fails to map to a
673
+ // CTraderError — reintroducing the 2132 phantom-success bug.
440
674
  message ProtoOAOrderErrorEvent {
441
675
  optional uint32 payloadType = 1;
442
- required int64 ctidTraderAccountId = 2;
443
- required string errorCode = 3;
444
- optional int64 orderId = 4;
445
- optional int64 positionId = 5;
446
- optional string description = 6;
676
+ required string errorCode = 2;
677
+ optional int64 orderId = 3;
678
+ required int64 ctidTraderAccountId = 5;
679
+ optional int64 positionId = 6;
680
+ optional string description = 7;
447
681
  }