diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 75c1e40..9ac8fb4 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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: @@ -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 diff --git a/README.md b/README.md index 5c67435..f6028ea 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/examples/ohlc.ts b/examples/ohlc.ts new file mode 100644 index 0000000..12f67cc --- /dev/null +++ b/examples/ohlc.ts @@ -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); diff --git a/llms.txt b/llms.txt index 9479d9c..62ff00c 100644 --- a/llms.txt +++ b/llms.txt @@ -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) — 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 diff --git a/package.json b/package.json index 31946c3..0079372 100644 --- a/package.json +++ b/package.json @@ -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", @@ -91,4 +92,4 @@ "czConfig": ".czrc.js" } } -} +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index 8e9f456..63f925f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, @@ -13,6 +13,7 @@ import { getInstruments, getSymbolLimits, getSymbolSuggestions, + getOHLC, getSymbolInfo, submitOrder, getTradeJournal, @@ -45,12 +46,16 @@ export class DxtradeClient { 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 { @@ -137,4 +142,16 @@ export class DxtradeClient { public async getAssessments(params: Assessments.Params): Promise { 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 { + return getOHLC(this._ctx, params); + } } diff --git a/src/client.types.ts b/src/client.types.ts index 972b2c7..0aef83c 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -25,6 +25,7 @@ export interface ClientContext { callbacks: DxtradeCallbacks; cookies: Record; csrf: string | null; + atmosphereId: string | null; broker: keyof typeof BROKER; retries: number; debug: boolean | string; diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts index 9453ac1..3bc0c43 100644 --- a/src/constants/endpoints.ts +++ b/src/constants/endpoints.ts @@ -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`, @@ -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`, }; diff --git a/src/constants/enums.ts b/src/constants/enums.ts index 6e11b5a..e569537 100644 --- a/src/constants/enums.ts +++ b/src/constants/enums.ts @@ -20,11 +20,50 @@ 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", @@ -32,5 +71,12 @@ export enum WS_MESSAGE { 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", + } +} diff --git a/src/domains/account/account.ts b/src/domains/account/account.ts index 3d586ca..fb3b0d9 100644 --- a/src/domains/account/account.ts +++ b/src/domains/account/account.ts @@ -1,5 +1,5 @@ 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 "."; @@ -7,7 +7,7 @@ import type { Account } from "."; export async function getAccountMetrics(ctx: ClientContext, timeout = 30_000): Promise { 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) => { @@ -15,7 +15,7 @@ export async function getAccountMetrics(ctx: ClientContext, timeout = 30_000): P 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) => { @@ -34,7 +34,7 @@ export async function getAccountMetrics(ctx: ClientContext, timeout = 30_000): P 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}`)); }); }); } @@ -60,11 +60,11 @@ export async function getTradeJournal(ctx: ClientContext, params: { from: number 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}`); } } diff --git a/src/domains/assessments/assessments.ts b/src/domains/assessments/assessments.ts index f843e29..ff62c7a 100644 --- a/src/domains/assessments/assessments.ts +++ b/src/domains/assessments/assessments.ts @@ -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 "."; @@ -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}`); } } diff --git a/src/domains/index.ts b/src/domains/index.ts index abb0aab..a115b94 100644 --- a/src/domains/index.ts +++ b/src/domains/index.ts @@ -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"; diff --git a/src/domains/instrument/instrument.ts b/src/domains/instrument/instrument.ts index ee27352..57354a0 100644 --- a/src/domains/instrument/instrument.ts +++ b/src/domains/instrument/instrument.ts @@ -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 "."; @@ -12,7 +11,7 @@ export async function getInstruments( ): Promise { 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) => { @@ -20,7 +19,7 @@ export async function getInstruments( 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[] = []; @@ -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}`)); }); }); } diff --git a/src/domains/ohlc/index.ts b/src/domains/ohlc/index.ts new file mode 100644 index 0000000..ddffcdc --- /dev/null +++ b/src/domains/ohlc/index.ts @@ -0,0 +1,2 @@ +export * from "./ohlc"; +export * from "./ohlc.types"; diff --git a/src/domains/ohlc/ohlc.ts b/src/domains/ohlc/ohlc.ts new file mode 100644 index 0000000..f3b088f --- /dev/null +++ b/src/domains/ohlc/ohlc.ts @@ -0,0 +1,114 @@ +import WebSocket from "ws"; +import { endpoints, DxtradeError, WS_MESSAGE, ERROR } from "@/constants"; +import { Cookies, authHeaders, retryRequest, parseWsData, shouldLog, debugLog } from "@/utils"; +import type { ClientContext } from "@/client.types"; +import type { OHLC } from "."; + +export async function getOHLC(ctx: ClientContext, params: OHLC.Params, timeout = 30_000): Promise { + ctx.ensureSession(); + + const { symbol, resolution = 60, range = 432_000, maxBars = 3500, priceField = "bid" } = params; + const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId); + const cookieStr = Cookies.serialize(ctx.cookies); + const headers = authHeaders(ctx.csrf!, cookieStr); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + const bars: OHLC.Bar[] = []; + let putsSent = false; + let initSettleTimer: ReturnType | null = null; + let barSettleTimer: ReturnType | null = null; + + const timer = setTimeout(() => { + ws.close(); + reject(new DxtradeError(ERROR.OHLC_TIMEOUT, "OHLC data timed out")); + }, timeout); + + function cleanup() { + clearTimeout(timer); + if (initSettleTimer) clearTimeout(initSettleTimer); + if (barSettleTimer) clearTimeout(barSettleTimer); + ws.close(); + } + + async function sendPuts() { + putsSent = true; + try { + await retryRequest( + { + method: "PUT", + url: endpoints.subscribeInstruments(ctx.broker), + data: { instruments: [symbol] }, + headers, + }, + ctx.retries, + ); + await retryRequest( + { + method: "PUT", + url: endpoints.charts(ctx.broker), + data: { + chartIds: [], + requests: [ + { + aggregationPeriodSeconds: resolution, + extendedSession: true, + forexPriceField: priceField, + id: 0, + maxBarsCount: maxBars, + range, + studySubscription: [], + subtopic: WS_MESSAGE.SUBTOPIC.BIG_CHART_COMPONENT, + symbol, + }, + ], + }, + headers, + }, + ctx.retries, + ); + } catch (error: unknown) { + cleanup(); + const message = error instanceof Error ? error.message : "Unknown error"; + reject(new DxtradeError(ERROR.OHLC_ERROR, `Error fetching OHLC data: ${message}`)); + } + } + + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, ctx.debug)) debugLog(msg); + if (typeof msg === "string") return; + + // Wait for init burst to settle before sending PUTs + if (!putsSent) { + if (initSettleTimer) clearTimeout(initSettleTimer); + initSettleTimer = setTimeout(() => sendPuts(), 1000); + return; + } + + // Collect chart bars + const body = msg.body as Record; + if (body?.subtopic !== WS_MESSAGE.SUBTOPIC.BIG_CHART_COMPONENT) return; + + if (Array.isArray(body.data)) { + bars.push(...(body.data as OHLC.Bar[])); + } + + if (barSettleTimer) clearTimeout(barSettleTimer); + if (body.snapshotEnd) { + cleanup(); + resolve(bars); + } else { + barSettleTimer = setTimeout(() => { + cleanup(); + resolve(bars); + }, 2000); + } + }); + + ws.on("error", (error) => { + cleanup(); + reject(new DxtradeError(ERROR.OHLC_ERROR, `OHLC WebSocket error: ${error.message}`)); + }); + }); +} diff --git a/src/domains/ohlc/ohlc.types.ts b/src/domains/ohlc/ohlc.types.ts new file mode 100644 index 0000000..9541f73 --- /dev/null +++ b/src/domains/ohlc/ohlc.types.ts @@ -0,0 +1,25 @@ +export namespace OHLC { + export interface Params { + /** Symbol to fetch bars for (e.g. "EURUSD"). */ + symbol: string; + /** Bar aggregation period in seconds (default: 60 = 1 min). Common values: 60 (1m), 300 (5m), 900 (15m), 1800 (30m), 3600 (1h), 14400 (4h), 86400 (1d). */ + resolution?: number; + /** Lookback window in seconds from now (default: 432000 = 5 days). Determines how far back in history to fetch bars. */ + range?: number; + /** Maximum number of bars to return (default: 3500). */ + maxBars?: number; + /** Price field to use (default: "bid"). */ + priceField?: "bid" | "ask"; + } + + export interface Bar { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + vwap: number; + time: number; + } +} diff --git a/src/domains/order/order.ts b/src/domains/order/order.ts index 34aa61f..852ffcd 100644 --- a/src/domains/order/order.ts +++ b/src/domains/order/order.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; import WebSocket from "ws"; -import { endpoints, ORDER_TYPE, SIDE, ACTION, DxtradeError } from "@/constants"; +import { endpoints, ORDER_TYPE, SIDE, ACTION, DxtradeError, ERROR } from "@/constants"; import { WS_MESSAGE } from "@/constants/enums"; import { Cookies, authHeaders, retryRequest, parseWsData, shouldLog, debugLog } from "@/utils"; import type { ClientContext } from "@/client.types"; @@ -167,7 +167,7 @@ export async function submitOrder(ctx: ClientContext, params: Order.SubmitParams try { // Open WS listener BEFORE submitting so we don't miss the response - const wsUrl = endpoints.websocket(ctx.broker); + const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId); const cookieStr = Cookies.serialize(ctx.cookies); const listener = createOrderListener(wsUrl, cookieStr, 30_000, ctx.debug); await listener.ready; @@ -192,6 +192,6 @@ export async function submitOrder(ctx: ClientContext, params: Order.SubmitParams if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? ((error as any).response?.data?.message ?? error.message) : "Unknown error"; - ctx.throwError("ORDER_ERROR", `Error submitting order: ${message}`); + ctx.throwError(ERROR.ORDER_ERROR, `Error submitting order: ${message}`); } } diff --git a/src/domains/position/position.ts b/src/domains/position/position.ts index a6fff75..96c4e4b 100644 --- a/src/domains/position/position.ts +++ b/src/domains/position/position.ts @@ -1,5 +1,5 @@ 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, authHeaders } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Position } from "."; @@ -7,7 +7,7 @@ import type { Position } from "."; export async function getPositions(ctx: ClientContext): Promise { 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) => { @@ -15,7 +15,7 @@ export async function getPositions(ctx: ClientContext): Promise const timer = setTimeout(() => { ws.close(); - reject(new DxtradeError("ACCOUNT_POSITIONS_TIMEOUT", "Account positions timed out")); + reject(new DxtradeError(ERROR.ACCOUNT_POSITIONS_TIMEOUT, "Account positions timed out")); }, 30_000); ws.on("message", (data) => { @@ -33,7 +33,7 @@ export async function getPositions(ctx: ClientContext): Promise ws.on("error", (error) => { clearTimeout(timer); ws.close(); - reject(new DxtradeError("ACCOUNT_POSITIONS_ERROR", `Account positions error: ${error.message}`)); + reject(new DxtradeError(ERROR.ACCOUNT_POSITIONS_ERROR, `Account positions error: ${error.message}`)); }); }); } @@ -54,6 +54,6 @@ export async function closePosition(ctx: ClientContext, data: Position.Close): P if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? ((error as any).response?.data?.message ?? error.message) : "Unknown error"; - ctx.throwError("POSITION_CLOSE_ERROR", `Position close error: ${message}`); + ctx.throwError(ERROR.POSITION_CLOSE_ERROR, `Position close error: ${message}`); } } diff --git a/src/domains/session/session.ts b/src/domains/session/session.ts index cd62a4a..18c79db 100644 --- a/src/domains/session/session.ts +++ b/src/domains/session/session.ts @@ -1,11 +1,12 @@ import WebSocket from "ws"; -import { endpoints, DxtradeError } from "@/constants"; +import { endpoints, DxtradeError, ERROR } from "@/constants"; import { Cookies, authHeaders, cookieOnlyHeaders, retryRequest, clearDebugLog, + parseAtmosphereId, parseWsData, shouldLog, debugLog, @@ -17,9 +18,10 @@ function waitForHandshake( cookieStr: string, timeout = 30_000, debug: boolean | string = false, -): Promise { +): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + let atmosphereId: string | null = null; const timer = setTimeout(() => { ws.close(); @@ -27,6 +29,10 @@ function waitForHandshake( }, timeout); ws.on("message", (data) => { + if (!atmosphereId) { + atmosphereId = parseAtmosphereId(data); + } + const msg = parseWsData(data); if (shouldLog(msg, debug)) debugLog(msg); @@ -34,7 +40,7 @@ function waitForHandshake( if (msg.accountId) { clearTimeout(timer); ws.close(); - resolve(); + resolve(atmosphereId); } }); @@ -68,12 +74,12 @@ export async function login(ctx: ClientContext): Promise { ctx.cookies = Cookies.merge(ctx.cookies, incoming); ctx.callbacks.onLogin?.(); } else { - ctx.throwError("LOGIN_FAILED", `Login failed: ${response.status}`); + ctx.throwError(ERROR.LOGIN_FAILED, `Login failed: ${response.status}`); } } catch (error: unknown) { if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError("LOGIN_ERROR", `Login error: ${message}`); + ctx.throwError(ERROR.LOGIN_ERROR, `Login error: ${message}`); } } @@ -93,12 +99,12 @@ export async function fetchCsrf(ctx: ClientContext): Promise { if (csrfMatch) { ctx.csrf = csrfMatch[1]; } else { - ctx.throwError("CSRF_NOT_FOUND", "CSRF token not found"); + ctx.throwError(ERROR.CSRF_NOT_FOUND, "CSRF token not found"); } } catch (error: unknown) { if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError("CSRF_ERROR", `CSRF fetch error: ${message}`); + ctx.throwError(ERROR.CSRF_ERROR, `CSRF fetch error: ${message}`); } } @@ -118,7 +124,7 @@ export async function switchAccount(ctx: ClientContext, accountId: string): Prom } catch (error: unknown) { if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError("ACCOUNT_SWITCH_ERROR", `Error switching account: ${message}`); + ctx.throwError(ERROR.ACCOUNT_SWITCH_ERROR, `Error switching account: ${message}`); } } @@ -127,12 +133,16 @@ export async function connect(ctx: ClientContext): Promise { await fetchCsrf(ctx); if (ctx.debug) clearDebugLog(); - const wsUrl = endpoints.websocket(ctx.broker); const cookieStr = Cookies.serialize(ctx.cookies); - await waitForHandshake(wsUrl, cookieStr, 30_000, ctx.debug); + ctx.atmosphereId = await waitForHandshake(endpoints.websocket(ctx.broker), cookieStr, 30_000, ctx.debug); if (ctx.config.accountId) { await switchAccount(ctx, ctx.config.accountId); - await waitForHandshake(endpoints.websocket(ctx.broker), Cookies.serialize(ctx.cookies), 30_000, ctx.debug); + ctx.atmosphereId = await waitForHandshake( + endpoints.websocket(ctx.broker, ctx.atmosphereId), + Cookies.serialize(ctx.cookies), + 30_000, + ctx.debug, + ); } } diff --git a/src/domains/symbol/symbol.ts b/src/domains/symbol/symbol.ts index 78f0e1e..3467014 100644 --- a/src/domains/symbol/symbol.ts +++ b/src/domains/symbol/symbol.ts @@ -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, baseHeaders, retryRequest, parseWsData, shouldLog, debugLog } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Symbol } from "."; @@ -21,13 +20,13 @@ export async function getSymbolSuggestions(ctx: ClientContext, text: string): Pr const suggests = response.data?.suggests; if (!suggests?.length) { - ctx.throwError("NO_SUGGESTIONS", "No symbol suggestions found"); + ctx.throwError(ERROR.NO_SUGGESTIONS, "No symbol suggestions found"); } return suggests as Symbol.Suggestion[]; } catch (error: unknown) { if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError("SUGGEST_ERROR", `Error getting symbol suggestions: ${message}`); + ctx.throwError(ERROR.SUGGEST_ERROR, `Error getting symbol suggestions: ${message}`); } } @@ -47,20 +46,20 @@ export async function getSymbolInfo(ctx: ClientContext, symbol: string): Promise ); if (!response.data) { - ctx.throwError("NO_SYMBOL_INFO", "No symbol info returned"); + ctx.throwError(ERROR.NO_SYMBOL_INFO, "No symbol info returned"); } return response.data as Symbol.Info; } catch (error: unknown) { if (error instanceof DxtradeError) throw error; const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError("SYMBOL_INFO_ERROR", `Error getting symbol info: ${message}`); + ctx.throwError(ERROR.SYMBOL_INFO_ERROR, `Error getting symbol info: ${message}`); } } export async function getSymbolLimits(ctx: ClientContext, timeout = 30_000): Promise { 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) => { @@ -68,7 +67,7 @@ export async function getSymbolLimits(ctx: ClientContext, timeout = 30_000): Pro const timer = setTimeout(() => { ws.close(); - reject(new DxtradeError("LIMITS_TIMEOUT", "Symbol limits request timed out")); + reject(new DxtradeError(ERROR.LIMITS_TIMEOUT, "Symbol limits request timed out")); }, timeout); let limits: Symbol.Limits[] = []; @@ -97,7 +96,7 @@ export async function getSymbolLimits(ctx: ClientContext, timeout = 30_000): Pro ws.on("error", (error) => { clearTimeout(timer); ws.close(); - reject(new DxtradeError("LIMITS_ERROR", `Symbol limits error: ${error.message}`)); + reject(new DxtradeError(ERROR.LIMITS_ERROR, `Symbol limits error: ${error.message}`)); }); }); } diff --git a/src/utils/websocket.ts b/src/utils/websocket.ts index 9ad7dd5..24b0e2b 100644 --- a/src/utils/websocket.ts +++ b/src/utils/websocket.ts @@ -22,6 +22,15 @@ export function clearDebugLog(): void { writeFileSync(DEBUG_LOG, ""); } +export function parseAtmosphereId(data: WebSocket.Data): string | null { + const raw = data.toString(); + const parts = raw.split("|"); + if (parts.length >= 2 && /^[0-9a-f-]{36}$/.test(parts[1])) { + return parts[1]; + } + return null; +} + export function parseWsData(data: WebSocket.Data): WsPayload | string { const raw = data.toString(); const pipeIndex = raw.indexOf("|");