Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ const client = new DxtradeClient({
})().catch(console.error);
```

## Debugging

When `DXTRADE_DEBUG=true` (or any truthy value) is set in `.env`, all WebSocket messages are logged to `debug.log` in the project root. Use this file to inspect raw WS payloads when troubleshooting features that rely on WebSocket data (orders, positions, OHLC, etc.).

## WebSocket / Atmosphere

DXtrade uses the Atmosphere framework for WebSocket communication. Each WS connection receives a server-assigned tracking ID (UUID) in its first message (format: `"length|tracking-id|0||"`). This ID identifies the Atmosphere session.

**Critical**: All WebSocket connections MUST reuse the same Atmosphere tracking ID (stored as `ctx.atmosphereId`). Opening a new WS with `X-Atmosphere-tracking-id=0` creates a **separate** Atmosphere session. When the server sends data (e.g. chart bars, order updates), it routes to ONE Atmosphere session — if multiple sessions exist, data may go to the wrong (closed) one. This caused intermittent failures where OHLC data was routed to the handshake WS instead of the listener WS.

The fix: `connect()` captures the tracking ID from the handshake and stores it in `ctx.atmosphereId`. All subsequent WS connections pass it via `endpoints.websocket(ctx.broker, ctx.atmosphereId)` so the server reuses the same session.

## Rules

### When adding or removing a feature:
Expand All @@ -90,6 +102,12 @@ const client = new DxtradeClient({
5. Add a corresponding npm script in package.json
6. Add or update tests in `tests/`

### Constants and enums:
- **Error codes**: Always use the `ERROR` enum from `@/constants` — never use raw strings for error codes (e.g. `ERROR.OHLC_TIMEOUT`, not `"OHLC_TIMEOUT"`)
- **WebSocket message types**: Always use the `WS_MESSAGE` enum — never use raw strings (e.g. `WS_MESSAGE.POSITIONS`, not `"POSITIONS"`)
- **WebSocket subtopics**: Use `WS_MESSAGE.SUBTOPIC` namespace (e.g. `WS_MESSAGE.SUBTOPIC.BIG_CHART_COMPONENT`)
- When adding a new error code, WebSocket message type, or subtopic, add it to `src/constants/enums.ts`

### General:
- Do not commit with `git commit` directly — use `npm run commit`
- Run `npm run lint` and `npm test` before committing
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ npm install dxtrade-api
- [x] Positions (get & close)
- [x] Account metrics & trade journal
- [x] Symbol search & instrument info
- [x] OHLC / price bar data
- [x] PnL assessments
- [x] Multi-broker support (FTMO, Eightcap, Lark Funding)
- [x] Full TypeScript support
Expand Down Expand Up @@ -99,6 +100,7 @@ BROKER.FTMO // "https://dxtrade.ftmo.com"
- `client.getSymbolInfo(symbol)` — Get instrument info (volume limits, lot size)
- `client.getSymbolLimits()` — Get order size limits and stop/limit distances for all symbols
- `client.getInstruments(params?)` — Get all available instruments, optionally filtered by partial match (e.g. `{ type: "FOREX" }`)
- `client.getOHLC(params)` — Fetch OHLC price bars for a symbol (resolution, range, maxBars, priceField)

### Trading

Expand Down Expand Up @@ -156,6 +158,7 @@ npm run example:assessments
npm run example:assessments:btc
npm run example:account
npm run example:instruments
npm run example:ohlc
npm run example:instruments:forex
npm run example:symbol
npm run example:symbol:btc
Expand Down
21 changes: 21 additions & 0 deletions examples/ohlc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import "dotenv/config";
import { DxtradeClient, BROKER } from "../src";

const client = new DxtradeClient({
username: process.env.DXTRADE_USERNAME!,
password: process.env.DXTRADE_PASSWORD!,
broker: process.env.DXTRADE_BROKER! || BROKER.FTMO,
accountId: process.env.DXTRADE_ACCOUNT_ID,
debug: process.env.DXTRADE_DEBUG || false,
});

const symbol = process.argv[2] ?? "EURUSD";

(async () => {
await client.connect();

const bars = await client.getOHLC({ symbol });

console.log("Last 5 bars:", "[\n", ...bars.slice(-5), `\n...and ${bars.length - 5} more`, "\n]");
console.log(`Fetched ${bars.length} bars for ${symbol}`);
})().catch(console.error);
3 changes: 3 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ await client.connect();
- client.getSymbolInfo(symbol: string) — Get instrument info (volume limits, lot size), returns Symbol.Info
- client.getSymbolLimits() — Get order size limits for all symbols, returns Symbol.Limits[]
- client.getInstruments(params?: Partial<Instrument.Info>) — Get all instruments, optionally filtered (e.g. { type: "FOREX" })
- client.getOHLC(params: OHLC.Params) — Fetch OHLC price bars for a symbol, returns OHLC.Bar[]
Required params: symbol (string)
Optional params: resolution (seconds, default 300), range (seconds, default 345600), maxBars (default 3500), priceField ("bid" | "ask", default "bid")

### Trading
- client.submitOrder(params: Order.SubmitParams) — Submit order and wait for WebSocket confirmation, returns Order.Update
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"example:instruments": "tsx examples/instruments.ts",
"example:instruments:forex": "tsx examples/instruments.ts FOREX",
"example:symbol": "tsx examples/symbol-info.ts",
"example:ohlc": "tsx examples/ohlc.ts",
"example:symbol:btc": "tsx examples/symbol-info.ts BTCUSD",
"============= Git =============": "",
"commit": "COMMITIZEN=1 cz",
Expand Down Expand Up @@ -91,4 +92,4 @@
"czConfig": ".czrc.js"
}
}
}
}
23 changes: 20 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DxtradeError } from "@/constants";
import { DxtradeError, ERROR } from "@/constants";
import type { ClientContext, DxtradeConfig } from "./client.types";
import type { Account, Assessments, Instrument, Order, Position, Symbol } from "./domains";
import type { Account, Assessments, Instrument, OHLC, Order, Position, Symbol } from "./domains";
import {
login,
fetchCsrf,
Expand All @@ -13,6 +13,7 @@
getInstruments,
getSymbolLimits,
getSymbolSuggestions,
getOHLC,
getSymbolInfo,
submitOrder,
getTradeJournal,
Expand Down Expand Up @@ -45,12 +46,16 @@
callbacks,
cookies: {},
csrf: null,
atmosphereId: null,
broker: config.broker,
retries: config.retries ?? 3,
debug: config.debug ?? false,
ensureSession() {
if (!this.csrf) {
throw new DxtradeError("NO_SESSION", "No active session. Call login() and fetchCsrf() or connect() first.");
throw new DxtradeError(
ERROR.NO_SESSION,
"No active session. Call login() and fetchCsrf() or connect() first.",
);
}
},
throwError(code: string, message: string): never {
Expand Down Expand Up @@ -124,7 +129,7 @@
* @param params.from - Start timestamp (Unix ms)
* @param params.to - End timestamp (Unix ms)
*/
public async getTradeJournal(params: { from: number; to: number }): Promise<any> {

Check warning on line 132 in src/client.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
return getTradeJournal(this._ctx, params);
}

Expand All @@ -137,4 +142,16 @@
public async getAssessments(params: Assessments.Params): Promise<Assessments.Response> {
return getAssessments(this._ctx, params);
}

/**
* Fetch OHLC price bars for a symbol.
* @param params.symbol - Instrument symbol (e.g. "EURUSD")
* @param params.resolution - Bar period in seconds (default: 60 = 1 min)
* @param params.range - Lookback window in seconds (default: 432000 = 5 days)
* @param params.maxBars - Maximum bars to return (default: 3500)
* @param params.priceField - "bid" or "ask" (default: "bid")
*/
public async getOHLC(params: OHLC.Params): Promise<OHLC.Bar[]> {
return getOHLC(this._ctx, params);
}
}
1 change: 1 addition & 0 deletions src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ClientContext {
callbacks: DxtradeCallbacks;
cookies: Record<string, string>;
csrf: string | null;
atmosphereId: string | null;
broker: keyof typeof BROKER;
retries: number;
debug: boolean | string;
Expand Down
21 changes: 15 additions & 6 deletions src/constants/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
const websocketQuery =
`?X-Atmosphere-tracking-id=0&X-Atmosphere-Framework=2.3.2-javascript` +
`&X-Atmosphere-Transport=websocket&X-Atmosphere-TrackMessageSize=true` +
`&Content-Type=text/x-gwt-rpc;%20charset=UTF-8&X-atmo-protocol=true` +
`&sessionState=dx-new&guest-mode=false`;
function websocketQuery(atmosphereId?: string | null): string {
const trackingId = atmosphereId ?? "0";
return (
`?X-Atmosphere-tracking-id=${trackingId}&X-Atmosphere-Framework=2.3.2-javascript` +
`&X-Atmosphere-Transport=websocket&X-Atmosphere-TrackMessageSize=true` +
`&Content-Type=text/x-gwt-rpc;%20charset=UTF-8&X-atmo-protocol=true` +
`&sessionState=dx-new&guest-mode=false`
);
}

export const endpoints = {
login: (base: string) => `${base}/api/auth/login`,
Expand All @@ -20,8 +24,13 @@ export const endpoints = {

assessments: (base: string) => `${base}/api/assessments`,

websocket: (base: string) => `wss://${base.split("//")[1]}/client/connector` + websocketQuery,
websocket: (base: string, atmosphereId?: string | null) =>
`wss://${base.split("//")[1]}/client/connector` + websocketQuery(atmosphereId),

tradeJournal: (base: string, params: { from: number; to: number }) =>
`${base}/api/tradejournal?from=${params.from}&to=${params.to}`,

subscribeInstruments: (base: string) => `${base}/api/instruments/subscribeInstrumentSymbols`,

charts: (base: string) => `${base}/api/charts`,
};
46 changes: 46 additions & 0 deletions src/constants/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,63 @@ export enum TIF {
GTD = "GTD",
}

export enum ERROR {
NO_SESSION = "NO_SESSION",

// Session
LOGIN_FAILED = "LOGIN_FAILED",
LOGIN_ERROR = "LOGIN_ERROR",
CSRF_NOT_FOUND = "CSRF_NOT_FOUND",
CSRF_ERROR = "CSRF_ERROR",
ACCOUNT_SWITCH_ERROR = "ACCOUNT_SWITCH_ERROR",

// Market data
NO_SUGGESTIONS = "NO_SUGGESTIONS",
SUGGEST_ERROR = "SUGGEST_ERROR",
NO_SYMBOL_INFO = "NO_SYMBOL_INFO",
SYMBOL_INFO_ERROR = "SYMBOL_INFO_ERROR",
INSTRUMENTS_TIMEOUT = "INSTRUMENTS_TIMEOUT",
INSTRUMENTS_ERROR = "INSTRUMENTS_ERROR",
LIMITS_TIMEOUT = "LIMITS_TIMEOUT",
LIMITS_ERROR = "LIMITS_ERROR",
OHLC_TIMEOUT = "OHLC_TIMEOUT",
OHLC_ERROR = "OHLC_ERROR",

// Trading
ORDER_ERROR = "ORDER_ERROR",
POSITION_CLOSE_ERROR = "POSITION_CLOSE_ERROR",

// Account
ACCOUNT_METRICS_TIMEOUT = "ACCOUNT_METRICS_TIMEOUT",
ACCOUNT_METRICS_ERROR = "ACCOUNT_METRICS_ERROR",
ACCOUNT_POSITIONS_TIMEOUT = "ACCOUNT_POSITIONS_TIMEOUT",
ACCOUNT_POSITIONS_ERROR = "ACCOUNT_POSITIONS_ERROR",
TRADE_JOURNAL_ERROR = "TRADE_JOURNAL_ERROR",

// Analytics
ASSESSMENTS_ERROR = "ASSESSMENTS_ERROR",
}

export enum WS_MESSAGE {
ACCOUNT_METRICS = "ACCOUNT_METRICS",
ACCOUNTS = "ACCOUNTS",
AVAILABLE_WATCHLISTS = "AVAILABLE_WATCHLISTS",
CHART_FEED_SUBTOPIC = "chartFeedSubtopic",
INSTRUMENTS = "INSTRUMENTS",
INSTRUMENT_METRICS = "INSTRUMENT_METRICS",
LIMITS = "LIMITS",
MESSAGE = "MESSAGE",
ORDERS = "ORDERS",
POSITIONS = "POSITIONS",
POSITION_CASH_TRANSFERS = "POSITION_CASH_TRANSFERS",
PRIVATE_LAYOUT_NAMES = "PRIVATE_LAYOUT_NAMES",
SHARED_PROPERTIES_MESSAGE = "SHARED_PROPERTIES_MESSAGE",
TRADE_STATUSES = "TRADE_STATUSES",
USER_LOGIN_INFO = "USER_LOGIN_INFO",
}

export namespace WS_MESSAGE {
export enum SUBTOPIC {
BIG_CHART_COMPONENT = "BigChartComponentPresenter-4",
}
}
12 changes: 6 additions & 6 deletions src/domains/account/account.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import WebSocket from "ws";
import { WS_MESSAGE, endpoints, DxtradeError } from "@/constants";
import { WS_MESSAGE, ERROR, endpoints, DxtradeError } from "@/constants";
import { Cookies, parseWsData, shouldLog, debugLog, retryRequest, baseHeaders } from "@/utils";
import type { ClientContext } from "@/client.types";
import type { Account } from ".";

export async function getAccountMetrics(ctx: ClientContext, timeout = 30_000): Promise<Account.Metrics> {
ctx.ensureSession();

const wsUrl = endpoints.websocket(ctx.broker);
const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId);
const cookieStr = Cookies.serialize(ctx.cookies);

return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } });

const timer = setTimeout(() => {
ws.close();
reject(new DxtradeError("ACCOUNT_METRICS_TIMEOUT", "Account metrics timed out"));
reject(new DxtradeError(ERROR.ACCOUNT_METRICS_TIMEOUT, "Account metrics timed out"));
}, timeout);

ws.on("message", (data) => {
Expand All @@ -34,12 +34,12 @@
ws.on("error", (error) => {
clearTimeout(timer);
ws.close();
reject(new DxtradeError("ACCOUNT_METRICS_ERROR", `Account metrics error: ${error.message}`));
reject(new DxtradeError(ERROR.ACCOUNT_METRICS_ERROR, `Account metrics error: ${error.message}`));
});
});
}

export async function getTradeJournal(ctx: ClientContext, params: { from: number; to: number }): Promise<any> {

Check warning on line 42 in src/domains/account/account.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
ctx.ensureSession();

try {
Expand All @@ -60,11 +60,11 @@
ctx.cookies = Cookies.merge(ctx.cookies, incoming);
return response.data;
} else {
ctx.throwError("TRADE_JOURNAL_ERROR", `Login failed: ${response.status}`);
ctx.throwError(ERROR.TRADE_JOURNAL_ERROR, `Login failed: ${response.status}`);
}
} catch (error: unknown) {
if (error instanceof DxtradeError) throw error;
const message = error instanceof Error ? error.message : "Unknown error";
ctx.throwError("TRADE_JOURNAL_ERROR", `Trade journal error: ${message}`);
ctx.throwError(ERROR.TRADE_JOURNAL_ERROR, `Trade journal error: ${message}`);
}
}
4 changes: 2 additions & 2 deletions src/domains/assessments/assessments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { endpoints, DxtradeError } from "@/constants";
import { endpoints, DxtradeError, ERROR } from "@/constants";
import { Cookies, authHeaders, retryRequest } from "@/utils";
import type { ClientContext } from "@/client.types";
import type { Assessments } from ".";
Expand Down Expand Up @@ -26,6 +26,6 @@ export async function getAssessments(ctx: ClientContext, params: Assessments.Par
} catch (error: unknown) {
if (error instanceof DxtradeError) throw error;
const message = error instanceof Error ? error.message : "Unknown error";
ctx.throwError("ASSESSMENTS_ERROR", `Error fetching assessments: ${message}`);
ctx.throwError(ERROR.ASSESSMENTS_ERROR, `Error fetching assessments: ${message}`);
}
}
1 change: 1 addition & 0 deletions src/domains/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./account";
export * from "./assessments";
export * from "./instrument";
export * from "./ohlc";
export * from "./order";
export * from "./position";
export * from "./session";
Expand Down
9 changes: 4 additions & 5 deletions src/domains/instrument/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import WebSocket from "ws";
import { endpoints, DxtradeError } from "@/constants";
import { WS_MESSAGE } from "@/constants/enums";
import { endpoints, DxtradeError, WS_MESSAGE, ERROR } from "@/constants";
import { Cookies, parseWsData, shouldLog, debugLog } from "@/utils";
import type { ClientContext } from "@/client.types";
import type { Instrument } from ".";
Expand All @@ -12,15 +11,15 @@ export async function getInstruments(
): Promise<Instrument.Info[]> {
ctx.ensureSession();

const wsUrl = endpoints.websocket(ctx.broker);
const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId);
const cookieStr = Cookies.serialize(ctx.cookies);

return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } });

const timer = setTimeout(() => {
ws.close();
reject(new DxtradeError("INSTRUMENTS_TIMEOUT", "Instruments request timed out"));
reject(new DxtradeError(ERROR.INSTRUMENTS_TIMEOUT, "Instruments request timed out"));
}, timeout);

let instruments: Instrument.Info[] = [];
Expand Down Expand Up @@ -56,7 +55,7 @@ export async function getInstruments(
ws.on("error", (error) => {
clearTimeout(timer);
ws.close();
reject(new DxtradeError("INSTRUMENTS_ERROR", `Instruments error: ${error.message}`));
reject(new DxtradeError(ERROR.INSTRUMENTS_ERROR, `Instruments error: ${error.message}`));
});
});
}
2 changes: 2 additions & 0 deletions src/domains/ohlc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./ohlc";
export * from "./ohlc.types";
Loading