@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 +614 -8
- package/package.json +3 -3
- package/proto/ctrader.proto +265 -31
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
|
|
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([
|
|
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
|
-
|
|
1066
|
-
|
|
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.
|
|
4
|
-
"description": "cTrader MCP server — full trading (
|
|
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.
|
|
24
|
+
"@alfe.ai/agent-api-client": "0.11.1",
|
|
25
25
|
"@alfe.ai/config": "0.3.0"
|
|
26
26
|
},
|
|
27
27
|
"license": "UNLICENSED",
|
package/proto/ctrader.proto
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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 =
|
|
203
|
-
optional double executionPrice =
|
|
204
|
-
optional int64 executedVolume =
|
|
205
|
-
optional int64 utcLastUpdateTimestamp =
|
|
206
|
-
optional double baseSlippagePrice =
|
|
207
|
-
optional int64 slippageInPoints =
|
|
208
|
-
optional bool closingOrder =
|
|
209
|
-
optional double limitPrice =
|
|
210
|
-
optional double stopPrice =
|
|
211
|
-
optional double stopLoss =
|
|
212
|
-
optional double takeProfit =
|
|
213
|
-
optional string clientOrderId =
|
|
214
|
-
optional uint32 timeInForce =
|
|
215
|
-
optional int64 positionId =
|
|
216
|
-
optional
|
|
217
|
-
optional
|
|
218
|
-
optional bool isStopOut =
|
|
219
|
-
optional bool trailingStopLoss =
|
|
220
|
-
optional uint32 stopTriggerMethod =
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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 =
|
|
431
|
-
optional bool isServerEvent =
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
optional int64 positionId =
|
|
446
|
-
optional string description =
|
|
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
|
}
|