From f71bdf3dc45607984ea1b040a36b0b40f99d3ca0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 12 Feb 2026 12:16:26 +0100 Subject: [PATCH 1/3] docs: Adding llm file for AI and scaffold claude folder --- .claude/CLAUDE.md | 96 +++++++++++++++++++++++++++++++++++++++ .claude/skills/broker.md | 24 ++++++++++ .claude/skills/domain.md | 43 ++++++++++++++++++ .claude/skills/release.md | 19 ++++++++ .claude/skills/testing.md | 11 +++++ README.md | 16 +++---- llms.txt | 79 ++++++++++++++++++++++++++++++++ package.json | 5 +- src/client.ts | 37 +++++++++++++++ 9 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/skills/broker.md create mode 100644 .claude/skills/domain.md create mode 100644 .claude/skills/release.md create mode 100644 .claude/skills/testing.md create mode 100644 llms.txt diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..75c1e40 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,96 @@ +# DXtrade API + +Unofficial TypeScript client for the DXtrade trading API. + +## Commands + +- `npm run build` — Build the project (tsup) +- `npm run dev` — Build in watch mode +- `npm test` — Run tests once (vitest) +- `npm run test:watch` — Run tests in watch mode +- `npm run lint` — Lint source and examples +- `npm run lint:fix` — Lint and auto-fix +- `npm run format` — Format with prettier +- `npm run format:check` — Check formatting +- `npm run commit` — Commit using commitizen (required — `git commit` is disabled via pre-commit hook) + +## Project Structure + +``` +src/ +├── client.ts # Main DxtradeClient class — delegates to domain functions +├── client.types.ts # Config, callbacks, and context types +├── constants/ # Enums, broker URLs, endpoints, errors +├── domains/ # Feature modules (one folder per domain) +│ ├── {domain}/ +│ │ ├── {domain}.ts # Implementation +│ │ ├── {domain}.types.ts # Types (namespace pattern) +│ │ └── index.ts # Barrel exports +│ └── index.ts # Re-exports all domains +└── utils/ # Helpers (websocket, cookies, headers, retry) +tests/ # Unit tests (vitest) +examples/ # Runnable example scripts +``` + +## Path Aliases + +`@/*` maps to `src/*` — configured in tsconfig.json, tsup.config.ts, and vitest.config.ts. + +## Code Style + +- **Quotes**: Double quotes +- **Semicolons**: Always +- **Trailing commas**: Always +- **Print width**: 120 characters +- **Indent**: 2 spaces +- **Naming**: camelCase (functions/vars), PascalCase (classes/types/interfaces), SCREAMING_SNAKE_CASE (enum values) +- **Private fields**: Underscore prefix (`_ctx`) +- **Imports**: Named imports, `import type` for type-only imports, `@/` path alias for src +- **Types**: Namespace pattern for domain types (`Order.SubmitParams`, `Symbol.Info`), `interface` for objects, `type` for unions +- **Async**: async/await, Promise wrappers for WebSocket operations with timeout + cleanup +- **Error handling**: Custom `DxtradeError` class, try-catch with `error: unknown` type guards +- **Nullish coalescing**: `??` over `||` for defaults + +## Domain Pattern + +When adding a new domain: +1. Create `src/domains/{name}/{name}.ts` — implementation functions that take `ClientContext` as first param +2. Create `src/domains/{name}/{name}.types.ts` — types using namespace pattern +3. Create `src/domains/{name}/index.ts` — barrel exports +4. Re-export from `src/domains/index.ts` +5. Add public method(s) to `src/client.ts` with JSDoc comment + +## Example Pattern + +```typescript +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, +}); + +(async () => { + await client.connect(); + // ... +})().catch(console.error); +``` + +## Rules + +### When adding or removing a feature: +1. Update `README.md` — Features checklist, API section, and Examples section +2. Update `llms.txt` — Keep the LLM reference in sync with the actual API +3. Add JSDoc comment to any new public method in `src/client.ts` +4. Add an example script in `examples/` if the feature is user-facing +5. Add a corresponding npm script in package.json +6. Add or update tests in `tests/` + +### General: +- Do not commit with `git commit` directly — use `npm run commit` +- Run `npm run lint` and `npm test` before committing +- Follow the existing domain pattern for new features diff --git a/.claude/skills/broker.md b/.claude/skills/broker.md new file mode 100644 index 0000000..e077874 --- /dev/null +++ b/.claude/skills/broker.md @@ -0,0 +1,24 @@ +# Broker + +Guide for adding, modifying, or removing a broker. + +## Adding a broker + +1. Add the broker URL to `src/constants/brokers.ts` in the `BROKER` object +2. Update `README.md`: Built-in Brokers section +3. Update `llms.txt`: Enums section (BROKER list) +4. Add a test for the new broker URL in `tests/constants.test.ts` + +## Modifying a broker + +1. Update the URL in `src/constants/brokers.ts` +2. Update `README.md`: Built-in Brokers section +3. Update `llms.txt`: Enums section (BROKER list) +4. Update the test in `tests/constants.test.ts` + +## Removing a broker + +1. Remove the entry from `src/constants/brokers.ts` +2. Update `README.md`: Built-in Brokers section +3. Update `llms.txt`: Enums section (BROKER list) +4. Remove the test from `tests/constants.test.ts` diff --git a/.claude/skills/domain.md b/.claude/skills/domain.md new file mode 100644 index 0000000..456ee63 --- /dev/null +++ b/.claude/skills/domain.md @@ -0,0 +1,43 @@ +# Domain + +Guide for adding, modifying, or removing a domain module. + +## Adding a domain + +1. Create the domain folder: `src/domains/{name}/` +2. Create `{name}.types.ts` with namespace pattern: + ```typescript + export namespace {Name} { + export interface ... { } + } + ``` +3. Create `{name}.ts` with implementation functions that take `ClientContext` as first param +4. Create `index.ts` with barrel exports +5. Re-export from `src/domains/index.ts` +6. Add public method(s) to `src/client.ts` with JSDoc comment +7. Add example script in `examples/` +8. Add npm script in package.json for the example +9. Add unit test in `tests/` +10. Update `README.md`: Features checklist, API section, Examples section +11. Update `llms.txt` with the new method(s) + +## Modifying a domain + +1. Update the implementation in `src/domains/{name}/{name}.ts` +2. Update types in `src/domains/{name}/{name}.types.ts` if signatures changed +3. Update the public method(s) and JSDoc in `src/client.ts` +4. Update the example script in `examples/` +5. Update or add tests in `tests/` +6. Update `README.md`: Features checklist, API section, Examples section +7. Update `llms.txt` to reflect the current API + +## Removing a domain + +1. Delete the domain folder: `src/domains/{name}/` +2. Remove the re-export from `src/domains/index.ts` +3. Remove the public method(s) and import from `src/client.ts` +4. Delete the example script from `examples/` +5. Remove the npm script from package.json +6. Remove or update tests in `tests/` +7. Update `README.md`: Features checklist, API section, Examples section +8. Update `llms.txt` to remove the method(s) diff --git a/.claude/skills/release.md b/.claude/skills/release.md new file mode 100644 index 0000000..3410435 --- /dev/null +++ b/.claude/skills/release.md @@ -0,0 +1,19 @@ +# Release + +Publishing is automated. When a PR is squash-merged to master, the publish workflow: + +1. Bumps the patch version in package.json +2. Publishes to npm with OIDC provenance +3. Creates a GitHub Release with auto-generated notes + +## Manual version bumps + +For minor or major releases, bump the version locally before merging: + +```bash +npm version minor --no-git-tag-version +# or +npm version major --no-git-tag-version +``` + +Then commit the version change and merge the PR. The workflow will skip the auto-bump if the version already changed. diff --git a/.claude/skills/testing.md b/.claude/skills/testing.md new file mode 100644 index 0000000..e7a02c1 --- /dev/null +++ b/.claude/skills/testing.md @@ -0,0 +1,11 @@ +# Testing + +> **Note:** Testing patterns still need to be thought out. Update this file as conventions are established. + +## Setup + +- Framework: vitest +- Config: `vitest.config.ts` +- Test location: `tests/` +- Run once: `npm test` +- Watch mode: `npm run test:watch` diff --git a/README.md b/README.md index b817812..3e3c58d 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,17 @@ [![npm downloads](https://img.shields.io/npm/dm/@danielgroen/dxtrade-api)](https://www.npmjs.com/package/@danielgroen/dxtrade-api) [![license](https://img.shields.io/npm/l/@danielgroen/dxtrade-api)](LICENSE) [![Tests](https://github.com/danielgroen/dxtrade-api/actions/workflows/tests.yml/badge.svg)](https://github.com/danielgroen/dxtrade-api/actions/workflows/tests.yml) -[![Publish to npm](https://github.com/danielgroen/dxtrade-api/actions/workflows/publish.yml/badge.svg)](https://github.com/danielgroen/dxtrade-api/actions/workflows/publish.yml) +[![Publish](https://github.com/danielgroen/dxtrade-api/actions/workflows/publish.yml/badge.svg)](https://github.com/danielgroen/dxtrade-api/actions/workflows/publish.yml) [![DXtrade API](https://raw.githubusercontent.com/danielgroen/dxtrade-api/master/public/logo-dxtrade.svg)](https://demo.dx.trade/developers/#/) -TypeScript client library for the DXtrade trading API based upon Nodejs. +Unofficial TypeScript client for the DXtrade trading API. Connect, trade, and manage positions on any broker that supports DXtrade. + +## Install + +```bash +npm install dxtrade-api +``` ## Features @@ -25,12 +31,6 @@ TypeScript client library for the DXtrade trading API based upon Nodejs. - [ ] Real-time price streaming - [ ] Order history -## Install - -```bash -npm install dxtrade-api -``` - ## Quick Start ```ts diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..9479d9c --- /dev/null +++ b/llms.txt @@ -0,0 +1,79 @@ +# DXtrade API + +Unofficial TypeScript client for the DXtrade trading API. +Connect, trade, and manage positions on any broker that supports DXtrade. + +## Installation + +npm install dxtrade-api + +## Quick Start + +import { DxtradeClient, ORDER_TYPE, SIDE, BROKER } from "dxtrade-api"; + +const client = new DxtradeClient({ + username: "your_username", + password: "your_password", + broker: BROKER.FTMO, + accountId: "optional_account_id", +}); + +await client.connect(); + +## Available Methods + +### Session +- client.connect() — Login, fetch CSRF, WebSocket handshake, optional account switch. Call this first. +- client.login() — Authenticate with broker (called automatically by connect) +- client.fetchCsrf() — Fetch CSRF token (called automatically by connect) +- client.switchAccount(accountId: string) — Switch to a specific trading account + +### Market Data +- client.getSymbolSuggestions(text: string) — Search symbols by name, returns Symbol.Suggestion[] +- 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" }) + +### Trading +- client.submitOrder(params: Order.SubmitParams) — Submit order and wait for WebSocket confirmation, returns Order.Update + Required params: symbol, side (SIDE.BUY | SIDE.SELL), quantity, orderType (ORDER_TYPE.MARKET | LIMIT | STOP), instrumentId + Optional params: limitPrice, stopPrice, stopLoss, takeProfit, timeInForce (TIF.GTC | DAY | GTD) + +### Positions +- client.getPositions() — Get all open positions via WebSocket, returns Position.Get[] +- client.closePosition(params: Position.Close) — Close a position + +### Account +- client.getAccountMetrics() — Get equity, balance, margin, open P&L, returns Account.Metrics +- client.getTradeJournal({ from: number, to: number }) — Fetch trade journal for date range (Unix timestamps) + +### Analytics +- client.getAssessments(params: Assessments.Params) — Fetch PnL assessments for a date range + +## Enums + +import { ORDER_TYPE, SIDE, ACTION, TIF, BROKER } from "dxtrade-api"; + +ORDER_TYPE: MARKET, LIMIT, STOP +SIDE: BUY, SELL +ACTION: OPENING, CLOSING +TIF: GTC, DAY, GTD +BROKER: LARKFUNDING, EIGHTCAP, FTMO + +## Callbacks + +const client = new DxtradeClient({ + ...config, + callbacks: { + onLogin: () => {}, + onAccountSwitch: (accountId: string) => {}, + onOrderPlaced: (order: Order.Response) => {}, + onOrderUpdate: (order: Order.Update) => {}, + onError: (error: DxtradeError) => {}, + }, +}); + +## Error Handling + +All errors are instances of DxtradeError with properties: code (string) and message (string). +Common error codes: NO_SESSION, ORDER_TIMEOUT, ORDER_ERROR, POSITION_CLOSE_ERROR. diff --git a/package.json b/package.json index d3034ff..bbac951 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@danielgroen/dxtrade-api", "version": "1.0.18", - "description": "DXtrade trading API client library", + "description": "Unofficial TypeScript client for the DXtrade trading API. Connect, trade, and manage positions on any broker that supports DXtrade.", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -13,7 +13,8 @@ } }, "files": [ - "dist" + "dist", + "llms.txt" ], "scripts": { "================ Build ===============": "", diff --git a/src/client.ts b/src/client.ts index cb2846e..8e9f456 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,6 +18,22 @@ import { getTradeJournal, } from "@/domains"; +/** + * Client for interacting with the DXtrade trading API. + * + * @example + * ```ts + * import { DxtradeClient, ORDER_TYPE, SIDE, BROKER } from "dxtrade-api"; + * + * const client = new DxtradeClient({ + * username: "your_username", + * password: "your_password", + * broker: BROKER.FTMO, + * }); + * + * await client.connect(); + * ``` + */ export class DxtradeClient { private _ctx: ClientContext; @@ -45,58 +61,79 @@ export class DxtradeClient { }; } + /** Authenticate with the broker using username and password. */ public async login(): Promise { return login(this._ctx); } + /** Fetch the CSRF token required for authenticated requests. */ public async fetchCsrf(): Promise { return fetchCsrf(this._ctx); } + /** Switch to a specific trading account by ID. */ public async switchAccount(accountId: string): Promise { return switchAccount(this._ctx, accountId); } + /** Connect to the broker: login, fetch CSRF, WebSocket handshake, and optional account switch. */ public async connect(): Promise { return connect(this._ctx); } + /** Search for symbols matching the given text (e.g. "EURUSD", "BTC"). */ public async getSymbolSuggestions(text: string): Promise { return getSymbolSuggestions(this._ctx, text); } + /** Get detailed instrument info for a symbol, including volume limits and lot size. */ public async getSymbolInfo(symbol: string): Promise { return getSymbolInfo(this._ctx, symbol); } + /** Get order size limits and stop/limit distances for all symbols. */ public async getSymbolLimits(): Promise { return getSymbolLimits(this._ctx); } + /** + * Submit a trading order and wait for WebSocket confirmation. + * Supports market, limit, and stop orders with optional stop loss and take profit. + */ public async submitOrder(params: Order.SubmitParams): Promise { return submitOrder(this._ctx, params); } + /** Get account metrics including equity, balance, margin, and open P&L. */ public async getAccountMetrics(): Promise { return getAccountMetrics(this._ctx); } + /** Get all open positions via WebSocket. */ public async getPositions(): Promise { return getPositions(this._ctx); } + /** Close a position. */ public async closePosition(position: Position.Close): Promise { return closePosition(this._ctx, position); } + /** + * Fetch trade journal entries for a date range. + * @param params.from - Start timestamp (Unix ms) + * @param params.to - End timestamp (Unix ms) + */ public async getTradeJournal(params: { from: number; to: number }): Promise { return getTradeJournal(this._ctx, params); } + /** Get all available instruments, optionally filtered by partial match (e.g. `{ type: "FOREX" }`). */ public async getInstruments(params: Partial = {}): Promise { return getInstruments(this._ctx, params); } + /** Fetch PnL assessments for an instrument within a date range. */ public async getAssessments(params: Assessments.Params): Promise { return getAssessments(this._ctx, params); } From a61cbe13ca1e9df27cdb475c6ed28e4d6b9dfac2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 12 Feb 2026 15:41:38 +0100 Subject: [PATCH 2/3] feat: Adding ohlc data --- .claude/CLAUDE.md | 18 ++++ README.md | 3 + examples/ohlc.ts | 21 +++++ llms.txt | 3 + package.json | 1 + src/client.ts | 20 ++++- src/client.types.ts | 1 + src/constants/endpoints.ts | 21 +++-- src/constants/enums.ts | 46 ++++++++++ src/domains/account/account.ts | 12 +-- src/domains/assessments/assessments.ts | 4 +- src/domains/index.ts | 1 + src/domains/instrument/instrument.ts | 9 +- src/domains/ohlc/index.ts | 2 + src/domains/ohlc/ohlc.ts | 114 +++++++++++++++++++++++++ src/domains/ohlc/ohlc.types.ts | 25 ++++++ src/domains/order/order.ts | 6 +- src/domains/position/position.ts | 10 +-- src/domains/session/session.ts | 32 ++++--- src/domains/symbol/symbol.ts | 17 ++-- src/utils/websocket.ts | 9 ++ 21 files changed, 325 insertions(+), 50 deletions(-) create mode 100644 examples/ohlc.ts create mode 100644 src/domains/ohlc/index.ts create mode 100644 src/domains/ohlc/ohlc.ts create mode 100644 src/domains/ohlc/ohlc.types.ts 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 d412075..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", diff --git a/src/client.ts b/src/client.ts index 8e9f456..062093d 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,13 @@ 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 +139,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("|"); From bec8c839f779b82517c3d02145a6f123b152e2de Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 12 Feb 2026 15:43:35 +0100 Subject: [PATCH 3/3] refactor: formatter bugfix --- src/client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 062093d..63f925f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -52,7 +52,10 @@ export class DxtradeClient { debug: config.debug ?? false, ensureSession() { if (!this.csrf) { - throw new DxtradeError(ERROR.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 {