diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9ac8fb4..ffe0919 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -106,6 +106,9 @@ The fix: `connect()` captures the tracking ID from the handshake and stores it i - **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`) +- **Message categories**: Always use `MESSAGE_CATEGORY` enum — never use raw strings (e.g. `MESSAGE_CATEGORY.TRADE_LOG`, not `"TRADE_LOG"`) +- **Message types**: Always use `MESSAGE_TYPE` enum — never use raw strings (e.g. `MESSAGE_TYPE.ORDER`, not `"ORDER"`) +- **Order statuses**: Always use `ORDER_STATUS` enum — never use raw strings (e.g. `ORDER_STATUS.FILLED`, not `"FILLED"`) - When adding a new error code, WebSocket message type, or subtopic, add it to `src/constants/enums.ts` ### General: diff --git a/README.md b/README.md index 7875b8c..c5fa451 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ BROKER.FTMO // "https://dxtrade.ftmo.com" ### Positions - `client.positions.get()` — Get all open positions with P&L metrics merged (margin, plOpen, marketValue, etc.) -- `client.positions.close(params)` — Close a position (supports partial closes via the quantity field) +- `client.positions.close(positionCode, options?)` — Close a position by its position code. Returns `Position.Full` with P&L metrics. Options: `waitForClose: "stream" | "poll"`, `timeout` (ms, default 30000), `pollInterval` (ms, default 1000) - `client.positions.closeAll()` — Close all open positions with market orders - `client.positions.stream(callback)` — Stream real-time position updates with live P&L (requires `connect()`). Returns an unsubscribe function. @@ -185,6 +185,7 @@ npm run example:debug npm run example:positions:get npm run example:positions:close npm run example:positions:close-all +npm run example:positions:close-by-code npm run example:positions:metrics npm run example:positions:stream npm run example:orders:submit diff --git a/examples/orders.submit.ts b/examples/orders.submit.ts index da2aa32..213d93a 100644 --- a/examples/orders.submit.ts +++ b/examples/orders.submit.ts @@ -14,7 +14,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); (async () => { await client.auth(); - const suggestions = await client.symbols.search("EURUSD"); + const suggestions = await client.symbols.search("ETHUSD"); const symbol = suggestions[0]; console.log(`Found symbol: ${symbol.name} (id: ${symbol.id})`); diff --git a/examples/positions.close-by-code.ts b/examples/positions.close-by-code.ts new file mode 100644 index 0000000..5c9622c --- /dev/null +++ b/examples/positions.close-by-code.ts @@ -0,0 +1,50 @@ +import "dotenv/config"; +import { DxtradeClient, ORDER_TYPE, SIDE, 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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +(async () => { + await client.connect(); + + const suggestions = await client.symbols.search("ETHUSD"); + const symbol = suggestions[0]; + console.log(`Found symbol: ${symbol.name} (id: ${symbol.id})`); + + const info = await client.symbols.info(symbol.name); + console.log(`Min volume: ${info.minVolume}, Lot size: ${info.lotSize}`); + + // 1. Submit a market order + const order = await client.orders.submit({ + symbol: symbol.name, + side: SIDE.BUY, + quantity: info.minVolume, + orderType: ORDER_TYPE.MARKET, + instrumentId: symbol.id, + }); + console.log(`Order filled: ${order.orderId} — positionCode: ${order.positionCode}`); + + // 2. Wait 3 seconds for P&L to update + console.log("\nWaiting 3 seconds..."); + await sleep(3000); + + // 3. Close the position by its code and wait for confirmation via streaming + console.log("Closing position by code (waiting for close confirmation)..."); + const position = await client.positions.close(order.positionCode!, { waitForClose: "stream" }); + + console.log("\nPosition closed:"); + console.log(` P&L Open: ${position.plOpen}`); + console.log(` P&L Closed: ${position.plClosed}`); + console.log(` Commissions: ${position.totalCommissions}`); + console.log(` Financing: ${position.totalFinancing}`); + console.log(` Market Value: ${position.marketValue}`); + + client.disconnect(); +})().catch(console.error); diff --git a/examples/positions.close.ts b/examples/positions.close.ts index 98c9844..cb0b8a7 100644 --- a/examples/positions.close.ts +++ b/examples/positions.close.ts @@ -11,23 +11,17 @@ const client = new DxtradeClient({ (async () => { await client.auth(); - // TODO:: improve parameters! maybe even have a "closeWholePosition" function - const positions = await client.positions.close({ - legs: [ - { - instrumentId: 3438, - positionCode: "191361108", - positionEffect: "CLOSING", - ratioQuantity: 1, - symbol: "EURUSD", - }, - ], - limitPrice: 1.18725, - orderType: "MARKET", - quantity: -1000, - timeInForce: "GTC", - }); + const positions = await client.positions.get(); + if (positions.length === 0) { + console.log("No open positions to close"); + return; + } - console.log("Positions: ", positions); + const position = positions[0]; + const code = position.positionKey.positionCode; + console.log(`Closing position ${code}...`); + + const closed = await client.positions.close(code); + console.log("Position closed:", closed); })().catch(console.error); diff --git a/llms.txt b/llms.txt index e7c243f..1ba7b67 100644 --- a/llms.txt +++ b/llms.txt @@ -33,7 +33,8 @@ await client.connect(); // auth + persistent WebSocket (recommended) ### Positions - client.positions.get() — Get all open positions with P&L metrics merged (margin, plOpen, marketValue, etc.), returns Position.Full[] -- client.positions.close(params: Position.Close) — Close a position (supports partial closes via the quantity field) +- client.positions.close(positionCode: string, options?: Position.CloseOptions) — Close a position by its position code, returns Position.Full with P&L metrics + Options: waitForClose ("stream" | "poll") to wait for the position to disappear, timeout (ms, default 30000), pollInterval (ms, default 1000, poll mode only) - client.positions.closeAll() — Close all open positions with market orders - client.positions.stream(callback: (positions: Position.Full[]) => void) — Stream real-time position updates with live P&L (requires connect()). Returns unsubscribe function. diff --git a/package-lock.json b/package-lock.json index a79e6db..9453a5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,32 +70,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/config-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@commitlint/execute-rule": { "version": "20.0.0", "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", @@ -723,6 +697,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -744,6 +735,13 @@ "node": ">= 4" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1665,16 +1663,17 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -1708,19 +1707,16 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "color-convert": "^1.9.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=4" } }, "node_modules/any-promise": { @@ -1932,20 +1928,18 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=4" } }, "node_modules/chardet": { @@ -2018,22 +2012,19 @@ } }, "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "color-name": "1.1.3" } }, "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, "license": "MIT" }, @@ -2193,84 +2184,6 @@ "@commitlint/load": ">6.1.1" } }, - "node_modules/cz-conventional-changelog/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cz-conventional-changelog/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cz-conventional-changelog/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/cz-conventional-changelog/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cz-conventional-changelog/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/cz-conventional-changelog/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/cz-conventional-changelog/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/cz-git": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/cz-git/-/cz-git-1.12.0.tgz", @@ -2507,16 +2420,13 @@ } }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.0" } }, "node_modules/eslint": { @@ -2626,6 +2536,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2637,33 +2580,100 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">= 4" + "node": ">=7.0.0" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -2673,6 +2683,19 @@ "node": "*" } }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2837,24 +2860,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -2871,16 +2876,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3285,13 +3280,13 @@ "license": "ISC" }, "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/has-symbols": { @@ -3496,6 +3491,82 @@ "node": ">=12.0.0" } }, + "node_modules/inquirer/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/inquirer/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/inquirer/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3666,11 +3737,12 @@ "optional": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -3808,6 +3880,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/longest": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", @@ -3872,19 +4020,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4096,6 +4231,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -4226,14 +4437,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -4726,16 +4936,16 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "has-flag": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/thenify": { @@ -4799,6 +5009,38 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -5104,6 +5346,38 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", @@ -5182,6 +5456,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest/node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -5263,6 +5550,42 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 214e3b7..121aacd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "example:positions:get": "tsx examples/positions.get.ts", "example:positions:close": "tsx examples/positions.close.ts", "example:positions:close-all": "tsx examples/positions.close-all.ts", + "example:positions:close-by-code": "tsx examples/positions.close-by-code.ts", "example:positions:stream": "tsx examples/positions.stream.ts", "example:orders:submit": "tsx examples/orders.submit.ts", "example:account:metrics": "tsx examples/account.metrics.ts", diff --git a/src/client.ts b/src/client.ts index 8ba4c1e..80922ad 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,169 +1,16 @@ import { DxtradeError, ERROR } from "@/constants"; import type { ClientContext, DxtradeConfig } from "./client.types"; -import type { Account, Assessments, Instrument, OHLC, Order, Position, Symbol } from "./domains"; import { - login, - fetchCsrf, - switchAccount, - auth, - connect, - disconnect, - getAccountMetrics, - getTradeHistory, - getPositions, - closePosition, - closeAllPositions, - streamPositions, - getAssessments, - getInstruments, - getSymbolLimits, - getSymbolSuggestions, - getOHLC, - streamOHLC, - getSymbolInfo, - getOrders, - cancelOrder, - cancelAllOrders, - submitOrder, - getTradeJournal, + SessionDomain, + PositionsDomain, + OrdersDomain, + AccountDomain, + SymbolsDomain, + InstrumentsDomain, + OhlcDomain, + AssessmentsDomain, } from "@/domains"; -class PositionsDomain { - constructor(private _ctx: ClientContext) {} - - /** Get all open positions with P&L metrics merged. */ - get(): Promise { - return getPositions(this._ctx); - } - - /** Close a position. Supports partial closes by specifying a quantity smaller than the full position size. */ - close(params: Position.Close): Promise { - return closePosition(this._ctx, params); - } - - /** Close all open positions with market orders. */ - closeAll(): Promise { - return closeAllPositions(this._ctx); - } - - /** Stream real-time position updates with P&L metrics. Requires connect(). Returns unsubscribe function. */ - stream(callback: (positions: Position.Full[]) => void): () => void { - return streamPositions(this._ctx, callback); - } -} - -class OrdersDomain { - constructor(private _ctx: ClientContext) {} - - /** Get all pending/open orders via WebSocket. */ - get(): Promise { - return getOrders(this._ctx); - } - - /** - * Submit a trading order and wait for WebSocket confirmation. - * Supports market, limit, and stop orders with optional stop loss and take profit. - */ - submit(params: Order.SubmitParams): Promise { - return submitOrder(this._ctx, params); - } - - /** Cancel a single pending order by its order chain ID. */ - cancel(orderChainId: number): Promise { - return cancelOrder(this._ctx, orderChainId); - } - - /** Cancel all pending orders. */ - cancelAll(): Promise { - return cancelAllOrders(this._ctx); - } -} - -class AccountDomain { - constructor(private _ctx: ClientContext) {} - - /** Get account metrics including equity, balance, margin, and open P&L. */ - metrics(): Promise { - return getAccountMetrics(this._ctx); - } - - /** - * Fetch trade journal entries for a date range. - * @param params.from - Start timestamp (Unix ms) - * @param params.to - End timestamp (Unix ms) - */ - tradeJournal(params: { from: number; to: number }): Promise { - 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) - */ - tradeHistory(params: { from: number; to: number }): Promise { - return getTradeHistory(this._ctx, params); - } -} - -class SymbolsDomain { - constructor(private _ctx: ClientContext) {} - - /** Search for symbols matching the given text (e.g. "EURUSD", "BTC"). */ - search(text: string): Promise { - return getSymbolSuggestions(this._ctx, text); - } - - /** Get detailed instrument info for a symbol, including volume limits and lot size. */ - info(symbol: string): Promise { - return getSymbolInfo(this._ctx, symbol); - } - - /** Get order size limits and stop/limit distances for all symbols. */ - limits(): Promise { - return getSymbolLimits(this._ctx); - } -} - -class InstrumentsDomain { - constructor(private _ctx: ClientContext) {} - - /** Get all available instruments, optionally filtered by partial match (e.g. `{ type: "FOREX" }`). */ - get(params: Partial = {}): Promise { - return getInstruments(this._ctx, params); - } -} - -class OhlcDomain { - constructor(private _ctx: ClientContext) {} - - /** - * 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") - */ - get(params: OHLC.Params): Promise { - return getOHLC(this._ctx, params); - } - - /** Stream real-time OHLC bar updates. Requires connect(). Returns unsubscribe function. */ - stream(params: OHLC.Params, callback: (bars: OHLC.Bar[]) => void): Promise<() => void> { - return streamOHLC(this._ctx, params, callback); - } -} - -class AssessmentsDomain { - constructor(private _ctx: ClientContext) {} - - /** Fetch PnL assessments for an instrument within a date range. */ - get(params: Assessments.Params): Promise { - return getAssessments(this._ctx, params); - } -} - /** * Client for interacting with the DXtrade trading API. * @@ -182,6 +29,7 @@ class AssessmentsDomain { */ export class DxtradeClient { private _ctx: ClientContext; + private _session: SessionDomain; /** Position operations: get, close, metrics, streaming. */ public readonly positions: PositionsDomain; @@ -224,6 +72,7 @@ export class DxtradeClient { }, }; + this._session = new SessionDomain(this._ctx); this.positions = new PositionsDomain(this._ctx); this.orders = new OrdersDomain(this._ctx); this.account = new AccountDomain(this._ctx); @@ -235,31 +84,31 @@ export class DxtradeClient { /** Authenticate with the broker using username and password. */ public async login(): Promise { - return login(this._ctx); + return this._session.login(); } /** Fetch the CSRF token required for authenticated requests. */ public async fetchCsrf(): Promise { - return fetchCsrf(this._ctx); + return this._session.fetchCsrf(); } /** Switch to a specific trading account by ID. */ public async switchAccount(accountId: string): Promise { - return switchAccount(this._ctx, accountId); + return this._session.switchAccount(accountId); } /** Authenticate and establish a session: login, fetch CSRF, WebSocket handshake, and optional account switch. */ public async auth(): Promise { - return auth(this._ctx); + return this._session.auth(); } /** Connect to the broker with a persistent WebSocket: auth + persistent WS for data reuse and streaming. */ public async connect(): Promise { - return connect(this._ctx); + return this._session.connect(); } /** Close the persistent WebSocket connection. */ public disconnect(): void { - return disconnect(this._ctx); + return this._session.disconnect(); } } diff --git a/src/constants/enums.ts b/src/constants/enums.ts index eb5f05b..dbc4572 100644 --- a/src/constants/enums.ts +++ b/src/constants/enums.ts @@ -47,7 +47,9 @@ export enum ERROR { ORDERS_TIMEOUT = "ORDERS_TIMEOUT", ORDERS_ERROR = "ORDERS_ERROR", CANCEL_ORDER_ERROR = "CANCEL_ORDER_ERROR", + POSITION_NOT_FOUND = "POSITION_NOT_FOUND", POSITION_CLOSE_ERROR = "POSITION_CLOSE_ERROR", + POSITION_CLOSE_TIMEOUT = "POSITION_CLOSE_TIMEOUT", POSITION_METRICS_TIMEOUT = "POSITION_METRICS_TIMEOUT", POSITION_METRICS_ERROR = "POSITION_METRICS_ERROR", @@ -70,6 +72,22 @@ export enum ERROR { STREAM_REQUIRES_CONNECT = "STREAM_REQUIRES_CONNECT", } +export enum MESSAGE_CATEGORY { + TRADE_LOG = "TRADE_LOG", + NOTIFICATION = "NOTIFICATION", +} + +export enum MESSAGE_TYPE { + ORDER = "ORDER", + INSTRUMENT_ACTIVATED = "INSTRUMENT_ACTIVATED", +} + +export enum ORDER_STATUS { + PLACED = "PLACED", + FILLED = "FILLED", + REJECTED = "REJECTED", +} + export enum WS_MESSAGE { ACCOUNT_METRICS = "ACCOUNT_METRICS", ACCOUNTS = "ACCOUNTS", diff --git a/src/domains/account/account.ts b/src/domains/account/account.ts index 62d5216..8c89b50 100644 --- a/src/domains/account/account.ts +++ b/src/domains/account/account.ts @@ -1,106 +1,131 @@ import WebSocket from "ws"; import { WS_MESSAGE, ERROR, endpoints, DxtradeError } from "@/constants"; -import { Cookies, parseWsData, shouldLog, debugLog, retryRequest, baseHeaders, authHeaders } from "@/utils"; +import { + Cookies, + parseWsData, + shouldLog, + debugLog, + retryRequest, + baseHeaders, + authHeaders, + checkWsRateLimit, +} from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Account } from "."; -export async function getAccountMetrics(ctx: ClientContext, timeout = 30_000): Promise { - ctx.ensureSession(); +export class AccountDomain { + constructor(private _ctx: ClientContext) {} - if (ctx.wsManager) { - const body = await ctx.wsManager.waitFor<{ allMetrics: Account.Metrics }>(WS_MESSAGE.ACCOUNT_METRICS, timeout); - return body.allMetrics; - } + /** Get account metrics including equity, balance, margin, and open P&L. */ + async metrics(timeout = 30_000): Promise { + this._ctx.ensureSession(); + + if (this._ctx.wsManager) { + const body = await this._ctx.wsManager.waitFor<{ allMetrics: Account.Metrics }>( + WS_MESSAGE.ACCOUNT_METRICS, + timeout, + ); + return body.allMetrics; + } + + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); - 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 } }); - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + const timer = setTimeout(() => { + ws.close(); + reject(new DxtradeError(ERROR.ACCOUNT_METRICS_TIMEOUT, "Account metrics timed out")); + }, timeout); - const timer = setTimeout(() => { - ws.close(); - reject(new DxtradeError(ERROR.ACCOUNT_METRICS_TIMEOUT, "Account metrics timed out")); - }, timeout); + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, this._ctx.debug)) debugLog(msg); - 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.ACCOUNT_METRICS) { + clearTimeout(timer); + ws.close(); + const body = msg.body as { allMetrics: Account.Metrics }; + resolve(body.allMetrics); + } + }); - if (typeof msg === "string") return; - if (msg.type === WS_MESSAGE.ACCOUNT_METRICS) { + ws.on("error", (error) => { clearTimeout(timer); ws.close(); - const body = msg.body as { allMetrics: Account.Metrics }; - resolve(body.allMetrics); - } + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.ACCOUNT_METRICS_ERROR, `Account metrics error: ${error.message}`)); + }); }); + } - ws.on("error", (error) => { - clearTimeout(timer); - ws.close(); - reject(new DxtradeError(ERROR.ACCOUNT_METRICS_ERROR, `Account metrics error: ${error.message}`)); - }); - }); -} + /** + * Fetch trade history for a date range. + * @param params.from - Start timestamp (Unix ms) + * @param params.to - End timestamp (Unix ms) + */ + async tradeHistory(params: { from: number; to: number }): Promise { + this._ctx.ensureSession(); + + try { + const response = await retryRequest( + { + method: "POST", + url: endpoints.tradeHistory(this._ctx.broker, params), + headers: authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)), + }, + this._ctx.retries, + ); -export async function getTradeHistory( - ctx: ClientContext, - params: { from: number; to: number }, -): Promise { - ctx.ensureSession(); - - try { - const response = await retryRequest( - { - method: "POST", - url: endpoints.tradeHistory(ctx.broker, params), - headers: authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)), - }, - 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}`); + if (response.status === 200) { + const setCookies = response.headers["set-cookie"] ?? []; + const incoming = Cookies.parse(setCookies); + this._ctx.cookies = Cookies.merge(this._ctx.cookies, incoming); + return response.data as Account.TradeHistory[]; + } else { + this._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"; + this._ctx.throwError(ERROR.TRADE_HISTORY_ERROR, `Trade history error: ${message}`); } - } 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(); - - try { - const cookieStr = Cookies.serialize(ctx.cookies); - - const response = await retryRequest( - { - method: "GET", - url: endpoints.tradeJournal(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; - } else { - ctx.throwError(ERROR.TRADE_JOURNAL_ERROR, `Login failed: ${response.status}`); + /** + * Fetch trade journal entries for a date range. + * @param params.from - Start timestamp (Unix ms) + * @param params.to - End timestamp (Unix ms) + */ + async tradeJournal(params: { from: number; to: number }): Promise { + this._ctx.ensureSession(); + + try { + const cookieStr = Cookies.serialize(this._ctx.cookies); + + const response = await retryRequest( + { + method: "GET", + url: endpoints.tradeJournal(this._ctx.broker, params), + headers: { ...baseHeaders(), Cookie: cookieStr }, + }, + this._ctx.retries, + ); + + if (response.status === 200) { + const setCookies = response.headers["set-cookie"] ?? []; + const incoming = Cookies.parse(setCookies); + this._ctx.cookies = Cookies.merge(this._ctx.cookies, incoming); + return response.data; + } else { + this._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"; + this._ctx.throwError(ERROR.TRADE_JOURNAL_ERROR, `Trade journal error: ${message}`); } - } catch (error: unknown) { - if (error instanceof DxtradeError) throw error; - const message = error instanceof Error ? error.message : "Unknown error"; - 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 ff62c7a..ad5bc2b 100644 --- a/src/domains/assessments/assessments.ts +++ b/src/domains/assessments/assessments.ts @@ -3,29 +3,34 @@ import { Cookies, authHeaders, retryRequest } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Assessments } from "."; -export async function getAssessments(ctx: ClientContext, params: Assessments.Params): Promise { - ctx.ensureSession(); +export class AssessmentsDomain { + constructor(private _ctx: ClientContext) {} - try { - const response = await retryRequest( - { - method: "POST", - url: endpoints.assessments(ctx.broker), - data: { - from: params.from, - instrument: params.instrument, - subtype: params.subtype ?? null, - to: params.to, + /** Fetch PnL assessments for an instrument within a date range. */ + async get(params: Assessments.Params): Promise { + this._ctx.ensureSession(); + + try { + const response = await retryRequest( + { + method: "POST", + url: endpoints.assessments(this._ctx.broker), + data: { + from: params.from, + instrument: params.instrument, + subtype: params.subtype ?? null, + to: params.to, + }, + headers: authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)), }, - headers: authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)), - }, - ctx.retries, - ); + this._ctx.retries, + ); - return response.data as Assessments.Response; - } catch (error: unknown) { - if (error instanceof DxtradeError) throw error; - const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError(ERROR.ASSESSMENTS_ERROR, `Error fetching assessments: ${message}`); + return response.data as Assessments.Response; + } catch (error: unknown) { + if (error instanceof DxtradeError) throw error; + const message = error instanceof Error ? error.message : "Unknown error"; + this._ctx.throwError(ERROR.ASSESSMENTS_ERROR, `Error fetching assessments: ${message}`); + } } } diff --git a/src/domains/instrument/instrument.ts b/src/domains/instrument/instrument.ts index 28d3e22..568c94f 100644 --- a/src/domains/instrument/instrument.ts +++ b/src/domains/instrument/instrument.ts @@ -1,61 +1,63 @@ import WebSocket from "ws"; import { endpoints, DxtradeError, WS_MESSAGE, ERROR } from "@/constants"; -import { Cookies, parseWsData, shouldLog, debugLog } from "@/utils"; +import { Cookies, parseWsData, shouldLog, debugLog, checkWsRateLimit } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Instrument } from "."; -export async function getInstruments( - ctx: ClientContext, - params: Partial = {}, - 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.INSTRUMENTS_TIMEOUT, "Instruments request timed out")); - }, timeout); - - let instruments: Instrument.Info[] = []; - let settleTimer: ReturnType | null = null; - - 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.INSTRUMENTS) { - instruments.push(...(msg.body as Instrument.Info[])); - - // Reset settle timer on each batch — resolve once no more arrive - if (settleTimer) clearTimeout(settleTimer); - settleTimer = setTimeout(() => { - clearTimeout(timer); - ws.close(); - resolve( - instruments.filter((instrument) => { - for (const key in params) { - if (params[key as keyof Instrument.Info] !== instrument[key as keyof Instrument.Info]) { - return false; +export class InstrumentsDomain { + constructor(private _ctx: ClientContext) {} + + /** Get all available instruments, optionally filtered by partial match (e.g. `{ type: "FOREX" }`). */ + async get(params: Partial = {}, timeout = 30_000): Promise { + this._ctx.ensureSession(); + + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + + const timer = setTimeout(() => { + ws.close(); + reject(new DxtradeError(ERROR.INSTRUMENTS_TIMEOUT, "Instruments request timed out")); + }, timeout); + + let instruments: Instrument.Info[] = []; + let settleTimer: ReturnType | null = null; + + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, this._ctx.debug)) debugLog(msg); + + if (typeof msg === "string") return; + if (msg.type === WS_MESSAGE.INSTRUMENTS) { + instruments.push(...(msg.body as Instrument.Info[])); + + // Reset settle timer on each batch — resolve once no more arrive + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + clearTimeout(timer); + ws.close(); + resolve( + instruments.filter((instrument) => { + for (const key in params) { + if (params[key as keyof Instrument.Info] !== instrument[key as keyof Instrument.Info]) { + return false; + } } - } - return true; - }), - ); - }, 200); - } + return true; + }), + ); + }, 200); + } + }); + + ws.on("error", (error) => { + clearTimeout(timer); + ws.close(); + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.INSTRUMENTS_ERROR, `Instruments error: ${error.message}`)); + }); }); - - ws.on("error", (error) => { - clearTimeout(timer); - ws.close(); - reject(new DxtradeError(ERROR.INSTRUMENTS_ERROR, `Instruments error: ${error.message}`)); - }); - }); + } } diff --git a/src/domains/ohlc/ohlc.ts b/src/domains/ohlc/ohlc.ts index 1d731b3..5d52260 100644 --- a/src/domains/ohlc/ohlc.ts +++ b/src/domains/ohlc/ohlc.ts @@ -1,217 +1,227 @@ import WebSocket from "ws"; import { endpoints, DxtradeError, WS_MESSAGE, ERROR } from "@/constants"; -import { Cookies, authHeaders, retryRequest, parseWsData, shouldLog, debugLog } from "@/utils"; +import { Cookies, authHeaders, retryRequest, parseWsData, shouldLog, debugLog, checkWsRateLimit } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { OHLC } from "."; -export async function streamOHLC( - ctx: ClientContext, - params: OHLC.Params, - callback: (bars: OHLC.Bar[]) => void, -): Promise<() => void> { - if (!ctx.wsManager) { - ctx.throwError( - ERROR.STREAM_REQUIRES_CONNECT, - "Streaming requires a persistent WebSocket. Use connect() instead of auth().", - ); - } +export class OhlcDomain { + constructor(private _ctx: ClientContext) {} - const { symbol, resolution = 60, range = 432_000, maxBars = 3500, priceField = "bid" } = params; - const subtopic = WS_MESSAGE.SUBTOPIC.OHLC_STREAM; - const headers = authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)); - const snapshotBars: OHLC.Bar[] = []; - let snapshotDone = false; - let resolveSnapshot: (() => void) | null = null; - - const onChartFeed = (body: Record) => { - if (body?.subtopic !== subtopic) return; - const data = body.data as OHLC.Bar[] | undefined; - if (!Array.isArray(data)) return; - - if (!snapshotDone) { - snapshotBars.push(...data); - if (body.snapshotEnd) { - snapshotDone = true; - callback([...snapshotBars]); - resolveSnapshot?.(); - } - } else { - callback(data); + /** Stream real-time OHLC bar updates. Requires connect(). Returns unsubscribe function. */ + async stream(params: OHLC.Params, callback: (bars: OHLC.Bar[]) => void): Promise<() => void> { + if (!this._ctx.wsManager) { + this._ctx.throwError( + ERROR.STREAM_REQUIRES_CONNECT, + "Streaming requires a persistent WebSocket. Use connect() instead of auth().", + ); } - }; - - ctx.wsManager.on(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); - - 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, - symbol, - }, - ], - }, - headers, - }, - ctx.retries, - ); - } catch (error: unknown) { - ctx.wsManager.removeListener(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); - const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError(ERROR.OHLC_ERROR, `OHLC stream subscription error: ${message}`); - } - - await new Promise((resolve, reject) => { - if (snapshotDone) return resolve(); - const timer = setTimeout(() => { - if (snapshotBars.length > 0) { - snapshotDone = true; - callback([...snapshotBars]); - resolve(); + const { symbol, resolution = 60, range = 432_000, maxBars = 3500, priceField = "bid" } = params; + const subtopic = WS_MESSAGE.SUBTOPIC.OHLC_STREAM; + const headers = authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)); + const snapshotBars: OHLC.Bar[] = []; + let snapshotDone = false; + let resolveSnapshot: (() => void) | null = null; + + const onChartFeed = (body: Record) => { + if (body?.subtopic !== subtopic) return; + const data = body.data as OHLC.Bar[] | undefined; + if (!Array.isArray(data)) return; + + if (!snapshotDone) { + snapshotBars.push(...data); + if (body.snapshotEnd) { + snapshotDone = true; + callback([...snapshotBars]); + resolveSnapshot?.(); + } } else { - ctx.wsManager?.removeListener(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); - reject(new DxtradeError(ERROR.OHLC_TIMEOUT, "OHLC stream snapshot timed out")); + callback(data); } - }, 30_000); - - resolveSnapshot = () => { - clearTimeout(timer); - resolve(); }; - }); - return () => { - ctx.wsManager?.removeListener(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); - }; -} + this._ctx.wsManager.on(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); -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, + try { + await retryRequest( + { + method: "PUT", + url: endpoints.subscribeInstruments(this._ctx.broker), + data: { instruments: [symbol] }, + headers, + }, + this._ctx.retries, + ); + await retryRequest( + { + method: "PUT", + url: endpoints.charts(this._ctx.broker), + data: { + chartIds: [], + requests: [ + { + aggregationPeriodSeconds: resolution, + extendedSession: true, + forexPriceField: priceField, + id: 0, + maxBarsCount: maxBars, + range, + studySubscription: [], + subtopic, + symbol, + }, + ], }, - 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}`)); - } + headers, + }, + this._ctx.retries, + ); + } catch (error: unknown) { + this._ctx.wsManager.removeListener(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); + const message = error instanceof Error ? error.message : "Unknown error"; + this._ctx.throwError(ERROR.OHLC_ERROR, `OHLC stream subscription error: ${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; - } + await new Promise((resolve, reject) => { + if (snapshotDone) return resolve(); + + const timer = setTimeout(() => { + if (snapshotBars.length > 0) { + snapshotDone = true; + callback([...snapshotBars]); + resolve(); + } else { + this._ctx.wsManager?.removeListener(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); + reject(new DxtradeError(ERROR.OHLC_TIMEOUT, "OHLC stream snapshot timed out")); + } + }, 30_000); + + resolveSnapshot = () => { + clearTimeout(timer); + resolve(); + }; + }); - // Collect chart bars - const body = msg.body as Record; - if (body?.subtopic !== WS_MESSAGE.SUBTOPIC.BIG_CHART_COMPONENT) return; + return () => { + this._ctx.wsManager?.removeListener(WS_MESSAGE.CHART_FEED_SUBTOPIC, onChartFeed); + }; + } - if (Array.isArray(body.data)) { - bars.push(...(body.data as OHLC.Bar[])); + /** + * 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") + */ + async get(params: OHLC.Params, timeout = 30_000): Promise { + this._ctx.ensureSession(); + + const { symbol, resolution = 60, range = 432_000, maxBars = 3500, priceField = "bid" } = params; + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); + const headers = authHeaders(this._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(); } - if (barSettleTimer) clearTimeout(barSettleTimer); - if (body.snapshotEnd) { - cleanup(); - resolve(bars); - } else { - barSettleTimer = setTimeout(() => { + const sendPuts = async () => { + putsSent = true; + try { + await retryRequest( + { + method: "PUT", + url: endpoints.subscribeInstruments(this._ctx.broker), + data: { instruments: [symbol] }, + headers, + }, + this._ctx.retries, + ); + await retryRequest( + { + method: "PUT", + url: endpoints.charts(this._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, + }, + this._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, this._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); - }, 2000); - } - }); - - ws.on("error", (error) => { - cleanup(); - reject(new DxtradeError(ERROR.OHLC_ERROR, `OHLC WebSocket error: ${error.message}`)); + } else { + barSettleTimer = setTimeout(() => { + cleanup(); + resolve(bars); + }, 2000); + } + }); + + ws.on("error", (error) => { + cleanup(); + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.OHLC_ERROR, `OHLC WebSocket error: ${error.message}`)); + }); }); - }); + } } diff --git a/src/domains/order/order.ts b/src/domains/order/order.ts index 1ca8016..1ad3210 100644 --- a/src/domains/order/order.ts +++ b/src/domains/order/order.ts @@ -1,10 +1,10 @@ import crypto from "crypto"; import WebSocket from "ws"; 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 { WS_MESSAGE, MESSAGE_CATEGORY, MESSAGE_TYPE, ORDER_STATUS } from "@/constants/enums"; +import { Cookies, authHeaders, retryRequest, parseWsData, shouldLog, debugLog, checkWsRateLimit } from "@/utils"; import type { ClientContext } from "@/client.types"; -import { getSymbolInfo } from "../symbol/symbol"; +import { SymbolsDomain } from "../symbol/symbol"; import type { Order, Message } from "."; function createOrderListener( @@ -46,21 +46,25 @@ function createOrderListener( if (msg.type === WS_MESSAGE.MESSAGE) { const messages = msg.body as Message.Entry[]; const orderMsg = messages?.findLast?.( - (m) => m.messageCategory === "TRADE_LOG" && m.messageType === "ORDER" && !m.historyMessage, + (m) => + m.messageCategory === MESSAGE_CATEGORY.TRADE_LOG && + m.messageType === MESSAGE_TYPE.ORDER && + !m.historyMessage, ); if (!orderMsg) return; const params = orderMsg.parametersTO as Message.OrderParams; - if (params.orderStatus === "REJECTED") { + if (params.orderStatus === ORDER_STATUS.REJECTED) { const reason = params.rejectReason?.key ?? "Unknown reason"; done(new Error(`[dxtrade-api] Order rejected: ${reason}`)); - } else if (params.orderStatus === "FILLED") { + } else if (params.orderStatus === ORDER_STATUS.FILLED) { done(null, { orderId: params.orderKey, status: params.orderStatus, symbol: params.symbol, filledQuantity: params.filledQuantity, filledPrice: params.filledPrice, + positionCode: params.positionCode, }); } return; @@ -71,9 +75,9 @@ function createOrderListener( const body = (msg.body as Order.Update[])?.[0]; if (!body?.orderId) return; - if (body.status === "REJECTED") { + if (body.status === ORDER_STATUS.REJECTED) { done(new Error(`[dxtrade-api] Order rejected: ${body.statusDescription ?? "Unknown reason"}`)); - } else if (body.status === "FILLED") { + } else if (body.status === ORDER_STATUS.FILLED) { done(null, body); } } @@ -84,186 +88,271 @@ function createOrderListener( settled = true; clearTimeout(timer); ws.close(); - reject(new Error(`[dxtrade-api] WebSocket order listener error: ${error.message}`)); + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.ORDER_ERROR, `WebSocket order listener error: ${error.message}`)); }); }); return { promise, ready }; } -export async function getOrders(ctx: ClientContext, timeout = 30_000): Promise { - ctx.ensureSession(); - - if (ctx.wsManager) { - return ctx.wsManager.waitFor(WS_MESSAGE.ORDERS, timeout); - } - - const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId); - const cookieStr = Cookies.serialize(ctx.cookies); - +function createWsManagerOrderListener(ctx: ClientContext, timeout = 30_000): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + let settled = false; const timer = setTimeout(() => { - ws.close(); - reject(new DxtradeError(ERROR.ORDERS_TIMEOUT, "Orders request timed out")); + if (settled) return; + settled = true; + cleanup(); + reject(new Error("[dxtrade-api] Order update timed out")); }, timeout); - ws.on("message", (data) => { - const msg = parseWsData(data); - if (shouldLog(msg, ctx.debug)) debugLog(msg); + function done(err: Error | null, result?: Order.Update) { + if (settled) return; + settled = true; + clearTimeout(timer); + cleanup(); + if (err) reject(err); + else resolve(result!); + } - if (typeof msg === "string") return; - if (msg.type === WS_MESSAGE.ORDERS) { - clearTimeout(timer); - ws.close(); - resolve(msg.body as Order.Get[]); + function onMessage(body: unknown) { + const messages = body as Message.Entry[]; + const orderMsg = messages?.findLast?.( + (m) => + m.messageCategory === MESSAGE_CATEGORY.TRADE_LOG && m.messageType === MESSAGE_TYPE.ORDER && !m.historyMessage, + ); + if (!orderMsg) return; + + const params = orderMsg.parametersTO as Message.OrderParams; + if (params.orderStatus === ORDER_STATUS.REJECTED) { + const reason = params.rejectReason?.key ?? "Unknown reason"; + done(new Error(`[dxtrade-api] Order rejected: ${reason}`)); + } else if (params.orderStatus === ORDER_STATUS.FILLED) { + done(null, { + orderId: params.orderKey, + status: params.orderStatus, + symbol: params.symbol, + filledQuantity: params.filledQuantity, + filledPrice: params.filledPrice, + positionCode: params.positionCode, + }); } - }); + } - ws.on("error", (error) => { - clearTimeout(timer); - ws.close(); - reject(new DxtradeError(ERROR.ORDERS_ERROR, `Orders error: ${error.message}`)); - }); + function onOrders(body: unknown) { + const order = (body as Order.Update[])?.[0]; + if (!order?.orderId) return; + + if (order.status === ORDER_STATUS.REJECTED) { + done(new Error(`[dxtrade-api] Order rejected: ${order.statusDescription ?? "Unknown reason"}`)); + } else if (order.status === ORDER_STATUS.FILLED) { + done(null, order); + } + } + + function cleanup() { + ctx.wsManager?.removeListener(WS_MESSAGE.MESSAGE, onMessage); + ctx.wsManager?.removeListener(WS_MESSAGE.ORDERS, onOrders); + } + + ctx.wsManager!.on(WS_MESSAGE.MESSAGE, onMessage); + ctx.wsManager!.on(WS_MESSAGE.ORDERS, onOrders); }); } -export async function cancelOrder(ctx: ClientContext, orderChainId: number): Promise { - ctx.ensureSession(); +export class OrdersDomain { + constructor(private _ctx: ClientContext) {} - const accountId = ctx.accountId ?? ctx.config.accountId; - if (!accountId) { - ctx.throwError(ERROR.CANCEL_ORDER_ERROR, "accountId is required to cancel an order"); - } + /** Get all pending/open orders via WebSocket. */ + async get(timeout = 30_000): Promise { + this._ctx.ensureSession(); - 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}`); - } -} + if (this._ctx.wsManager) { + return this._ctx.wsManager.waitFor(WS_MESSAGE.ORDERS, timeout); + } + + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); -export async function cancelAllOrders(ctx: ClientContext): Promise { - const orders = await getOrders(ctx); - const pending = orders.filter((o) => !o.finalStatus); + 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, this._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[]); + } + }); - for (const order of pending) { - await cancelOrder(ctx, order.orderId); + ws.on("error", (error) => { + clearTimeout(timer); + ws.close(); + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.ORDERS_ERROR, `Orders error: ${error.message}`)); + }); + }); } -} -export async function submitOrder(ctx: ClientContext, params: Order.SubmitParams): Promise { - ctx.ensureSession(); - - const { - symbol, - side, - quantity, - orderType, - orderCode, - price, - instrumentId, - stopLoss, - takeProfit, - positionEffect = ACTION.OPENING, - positionCode, - tif = "GTC", - expireDate, - metadata, - } = params; - const info = await getSymbolInfo(ctx, symbol); - const units = Math.round(quantity * info.lotSize); - const qty = side === SIDE.BUY ? units : -units; - const priceParam = orderType === ORDER_TYPE.STOP ? "stopPrice" : "limitPrice"; - - const orderData: Record = { - directExchange: false, - legs: [ - { - ...(instrumentId != null && { instrumentId }), - ...(positionCode != null && { positionCode }), - positionEffect, - ratioQuantity: 1, - symbol, - }, - ], - orderSide: side, - orderType, - quantity: qty, - requestId: orderCode ?? `gwt-uid-931-${crypto.randomUUID()}`, - timeInForce: tif, - ...(expireDate != null && { expireDate }), - ...(metadata != null && { metadata }), - }; - - if (price != null && orderType !== ORDER_TYPE.MARKET) { - orderData[priceParam] = price; + /** Cancel a single pending order by its order chain ID. */ + async cancel(orderChainId: number): Promise { + this._ctx.ensureSession(); + + const accountId = this._ctx.accountId ?? this._ctx.config.accountId; + if (!accountId) { + this._ctx.throwError(ERROR.CANCEL_ORDER_ERROR, "accountId is required to cancel an order"); + } + + try { + await retryRequest( + { + method: "DELETE", + url: endpoints.cancelOrder(this._ctx.broker, accountId, orderChainId), + headers: authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)), + }, + this._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"; + this._ctx.throwError(ERROR.CANCEL_ORDER_ERROR, `Cancel order error: ${message}`); + } } - if (stopLoss) { - orderData.stopLoss = { - ...(stopLoss.offset != null && { fixedOffset: stopLoss.offset }), - ...(stopLoss.price != null && { fixedPrice: stopLoss.price }), - priceFixed: stopLoss.price != null, - orderChainId: 0, - orderId: 0, - orderType: ORDER_TYPE.STOP, - quantityForProtection: qty, - removed: false, - }; + /** Cancel all pending orders. */ + async cancelAll(): Promise { + const orders = await this.get(); + const pending = orders.filter((o) => !o.finalStatus); + + for (const order of pending) { + await this.cancel(order.orderId); + } } - if (takeProfit) { - orderData.takeProfit = { - ...(takeProfit.offset != null && { fixedOffset: takeProfit.offset }), - ...(takeProfit.price != null && { fixedPrice: takeProfit.price }), - priceFixed: takeProfit.price != null, - orderChainId: 0, - orderId: 0, - orderType: ORDER_TYPE.LIMIT, - quantityForProtection: qty, - removed: false, + /** + * Submit a trading order and wait for WebSocket confirmation. + * Supports market, limit, and stop orders with optional stop loss and take profit. + */ + async submit(params: Order.SubmitParams): Promise { + this._ctx.ensureSession(); + + const { + symbol, + side, + quantity, + orderType, + orderCode, + price, + instrumentId, + stopLoss, + takeProfit, + positionEffect = ACTION.OPENING, + positionCode, + tif = "GTC", + expireDate, + metadata, + } = params; + const info = await new SymbolsDomain(this._ctx).info(symbol); + const units = quantity * info.lotSize; + const qty = side === SIDE.BUY ? units : -units; + const priceParam = orderType === ORDER_TYPE.STOP ? "stopPrice" : "limitPrice"; + + const orderData: Record = { + directExchange: false, + legs: [ + { + ...(instrumentId != null && { instrumentId }), + ...(positionCode != null && { positionCode }), + positionEffect, + ratioQuantity: 1, + symbol, + }, + ], + orderSide: side, + orderType, + quantity: qty, + requestId: orderCode ?? `gwt-uid-931-${crypto.randomUUID()}`, + timeInForce: tif, + ...(expireDate != null && { expireDate }), + ...(metadata != null && { metadata }), }; - } - try { - // Open WS listener BEFORE submitting so we don't miss the response - 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; - - const response = await retryRequest( - { - method: "POST", - url: endpoints.submitOrder(ctx.broker), - data: orderData, - headers: authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)), - }, - ctx.retries, - ); - - ctx.callbacks.onOrderPlaced?.(response.data as Order.Response); - - const orderUpdate = await listener.promise; - - ctx.callbacks.onOrderUpdate?.(orderUpdate); - return orderUpdate; - } 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.ORDER_ERROR, `Error submitting order: ${message}`); + if (price != null) { + orderData[priceParam] = price; + } + + if (stopLoss) { + orderData.stopLoss = { + ...(stopLoss.offset != null && { fixedOffset: stopLoss.offset }), + ...(stopLoss.price != null && { fixedPrice: stopLoss.price }), + priceFixed: stopLoss.price != null, + orderChainId: 0, + orderId: 0, + orderType: ORDER_TYPE.STOP, + quantityForProtection: qty, + removed: false, + }; + } + + if (takeProfit) { + orderData.takeProfit = { + ...(takeProfit.offset != null && { fixedOffset: takeProfit.offset }), + ...(takeProfit.price != null && { fixedPrice: takeProfit.price }), + priceFixed: takeProfit.price != null, + orderChainId: 0, + orderId: 0, + orderType: ORDER_TYPE.LIMIT, + quantityForProtection: qty, + removed: false, + }; + } + + try { + // Set up listener BEFORE submitting so we don't miss the response + let listenerPromise: Promise; + + if (this._ctx.wsManager) { + listenerPromise = createWsManagerOrderListener(this._ctx, 30_000); + } else { + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); + const listener = createOrderListener(wsUrl, cookieStr, 30_000, this._ctx.debug); + await listener.ready; + listenerPromise = listener.promise; + } + + const response = await retryRequest( + { + method: "POST", + url: endpoints.submitOrder(this._ctx.broker), + data: orderData, + headers: authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)), + }, + this._ctx.retries, + ); + + this._ctx.callbacks.onOrderPlaced?.(response.data as Order.Response); + + const orderUpdate = await listenerPromise; + + this._ctx.callbacks.onOrderUpdate?.(orderUpdate); + return orderUpdate; + } catch (error: unknown) { + if (error instanceof DxtradeError) throw error; + const message = + error instanceof Error ? ((error as any).response?.data?.message ?? error.message) : "Unknown error"; + this._ctx.throwError(ERROR.ORDER_ERROR, `Error submitting order: ${message}`); + } } } diff --git a/src/domains/order/order.types.ts b/src/domains/order/order.types.ts index afb7b02..530692f 100644 --- a/src/domains/order/order.types.ts +++ b/src/domains/order/order.types.ts @@ -1,4 +1,4 @@ -import type { ORDER_TYPE, SIDE, ACTION, TIF } from "@/constants/enums"; +import type { ORDER_TYPE, SIDE, ACTION, TIF, MESSAGE_CATEGORY, MESSAGE_TYPE, ORDER_STATUS } from "@/constants/enums"; export namespace Order { export interface Get { @@ -54,6 +54,7 @@ export namespace Order { orderId: string; status: string; statusDescription?: string; + positionCode?: string; [key: string]: unknown; } @@ -128,7 +129,7 @@ export namespace Message { symbol: string; orderType: string; orderSide: string; - orderStatus: "PLACED" | "FILLED" | "REJECTED"; + orderStatus: ORDER_STATUS; quantity: number; remainingQuantity: number; filledQuantity: number | string; @@ -158,8 +159,8 @@ export namespace Message { export interface Entry { principalLogin: string | null; accountId: string | null; - messageCategory: "TRADE_LOG" | "NOTIFICATION"; - messageType: "ORDER" | "INSTRUMENT_ACTIVATED"; + messageCategory: MESSAGE_CATEGORY; + messageType: MESSAGE_TYPE; historyMessage: boolean; triggeredBeforeLogin: boolean; critical: boolean; diff --git a/src/domains/position/position.ts b/src/domains/position/position.ts index c1343f7..eeba5c0 100644 --- a/src/domains/position/position.ts +++ b/src/domains/position/position.ts @@ -1,8 +1,9 @@ import WebSocket from "ws"; -import { WS_MESSAGE, ERROR, endpoints, DxtradeError } from "@/constants"; -import { Cookies, parseWsData, shouldLog, debugLog, retryRequest, authHeaders } from "@/utils"; +import { WS_MESSAGE, ERROR, endpoints, DxtradeError, MESSAGE_CATEGORY, MESSAGE_TYPE, ORDER_STATUS } from "@/constants"; +import { Cookies, parseWsData, shouldLog, debugLog, retryRequest, authHeaders, checkWsRateLimit } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Position } from "."; +import type { Message } from "../order"; function mergePositionsWithMetrics(positions: Position.Get[], metrics: Position.Metrics[]): Position.Full[] { const metricsMap = new Map(metrics.map((m) => [m.uid, m])); @@ -22,125 +23,297 @@ function mergePositionsWithMetrics(positions: Position.Get[], metrics: Position. }); } -export function streamPositions(ctx: ClientContext, callback: (positions: Position.Full[]) => void): () => void { - if (!ctx.wsManager) { - ctx.throwError( - ERROR.STREAM_REQUIRES_CONNECT, - "Streaming requires a persistent WebSocket. Use connect() instead of auth().", - ); - } +export class PositionsDomain { + constructor(private _ctx: ClientContext) {} - const emit = () => { - const positions = ctx.wsManager!.getCached(WS_MESSAGE.POSITIONS); - const metrics = ctx.wsManager!.getCached(WS_MESSAGE.POSITION_METRICS); - if (positions && metrics) { - callback(mergePositionsWithMetrics(positions, metrics)); + /** Stream real-time position updates with P&L metrics. Requires connect(). Returns unsubscribe function. */ + stream(callback: (positions: Position.Full[]) => void): () => void { + if (!this._ctx.wsManager) { + this._ctx.throwError( + ERROR.STREAM_REQUIRES_CONNECT, + "Streaming requires a persistent WebSocket. Use connect() instead of auth().", + ); } - }; - - const onPositions = () => emit(); - const onMetrics = () => emit(); - ctx.wsManager.on(WS_MESSAGE.POSITIONS, onPositions); - ctx.wsManager.on(WS_MESSAGE.POSITION_METRICS, onMetrics); + const emit = () => { + const positions = this._ctx.wsManager!.getCached(WS_MESSAGE.POSITIONS); + const metrics = this._ctx.wsManager!.getCached(WS_MESSAGE.POSITION_METRICS); + if (positions && metrics) { + callback(mergePositionsWithMetrics(positions, metrics)); + } + }; - emit(); + const onPositions = () => emit(); + const onMetrics = () => emit(); - return () => { - ctx.wsManager?.removeListener(WS_MESSAGE.POSITIONS, onPositions); - ctx.wsManager?.removeListener(WS_MESSAGE.POSITION_METRICS, onMetrics); - }; -} + this._ctx.wsManager.on(WS_MESSAGE.POSITIONS, onPositions); + this._ctx.wsManager.on(WS_MESSAGE.POSITION_METRICS, onMetrics); -export async function getPositions(ctx: ClientContext): Promise { - ctx.ensureSession(); + emit(); - if (ctx.wsManager) { - const [positions, metrics] = await Promise.all([ - ctx.wsManager.waitFor(WS_MESSAGE.POSITIONS), - ctx.wsManager.waitFor(WS_MESSAGE.POSITION_METRICS), - ]); - return mergePositionsWithMetrics(positions, metrics); + return () => { + this._ctx.wsManager?.removeListener(WS_MESSAGE.POSITIONS, onPositions); + this._ctx.wsManager?.removeListener(WS_MESSAGE.POSITION_METRICS, onMetrics); + }; } - const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId); - const cookieStr = Cookies.serialize(ctx.cookies); + /** Get all open positions with P&L metrics merged. */ + async get(): Promise { + this._ctx.ensureSession(); + + if (this._ctx.wsManager) { + const [positions, metrics] = await Promise.all([ + this._ctx.wsManager.waitFor(WS_MESSAGE.POSITIONS), + this._ctx.wsManager.waitFor(WS_MESSAGE.POSITION_METRICS), + ]); + return mergePositionsWithMetrics(positions, metrics); + } - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); - let positions: Position.Get[] | null = null; - let metrics: Position.Metrics[] | null = null; + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); - const timer = setTimeout(() => { - ws.close(); - reject(new DxtradeError(ERROR.ACCOUNT_POSITIONS_TIMEOUT, "Account positions timed out")); - }, 30_000); + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + let positions: Position.Get[] | null = null; + let metrics: Position.Metrics[] | null = null; - ws.on("message", (data) => { - const msg = parseWsData(data); - if (shouldLog(msg, ctx.debug)) debugLog(msg); + const timer = setTimeout(() => { + ws.close(); + reject(new DxtradeError(ERROR.ACCOUNT_POSITIONS_TIMEOUT, "Account positions timed out")); + }, 30_000); - if (typeof msg === "string") return; - if (msg.type === WS_MESSAGE.POSITIONS) { - positions = msg.body as Position.Get[]; - } - if (msg.type === WS_MESSAGE.POSITION_METRICS) { - metrics = msg.body as Position.Metrics[]; - } - if (positions && metrics) { + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, this._ctx.debug)) debugLog(msg); + + if (typeof msg === "string") return; + if (msg.type === WS_MESSAGE.POSITIONS) { + positions = msg.body as Position.Get[]; + } + if (msg.type === WS_MESSAGE.POSITION_METRICS) { + metrics = msg.body as Position.Metrics[]; + } + if (positions && metrics) { + clearTimeout(timer); + ws.close(); + resolve(mergePositionsWithMetrics(positions, metrics)); + } + }); + + ws.on("error", (error) => { clearTimeout(timer); ws.close(); - resolve(mergePositionsWithMetrics(positions, metrics)); - } + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.ACCOUNT_POSITIONS_ERROR, `Account positions error: ${error.message}`)); + }); }); + } - ws.on("error", (error) => { - clearTimeout(timer); - ws.close(); - reject(new DxtradeError(ERROR.ACCOUNT_POSITIONS_ERROR, `Account positions error: ${error.message}`)); - }); - }); -} + /** Close all open positions with market orders. */ + async closeAll(): Promise { + const positions = await this.get(); + + 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 this._sendCloseRequest(closeData); + } + } + + /** Close a position by its position code. Returns the position with P&L metrics. Optionally wait for close confirmation via `waitForClose: "stream" | "poll"`. */ + async close(positionCode: string, options?: Position.CloseOptions): Promise { + const positions = await this.get(); + const position = positions.find((p) => p.positionKey.positionCode === positionCode); -export async function closeAllPositions(ctx: ClientContext): Promise { - const positions = await getPositions(ctx); + if (!position) { + this._ctx.throwError(ERROR.POSITION_NOT_FOUND, `Position with code "${positionCode}" not found`); + } - for (const pos of positions) { const closeData: Position.Close = { legs: [ { - instrumentId: pos.positionKey.instrumentId, - positionCode: pos.positionKey.positionCode, + instrumentId: position.positionKey.instrumentId, + positionCode: position.positionKey.positionCode, positionEffect: "CLOSING", ratioQuantity: 1, - symbol: pos.positionKey.positionCode, + symbol: position.positionKey.positionCode, }, ], limitPrice: 0, orderType: "MARKET", - quantity: -pos.quantity, + quantity: -position.quantity, timeInForce: "GTC", }; - await closePosition(ctx, closeData); + + if (options?.waitForClose === "stream") { + return this._waitForCloseStream(positionCode, position, closeData, options.timeout ?? 30_000); + } + + await this._sendCloseRequest(closeData); + + if (options?.waitForClose === "poll") { + return this._waitForClosePoll(positionCode, position, options.timeout ?? 30_000, options.pollInterval ?? 1_000); + } + + return position; } -} -export async function closePosition(ctx: ClientContext, data: Position.Close): Promise { - try { - await retryRequest( - { - method: "POST", - url: endpoints.closePosition(ctx.broker), - data, - headers: authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)), - }, - ctx.retries, - ); - // TODO:: Check response just like in order submit - } 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.POSITION_CLOSE_ERROR, `Position close error: ${message}`); + private _waitForCloseStream( + positionCode: string, + lastSnapshot: Position.Full, + closeData: Position.Close, + timeout: number, + ): Promise { + if (!this._ctx.wsManager) { + this._ctx.throwError( + ERROR.STREAM_REQUIRES_CONNECT, + 'waitForClose: "stream" requires a persistent WebSocket. Use connect() instead of auth(), or use "poll" mode.', + ); + } + + return new Promise(async (resolve, reject) => { + let settled = false; + const result = lastSnapshot; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject( + new DxtradeError(ERROR.POSITION_CLOSE_TIMEOUT, `Position close confirmation timed out after ${timeout}ms`), + ); + }, timeout); + + function done(err: Error | null, res?: Position.Full) { + if (settled) return; + settled = true; + clearTimeout(timer); + cleanup(); + if (err) reject(err); + else resolve(res!); + } + + // Listen for close order FILLED via MESSAGE (trade log) + function onMessage(body: unknown) { + const messages = body as Message.Entry[]; + const orderMsg = messages?.findLast?.( + (m) => + m.messageCategory === MESSAGE_CATEGORY.TRADE_LOG && + m.messageType === MESSAGE_TYPE.ORDER && + !m.historyMessage, + ); + if (!orderMsg) return; + + const params = orderMsg.parametersTO as Message.OrderParams; + if (params.positionCode !== positionCode) return; + + if (params.orderStatus === ORDER_STATUS.REJECTED) { + done( + new DxtradeError( + ERROR.POSITION_CLOSE_ERROR, + `Close order rejected: ${params.rejectReason?.key ?? "Unknown reason"}`, + ), + ); + } else if (params.orderStatus === ORDER_STATUS.FILLED) { + done(null, result); + } + } + + // Listen for close order FILLED via ORDERS + function onOrders(body: unknown) { + const orders = body as { + orderId: string; + status: string; + statusDescription?: string; + [key: string]: unknown; + }[]; + const order = orders?.[0]; + if (!order?.orderId) return; + + if (order.status === ORDER_STATUS.REJECTED) { + done( + new DxtradeError( + ERROR.POSITION_CLOSE_ERROR, + `Close order rejected: ${order.statusDescription ?? "Unknown reason"}`, + ), + ); + } else if (order.status === ORDER_STATUS.FILLED) { + done(null, result); + } + } + + const wsManager = this._ctx.wsManager!; + + function cleanup() { + wsManager.removeListener(WS_MESSAGE.MESSAGE, onMessage); + wsManager.removeListener(WS_MESSAGE.ORDERS, onOrders); + } + + // Subscribe BEFORE sending the close request to avoid race condition + wsManager.on(WS_MESSAGE.MESSAGE, onMessage); + wsManager.on(WS_MESSAGE.ORDERS, onOrders); + + try { + await this._sendCloseRequest(closeData); + } catch (error) { + done(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + private async _waitForClosePoll( + positionCode: string, + lastSnapshot: Position.Full, + timeout: number, + interval: number, + ): Promise { + const deadline = Date.now() + timeout; + let result = lastSnapshot; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, interval)); + const positions = await this.get(); + const match = positions.find((p) => p.positionKey.positionCode === positionCode); + if (match) { + result = match; + } else { + return result; + } + } + + this._ctx.throwError(ERROR.POSITION_CLOSE_TIMEOUT, `Position close confirmation timed out after ${timeout}ms`); + } + + private async _sendCloseRequest(data: Position.Close): Promise { + try { + await retryRequest( + { + method: "POST", + url: endpoints.closePosition(this._ctx.broker), + data, + headers: authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)), + }, + this._ctx.retries, + ); + // TODO:: Check response just like in order submit + } catch (error: unknown) { + if (error instanceof DxtradeError) throw error; + const message = + error instanceof Error ? ((error as any).response?.data?.message ?? error.message) : "Unknown error"; + this._ctx.throwError(ERROR.POSITION_CLOSE_ERROR, `Position close error: ${message}`); + } } } diff --git a/src/domains/position/position.types.ts b/src/domains/position/position.types.ts index 0bb0916..2cd9449 100644 --- a/src/domains/position/position.types.ts +++ b/src/domains/position/position.types.ts @@ -40,6 +40,15 @@ export namespace Position { marketValue: number; } + export interface CloseOptions { + /** Wait for the position to disappear after closing. "stream" uses the persistent WebSocket (requires connect()), "poll" repeatedly calls getPositions(). */ + waitForClose?: "stream" | "poll"; + /** Timeout in ms for waitForClose (default: 30000). */ + timeout?: number; + /** Poll interval in ms when using waitForClose: "poll" (default: 1000). */ + pollInterval?: number; + } + export interface Close { legs: { instrumentId: number; diff --git a/src/domains/session/session.ts b/src/domains/session/session.ts index a7de1dc..17bab81 100644 --- a/src/domains/session/session.ts +++ b/src/domains/session/session.ts @@ -3,6 +3,7 @@ import { endpoints, DxtradeError, ERROR } from "@/constants"; import { Cookies, WsManager, + baseHeaders, authHeaders, cookieOnlyHeaders, retryRequest, @@ -11,6 +12,7 @@ import { parseWsData, shouldLog, debugLog, + checkWsRateLimit, } from "@/utils"; import type { ClientContext } from "@/client.types"; @@ -53,130 +55,170 @@ function waitForHandshake( ws.on("error", (error) => { clearTimeout(timer); ws.close(); + checkWsRateLimit(error); reject(new Error(`[dxtrade-api] WebSocket handshake error: ${error.message}`)); }); }); } -export async function login(ctx: ClientContext): Promise { - try { - const response = await retryRequest( - { - method: "POST", - url: endpoints.login(ctx.broker), - data: { - username: ctx.config.username, - password: ctx.config.password, - - // TODO:: take a look at this below, domain nor vendor seems required. it works if i comment out both. - // however i still use it since i see brokers use it as well in the login endpoint. - - // domain: ctx.config.broker, - vendor: ctx.config.broker, +export class SessionDomain { + constructor(private _ctx: ClientContext) {} + + /** Authenticate with the broker using username and password. */ + async login(): Promise { + try { + const response = await retryRequest( + { + method: "POST", + url: endpoints.login(this._ctx.broker), + data: { + username: this._ctx.config.username, + password: this._ctx.config.password, + + // TODO:: take a look at this below, domain nor vendor seems required. it works if i comment out both. + // however i still use it since i see brokers use it as well in the login endpoint. + + // domain: this._ctx.config.broker, + vendor: this._ctx.config.broker, + + // END TODO:: + }, + headers: { + ...baseHeaders(), + Origin: this._ctx.broker, + Referer: this._ctx.broker + "/", + Cookie: Cookies.serialize(this._ctx.cookies), + }, + }, + this._ctx.retries, + ); + + if (response.status === 200) { + const setCookies = response.headers["set-cookie"] ?? []; + const incoming = Cookies.parse(setCookies); + this._ctx.cookies = Cookies.merge(this._ctx.cookies, incoming); + this._ctx.callbacks.onLogin?.(); + } else { + this._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"; + this._ctx.throwError(ERROR.LOGIN_ERROR, `Login error: ${message}`); + } + } - // END TODO:: + /** Fetch the CSRF token required for authenticated requests. */ + async fetchCsrf(): Promise { + try { + const cookieStr = Cookies.serialize(this._ctx.cookies); + const response = await retryRequest( + { + method: "GET", + url: this._ctx.broker, + headers: { ...cookieOnlyHeaders(cookieStr), Referer: this._ctx.broker }, }, - headers: { "Content-Type": "application/json" }, - }, - ctx.retries, - ); + this._ctx.retries, + ); - if (response.status === 200) { const setCookies = response.headers["set-cookie"] ?? []; const incoming = Cookies.parse(setCookies); - ctx.cookies = Cookies.merge(ctx.cookies, incoming); - ctx.callbacks.onLogin?.(); - } else { - ctx.throwError(ERROR.LOGIN_FAILED, `Login failed: ${response.status}`); + this._ctx.cookies = Cookies.merge(this._ctx.cookies, incoming); + + const csrfMatch = response.data?.match(/name="csrf" content="([^"]+)"/); + if (csrfMatch) { + this._ctx.csrf = csrfMatch[1]; + } else { + this._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"; + this._ctx.throwError(ERROR.CSRF_ERROR, `CSRF fetch error: ${message}`); } - } catch (error: unknown) { - if (error instanceof DxtradeError) throw error; - const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError(ERROR.LOGIN_ERROR, `Login error: ${message}`); } -} -export async function fetchCsrf(ctx: ClientContext): Promise { - try { - const cookieStr = Cookies.serialize(ctx.cookies); - const response = await retryRequest( - { - method: "GET", - url: ctx.broker, - headers: { ...cookieOnlyHeaders(cookieStr), Referer: ctx.broker }, - }, - ctx.retries, - ); - - const csrfMatch = response.data?.match(/name="csrf" content="([^"]+)"/); - if (csrfMatch) { - ctx.csrf = csrfMatch[1]; - } else { - ctx.throwError(ERROR.CSRF_NOT_FOUND, "CSRF token not found"); + /** Switch to a specific trading account by ID. */ + async switchAccount(accountId: string): Promise { + this._ctx.ensureSession(); + + try { + await retryRequest( + { + method: "POST", + url: endpoints.switchAccount(this._ctx.broker, accountId), + headers: authHeaders(this._ctx.csrf!, Cookies.serialize(this._ctx.cookies)), + }, + this._ctx.retries, + ); + this._ctx.callbacks.onAccountSwitch?.(accountId); + } catch (error: unknown) { + if (error instanceof DxtradeError) throw error; + const message = error instanceof Error ? error.message : "Unknown error"; + this._ctx.throwError(ERROR.ACCOUNT_SWITCH_ERROR, `Error switching account: ${message}`); } - } catch (error: unknown) { - if (error instanceof DxtradeError) throw error; - const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError(ERROR.CSRF_ERROR, `CSRF fetch error: ${message}`); } -} -export async function switchAccount(ctx: ClientContext, accountId: string): Promise { - ctx.ensureSession(); - - try { - await retryRequest( - { - method: "POST", - url: endpoints.switchAccount(ctx.broker, accountId), - headers: authHeaders(ctx.csrf!, Cookies.serialize(ctx.cookies)), - }, - ctx.retries, - ); - ctx.callbacks.onAccountSwitch?.(accountId); - } catch (error: unknown) { - if (error instanceof DxtradeError) throw error; - const message = error instanceof Error ? error.message : "Unknown error"; - ctx.throwError(ERROR.ACCOUNT_SWITCH_ERROR, `Error switching account: ${message}`); + /** Hit the broker page to collect Cloudflare cookies before making API calls. */ + private async _preflight(): Promise { + try { + const response = await retryRequest( + { + method: "GET", + url: this._ctx.broker, + headers: { ...baseHeaders(), Referer: this._ctx.broker }, + }, + this._ctx.retries, + ); + const setCookies = response.headers["set-cookie"] ?? []; + const incoming = Cookies.parse(setCookies); + this._ctx.cookies = Cookies.merge(this._ctx.cookies, incoming); + } catch { + // Non-fatal: continue with login even if preflight fails + } } -} -export async function auth(ctx: ClientContext): Promise { - await login(ctx); - await fetchCsrf(ctx); - if (ctx.debug) clearDebugLog(); - - const cookieStr = Cookies.serialize(ctx.cookies); - 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); - 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; + /** Authenticate and establish a session: login, fetch CSRF, WebSocket handshake, and optional account switch. */ + async auth(): Promise { + await this._preflight(); + await this.login(); + await this.fetchCsrf(); + if (this._ctx.debug) clearDebugLog(); + + const cookieStr = Cookies.serialize(this._ctx.cookies); + const handshake = await waitForHandshake(endpoints.websocket(this._ctx.broker), cookieStr, 30_000, this._ctx.debug); + this._ctx.atmosphereId = handshake.atmosphereId; + this._ctx.accountId = handshake.accountId; + + if (this._ctx.config.accountId) { + await this.switchAccount(this._ctx.config.accountId); + const reconnect = await waitForHandshake( + endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId), + Cookies.serialize(this._ctx.cookies), + 30_000, + this._ctx.debug, + ); + this._ctx.atmosphereId = reconnect.atmosphereId; + this._ctx.accountId = reconnect.accountId; + } } -} -export async function connect(ctx: ClientContext): Promise { - await auth(ctx); + /** Connect to the broker with a persistent WebSocket: auth + persistent WS for data reuse and streaming. */ + async connect(): Promise { + await this.auth(); - const wsManager = new WsManager(); - const wsUrl = endpoints.websocket(ctx.broker, ctx.atmosphereId); - const cookieStr = Cookies.serialize(ctx.cookies); - await wsManager.connect(wsUrl, cookieStr, ctx.debug); - ctx.wsManager = wsManager; -} + const wsManager = new WsManager(); + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); + await wsManager.connect(wsUrl, cookieStr, this._ctx.debug); + this._ctx.wsManager = wsManager; + } -export function disconnect(ctx: ClientContext): void { - if (ctx.wsManager) { - ctx.wsManager.close(); - ctx.wsManager = null; + /** Close the persistent WebSocket connection. */ + disconnect(): void { + if (this._ctx.wsManager) { + this._ctx.wsManager.close(); + this._ctx.wsManager = null; + } } } diff --git a/src/domains/symbol/symbol.ts b/src/domains/symbol/symbol.ts index 2108c0b..2068321 100644 --- a/src/domains/symbol/symbol.ts +++ b/src/domains/symbol/symbol.ts @@ -1,102 +1,110 @@ import WebSocket from "ws"; import { endpoints, DxtradeError, WS_MESSAGE, ERROR } from "@/constants"; -import { Cookies, baseHeaders, retryRequest, parseWsData, shouldLog, debugLog } from "@/utils"; +import { Cookies, baseHeaders, retryRequest, parseWsData, shouldLog, debugLog, checkWsRateLimit } from "@/utils"; import type { ClientContext } from "@/client.types"; import type { Symbol } from "."; -export async function getSymbolSuggestions(ctx: ClientContext, text: string): Promise { - ctx.ensureSession(); - - try { - const cookieStr = Cookies.serialize(ctx.cookies); - const response = await retryRequest( - { - method: "GET", - url: endpoints.suggest(ctx.broker, text), - headers: { ...baseHeaders(), Cookie: cookieStr }, - }, - ctx.retries, - ); - - const suggests = response.data?.suggests; - if (!suggests?.length) { - ctx.throwError(ERROR.NO_SUGGESTIONS, "No symbol suggestions found"); +export class SymbolsDomain { + constructor(private _ctx: ClientContext) {} + + /** Search for symbols matching the given text (e.g. "EURUSD", "BTC"). */ + async search(text: string): Promise { + this._ctx.ensureSession(); + + try { + const cookieStr = Cookies.serialize(this._ctx.cookies); + const response = await retryRequest( + { + method: "GET", + url: endpoints.suggest(this._ctx.broker, text), + headers: { ...baseHeaders(), Cookie: cookieStr }, + }, + this._ctx.retries, + ); + + const suggests = response.data?.suggests; + if (!suggests?.length) { + this._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"; + this._ctx.throwError(ERROR.SUGGEST_ERROR, `Error getting symbol suggestions: ${message}`); } - 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(ERROR.SUGGEST_ERROR, `Error getting symbol suggestions: ${message}`); } -} -export async function getSymbolInfo(ctx: ClientContext, symbol: string): Promise { - ctx.ensureSession(); - - try { - const offsetMinutes = Math.abs(new Date().getTimezoneOffset()); - const cookieStr = Cookies.serialize(ctx.cookies); - const response = await retryRequest( - { - method: "GET", - url: endpoints.instrumentInfo(ctx.broker, symbol, offsetMinutes), - headers: { ...baseHeaders(), Cookie: cookieStr }, - }, - ctx.retries, - ); - - if (!response.data) { - ctx.throwError(ERROR.NO_SYMBOL_INFO, "No symbol info returned"); + /** Get detailed instrument info for a symbol, including volume limits and lot size. */ + async info(symbol: string): Promise { + this._ctx.ensureSession(); + + try { + const offsetMinutes = Math.abs(new Date().getTimezoneOffset()); + const cookieStr = Cookies.serialize(this._ctx.cookies); + const response = await retryRequest( + { + method: "GET", + url: endpoints.instrumentInfo(this._ctx.broker, symbol, offsetMinutes), + headers: { ...baseHeaders(), Cookie: cookieStr }, + }, + this._ctx.retries, + ); + + if (!response.data) { + this._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"; + this._ctx.throwError(ERROR.SYMBOL_INFO_ERROR, `Error getting symbol info: ${message}`); } - 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(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, 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.LIMITS_TIMEOUT, "Symbol limits request timed out")); - }, timeout); - let limits: Symbol.Limits[] = []; - let settleTimer: ReturnType | null = null; - - 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.LIMITS) { - const batch = msg.body as Symbol.Limits[]; - if (batch.length === 0) return; - - limits.push(...batch); - - if (settleTimer) clearTimeout(settleTimer); - settleTimer = setTimeout(() => { - clearTimeout(timer); - ws.close(); - resolve(limits); - }, 200); - } - }); - - ws.on("error", (error) => { - clearTimeout(timer); - ws.close(); - reject(new DxtradeError(ERROR.LIMITS_ERROR, `Symbol limits error: ${error.message}`)); + /** Get order size limits and stop/limit distances for all symbols. */ + async limits(timeout = 30_000): Promise { + this._ctx.ensureSession(); + + const wsUrl = endpoints.websocket(this._ctx.broker, this._ctx.atmosphereId); + const cookieStr = Cookies.serialize(this._ctx.cookies); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { headers: { Cookie: cookieStr } }); + + const timer = setTimeout(() => { + ws.close(); + reject(new DxtradeError(ERROR.LIMITS_TIMEOUT, "Symbol limits request timed out")); + }, timeout); + + let limits: Symbol.Limits[] = []; + let settleTimer: ReturnType | null = null; + + ws.on("message", (data) => { + const msg = parseWsData(data); + if (shouldLog(msg, this._ctx.debug)) debugLog(msg); + + if (typeof msg === "string") return; + if (msg.type === WS_MESSAGE.LIMITS) { + const batch = msg.body as Symbol.Limits[]; + if (batch.length === 0) return; + + limits.push(...batch); + + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + clearTimeout(timer); + ws.close(); + resolve(limits); + }, 200); + } + }); + + ws.on("error", (error) => { + clearTimeout(timer); + ws.close(); + checkWsRateLimit(error); + reject(new DxtradeError(ERROR.LIMITS_ERROR, `Symbol limits error: ${error.message}`)); + }); }); - }); + } } diff --git a/src/utils/headers.ts b/src/utils/headers.ts index f1bf1ae..adc04c4 100644 --- a/src/utils/headers.ts +++ b/src/utils/headers.ts @@ -1,7 +1,10 @@ export function baseHeaders(): Record { return { "Content-Type": "application/json; charset=UTF-8", + Accept: "*/*", "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", }; } diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 514a1c2..5c91df8 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -8,9 +8,15 @@ export async function retryRequest(config: AxiosRequestConfig, retries = 3): Pro } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; console.warn(`[dxtrade-api] Attempt ${attempt} failed: ${message}`, config.url); + if (isAxiosError(error) && error.response?.status === 429) { - throw new DxtradeError(ERROR.RATE_LIMITED, "Rate limited (429). Too many requests — try again later."); + if (attempt === retries) { + throw new DxtradeError(ERROR.RATE_LIMITED, "Rate limited (429). Too many requests — try again later."); + } + await new Promise((res) => setTimeout(res, 3000 * attempt)); + continue; } + if (attempt === retries) throw error; await new Promise((res) => setTimeout(res, 1000 * attempt)); } diff --git a/src/utils/websocket.ts b/src/utils/websocket.ts index 24b0e2b..0eee7bd 100644 --- a/src/utils/websocket.ts +++ b/src/utils/websocket.ts @@ -1,5 +1,6 @@ import { appendFileSync, writeFileSync } from "fs"; import type WebSocket from "ws"; +import { DxtradeError, ERROR } from "@/constants"; import type { WsPayload } from "./websocket.types"; export type { WsPayload } from "./websocket.types"; @@ -31,6 +32,13 @@ export function parseAtmosphereId(data: WebSocket.Data): string | null { return null; } +/** Check if a WebSocket error is a 429 rate limit. If so, throw a RATE_LIMITED DxtradeError. */ +export function checkWsRateLimit(error: Error): void { + if (error.message.includes("429")) { + throw new DxtradeError(ERROR.RATE_LIMITED, "Rate limited (429). Too many requests — try again later."); + } +} + export function parseWsData(data: WebSocket.Data): WsPayload | string { const raw = data.toString(); const pipeIndex = raw.indexOf("|"); diff --git a/src/utils/ws-manager.ts b/src/utils/ws-manager.ts index a5afbfd..bfee9e9 100644 --- a/src/utils/ws-manager.ts +++ b/src/utils/ws-manager.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "events"; import WebSocket from "ws"; -import { parseWsData, shouldLog, debugLog } from "./websocket"; +import { parseWsData, shouldLog, debugLog, checkWsRateLimit } from "./websocket"; import type { WsPayload } from "./websocket.types"; export class WsManager extends EventEmitter { @@ -27,10 +27,12 @@ export class WsManager extends EventEmitter { }); ws.on("error", (error) => { + checkWsRateLimit(error); + const err = new Error(`WebSocket manager error: ${error.message}`); if (!this._ws) { - return reject(error); + return reject(err); } - this.emit("error", error); + this.emit("error", err); }); ws.on("close", () => { diff --git a/tests/account.test.ts b/tests/account.test.ts index 24c2853..b2523a4 100644 --- a/tests/account.test.ts +++ b/tests/account.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { DxtradeError } from "@/constants/errors"; -import { getTradeHistory } from "@/domains/account"; +import { AccountDomain } from "@/domains/account"; import { createMockContext } from "./helpers"; // --- Mocks --- @@ -17,9 +17,10 @@ beforeEach(() => { // --- Tests --- -describe("getTradeHistory", () => { +describe("AccountDomain.tradeHistory", () => { it("should return trade history data on success", async () => { const ctx = createMockContext(); + const account = new AccountDomain(ctx); 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" }, @@ -31,7 +32,7 @@ describe("getTradeHistory", () => { headers: { "set-cookie": [] }, }); - const result = await getTradeHistory(ctx, { from: 1704067200000, to: 1704153600000 }); + const result = await account.tradeHistory({ from: 1704067200000, to: 1704153600000 }); expect(result).toEqual(mockHistory); expect(mockRetryRequest).toHaveBeenCalledWith( @@ -45,6 +46,7 @@ describe("getTradeHistory", () => { it("should merge cookies from response", async () => { const ctx = createMockContext(); + const account = new AccountDomain(ctx); mockRetryRequest.mockResolvedValue({ status: 200, @@ -52,13 +54,14 @@ describe("getTradeHistory", () => { headers: { "set-cookie": ["newCookie=value123; Path=/"] }, }); - await getTradeHistory(ctx, { from: 0, to: 1 }); + await account.tradeHistory({ from: 0, to: 1 }); expect(ctx.cookies).toHaveProperty("newCookie", "value123"); }); it("should throw TRADE_HISTORY_ERROR on non-200 status", async () => { const ctx = createMockContext(); + const account = new AccountDomain(ctx); mockRetryRequest.mockResolvedValue({ status: 500, @@ -66,29 +69,32 @@ describe("getTradeHistory", () => { 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"); + await expect(account.tradeHistory({ from: 0, to: 1 })).rejects.toThrow(DxtradeError); + await expect(account.tradeHistory({ from: 0, to: 1 })).rejects.toThrow("Trade history failed: 500"); }); it("should throw TRADE_HISTORY_ERROR on network error", async () => { const ctx = createMockContext(); + const account = new AccountDomain(ctx); 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"); + await expect(account.tradeHistory({ from: 0, to: 1 })).rejects.toThrow(DxtradeError); + await expect(account.tradeHistory({ from: 0, to: 1 })).rejects.toThrow("Trade history error: Network timeout"); }); it("should rethrow DxtradeError as-is", async () => { const ctx = createMockContext(); + const account = new AccountDomain(ctx); const original = new DxtradeError("CUSTOM", "custom"); mockRetryRequest.mockRejectedValue(original); - await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toBe(original); + await expect(account.tradeHistory({ from: 0, to: 1 })).rejects.toBe(original); }); it("should throw NO_SESSION when not authenticated", async () => { const ctx = createMockContext({ csrf: null }); + const account = new AccountDomain(ctx); - await expect(getTradeHistory(ctx, { from: 0, to: 1 })).rejects.toThrow("No active session"); + await expect(account.tradeHistory({ from: 0, to: 1 })).rejects.toThrow("No active session"); }); }); diff --git a/tests/orders.test.ts b/tests/orders.test.ts index e1e5958..b9d23d3 100644 --- a/tests/orders.test.ts +++ b/tests/orders.test.ts @@ -2,7 +2,7 @@ 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 { OrdersDomain } from "@/domains/order"; import { createMockContext } from "./helpers"; // --- Mocks --- @@ -34,9 +34,10 @@ beforeEach(() => { // --- Tests --- -describe("getOrders", () => { +describe("OrdersDomain.get", () => { it("should return orders from WebSocket ORDERS message", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); const mockOrders = [ { account: "ACC-123", @@ -68,7 +69,7 @@ describe("getOrders", () => { }, ]; - const promise = getOrders(ctx); + const promise = orders.get(); const payload = JSON.stringify({ accountId: "ACC-123", type: WS_MESSAGE.ORDERS, body: mockOrders }); wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); @@ -80,8 +81,9 @@ describe("getOrders", () => { it("should ignore string WS messages", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); - const promise = getOrders(ctx, 500); + const promise = orders.get(500); // First emit a string (atmosphere tracking id), then orders wsInstance.emit("message", Buffer.from("36|some-tracking-id|0||")); @@ -96,8 +98,9 @@ describe("getOrders", () => { it("should reject on WS error", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); - const promise = getOrders(ctx); + const promise = orders.get(); wsInstance.emit("error", new Error("connection failed")); await expect(promise).rejects.toThrow(DxtradeError); @@ -107,8 +110,9 @@ describe("getOrders", () => { it("should reject on timeout", async () => { vi.useFakeTimers(); const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); - const promise = getOrders(ctx, 1000); + const promise = orders.get(1000); vi.advanceTimersByTime(1001); @@ -120,18 +124,20 @@ describe("getOrders", () => { it("should throw NO_SESSION when not authenticated", async () => { const ctx = createMockContext({ csrf: null }); + const orders = new OrdersDomain(ctx); - await expect(getOrders(ctx)).rejects.toThrow(DxtradeError); - await expect(getOrders(ctx)).rejects.toThrow("No active session"); + await expect(orders.get()).rejects.toThrow(DxtradeError); + await expect(orders.get()).rejects.toThrow("No active session"); }); }); -describe("cancelOrder", () => { +describe("OrdersDomain.cancel", () => { it("should send DELETE request with correct URL", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); mockRetryRequest.mockResolvedValue({ status: 200 }); - await cancelOrder(ctx, 12345); + await orders.cancel(12345); expect(mockRetryRequest).toHaveBeenCalledWith( expect.objectContaining({ @@ -148,30 +154,34 @@ describe("cancelOrder", () => { accountId: null, config: { username: "test", password: "test", broker: "FTMO" }, }); + const orders = new OrdersDomain(ctx); - await expect(cancelOrder(ctx, 12345)).rejects.toThrow("accountId is required to cancel an order"); + await expect(orders.cancel(12345)).rejects.toThrow("accountId is required to cancel an order"); }); it("should throw CANCEL_ORDER_ERROR on request failure", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); mockRetryRequest.mockRejectedValue(new Error("Network error")); - await expect(cancelOrder(ctx, 12345)).rejects.toThrow(DxtradeError); - await expect(cancelOrder(ctx, 12345)).rejects.toThrow("Cancel order error"); + await expect(orders.cancel(12345)).rejects.toThrow(DxtradeError); + await expect(orders.cancel(12345)).rejects.toThrow("Cancel order error"); }); it("should rethrow DxtradeError as-is", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); const original = new DxtradeError("CUSTOM", "custom error"); mockRetryRequest.mockRejectedValue(original); - await expect(cancelOrder(ctx, 12345)).rejects.toBe(original); + await expect(orders.cancel(12345)).rejects.toBe(original); }); }); -describe("cancelAllOrders", () => { +describe("OrdersDomain.cancelAll", () => { it("should cancel only non-final orders", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); const mockOrders = [ { orderId: 1, finalStatus: false }, @@ -187,7 +197,7 @@ describe("cancelAllOrders", () => { mockRetryRequest.mockResolvedValue({ status: 200 }); - await cancelAllOrders(ctx); + await orders.cancelAll(); // Should have called cancelOrder for orders 1 and 3, not 2 expect(mockRetryRequest).toHaveBeenCalledTimes(2); @@ -197,6 +207,7 @@ describe("cancelAllOrders", () => { it("should do nothing when all orders are final", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); const mockOrders = [{ orderId: 1, finalStatus: true }]; @@ -205,20 +216,21 @@ describe("cancelAllOrders", () => { wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); }, 200); - await cancelAllOrders(ctx); + await orders.cancelAll(); expect(mockRetryRequest).not.toHaveBeenCalled(); }); it("should do nothing when there are no orders", async () => { const ctx = createMockContext(); + const orders = new OrdersDomain(ctx); setTimeout(() => { const payload = JSON.stringify({ accountId: null, type: WS_MESSAGE.ORDERS, body: [] }); wsInstance.emit("message", Buffer.from(`${payload.length}|${payload}`)); }, 200); - await cancelAllOrders(ctx); + await orders.cancelAll(); expect(mockRetryRequest).not.toHaveBeenCalled(); }); diff --git a/tests/positions.test.ts b/tests/positions.test.ts index dcea4d0..5b21298 100644 --- a/tests/positions.test.ts +++ b/tests/positions.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { EventEmitter } from "events"; import { DxtradeError } from "@/constants/errors"; import { WS_MESSAGE } from "@/constants/enums"; -import { getPositions, closeAllPositions } from "@/domains/position"; +import { PositionsDomain } from "@/domains/position"; import { createMockContext } from "./helpers"; // --- Mocks --- @@ -77,11 +77,12 @@ function emitBothMessages() { // --- Tests --- -describe("getPositions", () => { +describe("PositionsDomain.get", () => { it("should return merged positions with metrics", async () => { const ctx = createMockContext(); + const positions = new PositionsDomain(ctx); - const promise = getPositions(ctx); + const promise = positions.get(); emitBothMessages(); const result = await promise; @@ -96,8 +97,9 @@ describe("getPositions", () => { it("should wait for both POSITIONS and POSITION_METRICS before resolving", async () => { const ctx = createMockContext(); + const positions = new PositionsDomain(ctx); - const promise = getPositions(ctx); + const promise = positions.get(); // Send only POSITIONS first — should NOT resolve yet const posPayload = JSON.stringify({ accountId: null, type: WS_MESSAGE.POSITIONS, body: mockPositions }); @@ -117,8 +119,9 @@ describe("getPositions", () => { it("should reject on WS error", async () => { const ctx = createMockContext(); + const positions = new PositionsDomain(ctx); - const promise = getPositions(ctx); + const promise = positions.get(); wsInstance.emit("error", new Error("ws failed")); await expect(promise).rejects.toThrow(DxtradeError); @@ -128,8 +131,9 @@ describe("getPositions", () => { it("should reject on timeout", async () => { vi.useFakeTimers(); const ctx = createMockContext(); + const positions = new PositionsDomain(ctx); - const promise = getPositions(ctx); + const promise = positions.get(); vi.advanceTimersByTime(30_001); await expect(promise).rejects.toThrow(DxtradeError); @@ -140,14 +144,16 @@ describe("getPositions", () => { it("should throw NO_SESSION when not authenticated", async () => { const ctx = createMockContext({ csrf: null }); + const positions = new PositionsDomain(ctx); - await expect(getPositions(ctx)).rejects.toThrow("No active session"); + await expect(positions.get()).rejects.toThrow("No active session"); }); }); -describe("closeAllPositions", () => { +describe("PositionsDomain.closeAll", () => { it("should close each position with a market order", async () => { const ctx = createMockContext(); + const positions = new PositionsDomain(ctx); const twoPositions = [ ...mockPositions, @@ -184,7 +190,7 @@ describe("closeAllPositions", () => { mockRetryRequest.mockResolvedValue({ status: 200 }); - await closeAllPositions(ctx); + await positions.closeAll(); expect(mockRetryRequest).toHaveBeenCalledTimes(2); @@ -206,6 +212,7 @@ describe("closeAllPositions", () => { it("should do nothing when there are no positions", async () => { const ctx = createMockContext(); + const positions = new PositionsDomain(ctx); setTimeout(() => { const posPayload = JSON.stringify({ accountId: null, type: WS_MESSAGE.POSITIONS, body: [] }); @@ -215,7 +222,7 @@ describe("closeAllPositions", () => { wsInstance.emit("message", Buffer.from(`${metPayload.length}|${metPayload}`)); }, 200); - await closeAllPositions(ctx); + await positions.closeAll(); expect(mockRetryRequest).not.toHaveBeenCalled(); }); diff --git a/tests/stream-ohlc.test.ts b/tests/stream-ohlc.test.ts index 99e2da9..10bcfa6 100644 --- a/tests/stream-ohlc.test.ts +++ b/tests/stream-ohlc.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { EventEmitter } from "events"; import { WS_MESSAGE } from "@/constants/enums"; import { DxtradeError } from "@/constants/errors"; -import { streamOHLC } from "@/domains/ohlc"; +import { OhlcDomain } from "@/domains/ohlc"; import { createMockContext } from "./helpers"; import type { WsManager } from "@/utils/ws-manager"; @@ -49,13 +49,14 @@ function emitChartFeed(wsManager: WsManager, body: Record) { // --- Tests --- -describe("streamOHLC", () => { +describe("OhlcDomain.stream", () => { it("should emit snapshot bars after snapshotEnd", async () => { const wsManager = createMockWsManager(); const ctx = createMockContext({ wsManager }); + const ohlc = new OhlcDomain(ctx); const callback = vi.fn(); - const promise = streamOHLC(ctx, { symbol: "EURUSD" }, callback); + const promise = ohlc.stream({ symbol: "EURUSD" }, callback); // Simulate snapshot data arriving emitChartFeed(wsManager, { @@ -81,9 +82,10 @@ describe("streamOHLC", () => { it("should emit live bar updates after snapshot", async () => { const wsManager = createMockWsManager(); const ctx = createMockContext({ wsManager }); + const ohlc = new OhlcDomain(ctx); const callback = vi.fn(); - const promise = streamOHLC(ctx, { symbol: "EURUSD" }, callback); + const promise = ohlc.stream({ symbol: "EURUSD" }, callback); // Complete snapshot emitChartFeed(wsManager, { @@ -108,9 +110,10 @@ describe("streamOHLC", () => { it("should stop receiving updates after unsubscribe", async () => { const wsManager = createMockWsManager(); const ctx = createMockContext({ wsManager }); + const ohlc = new OhlcDomain(ctx); const callback = vi.fn(); - const promise = streamOHLC(ctx, { symbol: "EURUSD" }, callback); + const promise = ohlc.stream({ symbol: "EURUSD" }, callback); emitChartFeed(wsManager, { subtopic: WS_MESSAGE.SUBTOPIC.OHLC_STREAM, @@ -133,17 +136,19 @@ describe("streamOHLC", () => { it("should throw STREAM_REQUIRES_CONNECT when wsManager is null", async () => { const ctx = createMockContext({ wsManager: null }); + const ohlc = new OhlcDomain(ctx); - await expect(streamOHLC(ctx, { symbol: "EURUSD" }, vi.fn())).rejects.toThrow(DxtradeError); - await expect(streamOHLC(ctx, { symbol: "EURUSD" }, vi.fn())).rejects.toThrow("connect()"); + await expect(ohlc.stream({ symbol: "EURUSD" }, vi.fn())).rejects.toThrow(DxtradeError); + await expect(ohlc.stream({ symbol: "EURUSD" }, vi.fn())).rejects.toThrow("connect()"); }); it("should ignore messages with different subtopic", async () => { const wsManager = createMockWsManager(); const ctx = createMockContext({ wsManager }); + const ohlc = new OhlcDomain(ctx); const callback = vi.fn(); - const promise = streamOHLC(ctx, { symbol: "EURUSD" }, callback); + const promise = ohlc.stream({ symbol: "EURUSD" }, callback); // Emit message with wrong subtopic — should be ignored emitChartFeed(wsManager, { @@ -168,9 +173,10 @@ describe("streamOHLC", () => { it("should accumulate bars across multiple messages before snapshotEnd", async () => { const wsManager = createMockWsManager(); const ctx = createMockContext({ wsManager }); + const ohlc = new OhlcDomain(ctx); const callback = vi.fn(); - const promise = streamOHLC(ctx, { symbol: "EURUSD" }, callback); + const promise = ohlc.stream({ symbol: "EURUSD" }, callback); // First batch emitChartFeed(wsManager, { diff --git a/tests/stream-positions.test.ts b/tests/stream-positions.test.ts index 920f3b5..bf34fe8 100644 --- a/tests/stream-positions.test.ts +++ b/tests/stream-positions.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { EventEmitter } from "events"; import { WS_MESSAGE } from "@/constants/enums"; import { DxtradeError } from "@/constants/errors"; -import { streamPositions, getPositions } from "@/domains/position"; +import { PositionsDomain } from "@/domains/position"; import { createMockContext } from "./helpers"; import type { WsManager } from "@/utils/ws-manager"; @@ -86,15 +86,16 @@ function createMockWsManager(initialCache?: Record): WsManager // --- Tests --- -describe("streamPositions", () => { +describe("PositionsDomain.stream", () => { it("should emit merged positions on POSITION_METRICS update", () => { const wsManager = createMockWsManager({ [WS_MESSAGE.POSITIONS]: mockPositions, }); const ctx = createMockContext({ wsManager }); + const positions = new PositionsDomain(ctx); const callback = vi.fn(); - streamPositions(ctx, callback); + positions.stream(callback); // Clear the initial cached emission callback.mockClear(); @@ -116,9 +117,10 @@ describe("streamPositions", () => { [WS_MESSAGE.POSITION_METRICS]: mockMetrics, }); const ctx = createMockContext({ wsManager }); + const positions = new PositionsDomain(ctx); const callback = vi.fn(); - streamPositions(ctx, callback); + positions.stream(callback); expect(callback).toHaveBeenCalledTimes(1); const result = callback.mock.calls[0][0]; @@ -132,9 +134,10 @@ describe("streamPositions", () => { [WS_MESSAGE.POSITION_METRICS]: mockMetrics, }); const ctx = createMockContext({ wsManager }); + const positions = new PositionsDomain(ctx); const callback = vi.fn(); - const unsubscribe = streamPositions(ctx, callback); + const unsubscribe = positions.stream(callback); callback.mockClear(); (wsManager as unknown as EventEmitter).emit(WS_MESSAGE.POSITION_METRICS, mockMetrics); @@ -148,13 +151,14 @@ describe("streamPositions", () => { it("should throw STREAM_REQUIRES_CONNECT when wsManager is null", () => { const ctx = createMockContext({ wsManager: null }); + const positions = new PositionsDomain(ctx); - expect(() => streamPositions(ctx, vi.fn())).toThrow(DxtradeError); - expect(() => streamPositions(ctx, vi.fn())).toThrow("connect()"); + expect(() => positions.stream(vi.fn())).toThrow(DxtradeError); + expect(() => positions.stream(vi.fn())).toThrow("connect()"); }); }); -describe("getPositions with wsManager", () => { +describe("PositionsDomain.get with wsManager", () => { it("should use wsManager.waitFor and merge results", async () => { const wsManager = createMockWsManager({ [WS_MESSAGE.POSITIONS]: mockPositions, @@ -162,7 +166,8 @@ describe("getPositions with wsManager", () => { }); const ctx = createMockContext({ wsManager }); - const result = await getPositions(ctx); + const positions = new PositionsDomain(ctx); + const result = await positions.get(); expect(result).toHaveLength(1); expect(result[0].uid).toBe("u1"); @@ -173,8 +178,9 @@ describe("getPositions with wsManager", () => { it("should fall back to WebSocket when wsManager is null", async () => { const ctx = createMockContext({ wsManager: null }); + const positions = new PositionsDomain(ctx); - const promise = getPositions(ctx); + const promise = positions.get(); const posPayload = JSON.stringify({ accountId: null, type: WS_MESSAGE.POSITIONS, body: mockPositions }); wsInstance.emit("message", Buffer.from(`${posPayload.length}|${posPayload}`));