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