@himalaya-quant/ctrader-x
v0.0.29
Published
A typescript cTrader OpenAPI implementation
Maintainers
Readme
@himalaya-quant/ctrader-x
A TypeScript client for the cTrader Open API. Strongly typed, with a clean abstraction over the underlying ProtoBuffer protocol.
npm i @himalaya-quant/ctrader-xTable of Contents
- Quick Start
- Configuration
- Authentication
- Connection Lifecycle
- Symbols
- Live Bars
- Orders & Positions
- Order Events
- Logging
- Error Handling
- Design Behaviours
Quick Start
import { cTraderX } from '@himalaya-quant/ctrader-x';
const client = new cTraderX();
await client.connect();
const symbols = await client.symbols.getSymbolsList();
console.log(symbols);
client.disconnect();Configuration
All options are optional. When omitted, credentials are read from environment variables (see Authentication).
const client = new cTraderX({
live: false, // Connect to live API. Defaults to demo.
clientId: '...',
clientSecret: '...',
accessToken: '...',
ctidTraderAccountId: 123456,
debug: false, // Enable verbose debug logs
autoReconnect: true, // Auto-reconnect on server disconnection
reconnectIntervalMs: 5000, // Wait 5s between reconnection attempts
logger: customLogger, // Custom ILogger implementation
});| Option | Type | Default | Description |
| --------------------- | --------- | -------- | ------------------------------------------------------------------ |
| live | boolean | false | Connects to live.ctraderapi.com instead of demo.ctraderapi.com |
| clientId | string | env | Spotware application client ID |
| clientSecret | string | env | Spotware application client secret |
| accessToken | string | env | OAuth access token |
| ctidTraderAccountId | number | env | The trader account ID to operate on |
| debug | boolean | false | Emits additional debug-level log entries |
| autoReconnect | boolean | true | Automatically reconnects if the connection is lost |
| reconnectIntervalMs | number | 5000 | Fixed wait time (ms) between reconnection attempts |
| logger | ILogger | built-in | Custom logger (see Logging) |
Authentication
Credentials can be provided inline or via .env:
CTRADERX_SPOTWARE_CLIENT_ID=
CTRADERX_SPOTWARE_CLIENT_SECRET=
CTRADERX_SPOTWARE_ACCESS_TOKEN=
CTRADERX_SPOTWARE_CTID_TRADER_ACCOUNT_ID=connect() performs both application-level and account-level authentication automatically. There is no need to call any auth method manually.
Connection Lifecycle
await client.connect(); // Opens TCP connection, authenticates app + account
client.disconnect(); // Closes the connection and disposes all listenersCalling connect() when already connected is a no-op — it returns immediately without opening a second connection or re-authenticating.
Calling disconnect() before connect() is safe and has no effect.
Auto-reconnect is enabled by default. The client uses a dead man's switch that monitors server silence — if no message is received for 30 seconds (the server normally sends heartbeats every ~15 seconds), the connection is considered stale and a reconnect is triggered automatically. On reconnect failure (e.g. network still down), the client retries at a fixed interval (reconnectIntervalMs, default 5s) until the connection is restored. Set autoReconnect: false to disable this behaviour entirely.
Calling disconnect() during a reconnect loop stops the loop immediately. No further reconnection attempts are made.
⚠️ Accessing
client.orders,client.symbols, orclient.symbolsUpdatesbefore callingconnect()throws aClientNotConnectedError.
Symbols
Accessible via client.symbols after connecting.
Get symbols list
Returns the full list of symbols available for the account, enriched with their details.
const symbols = await client.symbols.getSymbolsList();
// symbols[0].symbolId, symbols[0].symbolName, ...
// Include archived symbols
const all = await client.symbols.getSymbolsList({
includeArchivedSymbols: true,
});Get symbol details
Fetches full detail records for one or more symbols by ID.
const details = await client.symbols.getSymbolsDetails([1, 10026]);
console.log(details.symbol[0].schedule);Get historical bars (OHLCV)
Returns an array of OHLCV tuples: [timestamp, open, high, low, close, volume].
import { ProtoOATrendbarPeriod } from '@himalaya-quant/ctrader-x';
const bars = await client.symbols.getTrendBars({
symbolId: 1,
period: ProtoOATrendbarPeriod.M1,
fromTimestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago
toTimestamp: Date.now(),
});
// bars[0] → [timestamp, open, high, low, close, volume]Live Bars
Accessible via client.symbolsUpdates after connecting.
Streams real-time OHLCV updates for a given symbol and timeframe. Each emission updates the current forming bar — it is not a completed candle event, it is a live tick-level update.
import { tap } from 'rxjs';
import { ProtoOATrendbarPeriod } from '@himalaya-quant/ctrader-x';
client.symbolsUpdates
.subscribeLiveTrendBars({
symbolId: 10026, // BTCUSD
period: ProtoOATrendbarPeriod.M1,
})
.pipe(
tap(({ symbolId, ohlcv, period }) => {
const [time, open, high, low, close, volume] = ohlcv;
console.log(`[${period}] O:${open} H:${high} L:${low} C:${close}`);
}),
)
.subscribe();Each emission carries:
| Field | Type | Description |
| ---------- | ----------------------- | ------------------------------------------------------- |
| symbolId | number | Symbol the update belongs to |
| period | ProtoOATrendbarPeriod | The subscribed timeframe |
| ohlcv | OHLCV | Current bar as [time, open, high, low, close, volume] |
Multiple subscribers, same symbol
Calling subscribeLiveTrendBars multiple times with the same symbolId and period replaces the previous subscriber — the old observable is completed, and the new one takes over. To run two independent consumers on the same feed, .pipe() and .subscribe() on the returned observable from a single call.
Unsubscribe
await client.symbolsUpdates.unsubscribeLiveTrendBars({
symbolId: 10026,
period: ProtoOATrendbarPeriod.M1,
});The underlying API subscription is only removed when there are no remaining subscribers for that symbolId. Unsubscribing a period that still has other period-subscribers on the same symbol will not send an unsubscribe request to the server.
Subscription persistence across reconnects
Active live bar subscriptions are restored automatically after a reconnect. No manual re-subscription is needed.
Orders & Positions
Accessible via client.orders after connecting.
All order operations require that the access token has trade permissions on the account.
Place a new order
Use LotsUtil.lotsToVolume() to convert standard lot sizes to the protocol's internal volume format.
import {
ProtoOAOrderType,
ProtoOATradeSide,
LotsUtil,
} from '@himalaya-quant/ctrader-x';
await client.orders.newOrder({
symbolId: 1,
clientOrderId: 'my-order-001', // Your own reference ID
orderType: ProtoOAOrderType.MARKET,
tradeSide: ProtoOATradeSide.BUY,
volume: LotsUtil.lotsToVolume(0.01), // 0.01 lots = 100,000 protocol units
});newOrder resolves when the order is accepted by the server, not when it is filled. Subscribe to order events to track execution.
Close a position
await client.orders.closePosition({
positionId: 123456789,
volume: LotsUtil.lotsToVolume(0.01), // Can be partial
});Get open positions and pending orders
const { position, order } = await client.orders.getOpenPositions();Returns all current open positions (position[]) and pending orders (order[]) for the account.
Get unrealized PnL
const result = await client.orders.getPositionUnrealizedPnL();Cancel a pending order
await client.orders.cancelPendingOrder({ orderId: 987654321 });Modify a pending order
await client.orders.modifyPendingOrder({
orderId: 987654321,
volume: LotsUtil.lotsToVolume(0.02),
limitPrice: 1.085,
});Volume conversion
LotsUtil converts between human-readable lot sizes and the cTrader protocol's internal volume unit:
LotsUtil.lotsToVolume(0.01); // → 100_000 (micro lot)
LotsUtil.lotsToVolume(0.1); // → 1_000_000 (mini lot)
LotsUtil.lotsToVolume(1); // → 10_000_000 (standard lot)
LotsUtil.volumeToLots(100_000); // → 0.01Order Events
Subscribe to a real-time stream of execution events for the account. The observable is hot and shared — all subscribers receive the same events.
import {
OrderAcceptedEvent,
OrderFilledEvent,
OrderRejectedEvent,
OrderCancelledEvent,
OrderExpiredEvent,
OrderErrorEvent,
} from '@himalaya-quant/ctrader-x';
client.orders.subscribeOrdersEvents().subscribe((event) => {
if (event instanceof OrderFilledEvent) {
console.log('Filled:', event.order.positionId, event.deal);
}
if (event instanceof OrderRejectedEvent) {
console.log('Rejected:', event.order.orderId);
}
if (event instanceof OrderErrorEvent) {
console.log('Error:', event.error.description);
}
});| Event class | Trigger |
| --------------------- | ----------------------------------------- |
| OrderAcceptedEvent | Order received and accepted by the server |
| OrderFilledEvent | Order fully executed |
| OrderRejectedEvent | Order rejected by the server |
| OrderCancelledEvent | Pending order cancelled |
| OrderExpiredEvent | Pending order expired |
| OrderErrorEvent | Protocol-level order error |
All events (except OrderErrorEvent) carry .order (ProtoOAOrder) and optionally .deal (ProtoOADeal).
Logging
The built-in logger writes to console. Debug logs are suppressed by default.
Enable debug output via environment variable:
CTRADERX_DEBUG_LOGS=trueOr inject a custom logger at instantiation:
const client = new cTraderX({
logger: {
debug: (msg) => myLogger.debug(msg),
log: (msg) => myLogger.info(msg),
warn: (msg) => myLogger.warn(msg),
error: (msg) => myLogger.error(msg),
},
});Error Handling
Every manager method throws a typed error on failure. All error classes extend a common base and wrap the original server error.
import { OpenOrderError } from '@himalaya-quant/ctrader-x';
try {
await client.orders.newOrder({ ... });
} catch (e) {
if (e instanceof OpenOrderError) {
// server rejected the order request
}
}| Error class | Thrown by |
| ------------------------------------- | ------------------------------------------- |
| ConnectionError | client.connect() |
| ClientNotConnectedError | Accessing any manager before connect() |
| OpenOrderError | orders.newOrder() |
| ClosePositionError | orders.closePosition() |
| CancelPendingOrderError | orders.cancelPendingOrder() |
| ModifyPendingOrderError | orders.modifyPendingOrder() |
| GetOpenPositionsError | orders.getOpenPositions() |
| GetPositionUnrealizedPnLError | orders.getPositionUnrealizedPnL() |
| GetSymbolsListError | symbols.getSymbolsList() |
| GetSymbolsDetailsError | symbols.getSymbolsDetails() |
| GetTrendBarsError | symbols.getTrendBars() |
| SubscribeLiveTrendBarsInternalError | symbolsUpdates.subscribeLiveTrendBars() |
| UnsubscribeLiveTrendBarsError | symbolsUpdates.unsubscribeLiveTrendBars() |
Design Behaviours
The following are intentional behaviours, not bugs.
newOrder() resolves before fill. The promise resolves when the server acknowledges the request. Use subscribeOrdersEvents() and listen for OrderFilledEvent to react to execution.
Live bars emit the forming candle, not closed candles. Every subscribeLiveTrendBars emission is an update to the current open bar. There is no separate "candle closed" event — a new bar starting is implicit when the ohlcv[0] timestamp changes.
CLOSE in live bars is the current tick price. The cTrader protocol does not provide deltaClose on open bars. The close price in each emission is always the latest bid/ask midpoint, not a settled close.
Duplicate subscribeLiveTrendBars calls replace the subscriber. Calling the method again with the same symbolId and period completes the previous observable and returns a new one. This is by design to avoid silent fan-out and duplicate processing. If you need multiple consumers on the same stream, use RxJS Subject or share() on the observable yourself.
getSymbolsList() is not a lightweight call. Internally it fetches the symbol list and then enriches each entry with full symbol details in a second request. Avoid calling it in hot paths or on every reconnect.
Order events are globally shared. subscribeOrdersEvents() returns a reference to a single Subject. All .subscribe() calls on any instance share the same event stream. This means events are not scoped to a single client instance if you create more than one — all instances dispatch to the same subject.
autoReconnect detects stale connections via a dead man's switch. The underlying ctrader-layer monitors server silence at the socket level. If no message (including heartbeats) is received for 30 seconds, the connection is declared stale and a reconnect is triggered. This catches silent network drops — cases where the TCP connection dies without a FIN packet — which a pure heartbeat-based approach would miss.
autoReconnect restores live bar subscriptions automatically. On reconnect, all active subscribeLiveTrendBars subscriptions are re-established with the server without requiring any action from the caller.
disconnect() is idempotent. Calling it multiple times, or calling it on a client that was never successfully connected, does not throw.
