diff --git a/README.md b/README.md index f6028ea..165c134 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ npm install dxtrade-api - [x] Authentication & session management - [x] Submit orders (market, limit, stop) -- [x] Positions (get & close) -- [x] Account metrics & trade journal +- [x] Get & cancel orders +- [x] Positions (get, close, close all) +- [x] Position metrics (per-position P&L) +- [x] Account metrics, trade journal & trade history - [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 - [ ] Batch orders -- [ ] Close whole position helper - [ ] Modify existing orders - [ ] Real-time price streaming -- [ ] Order history ## Quick Start @@ -105,16 +105,22 @@ BROKER.FTMO // "https://dxtrade.ftmo.com" ### Trading - `client.submitOrder(params)` — Submit an order and wait for WebSocket confirmation +- `client.getOrders()` — Get all pending/open orders via WebSocket +- `client.cancelOrder(orderChainId)` — Cancel a single pending order +- `client.cancelAllOrders()` — Cancel all pending orders ### Positions - `client.getPositions()` — Get all open positions via WebSocket -- `client.closePosition(params)` — Close a position +- `client.closePosition(params)` — Close a position (supports partial closes via the quantity field) +- `client.closeAllPositions()` — Close all open positions with market orders +- `client.getPositionMetrics()` — Get position-level P&L metrics via WebSocket ### Account - `client.getAccountMetrics()` — Get account metrics (equity, balance, margin, open P&L, etc.) - `client.getTradeJournal({ from, to })` — Fetch trade journal entries for a date range (Unix timestamps) +- `client.getTradeHistory({ from, to })` — Fetch trade history for a date range (Unix timestamps) ### Analytics @@ -152,8 +158,11 @@ const client = new DxtradeClient({ cp .env.example .env # fill in credentials npm run example:connect npm run example:order +npm run example:orders npm run example:positions npm run example:close-position +npm run example:close-all-positions +npm run example:position-metrics npm run example:assessments npm run example:assessments:btc npm run example:account @@ -163,6 +172,7 @@ npm run example:instruments:forex npm run example:symbol npm run example:symbol:btc npm run example:trade-journal +npm run example:trade-history npm run example:debug ``` diff --git a/examples/close-all-positions.ts b/examples/close-all-positions.ts new file mode 100644 index 0000000..ea6fe06 --- /dev/null +++ b/examples/close-all-positions.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, +}); + +(async () => { + await client.connect(); + + const positions = await client.getPositions(); + console.log(`Closing ${positions.length} position(s)...`); + + await client.closeAllPositions(); + + console.log("All positions closed"); +})().catch(console.error); diff --git a/examples/submit-order.ts b/examples/orders.ts similarity index 53% rename from examples/submit-order.ts rename to examples/orders.ts index 8280989..a39efcb 100644 --- a/examples/submit-order.ts +++ b/examples/orders.ts @@ -9,6 +9,8 @@ const client = new DxtradeClient({ debug: process.env.DXTRADE_DEBUG || false, }); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + (async () => { await client.connect(); @@ -19,6 +21,7 @@ const client = new DxtradeClient({ const info = await client.getSymbolInfo(symbol.name); console.log(`Min volume: ${info.minVolume}, Lot size: ${info.lotSize}`); + // 1. Submit a market order const order = await client.submitOrder({ symbol: symbol.name, side: SIDE.BUY, @@ -26,6 +29,28 @@ const client = new DxtradeClient({ orderType: ORDER_TYPE.MARKET, instrumentId: symbol.id, }); - console.log(`Order filled: ${order.orderId} — status: ${order.status}`); + + // 2. Wait 2 seconds, then close the position + await sleep(2000); + console.log("\nClosing position..."); + await client.closeAllPositions(); + console.log("All positions closed"); + + // 3. Wait 2 seconds, then submit a limit order and immediately cancel it + await sleep(2000); + console.log("\nPlacing limit order..."); + const limitOrder = await client.submitOrder({ + symbol: symbol.name, + side: SIDE.BUY, + quantity: info.minVolume, + orderType: ORDER_TYPE.LIMIT, + price: 1.0, + instrumentId: symbol.id, + }); + console.log(`Limit order placed: ${limitOrder.orderId} — status: ${limitOrder.status}`); + + console.log("Cancelling order..."); + await client.cancelAllOrders(); + console.log("All orders cancelled"); })().catch(console.error); diff --git a/examples/position-metrics.ts b/examples/position-metrics.ts new file mode 100644 index 0000000..5733cf6 --- /dev/null +++ b/examples/position-metrics.ts @@ -0,0 +1,17 @@ +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(); + const metrics = await client.getPositionMetrics(); + + console.log("Position metrics:", metrics); +})().catch(console.error); diff --git a/examples/trade-history.ts b/examples/trade-history.ts new file mode 100644 index 0000000..9906313 --- /dev/null +++ b/examples/trade-history.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, +}); + +(async () => { + await client.connect(); + + const from = new Date(new Date().setMonth(new Date().getMonth() - 1)).getTime(); + const to = Date.now(); + + const history = await client.getTradeHistory({ from, to }); + + console.log("Trade history:", history); +})().catch(console.error); diff --git a/llms.txt b/llms.txt index 62ff00c..b6bf313 100644 --- a/llms.txt +++ b/llms.txt @@ -41,14 +41,20 @@ await client.connect(); - 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) +- client.getOrders() — Get all pending/open orders via WebSocket, returns Order.Get[] +- client.cancelOrder(orderChainId: number) — Cancel a single pending order by its order chain ID +- client.cancelAllOrders() — Cancel all pending orders (fetches orders then cancels each) ### Positions - client.getPositions() — Get all open positions via WebSocket, returns Position.Get[] -- client.closePosition(params: Position.Close) — Close a position +- client.closePosition(params: Position.Close) — Close a position (supports partial closes via the quantity field) +- client.closeAllPositions() — Close all open positions with market orders +- client.getPositionMetrics() — Get position-level P&L metrics via WebSocket, returns Position.Metrics[] ### 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) +- client.getTradeHistory({ from: number, to: number }) — Fetch trade history for date range (Unix timestamps), returns Account.TradeHistory[] ### Analytics - client.getAssessments(params: Assessments.Params) — Fetch PnL assessments for a date range diff --git a/package.json b/package.json index 5a36917..e40189f 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,16 @@ "example:connect": "tsx examples/connect.ts", "example:positions": "tsx examples/positions.ts", "example:close-position": "tsx examples/close-position.ts", - "example:order": "tsx examples/submit-order.ts", + "example:close-all-positions": "tsx examples/close-all-positions.ts", + "example:position-metrics": "tsx examples/position-metrics.ts", + "example:orders": "tsx examples/orders.ts", "example:assessments": "tsx examples/get-assessments.ts", "example:assessments:btc": "tsx examples/get-assessments.ts BTCUSD", "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:trade-history": "tsx examples/trade-history.ts", "example:symbol:btc": "tsx examples/symbol-info.ts BTCUSD", "============= Git =============": "", "commit": "COMMITIZEN=1 cz", @@ -92,4 +95,4 @@ "czConfig": ".czrc.js" } } -} +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index 63f925f..e24cbd7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7,14 +7,20 @@ import { switchAccount, connect, getAccountMetrics, + getTradeHistory, getPositions, + getPositionMetrics, closePosition, + closeAllPositions, getAssessments, getInstruments, getSymbolLimits, getSymbolSuggestions, getOHLC, getSymbolInfo, + getOrders, + cancelOrder, + cancelAllOrders, submitOrder, getTradeJournal, } from "@/domains"; @@ -46,6 +52,7 @@ export class DxtradeClient { callbacks, cookies: {}, csrf: null, + accountId: config.accountId ?? null, atmosphereId: null, broker: config.broker, retries: config.retries ?? 3, @@ -109,6 +116,21 @@ export class DxtradeClient { return submitOrder(this._ctx, params); } + /** Get all pending/open orders via WebSocket. */ + public async getOrders(): Promise { + return getOrders(this._ctx); + } + + /** Cancel a single pending order by its order chain ID. */ + public async cancelOrder(orderChainId: number): Promise { + return cancelOrder(this._ctx, orderChainId); + } + + /** Cancel all pending orders. */ + public async cancelAllOrders(): Promise { + return cancelAllOrders(this._ctx); + } + /** Get account metrics including equity, balance, margin, and open P&L. */ public async getAccountMetrics(): Promise { return getAccountMetrics(this._ctx); @@ -119,11 +141,23 @@ export class DxtradeClient { return getPositions(this._ctx); } - /** Close a position. */ + /** + * Close a position. Supports partial closes by specifying a quantity smaller than the full position size. + */ public async closePosition(position: Position.Close): Promise { return closePosition(this._ctx, position); } + /** Close all open positions with market orders. */ + public async closeAllPositions(): Promise { + return closeAllPositions(this._ctx); + } + + /** Get position-level P&L metrics via WebSocket. */ + public async getPositionMetrics(): Promise { + return getPositionMetrics(this._ctx); + } + /** * Fetch trade journal entries for a date range. * @param params.from - Start timestamp (Unix ms) @@ -133,6 +167,15 @@ export class DxtradeClient { return getTradeJournal(this._ctx, params); } + /** + * Fetch trade history for a date range. + * @param params.from - Start timestamp (Unix ms) + * @param params.to - End timestamp (Unix ms) + */ + public async getTradeHistory(params: { from: number; to: number }): Promise { + return getTradeHistory(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); diff --git a/src/client.types.ts b/src/client.types.ts index 0aef83c..32f63f3 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; + accountId: string | null; atmosphereId: string | null; broker: keyof typeof BROKER; retries: number; diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts index 3bc0c43..29c6299 100644 --- a/src/constants/endpoints.ts +++ b/src/constants/endpoints.ts @@ -22,6 +22,9 @@ export const endpoints = { closePosition: (base: string) => `${base}/api/positions/close`, + cancelOrder: (base: string, accountId: string, orderChainId: number) => + `${base}/api/orders/cancel?accountId=${accountId}&orderChainId=${orderChainId}`, + assessments: (base: string) => `${base}/api/assessments`, websocket: (base: string, atmosphereId?: string | null) => @@ -30,6 +33,9 @@ export const endpoints = { tradeJournal: (base: string, params: { from: number; to: number }) => `${base}/api/tradejournal?from=${params.from}&to=${params.to}`, + tradeHistory: (base: string, params: { from: number; to: number }) => + `${base}/api/history?from=${params.from}&to=${params.to}&orderId=`, + 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 e569537..e780e67 100644 --- a/src/constants/enums.ts +++ b/src/constants/enums.ts @@ -44,7 +44,12 @@ export enum ERROR { // Trading ORDER_ERROR = "ORDER_ERROR", + ORDERS_TIMEOUT = "ORDERS_TIMEOUT", + ORDERS_ERROR = "ORDERS_ERROR", + CANCEL_ORDER_ERROR = "CANCEL_ORDER_ERROR", POSITION_CLOSE_ERROR = "POSITION_CLOSE_ERROR", + POSITION_METRICS_TIMEOUT = "POSITION_METRICS_TIMEOUT", + POSITION_METRICS_ERROR = "POSITION_METRICS_ERROR", // Account ACCOUNT_METRICS_TIMEOUT = "ACCOUNT_METRICS_TIMEOUT", @@ -52,6 +57,7 @@ export enum ERROR { ACCOUNT_POSITIONS_TIMEOUT = "ACCOUNT_POSITIONS_TIMEOUT", ACCOUNT_POSITIONS_ERROR = "ACCOUNT_POSITIONS_ERROR", TRADE_JOURNAL_ERROR = "TRADE_JOURNAL_ERROR", + TRADE_HISTORY_ERROR = "TRADE_HISTORY_ERROR", // Analytics ASSESSMENTS_ERROR = "ASSESSMENTS_ERROR", @@ -63,11 +69,12 @@ export enum WS_MESSAGE { AVAILABLE_WATCHLISTS = "AVAILABLE_WATCHLISTS", CHART_FEED_SUBTOPIC = "chartFeedSubtopic", INSTRUMENTS = "INSTRUMENTS", - INSTRUMENT_METRICS = "INSTRUMENT_METRICS", + // INSTRUMENT_METRICS = "INSTRUMENT_METRICS", LIMITS = "LIMITS", MESSAGE = "MESSAGE", ORDERS = "ORDERS", POSITIONS = "POSITIONS", + POSITION_METRICS = "POSITION_METRICS", POSITION_CASH_TRANSFERS = "POSITION_CASH_TRANSFERS", PRIVATE_LAYOUT_NAMES = "PRIVATE_LAYOUT_NAMES", SHARED_PROPERTIES_MESSAGE = "SHARED_PROPERTIES_MESSAGE", diff --git a/src/domains/account/account.ts b/src/domains/account/account.ts index fb3b0d9..95413e3 100644 --- a/src/domains/account/account.ts +++ b/src/domains/account/account.ts @@ -39,6 +39,39 @@ export async function getAccountMetrics(ctx: ClientContext, timeout = 30_000): P }); } +export async function getTradeHistory( + ctx: ClientContext, + params: { from: number; to: number }, +): Promise { + ctx.ensureSession(); + + try { + const cookieStr = Cookies.serialize(ctx.cookies); + + const response = await retryRequest( + { + method: "GET", + url: endpoints.tradeHistory(ctx.broker, params), + headers: { ...baseHeaders(), Cookie: cookieStr }, + }, + ctx.retries, + ); + + if (response.status === 200) { + const setCookies = response.headers["set-cookie"] ?? []; + const incoming = Cookies.parse(setCookies); + ctx.cookies = Cookies.merge(ctx.cookies, incoming); + return response.data as Account.TradeHistory[]; + } else { + ctx.throwError(ERROR.TRADE_HISTORY_ERROR, `Trade history failed: ${response.status}`); + } + } catch (error: unknown) { + if (error instanceof DxtradeError) throw error; + const message = error instanceof Error ? error.message : "Unknown error"; + ctx.throwError(ERROR.TRADE_HISTORY_ERROR, `Trade history error: ${message}`); + } +} + export async function getTradeJournal(ctx: ClientContext, params: { from: number; to: number }): Promise { ctx.ensureSession(); diff --git a/src/domains/account/account.types.ts b/src/domains/account/account.types.ts index cbef5be..9c50f36 100644 --- a/src/domains/account/account.types.ts +++ b/src/domains/account/account.types.ts @@ -1,4 +1,19 @@ export namespace Account { + export interface TradeHistory { + orderId: number; + orderCode: string; + instrument: string; + side: string; + type: string; + status: string; + quantity: number; + filledQuantity: number; + price: number; + averagePrice: number; + time: string; + [key: string]: unknown; + } + export interface Metrics { availableFunds: number; marginCallLevel: number | string; diff --git a/src/domains/order/order.ts b/src/domains/order/order.ts index 852ffcd..99a2939 100644 --- a/src/domains/order/order.ts +++ b/src/domains/order/order.ts @@ -91,6 +91,74 @@ function createOrderListener( return { promise, ready }; } +export async function getOrders(ctx: ClientContext, timeout = 30_000): Promise { + ctx.ensureSession(); + + 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(ERROR.ORDERS_TIMEOUT, "Orders request timed out")); + }, timeout); + + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, ctx.debug)) debugLog(msg); + + if (typeof msg === "string") return; + if (msg.type === WS_MESSAGE.ORDERS) { + clearTimeout(timer); + ws.close(); + resolve(msg.body as Order.Get[]); + } + }); + + ws.on("error", (error) => { + clearTimeout(timer); + ws.close(); + reject(new DxtradeError(ERROR.ORDERS_ERROR, `Orders error: ${error.message}`)); + }); + }); +} + +export async function cancelOrder(ctx: ClientContext, orderChainId: number): Promise { + ctx.ensureSession(); + + const accountId = ctx.accountId ?? ctx.config.accountId; + if (!accountId) { + ctx.throwError(ERROR.CANCEL_ORDER_ERROR, "accountId is required to cancel an order"); + } + + try { + await retryRequest( + { + method: "DELETE", + url: endpoints.cancelOrder(ctx.broker, accountId, orderChainId), + headers: authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)), + }, + ctx.retries, + ); + } catch (error: unknown) { + if (error instanceof DxtradeError) throw error; + const message = + error instanceof Error ? ((error as any).response?.data?.message ?? error.message) : "Unknown error"; + ctx.throwError(ERROR.CANCEL_ORDER_ERROR, `Cancel order error: ${message}`); + } +} + +export async function cancelAllOrders(ctx: ClientContext): Promise { + const orders = await getOrders(ctx); + const pending = orders.filter((o) => !o.finalStatus); + + for (const order of pending) { + await cancelOrder(ctx, order.orderId); + } +} + export async function submitOrder(ctx: ClientContext, params: Order.SubmitParams): Promise { ctx.ensureSession(); diff --git a/src/domains/order/order.types.ts b/src/domains/order/order.types.ts index e0228b0..afb7b02 100644 --- a/src/domains/order/order.types.ts +++ b/src/domains/order/order.types.ts @@ -1,6 +1,23 @@ import type { ORDER_TYPE, SIDE, ACTION, TIF } from "@/constants/enums"; export namespace Order { + export interface Get { + account: string; + orderId: number; + orderCode: string; + version: number; + type: ORDER_TYPE; + instrument: string; + status: string; + finalStatus: boolean; + side: SIDE; + tif: TIF; + legs: Leg[]; + issueTime: string; + transactionTime: string; + [key: string]: unknown; + } + export interface SubmitParams { symbol: string; side: SIDE; diff --git a/src/domains/position/position.ts b/src/domains/position/position.ts index 96c4e4b..5e52894 100644 --- a/src/domains/position/position.ts +++ b/src/domains/position/position.ts @@ -38,6 +38,63 @@ export async function getPositions(ctx: ClientContext): Promise }); } +export async function getPositionMetrics(ctx: ClientContext, timeout = 30_000): Promise { + ctx.ensureSession(); + + 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(ERROR.POSITION_METRICS_TIMEOUT, "Position metrics timed out")); + }, timeout); + + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, ctx.debug)) debugLog(msg); + + if (typeof msg === "string") return; + if (msg.type === WS_MESSAGE.POSITION_METRICS) { + clearTimeout(timer); + ws.close(); + resolve(msg.body as Position.Metrics[]); + } + }); + + ws.on("error", (error) => { + clearTimeout(timer); + ws.close(); + reject(new DxtradeError(ERROR.POSITION_METRICS_ERROR, `Position metrics error: ${error.message}`)); + }); + }); +} + +export async function closeAllPositions(ctx: ClientContext): Promise { + const positions = await getPositions(ctx); + + for (const pos of positions) { + const closeData: Position.Close = { + legs: [ + { + instrumentId: pos.positionKey.instrumentId, + positionCode: pos.positionKey.positionCode, + positionEffect: "CLOSING", + ratioQuantity: 1, + symbol: pos.positionKey.positionCode, + }, + ], + limitPrice: 0, + orderType: "MARKET", + quantity: -pos.quantity, + timeInForce: "GTC", + }; + await closePosition(ctx, closeData); + } +} + export async function closePosition(ctx: ClientContext, data: Position.Close): Promise { try { await retryRequest( diff --git a/src/domains/position/position.types.ts b/src/domains/position/position.types.ts index 4ae2e3e..f657b66 100644 --- a/src/domains/position/position.types.ts +++ b/src/domains/position/position.types.ts @@ -15,6 +15,15 @@ export namespace Position { stopLoss: number | null; } + export interface Metrics { + positionCode: string; + openPl: number; + openPlPerLot: number; + currentPrice: number; + convertedOpenPl: number; + [key: string]: unknown; + } + export interface Close { legs: { instrumentId: number; diff --git a/src/domains/session/session.ts b/src/domains/session/session.ts index 18c79db..403bb9b 100644 --- a/src/domains/session/session.ts +++ b/src/domains/session/session.ts @@ -13,12 +13,17 @@ import { } from "@/utils"; import type { ClientContext } from "@/client.types"; +interface HandshakeResult { + atmosphereId: string | null; + accountId: string | null; +} + function waitForHandshake( wsUrl: string, 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; @@ -40,7 +45,7 @@ function waitForHandshake( if (msg.accountId) { clearTimeout(timer); ws.close(); - resolve(atmosphereId); + resolve({ atmosphereId, accountId: msg.accountId }); } }); @@ -134,15 +139,19 @@ export async function connect(ctx: ClientContext): Promise { if (ctx.debug) clearDebugLog(); const cookieStr = Cookies.serialize(ctx.cookies); - ctx.atmosphereId = await waitForHandshake(endpoints.websocket(ctx.broker), cookieStr, 30_000, ctx.debug); + const handshake = await waitForHandshake(endpoints.websocket(ctx.broker), cookieStr, 30_000, ctx.debug); + ctx.atmosphereId = handshake.atmosphereId; + ctx.accountId = handshake.accountId; if (ctx.config.accountId) { await switchAccount(ctx, ctx.config.accountId); - ctx.atmosphereId = await waitForHandshake( + const reconnect = await waitForHandshake( endpoints.websocket(ctx.broker, ctx.atmosphereId), Cookies.serialize(ctx.cookies), 30_000, ctx.debug, ); + ctx.atmosphereId = reconnect.atmosphereId; + ctx.accountId = reconnect.accountId; } } diff --git a/tests/account.test.ts b/tests/account.test.ts new file mode 100644 index 0000000..d3231ce --- /dev/null +++ b/tests/account.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DxtradeError } from "@/constants/errors"; +import { getTradeHistory } from "@/domains/account"; +import { createMockContext } from "./helpers"; + +// --- Mocks --- + +const mockRetryRequest = vi.fn(); +vi.mock("@/utils", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, retryRequest: (...args: unknown[]) => mockRetryRequest(...args) }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Tests --- + +describe("getTradeHistory", () => { + it("should return trade history data on success", async () => { + const ctx = createMockContext(); + const mockHistory = [ + { orderId: 1, orderCode: "OC1", instrument: "EURUSD", side: "BUY", type: "MARKET", status: "FILLED", quantity: 1000, filledQuantity: 1000, price: 1.105, averagePrice: 1.105, time: "2024-01-01" }, + { orderId: 2, orderCode: "OC2", instrument: "BTCUSD", side: "SELL", type: "LIMIT", status: "FILLED", quantity: 100, filledQuantity: 100, price: 65000, averagePrice: 65000, time: "2024-01-02" }, + ]; + + mockRetryRequest.mockResolvedValue({ + status: 200, + data: mockHistory, + headers: { "set-cookie": [] }, + }); + + const result = await getTradeHistory(ctx, { from: 1704067200000, to: 1704153600000 }); + + expect(result).toEqual(mockHistory); + expect(mockRetryRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: expect.stringContaining("/api/history?from=1704067200000&to=1704153600000"), + }), + ctx.retries, + ); + }); + + it("should merge cookies from response", async () => { + const ctx = createMockContext(); + + mockRetryRequest.mockResolvedValue({ + status: 200, + data: [], + headers: { "set-cookie": ["newCookie=value123; Path=/"] }, + }); + + await getTradeHistory(ctx, { from: 0, to: 1 }); + + expect(ctx.cookies).toHaveProperty("newCookie", "value123"); + }); + + it("should throw TRADE_HISTORY_ERROR on non-200 status", async () => { + const ctx = createMockContext(); + + mockRetryRequest.mockResolvedValue({ + status: 500, + data: null, + headers: { "set-cookie": [] }, + }); + + await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toThrow(DxtradeError); + await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toThrow("Trade history failed: 500"); + }); + + it("should throw TRADE_HISTORY_ERROR on network error", async () => { + const ctx = createMockContext(); + mockRetryRequest.mockRejectedValue(new Error("Network timeout")); + + await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toThrow(DxtradeError); + await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toThrow("Trade history error: Network timeout"); + }); + + it("should rethrow DxtradeError as-is", async () => { + const ctx = createMockContext(); + const original = new DxtradeError("CUSTOM", "custom"); + mockRetryRequest.mockRejectedValue(original); + + await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toBe(original); + }); + + it("should throw NO_SESSION when not authenticated", async () => { + const ctx = createMockContext({ csrf: null }); + + await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toThrow("No active session"); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..61736be --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,30 @@ +import { DxtradeError } from "@/constants"; +import type { ClientContext } from "@/client.types"; + +export function createMockContext(overrides: Partial = {}): ClientContext { + return { + config: { + username: "test", + password: "test", + broker: "FTMO", + accountId: "ACC-123", + }, + callbacks: {}, + cookies: { session: "abc" }, + csrf: "csrf-token", + accountId: "ACC-123", + atmosphereId: "atm-id-123", + broker: "https://dxtrade.ftmo.com", + retries: 1, + debug: false, + ensureSession() { + if (!this.csrf) { + throw new DxtradeError("NO_SESSION", "No active session"); + } + }, + throwError(code: string, message: string): never { + throw new DxtradeError(code, message); + }, + ...overrides, + }; +} diff --git a/tests/orders.test.ts b/tests/orders.test.ts new file mode 100644 index 0000000..33e04e9 --- /dev/null +++ b/tests/orders.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; +import { DxtradeError } from "@/constants/errors"; +import { WS_MESSAGE } from "@/constants/enums"; +import { getOrders, cancelOrder, cancelAllOrders } from "@/domains/order"; +import { createMockContext } from "./helpers"; + +// --- Mocks --- + +let wsInstance: EventEmitter & { close: ReturnType }; + +vi.mock("ws", () => { + return { + default: class MockWebSocket extends EventEmitter { + close = vi.fn(); + constructor() { + super(); + // eslint-disable-next-line @typescript-eslint/no-this-alias + wsInstance = this as any; + } + }, + }; +}); + +const mockRetryRequest = vi.fn(); +vi.mock("@/utils", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, retryRequest: (...args: unknown[]) => mockRetryRequest(...args) }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Tests --- + +describe("getOrders", () => { + it("should return orders from WebSocket ORDERS message", async () => { + const ctx = createMockContext(); + const mockOrders = [ + { account: "ACC-123", orderId: 1, orderCode: "OC1", type: "LIMIT", instrument: "EURUSD", status: "WORKING", finalStatus: false, side: "BUY", tif: "GTC", legs: [], issueTime: "2024-01-01", transactionTime: "2024-01-01" }, + { account: "ACC-123", orderId: 2, orderCode: "OC2", type: "STOP", instrument: "BTCUSD", status: "WORKING", finalStatus: false, side: "SELL", tif: "GTC", legs: [], issueTime: "2024-01-01", transactionTime: "2024-01-01" }, + ]; + + const promise = getOrders(ctx); + + const payload = JSON.stringify({ accountId: "ACC-123", type: WS_MESSAGE.ORDERS, body: mockOrders }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + + const result = await promise; + expect(result).toEqual(mockOrders); + expect(wsInstance.close).toHaveBeenCalled(); + }); + + it("should ignore string WS messages", async () => { + const ctx = createMockContext(); + + const promise = getOrders(ctx, 500); + + // First emit a string (atmosphere tracking id), then orders + wsInstance.emit("message", Buffer.from("36|some-tracking-id|0||")); + + const mockOrders = [{ orderId: 1, status: "WORKING", finalStatus: false }]; + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.ORDERS, body: mockOrders }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + + const result = await promise; + expect(result).toEqual(mockOrders); + }); + + it("should reject on WS error", async () => { + const ctx = createMockContext(); + + const promise = getOrders(ctx); + wsInstance.emit("error", new Error("connection failed")); + + await expect(promise).rejects.toThrow(DxtradeError); + await expect(promise).rejects.toThrow("Orders error: connection failed"); + }); + + it("should reject on timeout", async () => { + vi.useFakeTimers(); + const ctx = createMockContext(); + + const promise = getOrders(ctx, 1000); + + vi.advanceTimersByTime(1001); + + await expect(promise).rejects.toThrow(DxtradeError); + await expect(promise).rejects.toThrow("Orders request timed out"); + + vi.useRealTimers(); + }); + + it("should throw NO_SESSION when not authenticated", async () => { + const ctx = createMockContext({ csrf: null }); + + await expect(getOrders(ctx)).rejects.toThrow(DxtradeError); + await expect(getOrders(ctx)).rejects.toThrow("No active session"); + }); +}); + +describe("cancelOrder", () => { + it("should send DELETE request with correct URL", async () => { + const ctx = createMockContext(); + mockRetryRequest.mockResolvedValue({ status: 200 }); + + await cancelOrder(ctx, 12345); + + expect(mockRetryRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "DELETE", + url: expect.stringContaining("orderChainId=12345"), + }), + ctx.retries, + ); + expect(mockRetryRequest.mock.calls[0][0].url).toContain("accountId=ACC-123"); + }); + + it("should throw when accountId is missing", async () => { + const ctx = createMockContext({ + accountId: null, + config: { username: "test", password: "test", broker: "FTMO" }, + }); + + await expect(cancelOrder(ctx, 12345)).rejects.toThrow("accountId is required to cancel an order"); + }); + + it("should throw CANCEL_ORDER_ERROR on request failure", async () => { + const ctx = createMockContext(); + mockRetryRequest.mockRejectedValue(new Error("Network error")); + + await expect(cancelOrder(ctx, 12345)).rejects.toThrow(DxtradeError); + await expect(cancelOrder(ctx, 12345)).rejects.toThrow("Cancel order error"); + }); + + it("should rethrow DxtradeError as-is", async () => { + const ctx = createMockContext(); + const original = new DxtradeError("CUSTOM", "custom error"); + mockRetryRequest.mockRejectedValue(original); + + await expect(cancelOrder(ctx, 12345)).rejects.toBe(original); + }); +}); + +describe("cancelAllOrders", () => { + it("should cancel only non-final orders", async () => { + const ctx = createMockContext(); + + const mockOrders = [ + { orderId: 1, finalStatus: false }, + { orderId: 2, finalStatus: true }, + { orderId: 3, finalStatus: false }, + ]; + + // getOrders will use the WS mock + setTimeout(() => { + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.ORDERS, body: mockOrders }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + }, 0); + + mockRetryRequest.mockResolvedValue({ status: 200 }); + + await cancelAllOrders(ctx); + + // Should have called cancelOrder for orders 1 and 3, not 2 + expect(mockRetryRequest).toHaveBeenCalledTimes(2); + expect(mockRetryRequest.mock.calls[0][0].url).toContain("orderChainId=1"); + expect(mockRetryRequest.mock.calls[1][0].url).toContain("orderChainId=3"); + }); + + it("should do nothing when all orders are final", async () => { + const ctx = createMockContext(); + + const mockOrders = [{ orderId: 1, finalStatus: true }]; + + setTimeout(() => { + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.ORDERS, body: mockOrders }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + }, 0); + + await cancelAllOrders(ctx); + + expect(mockRetryRequest).not.toHaveBeenCalled(); + }); + + it("should do nothing when there are no orders", async () => { + const ctx = createMockContext(); + + setTimeout(() => { + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.ORDERS, body: [] }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + }, 0); + + await cancelAllOrders(ctx); + + expect(mockRetryRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/positions.test.ts b/tests/positions.test.ts new file mode 100644 index 0000000..1f61df8 --- /dev/null +++ b/tests/positions.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; +import { DxtradeError } from "@/constants/errors"; +import { WS_MESSAGE } from "@/constants/enums"; +import { getPositionMetrics, closeAllPositions } from "@/domains/position"; +import { createMockContext } from "./helpers"; + +// --- Mocks --- + +let wsInstance: EventEmitter & { close: ReturnType }; + +vi.mock("ws", () => { + return { + default: class MockWebSocket extends EventEmitter { + close = vi.fn(); + constructor() { + super(); + // eslint-disable-next-line @typescript-eslint/no-this-alias + wsInstance = this as any; + } + }, + }; +}); + +const mockRetryRequest = vi.fn(); +vi.mock("@/utils", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, retryRequest: (...args: unknown[]) => mockRetryRequest(...args) }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Tests --- + +describe("getPositionMetrics", () => { + it("should return metrics from WebSocket POSITION_METRICS message", async () => { + const ctx = createMockContext(); + const mockMetrics = [ + { positionCode: "POS-1", openPl: 150.5, openPlPerLot: 15.05, currentPrice: 1.1234, convertedOpenPl: 150.5 }, + { positionCode: "POS-2", openPl: -42.0, openPlPerLot: -4.2, currentPrice: 65000.0, convertedOpenPl: -42.0 }, + ]; + + const promise = getPositionMetrics(ctx); + + const payload = JSON.stringify({ accountId: "ACC-123", type: WS_MESSAGE.POSITION_METRICS, body: mockMetrics }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + + const result = await promise; + expect(result).toEqual(mockMetrics); + expect(wsInstance.close).toHaveBeenCalled(); + }); + + it("should ignore non-matching WS message types", async () => { + const ctx = createMockContext(); + const mockMetrics = [{ positionCode: "POS-1", openPl: 100, openPlPerLot: 10, currentPrice: 1.1, convertedOpenPl: 100 }]; + + const promise = getPositionMetrics(ctx); + + // Send an unrelated message first + const otherPayload = JSON.stringify({ accountId: null, type: WS_MESSAGE.ACCOUNT_METRICS, body: {} }); + wsInstance.emit("message", Buffer.from(`${otherPayload.length}|${otherPayload}`)); + + // Then send the real one + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.POSITION_METRICS, body: mockMetrics }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + + const result = await promise; + expect(result).toEqual(mockMetrics); + }); + + it("should reject on WS error", async () => { + const ctx = createMockContext(); + + const promise = getPositionMetrics(ctx); + wsInstance.emit("error", new Error("ws failed")); + + await expect(promise).rejects.toThrow(DxtradeError); + await expect(promise).rejects.toThrow("Position metrics error: ws failed"); + }); + + it("should reject on timeout", async () => { + vi.useFakeTimers(); + const ctx = createMockContext(); + + const promise = getPositionMetrics(ctx, 2000); + vi.advanceTimersByTime(2001); + + await expect(promise).rejects.toThrow(DxtradeError); + await expect(promise).rejects.toThrow("Position metrics timed out"); + + vi.useRealTimers(); + }); + + it("should throw NO_SESSION when not authenticated", async () => { + const ctx = createMockContext({ csrf: null }); + + await expect(getPositionMetrics(ctx)).rejects.toThrow("No active session"); + }); +}); + +describe("closeAllPositions", () => { + it("should close each position with a market order", async () => { + const ctx = createMockContext(); + + const mockPositions = [ + { + uid: "u1", + accountId: "ACC-123", + positionKey: { instrumentId: 3438, positionCode: "POS-1" }, + quantity: 1000, + cost: 1000, + costBasis: 1000, + openCost: 1000, + marginRate: 0.01, + time: 0, + modifiedTime: 0, + userLogin: "test", + takeProfit: null, + stopLoss: null, + }, + { + uid: "u2", + accountId: "ACC-123", + positionKey: { instrumentId: 4567, positionCode: "POS-2" }, + quantity: -500, + cost: 500, + costBasis: 500, + openCost: 500, + marginRate: 0.01, + time: 0, + modifiedTime: 0, + userLogin: "test", + takeProfit: null, + stopLoss: null, + }, + ]; + + // getPositions uses WS, closePosition uses retryRequest + setTimeout(() => { + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.POSITIONS, body: mockPositions }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + }, 0); + + mockRetryRequest.mockResolvedValue({ status: 200 }); + + await closeAllPositions(ctx); + + expect(mockRetryRequest).toHaveBeenCalledTimes(2); + + // First call: close POS-1 with quantity -1000 + const firstCall = mockRetryRequest.mock.calls[0][0]; + expect(firstCall.method).toBe("POST"); + expect(firstCall.data.quantity).toBe(-1000); + expect(firstCall.data.orderType).toBe("MARKET"); + expect(firstCall.data.legs[0].instrumentId).toBe(3438); + expect(firstCall.data.legs[0].positionCode).toBe("POS-1"); + expect(firstCall.data.legs[0].positionEffect).toBe("CLOSING"); + + // Second call: close POS-2 with quantity 500 (negated from -500) + const secondCall = mockRetryRequest.mock.calls[1][0]; + expect(secondCall.data.quantity).toBe(500); + expect(secondCall.data.legs[0].instrumentId).toBe(4567); + expect(secondCall.data.legs[0].positionCode).toBe("POS-2"); + }); + + it("should do nothing when there are no positions", async () => { + const ctx = createMockContext(); + + setTimeout(() => { + const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.POSITIONS, body: [] }); + wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); + }, 0); + + await closeAllPositions(ctx); + + expect(mockRetryRequest).not.toHaveBeenCalled(); + }); +});