Author SHA1 Message Date
discountry 2a1cf24510 Enhance Binance depth health monitoring and defense mode logic
- Updated translations for Binance depth status messages to include depth window information.
- Modified `MakerPointsEngine` to incorporate health checks for the Binance depth tracker, including handling of unhealthy states.
- Improved defense mode activation logic to respond to Binance depth health status, ensuring appropriate logging and notifications.
- Added integration tests for defense mode behavior based on Binance depth health, validating transitions into and out of defense mode.
- Refactored `BinanceDepthTracker` to support health checks and improved connection management.
2026-02-07 21:54:22 +08:00
DisneyandGitHub fa82d45bfb fix size (#20) 2026-02-04 13:26:06 +08:00
discountry d0154e5721 fix den 2026-02-03 18:48:06 +08:00
discountry 52b6a8a076 Add invitation links for Nado registration in trading tutorial 2026-02-03 12:04:36 +08:00
discountry db6a9cfc68 Add Nado trading tutorial for ritmex-bot 2026-02-03 12:02:55 +08:00
DisneyandGitHub 03b8e53d30 Merge pull request #19 from discountry/feat/arb
Feat/arb
2026-02-01 11:15:42 +08:00
discountry 8d79ace8b3 Refactor triggerType handling in order placement logic
- Updated the triggerType assignment in placeStopLossOrder and related functions to default to "STOP_LOSS" instead of conditionally setting it based on the order side.
- This change simplifies the logic for stop market orders across the order coordinator and GRVT exchange gateway, ensuring consistent behavior.
2026-02-01 11:15:00 +08:00
discountry 1fb6d3d62d Add swing trading configuration options to .env.example
- Added new environment variables for swing trading, including SWING_DIRECTION and SWING_STOP_LOSS_PCT.
- Updated documentation in .env.example to reflect the new swing trading parameters for better clarity and usability.
2026-01-31 16:19:19 +08:00
discountry c4559cb0d7 Add swing trading strategy with RSI signals and Binance integration
- Introduced a new swing trading strategy utilizing the RSI indicator on the ETHBTC pair from Binance.
- Implemented the `SwingEngine` to manage trading logic, including entry and exit conditions based on RSI thresholds.
- Added configuration options for swing direction, trade amount, and RSI parameters in `config.ts`.
- Created new documentation for the swing strategy, detailing its behavior and configuration.
- Enhanced CLI to support the new swing strategy option.
- Added tests for swing logic to ensure correct behavior under various market conditions.
2026-01-31 16:15:07 +08:00
discountry 1d88ddefb5 Enhance account snapshot handling and staleness checks in MakerPointsEngine
- Updated `emitAccountSnapshot` method in `StandxGateway` to accept an optional `updateTime` parameter, allowing for more accurate timestamping.
- Introduced logic to determine the appropriate `updateTime` based on the latest position or balance data.
- Added `time` property to `StandxPosition` interface for improved timestamp management.
- Implemented `applyAccountSnapshot` method in `MakerPointsEngine` to streamline account snapshot processing and ensure accurate time tracking.
- Added tests to validate the behavior of account staleness checks and defense mode activation based on account data freshness.
2026-01-24 23:53:36 +08:00
discountry 683352f737 Add changeMarginMode method to ExchangeAdapter and Standx classes
- Introduced `changeMarginMode` method in `ExchangeAdapter` interface to allow margin mode adjustments.
- Implemented the `changeMarginMode` method in `StandxExchangeAdapter` to interact with the gateway for changing margin modes.
- Added corresponding `changeMarginMode` method in `StandxGateway` to handle API requests for margin mode changes.
- Enhanced `MakerPointsEngine` to ensure isolated margin mode before order placement, with appropriate logging and defense mode activation if the change fails.
- Created tests for margin mode functionality to validate behavior under different scenarios.
2026-01-24 22:57:03 +08:00
discountry a629bc940c Enhance environment variable parsing and account snapshot validation
- Introduced `normalizeEnvValue` function to improve handling of environment variable values, including trimming, unquoting, and stripping inline comments.
- Updated `resolveSymbolFromEnv` and parsing functions to utilize the new normalization logic.
- Added `validateAccountSnapshotForSymbol` function to validate account snapshots, ensuring numeric fields are correctly formatted and flagging any issues.
- Implemented tests for environment variable parsing and account snapshot validation to ensure robustness and correctness.
2026-01-24 22:46:14 +08:00
discountry fe7b8eb6f3 Update Binance WebSocket configuration and enhance depth handling
- Changed WebSocket base URL to support both spot and futures trading.
- Adjusted depth tracking parameters for improved performance, increasing the ratio and reducing speed.
- Enhanced payload parsing to accommodate additional data structures from Binance, ensuring robust handling of bids and asks.
- Updated comments for clarity on connection behavior and heartbeat monitoring.
2026-01-22 10:23:07 +08:00
discountry 24339929dc Enhance MakerPoints functionality and configuration
- Updated `filterMinDepth` in `config.ts` from 1 to 50 to improve depth filtering logic.
- Added new translation entries for band depth display in `i18n/index.ts`.
- Introduced `bandDepths` to `MakerPointsSnapshot` in `maker-points-engine.ts` to track depth across different bands.
- Enhanced `BinanceDepthTracker` to support dynamic depth levels and speed settings.
- Updated `MakerPointsApp` to display band depth information, improving user interface clarity.
2026-01-22 02:27:45 +08:00
discountry ed855f6859 Refine data staleness checks in MakerPointsEngine
- Updated the logic to only consider depth data for staleness checks, excluding account data from the criteria.
- Removed unnecessary account staleness checks from defense mode activation, streamlining the data validation process.
- Enhanced comments for clarity on the rationale behind the changes.
2026-01-21 16:30:51 +08:00
discountry e144c1822f Implement data staleness defense mode in MakerPointsEngine
- Introduced a defense mode that activates when data from StandX or Binance is stale for over 5 seconds.
- Added methods to check data freshness, enter and exit defense mode, and cancel all orders during defense mode.
- Enhanced logging to provide insights into data staleness and defense mode transitions.
- Updated connection state management for clarity and consistency.
2026-01-21 16:24:17 +08:00
discountry 69271d33ca Refactor MakerPointsEngine and BinanceDepthTracker for improved connection management
- Renamed connection state variable in MakerPointsEngine for clarity.
- Added connection state change listeners in BinanceDepthTracker to handle connection status updates.
- Implemented heartbeat monitoring and connection duration checks in BinanceDepthTracker to enhance WebSocket reliability.
- Introduced data staleness checks and improved error handling for WebSocket connections.
- Enhanced logging for connection events to provide better insights into connection status changes.
2026-01-21 16:03:34 +08:00
discountry f1140f106a Enhance WebSocket connection management and data handling
- Introduced constants for WebSocket reconnection delays, heartbeat timeout, and data staleness thresholds.
- Implemented heartbeat monitoring to ensure timely reconnections on inactivity.
- Added data staleness checks to trigger REST API calls when market or account data is outdated.
- Enhanced the StandxGateway class with methods for managing heartbeat and data checks, improving overall connection reliability and data integrity.
2026-01-21 15:40:28 +08:00
42 changed files with 5613 additions and 178 deletions
+4
View File
@@ -27,6 +27,10 @@ STANDX_SYMBOL=BTC-USD
TRADE_SYMBOL=BTCUSDT # Trading pair symbol TRADE_SYMBOL=BTCUSDT # Trading pair symbol
TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC) TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC)
# Swing Trading
SWING_DIRECTION=short # short | long | both
SWING_STOP_LOSS_PCT=0.05 # 0.05 = 5%
# Risk management (USD amounts unless noted) # Risk management (USD amounts unless noted)
LOSS_LIMIT=0.04 # Max loss per trade in USDT before forced close LOSS_LIMIT=0.04 # Max loss per trade in USDT before forced close
TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT) TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
+31 -29
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "ritmex-bot", "name": "ritmex-bot",
@@ -14,6 +15,7 @@
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ink": "^6.3.1", "ink": "^6.3.1",
"react": "^19.1.1", "react": "^19.1.1",
"trading-signals": "^7.4.3",
"viem": "^2.43.1", "viem": "^2.43.1",
"ws": "^8.18.3", "ws": "^8.18.3",
}, },
@@ -29,7 +31,7 @@
"packages": { "packages": {
"@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="],
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-qI/5TaaaCZE4yeSZ83lu0+xi1r88JSxUjnH4OP/iZF7+KKZ75u3ee5isd0LxX+6N8U0npL61YrpbthILHB6BnA=="], "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
@@ -83,19 +85,19 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@grvt/client": ["@grvt/client@1.6.4", "", { "dependencies": { "axios": "^1.12.2" } }, "sha512-yZfEvsC/BtkcdMjoB7Eh6gshBraz/9810qCllvNXDx6OmdxR+NfxPO+ucNlPbyjEt1HfSVzHBWB1yGnVl1usNA=="], "@grvt/client": ["@grvt/client@1.6.25", "", { "dependencies": { "axios": "^1.13.2" } }, "sha512-LG3oZSJDq1Qz6mLiHDmg+v/Hp+Nd+yOp8GIvkBVyIdOcPVzuzC8UQD7IxvS+g5EfQQKLiKzXSjKLc39a11Uj1A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@nadohq/client": ["@nadohq/client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.41", "@nadohq/indexer-client": "^0.1.0-alpha.41", "@nadohq/shared": "^0.1.0-alpha.41", "@nadohq/trigger-client": "^0.1.0-alpha.41", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-BbJjG32AsaBJtFA9VPH+3/sfR/pUCXVJ0spgairReb+FaZY2rYsasWGPi07y7OROXL7ZS9/LewntC6iu2NVzQg=="], "@nadohq/client": ["@nadohq/client@0.1.0-alpha.45", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.45", "@nadohq/indexer-client": "^0.1.0-alpha.45", "@nadohq/shared": "^0.1.0-alpha.45", "@nadohq/trigger-client": "^0.1.0-alpha.45", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-63S7eT5xSk3hxEXRLGDI94mPeu/zd1Nu4oUOZrY8wzhaT7Gl7PVP2snb8JH/PduVQzgs7+qblr4aTBI/j6QvLQ=="],
"@nadohq/engine-client": ["@nadohq/engine-client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/shared": "^0.1.0-alpha.41", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-PR2/MaZ2rprd42572UL26B6zBzUFLUVbvAwPFGf6N733RysUxMRixEJmUfSrPVKgZkA4gxqqEhLB0iZA08DBjw=="], "@nadohq/engine-client": ["@nadohq/engine-client@0.1.0-alpha.45", "", { "dependencies": { "@nadohq/shared": "^0.1.0-alpha.45", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-GqaRrsB0Z2EgNY6wu4nG5OTc5hF+buNRMc0+jPLlOQonlPJAC4OiMCgCigm4nJecYwsdiQD05W3Z4YUqggxbyw=="],
"@nadohq/indexer-client": ["@nadohq/indexer-client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.41", "@nadohq/shared": "^0.1.0-alpha.41", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-5oXM+r3xQmbAHx00MlZmZj64VfcVNNWCtZgJu1Hx+A6F7zZtt0zBmDR6WnY1qWQBM/yZc/xa/Rr3ypXN5VSyQg=="], "@nadohq/indexer-client": ["@nadohq/indexer-client@0.1.0-alpha.45", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.45", "@nadohq/shared": "^0.1.0-alpha.45", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-8PzG9taYh380lsKT8i4UvGfhZiPpt+zPisx+ErVqMifyhcJAhgMeYNcfijwMQFNJdgTaBvtI0R5JK98l4Y0YQw=="],
"@nadohq/shared": ["@nadohq/shared@0.1.0-alpha.41", "", { "peerDependencies": { "bignumber.js": "^9.3.0", "viem": "*" } }, "sha512-c6rRU/9RjIRV0RSHwmliNOE3i3xi65zKGqW9O0M4Oc8a0SQXB5SpskQeSFOaZ1b9BHGi0TYg1xaLBjtYzbysJQ=="], "@nadohq/shared": ["@nadohq/shared@0.1.0-alpha.45", "", { "peerDependencies": { "bignumber.js": "^9.3.0", "viem": "*" } }, "sha512-4Ml8dO9mkwnIidflpunwVMLX2KJA79b53T9xTBkH1Yzy0iOnbEE27r25wvgJgyszU0aznbPBv2++FZ0d0B5Zhg=="],
"@nadohq/trigger-client": ["@nadohq/trigger-client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.41", "@nadohq/shared": "^0.1.0-alpha.41", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-PxHeqi+aGUHs6S2jHRz2GC9OQD3Mb25sqqwgvDz3EoXWAfv+tYAaSDsLL8FmYEPsbTswjfXm0PoThPDyImyU9A=="], "@nadohq/trigger-client": ["@nadohq/trigger-client@0.1.0-alpha.45", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.45", "@nadohq/shared": "^0.1.0-alpha.45", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-Xt8W9zxOZhMvoXSRnx9kUR26wWuHc3EgTVTBbUXt7SlI9v3IP9I3idS6SU3j2f08flcvNqRmC2EbXyYDC2XeyQ=="],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
@@ -155,7 +157,7 @@
"@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="], "@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
@@ -169,8 +171,6 @@
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -187,7 +187,7 @@
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
"ansi-escapes": ["ansi-escapes@7.1.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g=="], "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -199,17 +199,17 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], "axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"ccxt": ["ccxt@4.5.12", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-2lfL2TKjq4vBkQUQWJfDqFywhvYCZmk9r0SWC8GqA4AHZ6qozKVUJowxQTvdRsLX9jBwYSE0nc7JVurBrQ6SHg=="], "ccxt": ["ccxt@4.5.35", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-aFn1jq/vR9wD3Vxu/2UFoS8snqlvfYn/iYrNhyZZfzB3N+kAHhP5+sAgO6ZwNHkHiUT9IPPahC1ZSFdPtjpIYA=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -221,7 +221,7 @@
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
@@ -229,15 +229,13 @@
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dotenv": ["dotenv@17.2.2", "", {}, "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -295,7 +293,7 @@
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"ink": ["ink@6.3.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.32.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-3wGwITGrzL6rkWsi2gEKzgwdafGn4ZYd3u4oRp+sOPvfoxEHlnoB5Vnk9Uy5dMRUhDOqF3hqr4rLQ4lEzBc2sQ=="], "ink": ["ink@6.6.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
@@ -323,7 +321,7 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"ox": ["ox@0.10.5", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-mXJRiZswmX46abrzNkJpTN9sPJ/Rhevsp5Dfg0z80D55aoLNmEV4oN+/+feSNW593c2CnHavMqSVBanpJ0lUkQ=="], "ox": ["ox@0.11.3", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-1bWYGk/xZel3xro3l8WGg6eq4YEKlaqvyMtVhfMFpbJzK2F6rj4EDRtqDCWVEJMkzcmEi9uW2QxsqELokOlarw=="],
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
@@ -339,15 +337,15 @@
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"rollup": ["rollup@4.52.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.0", "@rollup/rollup-android-arm64": "4.52.0", "@rollup/rollup-darwin-arm64": "4.52.0", "@rollup/rollup-darwin-x64": "4.52.0", "@rollup/rollup-freebsd-arm64": "4.52.0", "@rollup/rollup-freebsd-x64": "4.52.0", "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", "@rollup/rollup-linux-arm-musleabihf": "4.52.0", "@rollup/rollup-linux-arm64-gnu": "4.52.0", "@rollup/rollup-linux-arm64-musl": "4.52.0", "@rollup/rollup-linux-loong64-gnu": "4.52.0", "@rollup/rollup-linux-ppc64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-musl": "4.52.0", "@rollup/rollup-linux-s390x-gnu": "4.52.0", "@rollup/rollup-linux-x64-gnu": "4.52.0", "@rollup/rollup-linux-x64-musl": "4.52.0", "@rollup/rollup-openharmony-arm64": "4.52.0", "@rollup/rollup-win32-arm64-msvc": "4.52.0", "@rollup/rollup-win32-ia32-msvc": "4.52.0", "@rollup/rollup-win32-x64-gnu": "4.52.0", "@rollup/rollup-win32-x64-msvc": "4.52.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g=="], "rollup": ["rollup@4.52.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.0", "@rollup/rollup-android-arm64": "4.52.0", "@rollup/rollup-darwin-arm64": "4.52.0", "@rollup/rollup-darwin-x64": "4.52.0", "@rollup/rollup-freebsd-arm64": "4.52.0", "@rollup/rollup-freebsd-x64": "4.52.0", "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", "@rollup/rollup-linux-arm-musleabihf": "4.52.0", "@rollup/rollup-linux-arm64-gnu": "4.52.0", "@rollup/rollup-linux-arm64-musl": "4.52.0", "@rollup/rollup-linux-loong64-gnu": "4.52.0", "@rollup/rollup-linux-ppc64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-musl": "4.52.0", "@rollup/rollup-linux-s390x-gnu": "4.52.0", "@rollup/rollup-linux-x64-gnu": "4.52.0", "@rollup/rollup-linux-x64-musl": "4.52.0", "@rollup/rollup-openharmony-arm64": "4.52.0", "@rollup/rollup-win32-arm64-msvc": "4.52.0", "@rollup/rollup-win32-ia32-msvc": "4.52.0", "@rollup/rollup-win32-x64-gnu": "4.52.0", "@rollup/rollup-win32-x64-msvc": "4.52.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
@@ -363,7 +361,7 @@
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -381,6 +379,8 @@
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
"trading-signals": ["trading-signals@7.4.3", "", {}, "sha512-kOyzd85qhuhU7yGkB5z74MVP6j30xBCfZy5+bgBnvfxY1ZdvaQsdD+C5j+CoIRtCAgSOuxB7jmPpBrqO6h17sQ=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -389,7 +389,7 @@
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
"viem": ["viem@2.43.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.10.5", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-S33pBNlRvOlVv4+L94Z8ydCMDB1j0cuHFUvaC28i6OTxw3uY1P4M3h1YDFK8YC1H9/lIbeBTTvCRhi0FqU/2iw=="], "viem": ["viem@2.45.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.11.3", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-LN6Pp7vSfv50LgwhkfSbIXftAM5J89lP9x8TeDa8QM7o41IxlHrDh0F9X+FfnCWtsz11pEVV5sn+yBUoOHNqYA=="],
"vite": ["vite@7.1.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA=="], "vite": ["vite@7.1.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA=="],
@@ -403,12 +403,10 @@
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], "ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
"ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
@@ -425,7 +423,11 @@
"viem/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], "viem/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
"cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], "viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"ox/@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], "ox/@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
+550
View File
@@ -0,0 +1,550 @@
# Nado 交易机器人完全新手教程
> 本教程面向零基础用户,手把手教你如何在 Nado 交易所使用 ritmex-bot 进行自动化交易。
## 目录
1. [什么是 Nado 和 ritmex-bot](#什么是-nado-和-ritmex-bot)
2. [准备工作](#准备工作)
3. [安装 Bun 运行环境](#安装-bun-运行环境)
4. [下载并安装 ritmex-bot](#下载并安装-ritmex-bot)
5. [在 Nado 创建账户并入金](#在-nado-创建账户并入金)
6. [获取 Nado 鉴权信息(重要)](#获取-nado-鉴权信息重要)
7. [配置 .env 文件](#配置-env-文件)
8. [启动交易机器人](#启动交易机器人)
9. [策略说明与选择](#策略说明与选择)
10. [常见问题与排查](#常见问题与排查)
---
## 什么是 Nado 和 ritmex-bot
### Nado 交易所
Nado 是一个运行在 **Ink L2 网络**(基于 Optimism Superchain)的去中心化永续合约交易所。它支持 BTC、ETH 等主流加密货币的永续合约交易,具有以下特点:
- **去中心化**:资产由用户钱包控制,无需信任中心化机构
- **低延迟**:使用 L2 网络,交易确认快速
- **统一保证金**:支持多种资产作为抵押品
注册使用 Nado 目前需要邀请码:
* [https://app.nado.xyz?join=eGp9qPD](https://app.nado.xyz?join=eGp9qPD)
* [https://app.nado.xyz?join=LKbIUs5](https://app.nado.xyz?join=LKbIUs5)
### ritmex-bot 交易机器人
ritmex-bot 是一个多交易所量化交易终端,支持多种自动化交易策略:
- **趋势跟随策略**:基于 SMA30 均线的趋势交易
- **做市策略**:双边挂单赚取买卖价差
- **Guardian 防守策略**:自动监控仓位,补挂止损止盈
- **网格策略**:区间震荡行情的网格交易
---
## 准备工作
在开始之前,请确保你具备以下条件:
### 硬件要求
- 一台可以联网的电脑(macOS、Linux 或 Windows
- 稳定的网络连接
### 软件要求
- **终端/命令行工具**
- macOS:使用自带的"终端"应用
- Windows:推荐使用 WSLWindows Subsystem for Linux)或 PowerShell
- Linux:使用自带的终端
### 资金要求
- 至少 **50-100 USDT** 等值的资金用于交易
- 少量 **ETH**(约 0.01-0.05 ETH)用于支付 Gas 费用
### 钱包要求
- 一个支持 EVM 的钱包,推荐:
- **MetaMask**(浏览器插件)
- **Rabby Wallet**(更专业的交易钱包)
---
## 安装 Bun 运行环境
ritmex-bot 使用 **Bun** 作为运行环境。Bun 是一个快速的 JavaScript 运行时。
### macOS / Linux 安装
打开终端,输入以下命令:
```bash
curl -fsSL https://bun.sh/install | bash
```
安装完成后,**关闭并重新打开终端**,然后验证安装:
```bash
bun -v
```
如果显示版本号(如 `1.2.x`),说明安装成功。
### Windows 安装
**方法一:使用 PowerShell(推荐)**
以管理员身份打开 PowerShell,输入:
```powershell
powershell -c "irm bun.sh/install.ps1 | iex"
```
**方法二:使用 WSL(更稳定)**
1. 先安装 WSL:在 PowerShell 中运行 `wsl --install`
2. 重启电脑
3. 打开 WSL 终端,按照 Linux 方式安装 Bun
---
## 下载并安装 ritmex-bot
### 方法一:使用 Git(推荐)
```bash
# 克隆代码仓库
git clone https://github.com/discountry/ritmex-bot.git
# 进入项目目录
cd ritmex-bot
# 安装依赖
bun install
```
### 方法二:直接下载 ZIP
1. 访问 https://github.com/discountry/ritmex-bot
2. 点击绿色的 "Code" 按钮
3. 选择 "Download ZIP"
4. 解压下载的文件
5. 在终端中进入解压后的目录
6. 运行 `bun install`
---
## 在 Nado 创建账户并入金
### 步骤 1:连接钱包
1. 访问 Nado 官网:https://app.nado.xyz
2. 点击右上角的 **"Connect Wallet"**
3. 选择你的钱包(如 MetaMask)
4. 在钱包中确认连接
### 步骤 2:添加 Ink 网络到钱包
Nado 运行在 Ink L2 网络上,你需要先添加这个网络:
**自动添加方式:**
1. 访问 https://chainlist.org/
2. 搜索 "Ink"
3. 点击 "Add to MetaMask"
**手动添加方式:**
在钱包设置中添加自定义网络,填写以下信息:
| 参数 | 值 |
|------|-----|
| 网络名称 | INK |
| RPC URL | https://rpc-gel.inkonchain.com |
| Chain ID | 57073 |
| 货币符号 | ETH |
| 区块浏览器 | https://explorer.inkonchain.com |
### 步骤 3:获取 Gas 费用(ETH)
在 Ink 网络上进行任何操作都需要少量 ETH 作为 Gas 费用。
**获取方式:**
- **从 CEX 直接提现**:Kraken 支持零手续费提现到 Ink 网络
- **跨链桥接**:使用 [Superbridge](https://superbridge.app/)、[Bungee](https://bungee.exchange/) 或 [Relay](https://relay.link/) 从其他链桥接 ETH 到 Ink
> 建议至少准备 0.01-0.05 ETH 用于支付 Gas 费用
### 步骤 4:存入交易资金
1. 在 Nado 网站导航到 **Portfolio** 页面
2. 点击 **Deposit**
3. 选择要存入的资产(支持 USDT0、wETH、USDC、kBTC、wBTC
4. 输入金额并确认交易
---
## 获取 Nado 鉴权信息(重要)
这是配置机器人最关键的一步。Nado 使用基于 EVM 的签名认证方式,你需要获取以下两个关键信息:
- **NADO_SIGNER_PRIVATE_KEY**:签名私钥
- **NADO_SUBACCOUNT_OWNER**:子账户所有者地址
### 获取方式:从浏览器开发者工具提取
#### 步骤 1:登录 Nado 交易界面
1. 访问 https://app.nado.xyz
2. 连接你的钱包并完成登录
3. 确保你已经在 Nado 启用了一键交易功能并能正常下单
#### 步骤 2:打开浏览器开发者工具
- **Chrome / Edge**:按 `F12``Ctrl+Shift+I`Mac 上是 `Cmd+Option+I`
- **Firefox**:按 `F12``Ctrl+Shift+I`
- **Safari**:先在偏好设置中启用开发者菜单,然后按 `Cmd+Option+I`
#### 步骤 3:找到 Local Storage 中的私钥
1. 在开发者工具中,切换到 **Application** 标签(Chrome/Edge)或 **Storage** 标签(Firefox
2. 在左侧菜单找到 **Local Storage**
3. 点击展开,找到 `https://app.nado.xyz`
4. 在右侧列表中找到 **`nado.userSettings`** 这一项
5. 点击这一项,查看其 Value(值)
#### 步骤 4:提取私钥
`nado.userSettings` 的值是一个 JSON 对象,大致结构如下:
```json
"signingPreferenceBySubaccountKey": {
"inkMainnet_default": {
{
"privateKey": "0x1234567890abcdef...",
...
}
}
}
```
**你需要的是 `privateKey` 字段的值**,它是一个以 `0x` 开头的 64 位十六进制字符串。
将这个值复制下来,这就是你的 `NADO_SIGNER_PRIVATE_KEY`
#### 步骤 5:获取子账户所有者地址
`NADO_SUBACCOUNT_OWNER` 就是你连接到 Nado 的钱包地址。
获取方式:
1. 打开你的钱包(如 MetaMask)
2. 复制你的钱包地址(以 `0x` 开头的 42 位地址)
这就是你的 `NADO_SUBACCOUNT_OWNER`
### 关于 Linked Signer(进阶)
Nado 支持 "Linked Signer" 功能,允许你使用一个专门的签名密钥来代表你的主账户进行交易。这提供了额外的安全层:
- 主钱包私钥保持离线安全
- Linked Signer 只有交易权限,无法提取资金
- 可以随时撤销 Linked Signer
当你在 Nado 网站首次连接钱包时,系统会自动为你创建一个 Linked Signer,这个签名密钥就存储在浏览器的 Local Storage 中。
> **重要安全提示**
> - `NADO_SIGNER_PRIVATE_KEY` 是 Linked Signer 的私钥,不是你主钱包的私钥
> - 这个私钥只能用于在 Nado 上签署交易,无法直接转移你的链上资产
> - 但仍需妥善保管,不要分享给他人
---
## 配置 .env 文件
### 步骤 1:创建 .env 文件
在项目根目录下,复制示例配置文件:
```bash
cp .env.example .env
```
### 步骤 2:编辑 .env 文件
使用任意文本编辑器打开 `.env` 文件:
```bash
# macOS
open -e .env
# Linux
nano .env
# Windows
notepad .env
```
### 步骤 3:填写 Nado 配置
`.env` 文件中找到并修改以下配置项:
```bash
# ============================================
# 交易所选择
# ============================================
EXCHANGE=nado
# ============================================
# Nado 交易所配置(必填)
# ============================================
# 签名私钥 - 从浏览器 Local Storage 获取
# 格式:以 0x 开头的 64 位十六进制字符串
NADO_SIGNER_PRIVATE_KEY=0x你的私钥
# 子账户所有者地址 - 你的钱包地址
# 格式:以 0x 开头的 42 位地址
NADO_SUBACCOUNT_OWNER=0x你的钱包地址
# 子账户名称(可选,默认为 default)
NADO_SUBACCOUNT_NAME=default
# 网络环境(可选)
# inkMainnet = 主网(真实交易)
# inkTestnet = 测试网(模拟交易)
NADO_ENV=inkMainnet
# 交易品种(可选,默认 BTC-PERP)
NADO_SYMBOL=BTC-PERP
# ============================================
# 交易参数配置
# ============================================
# 单笔交易数量(以 BTC 计)
TRADE_AMOUNT=0.001
# 止损金额(USD)- 亏损超过此金额触发平仓
LOSS_LIMIT=10
# 移动止盈触发金额(USD
TRAILING_PROFIT=5
# 移动止盈回撤比例(0.2 = 20%)
TRAILING_CALLBACK_RATE=0.2
```
### 完整的 Nado 配置示例
```bash
# 交易所选择
EXCHANGE=nado
# Nado 凭证
NADO_SIGNER_PRIVATE_KEY=0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
NADO_SUBACCOUNT_OWNER=0xAbCdEf1234567890AbCdEf1234567890AbCdEf12
NADO_SUBACCOUNT_NAME=default
NADO_ENV=inkMainnet
NADO_SYMBOL=BTC-PERP
# 交易参数
TRADE_AMOUNT=0.001
# 趋势策略
LOSS_LIMIT=10
TRAILING_PROFIT=5
TRAILING_CALLBACK_RATE=0.2
# 波段策略
SWING_DIRECTION=short
SWING_STOP_LOSS_PCT=0.05 # 价格反向运动 5% 止损
# 做市策略参数(如果使用做市策略)
LOSS_LIMIT=0.08
MAKER_LOSS_LIMIT=0.08
LIQUIDITY_MAKER_LOSS_LIMIT=0.08
LIQUIDITY_MAKER_CLOSE_TICK_OFFSET=5
MAKER_ENTRY_DEPTH_LEVEL=1
```
## 启动交易机器人
### 方法一:交互式启动(推荐新手)
```bash
bun run index.ts
```
启动后会显示一个交互式菜单,使用方向键选择策略,按回车确认。
### 方法二:命令行直接启动
```bash
# 启动趋势策略
bun run index.ts --strategy trend --silent
# 启动做市策略
bun run index.ts --strategy maker --silent
```
### 方法三:使用 PM2 后台运行(推荐生产环境)
```bash
# 安装 PM2
bun add -d pm2
# 后台启动趋势策略
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
# 查看运行状态
bunx pm2 list
# 查看日志
bunx pm2 logs ritmex-trend
# 停止运行
bunx pm2 stop ritmex-trend
```
---
## 策略说明与选择
### 趋势策略(Trend
**适用场景**:单边行情,趋势明显的市场
**工作原理**
- 使用 SMA30(30 周期简单移动平均线)判断趋势
- 价格突破均线时开仓
- 内置止损和移动止盈
**配置参数**
```bash
TRADE_AMOUNT=0.001 # 单笔交易量
LOSS_LIMIT=10 # 止损金额(USD
TRAILING_PROFIT=5 # 移动止盈触发金额
TRAILING_CALLBACK_RATE=0.2 # 回撤比例
```
### 做市策略(Maker
**适用场景**:震荡行情,低波动市场
**工作原理**
- 在买卖盘口双边挂单
- 赚取买卖价差
- 根据持仓自动调整挂单方向
**配置参数**
```bash
MAKER_BID_OFFSET=0 # 买单价格偏移
MAKER_ASK_OFFSET=0 # 卖单价格偏移
MAKER_REFRESH_INTERVAL_MS=500 # 刷新间隔
```
## 常见问题与排查
### 问题 1:启动时提示 "Missing NADO_SIGNER_PRIVATE_KEY"
**原因**:未正确配置签名私钥
**解决方案**
1. 确认 `.env` 文件存在于项目根目录
2. 检查 `NADO_SIGNER_PRIVATE_KEY` 是否正确填写
3. 确保私钥以 `0x` 开头
### 问题 2:启动时提示 "Missing NADO_SUBACCOUNT_OWNER"
**原因**:未配置子账户所有者地址
**解决方案**
1.`.env` 中填写 `NADO_SUBACCOUNT_OWNER` 为你的钱包地址
2. 也可以使用 `NADO_EVM_ADDRESS` 作为替代
### 问题 3:连接失败,无法获取账户信息
**可能原因及解决方案**
1. **网络问题**:检查网络连接,确保能访问 Nado API
2. **私钥错误**:重新从浏览器 Local Storage 获取正确的私钥
3. **账户未激活**:确保在 Nado 网站上至少存入过资金
4. **Linked Signer 未启用**:账户需要至少有 5 USDT 价值的资产才能使用 Linked Signer
### 问题 4:下单失败,提示精度错误
**原因**:价格或数量精度不符合交易所要求
**解决方案**
```bash
# BTC-PERP 的标准精度
PRICE_TICK=0.1 # 价格精度 0.1 USD
QTY_STEP=0.001 # 数量精度 0.001 BTC
```
### 问题 5:时间同步错误
**原因**:本地时间与服务器时间相差太大
**解决方案**
- macOS/Linux`sudo ntpdate -u time.apple.com`
- Windows:设置 → 时间和语言 → 同步时间
### 问题 6Gas 费用不足
**原因**Ink 网络上的 ETH 余额不足
**解决方案**
1. 通过跨链桥向 Ink 网络转入少量 ETH0.01-0.05 ETH
2. 推荐使用 Superbridge 或 Relay 进行跨链
### 问题 7:找不到 Local Storage 中的私钥
**可能原因**
1. 尚未在 Nado 完成首次连接/操作
2. 浏览器清除了缓存
**解决方案**
1. 重新访问 https://app.nado.xyz
2. 连接钱包并进行一次操作(如查看账户)
3. 再次检查 Local Storage
---
## 安全提示
1. **永远不要分享你的私钥**`NADO_SIGNER_PRIVATE_KEY` 虽然不是主钱包私钥,但仍可用于在 Nado 上进行交易
2. **使用专用交易账户**:建议创建一个专门用于交易的新钱包,不要放置大量资产
3. **定期检查授权**:在钱包中定期检查并撤销不需要的 DApp 授权
4. **小额测试**:首次运行时使用小额资金测试,确认一切正常后再增加资金
5. **保护 .env 文件**
- 不要将 `.env` 文件提交到 Git
- 不要分享给他人
- 定期更换凭证
---
## 社区与支持
- **Telegram 交流群**https://t.me/+4fdo0quY87o4Mjhh
- **GitHub Issues**https://github.com/discountry/ritmex-bot/issues
- **Nado 官方文档**https://docs.nado.xyz
---
## 风险提示
量化交易具备风险。请注意:
- 市场波动可能导致亏损
- 技术故障可能导致订单执行异常
- 请勿投入无法承受损失的资金
- 建议先在测试网或小额账户中验证策略
祝交易顺利!
+369
View File
@@ -0,0 +1,369 @@
- The following base endpoints are available. Please use whichever works best for your setup:
- **[https://api.binance.com](https://api.binance.com/)**
- **[https://api-gcp.binance.com](https://api-gcp.binance.com/)**
- **[https://api1.binance.com](https://api1.binance.com/)**
- **[https://api2.binance.com](https://api2.binance.com/)**
- **[https://api3.binance.com](https://api3.binance.com/)**
- **[https://api4.binance.com](https://api4.binance.com/)**
- The last 4 endpoints in the point above (`api1` - `api4`) should give better performance but have less stability.
- Responses are in JSON by default. To receive responses in SBE, refer to the [SBE FAQ](https://developers.binance.com/docs/binance-spot-api-docs/faqs/sbe_faq) page.
- If your request contains a symbol name containing non-ASCII characters, then the response may contain non-ASCII characters encoded in UTF-8.
- Some endpoints may return asset and/or symbol names containing non-ASCII characters encoded in UTF-8 even if the request did not contain non-ASCII characters.
- Data is returned in **chronological order**, unless noted otherwise.
- Without `startTime` or `endTime`, returns the most recent items up to the limit.
- With `startTime`, returns oldest items from `startTime` up to the limit.
- With `endTime`, returns most recent items up to `endTime` and the limit.
- With both, behaves like `startTime` but does not exceed `endTime`.
- All time and timestamp related fields in the JSON responses are in **milliseconds by default.** To receive the information in microseconds, please add the header `X-MBX-TIME-UNIT:MICROSECOND` or `X-MBX-TIME-UNIT:microsecond`.
- We support HMAC, RSA, and Ed25519 keys. For more information, please see [API Key types](https://developers.binance.com/docs/binance-spot-api-docs/faqs/api_key_types).
- Timestamp parameters (e.g. `startTime`, `endTime`, `timestamp`) can be passed in milliseconds or microseconds.
- For APIs that only send public market data, please use the base endpoint **[https://data-api.binance.vision](https://data-api.binance.vision/)**. Please refer to [Market Data Only](https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only) page.
- If there are enums or terms you want clarification on, please see the [SPOT Glossary](https://developers.binance.com/docs/binance-spot-api-docs/faqs/spot_glossary) for more information.
- APIs have a timeout of 10 seconds when processing a request. If a response from the Matching Engine takes longer than this, the API responds with "Timeout waiting for response from backend server. Send status unknown; execution status unknown." [(-1007 TIMEOUT)](https://developers.binance.com/docs/binance-spot-api-docs/errors#-1007-timeout)
- This does not always mean that the request failed in the Matching Engine.
- If the status of the request has not appeared in [User Data Stream](https://developers.binance.com/docs/binance-spot-api-docs/user-data-stream), please perform an API query for its status.
- **Please avoid SQL keywords in requests** as they may trigger a security block by a WAF (Web Application Firewall) rule. See [https://www.binance.com/en/support/faq/detail/360004492232](https://www.binance.com/en/support/faq/detail/360004492232) for more details.
- If your request contains a symbol name containing non-ASCII characters, then the response may contain non-ASCII characters encoded in UTF-8.
- Some endpoints may return asset and/or symbol names containing non-ASCII characters encoded in UTF-8 even if the request did not contain non-ASCII characters.
Kline/Candlestick data
GET /api/v3/klines
Kline/candlestick bars for a symbol. Klines are uniquely identified by their open time.
Weight: 2
Parameters:
Name Type Mandatory Description
symbol STRING YES
interval ENUM YES
startTime LONG NO
endTime LONG NO
timeZone STRING NO Default: 0 (UTC)
limit INT NO Default: 500; Maximum: 1000.
Supported kline intervals (case-sensitive):
Interval interval value
seconds 1s
minutes 1m, 3m, 5m, 15m, 30m
hours 1h, 2h, 4h, 6h, 8h, 12h
days 1d, 3d
weeks 1w
months 1M
Notes:
If startTime and endTime are not sent, the most recent klines are returned.
Supported values for timeZone:
Hours and minutes (e.g. -1:00, 05:45)
Only hours (e.g. 0, 8, 4)
Accepted range is strictly [-12:00 to +14:00] inclusive
If timeZone provided, kline intervals are interpreted in that timezone instead of UTC.
Note that startTime and endTime are always interpreted in UTC, regardless of timeZone.
Data Source: Database
Response:
[
[
1499040000000, // Kline open time
"0.01634790", // Open price
"0.80000000", // High price
"0.01575800", // Low price
"0.01577100", // Close price
"148976.11427815", // Volume
1499644799999, // Kline Close time
"2434.19055334", // Quote asset volume
308, // Number of trades
"1756.87402397", // Taker buy base asset volume
"28.46694368", // Taker buy quote asset volume
"0" // Unused field, ignore.
]
]
## WebSocket Streams for Binance
## General WSS information
- The base endpoint is: **wss://stream.binance.com:9443** or **wss://stream.binance.com:443**.
- Streams can be accessed either in a single raw stream or in a combined stream.
- Raw streams are accessed at **/ws/<streamName>**
- Combined streams are accessed at **/stream?streams=<streamName1>/<streamName2>/<streamName3>**
- Combined stream events are wrapped as follows: **{"stream":"<streamName>","data":<rawPayload>}**
- All symbols for streams are **lowercase**
- A single connection to **stream.binance.com** is only valid for 24 hours; expect to be disconnected at the 24 hour mark
- The WebSocket server will send a `ping frame` every 20 seconds.
- If the WebSocket server does not receive a `pong frame` back from the connection within a minute the connection will be disconnected.
- When you receive a ping, you must send a pong with a copy of ping's payload as soon as possible.
- Unsolicited `pong frames` are allowed, but will not prevent disconnection. **It is recommended that the payload for these pong frames are empty.**
- The base endpoint **wss://data-stream.binance.vision** can be subscribed to receive **only** market data messages.
User data stream is **NOT** available from this URL.
- All time and timestamp related fields are **milliseconds by default**. To receive the information in microseconds, please add the parameter `timeUnit=MICROSECOND or timeUnit=microsecond` in the URL.
- For example: `/stream?streams=btcusdt@trade&timeUnit=MICROSECOND`
- If your request contains a symbol name containing non-ASCII characters, then the stream events may contain non-ASCII characters encoded in UTF-8.
- \[All Market Mini Tickers Stream\](#all-market-mini-tickers-stream and [All Market Rolling Window Statistics Streams](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#all-market-rolling-window-statistics-streams) events may contain non-ASCII characters encoded in UTF-8.
## WebSocket Limits
- WebSocket connections have a limit of 5 incoming messages per second. A message is considered:
- A PING frame
- A PONG frame
- A JSON controlled message (e.g. subscribe, unsubscribe)
- A connection that goes beyond the limit will be disconnected; IPs that are repeatedly disconnected may be banned.
- A single connection can listen to a maximum of 1024 streams.
- There is a limit of **300 connections per attempt every 5 minutes per IP**.
## Live Subscribing/Unsubscribing to streams
- The following data can be sent through the WebSocket instance in order to subscribe/unsubscribe from streams. Examples can be seen below.
- The `id` is used as an identifier to uniquely identify the messages going back and forth. The following formats are accepted:
- 64-bit signed integer
- alphanumeric strings; max length 36
- `null`
- In the response, if the `result` received is `null` this means the request sent was a success for non-query requests (e.g. Subscribing/Unsubscribing).
### Subscribe to a stream
- Request
- Response
```javascript
{
"result": null,
"id": 1
}
```
### Unsubscribe to a stream
- Request
- Response
```javascript
{
"result": null,
"id": 312
}
```
### Listing Subscriptions
- Request
```javascript
{
"method": "LIST_SUBSCRIPTIONS",
"id": 3
}
```
- Response
```javascript
{
"result": ["btcusdt@aggTrade"],
"id": 3
}
```
### Setting Properties
Currently, the only property that can be set is whether `combined` stream payloads are enabled or not. The combined property is set to `false` when connecting using `/ws/` ("raw streams") and `true` when connecting using `/stream/`.
- Request
```javascript
{
"method": "SET_PROPERTY",
"params": ["combined", true],
"id": 5
}
```
- Response
```javascript
{
"result": null,
"id": 5
}
```
### Retrieving Properties
- Request
```javascript
{
"method": "GET_PROPERTY",
"params": ["combined"],
"id": 2
}
```
- Response
```javascript
{
"result": true, // Indicates that combined is set to true.
"id": 2
}
```
| Error Message | Description |
| --- | --- |
| {"code": 0, "msg": "Unknown property","id": %s} | Parameter used in the `SET_PROPERTY` or `GET_PROPERTY` was invalid |
| {"code": 1, "msg": "Invalid value type: expected Boolean"} | Value should only be `true` or `false` |
| {"code": 2, "msg": "Invalid request: property name must be a string"} | Property name provided was invalid |
| {"code": 2, "msg": "Invalid request: request ID must be an unsigned integer"} | Parameter `id` had to be provided or the value provided in the `id` parameter is an unsupported type |
| {"code": 2, "msg": "Invalid request: unknown variant %s, expected one of `SUBSCRIBE`, `UNSUBSCRIBE`, `LIST_SUBSCRIPTIONS`, `SET_PROPERTY`, `GET_PROPERTY` at line 1 column 28"} | Possible typo in the provided method or provided method was neither of the expected values |
| {"code": 2, "msg": "Invalid request: too many parameters"} | Unnecessary parameters provided in the data |
| {"code": 2, "msg": "Invalid request: property name must be a string"} | Property name was not provided |
| {"code": 2, "msg": "Invalid request: missing field `method` at line 1 column 73"} | `method` was not provided in the data |
| {"code":3,"msg":"Invalid JSON: expected value at line %s column %s"} | JSON data sent has incorrect syntax. |
## Detailed Stream information
## Aggregate Trade Streams
The Aggregate Trade Streams push trade information that is aggregated for a single taker order.
**Stream Name:** <symbol>@aggTrade
**Update Speed:** Real-time
**Payload:**
```javascript
{
"e": "aggTrade", // Event type
"E": 1672515782136, // Event time
"s": "BNBBTC", // Symbol
"a": 12345, // Aggregate trade ID
"p": "0.001", // Price
"q": "100", // Quantity
"f": 100, // First trade ID
"l": 105, // Last trade ID
"T": 1672515782136, // Trade time
"m": true, // Is the buyer the market maker?
"M": true // Ignore
}
```
## Trade Streams
The Trade Streams push raw trade information; each trade has a unique buyer and seller.
**Stream Name:** <symbol>@trade
**Update Speed:** Real-time
**Payload:**
```javascript
{
"e": "trade", // Event type
"E": 1672515782136, // Event time
"s": "BNBBTC", // Symbol
"t": 12345, // Trade ID
"p": "0.001", // Price
"q": "100", // Quantity
"T": 1672515782136, // Trade time
"m": true, // Is the buyer the market maker?
"M": true // Ignore
}
```
## Kline/Candlestick Streams for UTC
The Kline/Candlestick Stream push updates to the current klines/candlestick every second in `UTC+0` timezone
**Kline/Candlestick chart intervals:**
s-> seconds; m -> minutes; h -> hours; d -> days; w -> weeks; M -> months
- 1s
- 1m
- 3m
- 5m
- 15m
- 30m
- 1h
- 2h
- 4h
- 6h
- 8h
- 12h
- 1d
- 3d
- 1w
- 1M
**Stream Name:** <symbol>@kline\_<interval>
**Update Speed:** 1000ms for `1s`, 2000ms for the other intervals
**Payload:**
```javascript
{
"e": "kline", // Event type
"E": 1672515782136, // Event time
"s": "BNBBTC", // Symbol
"k": {
"t": 1672515780000, // Kline start time
"T": 1672515839999, // Kline close time
"s": "BNBBTC", // Symbol
"i": "1m", // Interval
"f": 100, // First trade ID
"L": 200, // Last trade ID
"o": "0.0010", // Open price
"c": "0.0020", // Close price
"h": "0.0025", // High price
"l": "0.0015", // Low price
"v": "1000", // Base asset volume
"n": 100, // Number of trades
"x": false, // Is this kline closed?
"q": "1.0000", // Quote asset volume
"V": "500", // Taker buy base asset volume
"Q": "0.500", // Taker buy quote asset volume
"B": "123456" // Ignore
}
}
```
## Kline/Candlestick Streams with timezone offset
The Kline/Candlestick Stream push updates to the current klines/candlestick every second in `UTC+8` timezone
**Kline/Candlestick chart intervals:**
Supported intervals: See [`Kline/Candlestick chart intervals`](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#kline-intervals)
**UTC+8 timezone offset:**
- Kline intervals open and close in the `UTC+8` timezone. For example the `1d` klines will open at the beginning of the `UTC+8` day, and close at the end of the `UTC+8` day.
- Note that `E` (event time), `t` (start time) and `T` (close time) in the payload are Unix timestamps, which are always interpreted in UTC.
**Stream Name:** <symbol>@kline\_<interval>@+08:00
**Update Speed:** 1000ms for `1s`, 2000ms for the other intervals
**Payload:**
```javascript
{
"e": "kline", // Event type
"E": 1672515782136, // Event time
"s": "BNBBTC", // Symbol
"k": {
"t": 1672515780000, // Kline start time
"T": 1672515839999, // Kline close time
"s": "BNBBTC", // Symbol
"i": "1m", // Interval
"f": 100, // First trade ID
"L": 200, // Last trade ID
"o": "0.0010", // Open price
"c": "0.0020", // Close price
"h": "0.0025", // High price
"l": "0.0015", // Low price
"v": "1000", // Base asset volume
"n": 100, // Number of trades
"x": false, // Is this kline closed?
"q": "1.0000", // Quote asset volume
"V": "500", // Taker buy base asset volume
"Q": "0.500", // Taker buy quote asset volume
"B": "123456" // Ignore
}
}
```
+287
View File
@@ -0,0 +1,287 @@
# Trading Signals
![Language Details](https://img.shields.io/github/languages/top/bennycode/trading-signals) ![Code Coverage](https://img.shields.io/codecov/c/github/bennycode/trading-signals/main) ![License](https://img.shields.io/npm/l/trading-signals.svg) ![Package Version](https://img.shields.io/npm/v/trading-signals.svg)
Technical indicators and overlays to run [technical analysis](https://en.wikipedia.org/wiki/Technical_analysis) with JavaScript / TypeScript.
## Motivation
The "trading-signals" library provides a TypeScript implementation for common technical indicators. It is well-suited for algorithmic trading, allowing developers to perform signal computations for automated trading strategies.
All indicators can be updated over time by streaming data (prices or [candles](https://en.wikipedia.org/wiki/Candlestick_chart)) to the `add` method. Some indicators also provide `static` batch methods for further performance improvements when providing data up-front during a backtest or historical data import. You can try it out streaming input data by running the provided [demo script](./src/start/demo.ts) with `npm start`, which uses a keyboard input stream.
## Features
- **Streaming Updates:** No need to reprocess historical data
- **Replace Mode:** Efficient live chart updates
- **Lazy Evaluation:** Indicators only calculate when stable
- **Memory Efficiency:** Rolling windows, not full history storage
- **Excellent Test Coverage:** 100% across all metrics
- **Zero Runtime Dependencies:** Minimal bundle size
- **Type Safety:** Full TypeScript with strict mode
## Installation
```bash
npm install trading-signals
```
## Usage
**CommonJS:**
```ts
const {SMA} = require('trading-signals');
```
**ESM:**
```ts
import {SMA} from 'trading-signals';
```
**Example:**
```typescript
import {SMA} from 'trading-signals';
const sma = new SMA(3);
// You can add values individually:
sma.add(40);
sma.add(30);
sma.add(20);
// You can add multiple values at once:
sma.updates([20, 40, 80]);
// You can replace a previous value (useful for live charting):
sma.replace(40);
// You can check if an indicator is stable:
console.log(sma.isStable); // true
// If an indicator is stable, you can get its result:
console.log(sma.getResult()); // 50.0003
// You can also get the result without optional chaining:
console.log(sma.getResultOrThrow()); // 50.0003
// Various precisions are available too:
console.log(sma.getResultOrThrow().toFixed(2)); // "50.00"
console.log(sma.getResultOrThrow().toFixed(4)); // "50.0003"
// Each indicator also includes convenient features such as "lowest" and "highest" lifetime values:
console.log(sma.lowest?.toFixed(2)); // "23.33"
console.log(sma.highest?.toFixed(2)); // "53.33"
```
### When to use `add(...)`?
To input data, you need to call the indicator's `add` method. Depending on whether the minimum required input data for the interval has been reached, the `add` method may or may not return a result from the indicator.
### When to use `getResultOrThrow()`?
You can call `getResultOrThrow()` at any point in time, but it throws errors unless an indicator has received the minimum amount of data. If you call `getResultOrThrow()` before an indicator has received the required amount of input values, a `NotEnoughDataError` will be thrown.
**Example:**
```ts
import {SMA} from 'trading-signals';
// Our interval is 3, so we need 3 input values
const sma = new SMA(3);
// We supply 2 input values
sma.add(10);
sma.add(40);
try {
// We will get an error, because the minimum amount of inputs is 3
sma.getResultOrThrow();
} catch (error) {
console.log(error.constructor.name); // "NotEnoughDataError"
}
// We will supply the 3rd input value
sma.add(70);
// Now, we will receive a proper result
console.log(sma.getResultOrThrow()); // 40
```
Most of the time, the minimum amount of data depends on the interval / time period used. If you're not sure, take a look at the test files for the indicator to see examples of correct usage.
### When to use `getRequiredInputs()`?
Every indicator provides a `getRequiredInputs()` method that returns the minimum number of input values needed before the indicator becomes stable and can produce results. This is useful for validation and understanding when an indicator will start producing return values.
**Example:**
```ts
import {SMA, EMA, RSI} from 'trading-signals';
const sma = new SMA(5);
console.log(sma.getRequiredInputs()); // 5
```
The required inputs often depend on the indicator's configuration (like interval/period) and its internal calculation requirements. Some indicators like **MACD** or **Stochastic Oscillator** may require more inputs than their primary period because they use multiple moving averages or lookback periods internally.
### When to use `getSignal()`?
Many momentum and trend indicators provide a `getSignal()` method that returns the current trading signal state along with change detection. This is useful for identifying potential trading opportunities.
**Example:**
```ts
import {RSI} from 'trading-signals';
const rsi = new RSI(14);
// Add price data
// ...
// Get the trading signal
const signal = rsi.getSignal();
console.log(signal.state); // "BEARISH", "BULLISH", "SIDEWAYS", or "UNKNOWN"
console.log(signal.hasChanged); // true if the signal state changed from the previous value
```
## Technical Indicator Types
### Indicator Function
- Momentum indicators: Measure the speed and strength (intensity) of price movements in a particular direction (overbought/oversold)
- Trend indicators: Measure the direction of a trend (bullish/bearish)
- Volatility indicators: Measure the degree of variation in prices over time, regardless of direction
- Volume indicators: Measure the strength of a trend based on volume
**Key readings:**
- Bullish sentiment: expect prices to rise
- Bearish sentiment: expect prices to fall
- Overbought condition: price may have risen too much too fast, meaning its trending up, but traders expect a short-term dip before continuing higher
- Oversold condition: price may have dropped too much too fast, meaning its trending down, but traders expect a short-term bounce before continuing lower or reversing upward
### Indicator Timing
- Leading Indicators: Predictive tools that try to signal future price movements before they happen (i.e. RSI, Stochastic Oscillator, Volume spikes)
- Lagging Indicators: Confirmative tools that signal after a trend or move has already started (i.e. Moving Averages, MACD, ADX)
### Indicator Scale
- Indicators: Have no upper or lower limits
- Oscillators: Move within a fixed range (e.g. 0-100, 1 to +1)
## Supported Technical Indicators
1. Acceleration Bands (ABANDS)
1. Accelerator Oscillator (AC)
1. Average Directional Index (ADX)
1. Average True Range (ATR)
1. Awesome Oscillator (AO)
1. Bollinger Bands (BBANDS)
1. Bollinger Bands Width (BBW)
1. Center of Gravity (CG)
1. Commodity Channel Index (CCI)
1. Directional Movement Index (DMI / DX)
1. Double Exponential Moving Average (DEMA)
1. Dual Moving Average (DMA)
1. Exponential Moving Average (EMA)
1. Interquartile Range (IQR)
1. Linear Regression (LINREG)
1. Mean Absolute Deviation (MAD)
1. Momentum (MOM / MTM)
1. Moving Average Convergence Divergence (MACD)
1. On-Balance Volume (OBV)
1. Parabolic SAR (PSAR)
1. Range Expansion Index (REI)
1. Rate-of-Change (ROC)
1. Relative Moving Average (RMA)
1. Relative Strength Index (RSI)
1. Simple Moving Average (SMA)
1. Spencer's 15-Point Moving Average (SMA15)
1. Stochastic Oscillator (STOCH)
1. Stochastic RSI (STOCHRSI)
1. Tom Demark's Sequential Indicator (TDS)
1. True Range (TR)
1. Volume-Weighted Average Price (VWAP)
1. Weighted Moving Average (WMA)
1. Wilder's Smoothed Moving Average (WSMA / WWS / SMMA / MEMA)
1. Williams %R (WILLR)
1. Zig Zag Indicator (ZigZag)
Utility Methods:
1. Average / Mean
1. Grid Sizing (for [grid trading bots](https://b2broker.com/news/understanding-grid-trading-purpose-pros-cons/))
1. Maximum
1. Median
1. Minimum
1. Quartile
1. Standard Deviation
1. Streaks
1. Weekday
## Performance
### Floating-point arithmetic caveats
JavaScript uses double-precision floating-point arithmetic. For example, `0.1 + 0.2` yields `0.30000000000000004` due to binary floating-point representation.
![JavaScript arithmetic](https://raw.githubusercontent.com/bennycode/trading-signals/main/packages/trading-signals/js-arithmetic.png)
While this isnt perfectly accurate, it usually doesnt matter in practice since indicators often work with averages, which already smooth out precision. In test cases, you can control precision by using Vitests [toBeCloseTo](https://vitest.dev/api/expect.html#tobecloseto) assertion.
Earlier versions of this library (up to version 6) used [big.js][1] for arbitrary-precision arithmetic, but that made calculations about 100x slower on average. For this reason, support for [big.js][1] was removed starting with version 7.
## Disclaimer
The information and publications of [trading-signals](https://github.com/bennycode/trading-signals) do not constitute financial advice, investment advice, trading advice or any other form of advice. All results from [trading-signals](https://github.com/bennycode/trading-signals) are intended for information purposes only.
It is very important to do your own analysis before making any investment based on your own personal circumstances. If you need financial advice or further advice in general, it is recommended that you identify a relevantly qualified individual in your jurisdiction who can advise you accordingly.
## Alternatives
- [Cloud9Trader Indicators (JavaScript)](https://github.com/Cloud9Trader/TechnicalIndicators)
- [Crypto Trading Hub Indicators (TypeScript)](https://github.com/anandanand84/technicalindicators)
- [Highcharts Indicators (TypeScript)](https://github.com/highcharts/highcharts/tree/v12.3.0/ts/Stock/Indicators)
- [Indicator TS (TypeScript)](https://github.com/cinar/indicatorts)
- [Jesse Trading Bot Indicators (Python)](https://docs.jesse.trade/docs/indicators/reference.html)
- [LEAN Indicators (C#)](https://github.com/QuantConnect/Lean/tree/master/Indicators)
- [libindicators (C#)](https://github.com/mgfx/libindicators)
- [Pandas TA (Python)](https://github.com/twopirllc/pandas-ta)
- [Stock Indicators for .NET (C#)](https://github.com/DaveSkender/Stock.Indicators)
- [StockSharp (C#)](https://github.com/StockSharp/StockSharp)
- [ta-lib (C)](https://github.com/TA-Lib/ta-lib/tree/main/src/ta_func)
- [ta-math (TypeScript)](https://github.com/munrocket/ta-math)
- [ta4j (Java)](https://github.com/ta4j/ta4j)
- [Technical Analysis for Rust (Rust)](https://github.com/greyblake/ta-rs)
- [Technical Analysis Library using Pandas and Numpy (Python)](https://github.com/bukosabino/ta)
- [Tulip Indicators (ANSI C)](https://github.com/TulipCharts/tulipindicators)
## Documentation
Build and run the documentation:
```bash
npm run docs
```
## Maintainers
[![Benny Neugebauer on Stack Exchange][stack_exchange_bennycode_badge]][stack_exchange_bennycode_url]
## ⭐️ Become a TypeScript rockstar! ⭐️
This package was built by Benny Neugebauer. Checkout my [**TypeScript course**](https://typescript.tv/) to become a coding rockstar!
[<img src="https://raw.githubusercontent.com/bennycode/trading-signals/main/packages/trading-signals/tstv.png">](https://typescript.tv/)
[1]: http://mikemcl.github.io/big.js/
[stack_exchange_bennycode_badge]: https://stackexchange.com/users/flair/203782.png?theme=default
[stack_exchange_bennycode_url]: https://stackexchange.com/users/203782/benny-neugebauer?tab=accounts
## License
This project is [MIT](./LICENSE) licensed.
+43
View File
@@ -0,0 +1,43 @@
# Swing strategy (RSI on Binance ETHBTC)
This repos `swing` strategy **trades the exchange symbol you run the bot with** (e.g. `TRADE_SYMBOL`), but **uses Binance spot `ETHBTC` 4h RSI(14)** as the global signal source (per `docs/swingtrading/strategy.md`).
## Key behavior
- **Signal source**: Binance spot `ETHBTC`, `4h` klines, RSI period `14`.
- **Trade target**: your selected exchanges `TRADE_SYMBOL` / `*_SYMBOL` (same as other strategies).
- **Default mode**: short-only (configurable to long / short / both).
- **Stop-loss**:
- Tries to place a stop-loss order when supported by the venue.
- Always runs a real-time “kill-switch”: if price crosses the stop threshold, it market-closes.
- **Spot accounts**: if the connected account is `marketType=spot` and you configure `SWING_DIRECTION=short` (or `both`), the strategy will refuse to trade.
## Environment variables
- **Core**
- `SWING_DIRECTION`: `short` (default) | `long` | `both`
- `SWING_TRADE_AMOUNT`: position size (falls back to `TRADE_AMOUNT`)
- `SWING_POLL_INTERVAL_MS`: loop interval (default `500`)
- **RSI / signal**
- `SWING_RSI_PERIOD`: default `14`
- `SWING_RSI_HIGH`: default `70`
- `SWING_RSI_LOW`: default `30`
- `SWING_SIGNAL_SYMBOL`: default `ETHBTC`
- `SWING_SIGNAL_INTERVAL`: default `4h`
- **Risk / precision**
- `SWING_STOP_LOSS_PCT`: default `0.05` (5%)
- `SWING_MAX_CLOSE_SLIPPAGE_PCT`: default `0.05`
- `SWING_PRICE_TICK`, `SWING_QTY_STEP`: optional overrides (otherwise synced from exchange when supported)
## Run
```bash
bun run index.ts --strategy swing
```
Silent mode:
```bash
bun run index.ts --strategy swing --silent
```
+24
View File
@@ -0,0 +1,24 @@
无论在任何交易所或者平台运行,都遵循以下逻辑:
无论交易任何交易对,都参照 ETHBTC 数据进行交易
从binance现货接口获取 ETHBTC 交易对 4 小时k线数据,rest 接口获取历史数据,获取500条即可
订阅ws获取最新一根K线的数据
参考 trading-signals 库,计算 RSI 指标,RSI 参数为 14
我们的策略仅单边做空,但需要预留配置,可以支持单边做多,单边做空,双向交易
下面针对单边做空的情况进行说明
RSI 指标上穿 70 时,进入等待做空状态,后续当 RSI 指标下穿 70 时,执行做空开仓操作
如果对应的平台支持挂止损 stop loss,则在开仓的同时需要挂一个开仓成本价上涨 5% 的止损单,止损百分比可以设置参数调整,同时也需要有实时监控的止损逻辑,如果在持仓状态下,当前交易对比开仓价格上涨超过 5% ,则执行主动的市价止损操作
策略需要实时监控当前的交易对ticker数据、orderbook数据,open orders、position,支持ws的全都使用ws数据实时更新
在有仓位的情况下,如果发现 ETHBTC 的 RSI 指标下穿 30,则进入等待平空状态,如果后续 RSI 指标上穿 30,并且确认仓位有盈利,则执行平空操作
如此循环往复
+12 -11
View File
@@ -21,20 +21,21 @@
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5.9.2"
}, },
"dependencies": { "dependencies": {
"@grvt/client": "^1.6.4", "@grvt/client": "^1.6.25",
"@nadohq/client": "^0.1.0-alpha.41", "@nadohq/client": "^0.1.0-alpha.45",
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
"axios": "^1.12.2", "axios": "^1.13.4",
"bignumber.js": "^9.3.1", "bignumber.js": "^9.3.1",
"ccxt": "^4.5.12", "ccxt": "^4.5.35",
"dotenv": "^17.2.2", "dotenv": "^17.2.3",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.2.1",
"ink": "^6.3.1", "ink": "^6.6.0",
"react": "^19.1.1", "react": "^19.2.4",
"viem": "^2.43.1", "trading-signals": "^7.4.3",
"ws": "^8.18.3" "viem": "^2.45.1",
"ws": "^8.19.0"
} }
} }
+3 -2
View File
@@ -1,4 +1,4 @@
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
export interface CliOptions { export interface CliOptions {
strategy?: StrategyId; strategy?: StrategyId;
@@ -9,6 +9,7 @@ export interface CliOptions {
const STRATEGY_VALUES = new Set<StrategyId>([ const STRATEGY_VALUES = new Set<StrategyId>([
"trend", "trend",
"swing",
"guardian", "guardian",
"maker", "maker",
"maker-points", "maker-points",
@@ -98,7 +99,7 @@ function assignExchange(options: CliOptions, raw: string): void {
export function printCliHelp(): void { export function printCliHelp(): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` + console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
`Options:\n` + `Options:\n` +
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
` Aliases: offset, offset-maker for the offset maker engine.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` +
+17 -1
View File
@@ -1,4 +1,4 @@
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, tradingConfig } from "../config"; import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter"; import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
@@ -7,6 +7,7 @@ import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/o
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine"; import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
@@ -21,6 +22,7 @@ type StrategyRunner = (options: RunnerOptions) => Promise<void>;
export const STRATEGY_LABELS: Record<StrategyId, string> = { export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following", trend: "Trend Following",
swing: "Swing",
guardian: "Guardian", guardian: "Guardian",
maker: "Maker", maker: "Maker",
"maker-points": "Maker Points", "maker-points": "Maker Points",
@@ -52,6 +54,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
offUpdate: (emitter) => engine.off("update", emitter), offUpdate: (emitter) => engine.off("update", emitter),
}); });
}, },
swing: async (opts) => {
const config = swingConfig;
const adapter = createAdapterOrThrow(config.symbol);
const engine = new SwingEngine(config, adapter);
await runEngine({
engine,
strategy: "swing",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
guardian: async (opts) => { guardian: async (opts) => {
const config = tradingConfig; const config = tradingConfig;
const adapter = createAdapterOrThrow(config.symbol); const adapter = createAdapterOrThrow(config.symbol);
@@ -167,6 +182,7 @@ interface EngineHarness<TSnapshot> {
async function runEngine< async function runEngine<
TSnapshot extends TSnapshot extends
| TrendEngineSnapshot | TrendEngineSnapshot
| SwingEngineSnapshot
| GuardianEngineSnapshot | GuardianEngineSnapshot
| MakerEngineSnapshot | MakerEngineSnapshot
| MakerPointsSnapshot | MakerPointsSnapshot
+85 -9
View File
@@ -105,23 +105,51 @@ export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId |
: resolveExchangeId(); : resolveExchangeId();
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId]; const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
for (const key of envKeys) { for (const key of envKeys) {
const value = process.env[key]; const value = normalizeEnvValue(process.env[key]);
if (value && value.trim()) { if (value) {
return value.trim(); return value;
} }
} }
return fallback; return fallback;
} }
function normalizeEnvValue(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const quote = trimmed[0];
if ((quote === "'" || quote === "\"") && trimmed.endsWith(quote)) {
const unquoted = trimmed.slice(1, -1).trim();
return unquoted ? unquoted : undefined;
}
// Allow shell-style inline comments: KEY=value # comment
const commentIndexHash = trimmed.search(/\s#/);
const commentIndexSemi = trimmed.search(/\s;/);
const commentIndex =
commentIndexHash === -1
? commentIndexSemi
: commentIndexSemi === -1
? commentIndexHash
: Math.min(commentIndexHash, commentIndexSemi);
if (commentIndex !== -1) {
const withoutComment = trimmed.slice(0, commentIndex).trim();
return withoutComment ? withoutComment : undefined;
}
return trimmed;
}
function parseNumber(value: string | undefined, fallback: number): number { function parseNumber(value: string | undefined, fallback: number): number {
if (!value) return fallback; const normalized = normalizeEnvValue(value);
const next = Number(value); if (!normalized) return fallback;
const next = Number(normalized);
return Number.isFinite(next) ? next : fallback; return Number.isFinite(next) ? next : fallback;
} }
function parseBoolean(value: string | undefined, fallback: boolean): boolean { function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback; const normalized = normalizeEnvValue(value)?.toLowerCase();
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback; if (!normalized) return fallback;
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true; if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false; if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
@@ -199,7 +227,7 @@ export interface MakerPointsConfig {
minRepriceBps: number; minRepriceBps: number;
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */ /** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
enableBinanceDepthCancel: boolean; enableBinanceDepthCancel: boolean;
/** 各档位最小深度阈值 (BTC),盘口到目标价之间的挂单量低于此值则跳过该档位,默认 1 */ /** 各档位最小深度阈值 (BTC),盘口到目标价之间的挂单量低于此值则跳过该档位,默认 50 */
filterMinDepth: number; filterMinDepth: number;
} }
@@ -226,7 +254,7 @@ export const makerPointsConfig: MakerPointsConfig = {
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount), band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3), minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true), enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
filterMinDepth: parseNumber(process.env.MAKER_POINTS_FILTER_MIN_DEPTH, 1), filterMinDepth: parseNumber(process.env.MAKER_POINTS_FILTER_MIN_DEPTH, 50),
}; };
export interface BasisArbConfig { export interface BasisArbConfig {
@@ -375,6 +403,54 @@ export const liquidityMakerConfig: LiquidityMakerConfig = {
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))), entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
}; };
export type SwingDirection = "both" | "long" | "short";
export interface SwingConfig {
symbol: string;
tradeAmount: number;
pollIntervalMs: number;
maxLogEntries: number;
maxCloseSlippagePct: number;
priceTick: number;
qtyStep: number;
direction: SwingDirection;
rsiPeriod: number;
rsiHigh: number;
rsiLow: number;
stopLossPct: number;
signalSymbol: string;
signalInterval: string;
}
const resolveSwingDirection = (raw: string | undefined, fallback: SwingDirection): SwingDirection => {
if (!raw) return fallback;
const normalized = raw.trim().toLowerCase();
if (normalized === "long" || normalized === "long-only") return "long";
if (normalized === "short" || normalized === "short-only") return "short";
if (normalized === "both" || normalized === "dual" || normalized === "bi" || normalized === "two-way") return "both";
return fallback;
};
export const swingConfig: SwingConfig = {
symbol: resolveSymbolFromEnv(),
tradeAmount: parseNumber(process.env.SWING_TRADE_AMOUNT ?? process.env.TRADE_AMOUNT, 0.001),
pollIntervalMs: parseNumber(process.env.SWING_POLL_INTERVAL_MS, parseNumber(process.env.POLL_INTERVAL_MS, 500)),
maxLogEntries: parseNumber(process.env.SWING_MAX_LOG_ENTRIES, parseNumber(process.env.MAX_LOG_ENTRIES, 200)),
maxCloseSlippagePct: parseNumber(
process.env.SWING_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
0.05
),
priceTick: parseNumber(process.env.SWING_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
qtyStep: parseNumber(process.env.SWING_QTY_STEP ?? process.env.QTY_STEP, 0.001),
direction: resolveSwingDirection(process.env.SWING_DIRECTION, "short"),
rsiPeriod: Math.max(1, Math.floor(parseNumber(process.env.SWING_RSI_PERIOD, 14))),
rsiHigh: parseNumber(process.env.SWING_RSI_HIGH, 70),
rsiLow: parseNumber(process.env.SWING_RSI_LOW, 30),
stopLossPct: Math.max(0, parseNumber(process.env.SWING_STOP_LOSS_PCT, 0.05)),
signalSymbol: (process.env.SWING_SIGNAL_SYMBOL ?? "ETHBTC").trim().toUpperCase(),
signalInterval: (process.env.SWING_SIGNAL_INTERVAL ?? "4h").trim(),
};
export function isBasisStrategyEnabled(): boolean { export function isBasisStrategyEnabled(): boolean {
const raw = process.env.ENABLE_BASIS_STRATEGY; const raw = process.env.ENABLE_BASIS_STRATEGY;
if (!raw) return false; if (!raw) return false;
+1 -1
View File
@@ -296,7 +296,7 @@ export async function placeStopLossOrder(
timeInForce: "GTC", timeInForce: "GTC",
reduceOnly: true, reduceOnly: true,
closePosition: true, closePosition: true,
triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS", triggerType: "STOP_LOSS",
}); });
pendings[type] = String(order.orderId); pendings[type] = String(order.orderId);
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`); log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
+15
View File
@@ -37,6 +37,17 @@ export interface FundingRateListener {
(snapshot: FundingRateSnapshot): void; (snapshot: FundingRateSnapshot): void;
} }
export type RestHealthState = "healthy" | "unhealthy";
export interface RestHealthInfo {
consecutiveErrors: number;
method?: string;
path?: string;
error?: string;
}
export interface RestHealthListener {
(state: RestHealthState, info: RestHealthInfo): void;
}
export interface ExchangePrecision { export interface ExchangePrecision {
priceTick: number; priceTick: number;
qtyStep: number; qtyStep: number;
@@ -69,6 +80,10 @@ export interface ExchangeAdapter {
// 连接保护相关方法(可选,仅 StandX 支持) // 连接保护相关方法(可选,仅 StandX 支持)
onConnectionEvent?(listener: ConnectionEventListener): void; onConnectionEvent?(listener: ConnectionEventListener): void;
offConnectionEvent?(listener: ConnectionEventListener): void; offConnectionEvent?(listener: ConnectionEventListener): void;
onRestHealthEvent?(listener: RestHealthListener): void;
offRestHealthEvent?(listener: RestHealthListener): void;
queryOpenOrders?(): Promise<AsterOrder[]>; queryOpenOrders?(): Promise<AsterOrder[]>;
queryAccountSnapshot?(): Promise<AsterAccountSnapshot | null>;
changeMarginMode?(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void>;
forceCancelAllOrders?(): Promise<boolean>; forceCancelAllOrders?(): Promise<boolean>;
} }
+1 -1
View File
@@ -1337,7 +1337,7 @@ function buildUnsignedOrder(params: {
function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined { function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined {
if (params.type === "STOP_MARKET") { if (params.type === "STOP_MARKET") {
const triggerType = params.triggerType ?? (params.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"); const triggerType = params.triggerType ?? "STOP_LOSS";
const stopPrice = params.stopPrice ?? params.activationPrice; const stopPrice = params.stopPrice ?? params.activationPrice;
if (!stopPrice) { if (!stopPrice) {
throw new Error("GRVT stop orders require a stopPrice or activationPrice"); throw new Error("GRVT stop orders require a stopPrice or activationPrice");
+1 -1
View File
@@ -62,7 +62,7 @@ export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrd
quantity: intent.quantity, quantity: intent.quantity,
stopPrice: intent.stopPrice, stopPrice: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC", timeInForce: intent.timeInForce ?? "GTC",
triggerType: intent.triggerType ?? (intent.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"), triggerType: intent.triggerType ?? "STOP_LOSS",
closePosition: toStringBoolean(intent.closePosition ?? true), closePosition: toStringBoolean(intent.closePosition ?? true),
reduceOnly: toStringBoolean(intent.reduceOnly ?? true), reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
}, },
+4 -1
View File
@@ -602,7 +602,10 @@ export class ParadexGateway {
// Only omit amount for MARKET close-position orders; STOP requires explicit size // Only omit amount for MARKET close-position orders; STOP requires explicit size
const shouldOmitAmount = isClosePosition && type === "market"; const shouldOmitAmount = isClosePosition && type === "market";
const amountArg: any = shouldOmitAmount ? undefined : amount; const amountArg: any = shouldOmitAmount ? undefined : amount;
if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) { // Paradex/ccxt may require `size` even when amount is omitted for closePosition MARKET orders.
if (shouldOmitAmount && amount != null && extraParams.size === undefined) {
extraParams.size = amount.toString();
} else if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) {
extraParams.size = amountArg.toString(); extraParams.size = amountArg.toString();
} }
const order = (await this.exchange.createOrder( const order = (await this.exchange.createOrder(
+19
View File
@@ -7,6 +7,7 @@ import type {
FundingRateListener, FundingRateListener,
KlineListener, KlineListener,
OrderListener, OrderListener,
RestHealthListener,
TickerListener, TickerListener,
} from "../adapter"; } from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types"; import type { AsterOrder, CreateOrderParams } from "../types";
@@ -138,6 +139,14 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
this.gateway.offConnectionEvent(listener); this.gateway.offConnectionEvent(listener);
} }
onRestHealthEvent(listener: RestHealthListener): void {
this.gateway.onRestHealthEvent(listener);
}
offRestHealthEvent(listener: RestHealthListener): void {
this.gateway.offRestHealthEvent(listener);
}
/** /**
* 查询当前真实的挂单状态(通过 HTTP API) * 查询当前真实的挂单状态(通过 HTTP API)
* 用于验证实际挂单情况,防止取消请求丢失 * 用于验证实际挂单情况,防止取消请求丢失
@@ -147,6 +156,16 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
return this.gateway.queryOpenOrders(this.symbol); return this.gateway.queryOpenOrders(this.symbol);
} }
async queryAccountSnapshot() {
await this.ensureInitialized("queryAccountSnapshot");
return this.gateway.queryAccountSnapshot();
}
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
await this.ensureInitialized("changeMarginMode");
await this.gateway.changeMarginMode(params.symbol, params.marginMode);
}
/** /**
* 强制取消所有挂单 * 强制取消所有挂单
* 会查询当前挂单然后取消,并验证取消成功 * 会查询当前挂单然后取消,并验证取消成功
+381 -27
View File
@@ -8,6 +8,9 @@ import type {
FundingRateListener, FundingRateListener,
KlineListener, KlineListener,
OrderListener, OrderListener,
RestHealthInfo,
RestHealthListener,
RestHealthState,
TickerListener, TickerListener,
} from "../adapter"; } from "../adapter";
import type { import type {
@@ -48,7 +51,21 @@ const DEFAULT_WS_URL = "wss://perps.standx.com/ws-stream/v1";
const DEFAULT_KLINE_LIMIT = 200; const DEFAULT_KLINE_LIMIT = 200;
const KLINE_REFRESH_MS = 30_000; const KLINE_REFRESH_MS = 30_000;
const FUNDING_REFRESH_MS = 60_000; const FUNDING_REFRESH_MS = 60_000;
const WS_RECONNECT_DELAY = 2000;
// ========== WebSocket 连接管理常量 ==========
// 基础重连延迟(毫秒)
const WS_RECONNECT_DELAY_BASE = 2000;
// 最大重连延迟(毫秒)- 指数退避上限
const WS_RECONNECT_DELAY_MAX = 30_000;
// 心跳超时(毫秒)- StandX 服务器 5 分钟无 pong 会断连,我们设置 2 分钟作为安全阈值
const WS_HEARTBEAT_TIMEOUT = 120_000;
// 心跳检查间隔(毫秒)- 每 30 秒检查一次是否收到消息
const WS_HEARTBEAT_CHECK_INTERVAL = 30_000;
// 数据过时阈值(毫秒)- 超过此时间未收到行情/仓位数据,启动 REST 主动拉取
const WS_DATA_STALE_THRESHOLD = 3000;
// REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔
const REST_POLL_INTERVAL = 2000;
const REST_ERROR_DEFENSE_THRESHOLD = 3;
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"]; const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
@@ -100,7 +117,7 @@ class StandxRequestSigner {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const timestamp = Date.now(); const timestamp = Date.now();
const signMessage = `${version},${requestId},${timestamp},${payload}`; const signMessage = `${version},${requestId},${timestamp},${payload}`;
const signatureBytes = await sign(Buffer.from(signMessage, "utf-8"), this.privateKey); const signatureBytes = sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
return { return {
"x-request-sign-version": version, "x-request-sign-version": version,
"x-request-id": requestId, "x-request-id": requestId,
@@ -407,6 +424,10 @@ export class StandxGateway {
private readonly virtualStops = new Map<string, VirtualStop>(); private readonly virtualStops = new Map<string, VirtualStop>();
private accountSnapshot: AsterAccountSnapshot | null = null; private accountSnapshot: AsterAccountSnapshot | null = null;
private readonly restHealthListeners = new Set<RestHealthListener>();
private restConsecutiveErrors = 0;
private restUnhealthy = false;
private restLastError: string | null = null;
private fundingState = new Map<string, FundingState>(); private fundingState = new Map<string, FundingState>();
private marketWs: WebSocket | null = null; private marketWs: WebSocket | null = null;
@@ -416,6 +437,26 @@ export class StandxGateway {
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null; private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly subscriptions = new Set<string>(); private readonly subscriptions = new Set<string>();
// ========== 心跳与连接管理 ==========
// 上次收到消息的时间戳
private lastMessageTime = 0;
// 心跳检查定时器
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// 重连次数(用于指数退避)
private reconnectAttempts = 0;
// ========== 数据过时检测与 REST 备用 ==========
// 上次收到行情数据(price/depth)的时间戳
private lastMarketDataTime = 0;
// 上次收到账户数据(position/balance)的时间戳
private lastAccountDataTime = 0;
// 数据过时检查定时器
private dataStaleCheckTimer: ReturnType<typeof setInterval> | null = null;
// REST 轮询定时器(WS 断连时启用)
private restPollTimer: ReturnType<typeof setInterval> | null = null;
// REST 轮询是否激活
private restPollActive = false;
private readonly klineTimers = new Map<string, PollTimer>(); private readonly klineTimers = new Map<string, PollTimer>();
private readonly fundingTimers = new Map<string, PollTimer>(); private readonly fundingTimers = new Map<string, PollTimer>();
@@ -525,6 +566,14 @@ export class StandxGateway {
this.connectionListeners.add(listener); this.connectionListeners.add(listener);
} }
onRestHealthEvent(listener: RestHealthListener): void {
this.restHealthListeners.add(listener);
}
offRestHealthEvent(listener: RestHealthListener): void {
this.restHealthListeners.delete(listener);
}
offConnectionEvent(listener: ConnectionEventListener): void { offConnectionEvent(listener: ConnectionEventListener): void {
this.connectionListeners.delete(listener); this.connectionListeners.delete(listener);
} }
@@ -852,7 +901,18 @@ export class StandxGateway {
const handleOpen = () => { const handleOpen = () => {
this.marketWsReady = true; this.marketWsReady = true;
this.marketWsAuthed = false; this.marketWsAuthed = false;
// 重置重连计数和时间戳
this.reconnectAttempts = 0;
this.lastMessageTime = Date.now();
this.lastMarketDataTime = Date.now();
this.lastAccountDataTime = Date.now();
this.logDebug("ws open"); this.logDebug("ws open");
// 启动心跳监控
this.startHeartbeatMonitor();
// 启动数据过时检测
this.startDataStaleCheck();
// 停止 REST 轮询(WS 恢复后不再需要)
this.stopRestPoll();
this.sendAuthIfNeeded(); this.sendAuthIfNeeded();
}; };
const handleClose = () => { const handleClose = () => {
@@ -861,6 +921,9 @@ export class StandxGateway {
this.marketWsAuthed = false; this.marketWsAuthed = false;
this.marketWsAuthRequested = false; this.marketWsAuthRequested = false;
this.marketWs = null; this.marketWs = null;
// 停止心跳监控和数据过时检测
this.stopHeartbeatMonitor();
this.stopDataStaleCheck();
this.logDebug("ws close"); this.logDebug("ws close");
// 触发断连事件,启动断连保护 // 触发断连事件,启动断连保护
if (wasReady) { if (wasReady) {
@@ -899,15 +962,23 @@ export class StandxGateway {
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.marketReconnectTimer) return; if (this.marketReconnectTimer) return;
this.logDebug("scheduling reconnect in 2s"); // 指数退避:delay = min(base * 2^attempts, max)
const delay = Math.min(
WS_RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts),
WS_RECONNECT_DELAY_MAX
);
this.reconnectAttempts += 1;
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.marketReconnectTimer = setTimeout(() => { this.marketReconnectTimer = setTimeout(() => {
this.marketReconnectTimer = null; this.marketReconnectTimer = null;
this.logDebug("attempting reconnect"); this.logDebug("attempting reconnect");
this.connectMarketWs(); this.connectMarketWs();
}, WS_RECONNECT_DELAY); }, delay);
} }
private handleMarketMessage(event: { data: any }): void { private handleMarketMessage(event: { data: any }): void {
// 更新最后收到消息的时间(心跳监控)
this.lastMessageTime = Date.now();
this.logRawPayload(event.data); this.logRawPayload(event.data);
const payloads = parseJsonPayloads(event.data); const payloads = parseJsonPayloads(event.data);
if (payloads.length === 0) return; if (payloads.length === 0) return;
@@ -938,9 +1009,11 @@ export class StandxGateway {
return; return;
} }
if (channel === "depth_book") { if (channel === "depth_book") {
// 更新行情数据时间戳
this.lastMarketDataTime = Date.now();
const data = message.data as StandxDepthBook | undefined; const data = message.data as StandxDepthBook | undefined;
const rawSymbol = data?.symbol ?? message?.symbol; const rawSymbol = data?.symbol ?? message?.symbol;
if (!rawSymbol) return; if (!rawSymbol || !data) return;
const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid"); const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid");
const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask"); const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask");
const decrossed = decrossDepthBook(bids, asks); const decrossed = decrossDepthBook(bids, asks);
@@ -969,13 +1042,15 @@ export class StandxGateway {
lastUpdateId: Number(message.seq ?? Date.now()), lastUpdateId: Number(message.seq ?? Date.now()),
bids: finalBids, bids: finalBids,
asks: finalAsks, asks: finalAsks,
eventTime: toTimestamp(data.time), eventTime: Date.now(),
symbol: rawSymbol, symbol: rawSymbol,
}; };
this.emitDepth(rawSymbol, depth); this.emitDepth(rawSymbol, depth);
return; return;
} }
if (channel === "price") { if (channel === "price") {
// 更新行情数据时间戳
this.lastMarketDataTime = Date.now();
const data = message.data as StandxPrice | undefined; const data = message.data as StandxPrice | undefined;
if (!data?.symbol) return; if (!data?.symbol) return;
const ticker = this.mapTicker(data); const ticker = this.mapTicker(data);
@@ -983,6 +1058,8 @@ export class StandxGateway {
return; return;
} }
if (channel === "order") { if (channel === "order") {
// 更新账户数据时间戳
this.lastAccountDataTime = Date.now();
const payload = message.data as StandxOrder | StandxOrder[] | undefined; const payload = message.data as StandxOrder | StandxOrder[] | undefined;
if (!payload) return; if (!payload) return;
const items = Array.isArray(payload) ? payload : [payload]; const items = Array.isArray(payload) ? payload : [payload];
@@ -994,6 +1071,8 @@ export class StandxGateway {
return; return;
} }
if (channel === "position") { if (channel === "position") {
// 更新账户数据时间戳
this.lastAccountDataTime = Date.now();
const payload = message.data as StandxPosition | StandxPosition[] | undefined; const payload = message.data as StandxPosition | StandxPosition[] | undefined;
if (!payload) return; if (!payload) return;
const items = Array.isArray(payload) ? payload : [payload]; const items = Array.isArray(payload) ? payload : [payload];
@@ -1006,6 +1085,8 @@ export class StandxGateway {
return; return;
} }
if (channel === "balance") { if (channel === "balance") {
// 更新账户数据时间戳
this.lastAccountDataTime = Date.now();
const payload = message.data as StandxBalance | StandxBalance[] | undefined; const payload = message.data as StandxBalance | StandxBalance[] | undefined;
if (!payload) return; if (!payload) return;
const items = Array.isArray(payload) ? payload : [payload]; const items = Array.isArray(payload) ? payload : [payload];
@@ -1048,6 +1129,7 @@ export class StandxGateway {
if (!this.marketWsAuthed) return; if (!this.marketWsAuthed) return;
for (const entry of this.subscriptions) { for (const entry of this.subscriptions) {
const [channel, symbol] = entry.split(":"); const [channel, symbol] = entry.split(":");
if (!channel) continue;
this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) }); this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) });
} }
} }
@@ -1057,6 +1139,194 @@ export class StandxGateway {
this.marketWs?.send(JSON.stringify({ subscribe: stream })); this.marketWs?.send(JSON.stringify({ subscribe: stream }));
} }
// ========== 心跳监控 ==========
/**
* 启动心跳监控
* 定期检查是否收到消息,如果超时则主动触发重连
*/
private startHeartbeatMonitor(): void {
this.stopHeartbeatMonitor();
this.heartbeatTimer = setInterval(() => {
const now = Date.now();
const elapsed = now - this.lastMessageTime;
if (elapsed > WS_HEARTBEAT_TIMEOUT) {
this.logDebug(`heartbeat timeout (${elapsed}ms since last message), forcing reconnect`);
this.forceReconnect("heartbeat_timeout");
}
}, WS_HEARTBEAT_CHECK_INTERVAL);
}
/**
* 停止心跳监控
*/
private stopHeartbeatMonitor(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// ========== 数据过时检测与 REST 备用拉取 ==========
/**
* 启动数据过时检测
* 即使 WS 连接正常,如果超过 3 秒未收到行情/账户数据,也主动通过 REST 拉取
*/
private startDataStaleCheck(): void {
this.stopDataStaleCheck();
this.dataStaleCheckTimer = setInterval(() => {
const now = Date.now();
const marketStale = now - this.lastMarketDataTime > WS_DATA_STALE_THRESHOLD;
const accountStale = now - this.lastAccountDataTime > WS_DATA_STALE_THRESHOLD;
if (marketStale || accountStale) {
this.logDebug("data stale detected", {
marketStaleMs: now - this.lastMarketDataTime,
accountStaleMs: now - this.lastAccountDataTime,
marketStale,
accountStale,
});
// 主动通过 REST 拉取数据
this.fetchStaleData(marketStale, accountStale);
}
}, 1000); // 每秒检查一次
}
/**
* 停止数据过时检测
*/
private stopDataStaleCheck(): void {
if (this.dataStaleCheckTimer) {
clearInterval(this.dataStaleCheckTimer);
this.dataStaleCheckTimer = null;
}
}
/**
* 主动拉取过时的数据
*/
private fetchStaleData(marketStale: boolean, accountStale: boolean): void {
// 获取当前订阅的 symbols
const symbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) symbols.add(symbol);
}
if (marketStale) {
for (const symbol of symbols) {
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("staleTickerFetch", e));
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("staleDepthFetch", e));
}
// 更新时间戳避免重复拉取
this.lastMarketDataTime = Date.now();
}
if (accountStale) {
void this.refreshAccountSnapshot().catch((e) => this.logger("staleAccountFetch", e));
for (const symbol of symbols) {
void this.refreshOpenOrders(symbol).catch((e) => this.logger("staleOrdersFetch", e));
}
// 更新时间戳避免重复拉取
this.lastAccountDataTime = Date.now();
}
}
/**
* 启动 REST 轮询(WS 断连时使用)
* 持续通过 REST API 拉取行情和账户数据,确保止损等逻辑能正常工作
*/
private startRestPoll(): void {
if (this.restPollActive) return;
this.restPollActive = true;
this.logDebug("REST poll started (WS disconnected)");
const poll = async () => {
if (!this.restPollActive) return;
// 获取当前订阅的 symbols
const symbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) symbols.add(symbol);
}
// 拉取行情数据
for (const symbol of symbols) {
try {
await this.fetchTickerSnapshot(symbol);
this.lastMarketDataTime = Date.now();
} catch (e) {
this.logger("restPollTicker", e);
}
try {
await this.fetchDepthSnapshot(symbol);
} catch (e) {
this.logger("restPollDepth", e);
}
}
// 拉取账户数据
try {
await this.refreshAccountSnapshot();
this.lastAccountDataTime = Date.now();
} catch (e) {
this.logger("restPollAccount", e);
}
// 继续下一次轮询
if (this.restPollActive) {
this.restPollTimer = setTimeout(() => void poll(), REST_POLL_INTERVAL);
}
};
void poll();
}
/**
* 停止 REST 轮询
*/
private stopRestPoll(): void {
if (!this.restPollActive) return;
this.restPollActive = false;
if (this.restPollTimer) {
clearTimeout(this.restPollTimer);
this.restPollTimer = null;
}
this.logDebug("REST poll stopped (WS restored)");
}
/**
* 强制重连(用于心跳超时)
* 与普通断连不同,这里不增加重连计数(因为是主动行为)
*/
private forceReconnect(reason: string): void {
this.logDebug(`force reconnect: ${reason}`);
// 停止监控
this.stopHeartbeatMonitor();
this.stopDataStaleCheck();
// 关闭现有连接
if (this.marketWs) {
try {
this.marketWs.close();
} catch {
// ignore close errors
}
this.marketWs = null;
}
// 重置状态
const wasReady = this.marketWsReady;
this.marketWsReady = false;
this.marketWsAuthed = false;
this.marketWsAuthRequested = false;
// 触发断连事件
if (wasReady) {
this.onDisconnect();
}
// 立即重连(不使用指数退避,因为是主动行为)
this.reconnectAttempts = 0;
this.scheduleReconnect();
}
private logDebug(context: string, detail?: unknown): void { private logDebug(context: string, detail?: unknown): void {
if (!this.debugWs) return; if (!this.debugWs) return;
if (detail === undefined) { if (detail === undefined) {
@@ -1128,7 +1398,7 @@ export class StandxGateway {
} }
} }
private emitAccountSnapshot(): void { private emitAccountSnapshot(updateTime?: number): void {
const positions = Array.from(this.positions.values()); const positions = Array.from(this.positions.values());
const assets = Array.from(this.balances.values()); const assets = Array.from(this.balances.values());
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0); const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
@@ -1140,7 +1410,7 @@ export class StandxGateway {
canTrade: true, canTrade: true,
canDeposit: true, canDeposit: true,
canWithdraw: true, canWithdraw: true,
updateTime: Date.now(), updateTime: typeof updateTime === "number" && Number.isFinite(updateTime) && updateTime > 0 ? updateTime : Date.now(),
totalWalletBalance: String(totalWalletBalance || 0), totalWalletBalance: String(totalWalletBalance || 0),
totalUnrealizedProfit: String(totalUnrealizedProfit || 0), totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
positions, positions,
@@ -1157,16 +1427,19 @@ export class StandxGateway {
} }
} }
private async refreshAccountSnapshot(): Promise<void> { private async refreshAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
try { try {
const [balance, positions] = await Promise.all([ const [balance, positions] = await Promise.all([
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }), this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }), this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
]); ]);
let restSnapshotTime = 0;
if (Array.isArray(positions)) { if (Array.isArray(positions)) {
for (const position of positions) { for (const position of positions) {
const mapped = this.mapPosition(position); const mapped = this.mapPosition(position);
this.positions.set(mapped.symbol, mapped); this.positions.set(mapped.symbol, mapped);
const positionTime = toTimestamp(position.time ?? position.updated_at);
restSnapshotTime = Math.max(restSnapshotTime, positionTime);
} }
} }
if (balance) { if (balance) {
@@ -1175,14 +1448,44 @@ export class StandxGateway {
asset: token, asset: token,
walletBalance: String(balance.balance ?? "0"), walletBalance: String(balance.balance ?? "0"),
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"), availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
updateTime: Date.now(), updateTime: restSnapshotTime > 0 ? restSnapshotTime : Date.now(),
unrealizedProfit: String(balance.upnl ?? "0"), unrealizedProfit: String(balance.upnl ?? "0"),
}; };
this.balances.set(token, asset); this.balances.set(token, asset);
} }
this.emitAccountSnapshot(); this.emitAccountSnapshot(restSnapshotTime > 0 ? restSnapshotTime : undefined);
return this.accountSnapshot;
} catch (error) { } catch (error) {
this.logger("accountSnapshot", error); this.logger("accountSnapshot", error);
return null;
}
}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return await this.refreshAccountSnapshot();
}
async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise<void> {
if (!this.signer.hasKey()) {
throw new Error("StandX change_margin_mode requires STANDX_REQUEST_PRIVATE_KEY for signed requests");
}
const normalized = normalizeSymbol(symbol);
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
"/api/change_margin_mode",
{
method: "POST",
body: {
symbol: normalized,
margin_mode: marginMode,
},
signed: true,
extraHeaders: {
"x-session-id": this.sessionId,
},
}
);
if (response && typeof response.code === "number" && response.code !== 0) {
throw new Error(response.message ?? "StandX change margin mode rejected");
} }
} }
@@ -1379,7 +1682,7 @@ export class StandxGateway {
entryPrice: String(data.entry_price ?? "0"), entryPrice: String(data.entry_price ?? "0"),
unrealizedProfit: String(data.upnl ?? "0"), unrealizedProfit: String(data.upnl ?? "0"),
positionSide: "BOTH", positionSide: "BOTH",
updateTime: toTimestamp(data.updated_at), updateTime: toTimestamp(data.time ?? data.updated_at),
leverage: data.leverage ? String(data.leverage) : undefined, leverage: data.leverage ? String(data.leverage) : undefined,
marginType: data.margin_mode, marginType: data.margin_mode,
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined, liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
@@ -1454,22 +1757,70 @@ export class StandxGateway {
} }
} }
} }
const response = await fetch(url.toString(), {
method: options.method,
headers,
body: options.method === "GET" ? undefined : body,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
}
if (!text) {
return {} as T;
}
try { try {
return JSON.parse(text) as T; const response = await fetch(url.toString(), {
} catch { method: options.method,
return text as unknown as T; headers,
body: options.method === "GET" ? undefined : body,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
}
if (!text) {
this.recordRestSuccess();
return {} as T;
}
try {
const parsed = JSON.parse(text) as T;
this.recordRestSuccess();
return parsed;
} catch {
this.recordRestSuccess();
return text as unknown as T;
}
} catch (error) {
this.recordRestError({
consecutiveErrors: this.restConsecutiveErrors + 1,
method: options.method,
path,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
private recordRestSuccess(): void {
if (this.restConsecutiveErrors === 0 && !this.restUnhealthy) return;
this.restConsecutiveErrors = 0;
this.restLastError = null;
if (!this.restUnhealthy) return;
this.restUnhealthy = false;
this.emitRestHealth("healthy", { consecutiveErrors: 0 });
}
private recordRestError(info: RestHealthInfo): void {
this.restConsecutiveErrors = Math.max(0, Number(info.consecutiveErrors) || 0);
this.restLastError = info.error ?? this.restLastError;
if (!this.restUnhealthy && this.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD) {
this.restUnhealthy = true;
this.emitRestHealth("unhealthy", {
consecutiveErrors: this.restConsecutiveErrors,
method: info.method,
path: info.path,
error: info.error ?? this.restLastError ?? undefined,
});
}
}
private emitRestHealth(state: RestHealthState, info: RestHealthInfo): void {
for (const listener of this.restHealthListeners) {
try {
listener(state, info);
} catch (error) {
this.logger("restHealthListener", error);
}
} }
} }
@@ -1509,6 +1860,9 @@ export class StandxGateway {
if (this.lastKnownOpenOrders.length > 0 && this.disconnectedSymbol) { if (this.lastKnownOpenOrders.length > 0 && this.disconnectedSymbol) {
this.startDisconnectCancelRetry(this.disconnectedSymbol); this.startDisconnectCancelRetry(this.disconnectedSymbol);
} }
// 启动 REST 轮询,确保断连期间仍能获取行情和账户数据(用于止损等逻辑)
this.startRestPoll();
} }
/** /**
+1
View File
@@ -24,6 +24,7 @@ export interface StandxPosition {
leverage?: string; leverage?: string;
liq_price?: string; liq_price?: string;
margin_mode?: string; margin_mode?: string;
time?: string;
updated_at?: string; updated_at?: string;
} }
+55 -2
View File
@@ -25,6 +25,11 @@ const translations: Record<string, TranslationEntry> = {
zh: "监控均线信号,自动进出场并维护止损/止盈", zh: "监控均线信号,自动进出场并维护止损/止盈",
en: "Monitors SMA signals, automates entries/exits, maintains stops.", en: "Monitors SMA signals, automates entries/exits, maintains stops.",
}, },
"app.strategy.swing.label": { zh: "Swing 策略 (RSI14/4h)", en: "Swing (RSI14/4h)" },
"app.strategy.swing.desc": {
zh: "使用 Binance ETHBTC 4h RSI 信号,主动开平仓并维护止损",
en: "Uses Binance ETHBTC 4h RSI signals to actively trade and maintain stops.",
},
"app.strategy.guardian.label": { zh: "Guardian 防守策略", en: "Guardian Protection" }, "app.strategy.guardian.label": { zh: "Guardian 防守策略", en: "Guardian Protection" },
"app.strategy.guardian.desc": { "app.strategy.guardian.desc": {
zh: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔", zh: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔",
@@ -132,6 +137,50 @@ const translations: Record<string, TranslationEntry> = {
"trend.label.long": { zh: "做多", en: "Long" }, "trend.label.long": { zh: "做多", en: "Long" },
"trend.label.short": { zh: "做空", en: "Short" }, "trend.label.short": { zh: "做空", en: "Short" },
"trend.label.none": { zh: "无信号", en: "No signal" }, "trend.label.none": { zh: "无信号", en: "No signal" },
"swing.name": { zh: "Swing 策略", en: "swing strategy" },
"swing.title": { zh: "Swing 策略仪表盘", en: "Swing Strategy Dashboard" },
"swing.readyMessage": { zh: "正在等待交易所/RSI 信号…", en: "Waiting for exchange feeds / RSI signal..." },
"swing.headerLine": {
zh: "交易所: {exchange} 交易对: {symbol} 方向: {direction} 最近价格: {lastPrice} 状态: {phase}",
en: "Exchange: {exchange} | Symbol: {symbol} | Mode: {direction} | Last: {lastPrice} | Phase: {phase}",
},
"swing.signalLine": {
zh: "信号源: Binance {binanceSymbol} 价格: {binancePrice} RSI: {rsi} ({zone}) 连接: {connection}",
en: "Signal: Binance {binanceSymbol} | Price: {binancePrice} | RSI: {rsi} ({zone}) | Conn: {connection}",
},
"swing.statusLine": {
zh: "状态: {status} 按 Esc 返回策略选择",
en: "Status: {status} | Press Esc to return to menu.",
},
"swing.zone.overbought": { zh: "超买", en: "Overbought" },
"swing.zone.oversold": { zh: "超卖", en: "Oversold" },
"swing.zone.neutral": { zh: "正常区间", en: "Neutral" },
"swing.zone.unknown": { zh: "未知", en: "Unknown" },
"swing.phase.disabled": { zh: "已禁用", en: "Disabled" },
"swing.phase.initializing": { zh: "初始化/同步中", en: "Initializing" },
"swing.phase.observing": { zh: "观察", en: "Observing" },
"swing.phase.waitingOpenShort": { zh: "等待开空", en: "Waiting to open short" },
"swing.phase.waitingCloseShort": { zh: "等待平空", en: "Waiting to close short" },
"swing.phase.waitingOpenLong": { zh: "等待开多", en: "Waiting to open long" },
"swing.phase.waitingCloseLong": { zh: "等待平多", en: "Waiting to close long" },
"swing.positionLine": {
zh: "方向: {direction} 数量: {qty} 开仓价: {entry}",
en: "Direction: {direction} | Size: {qty} | Entry: {entry}",
},
"swing.pnlLine": {
zh: "浮动盈亏: {pnl} USDT 账户未实现盈亏: {unrealized} USDT",
en: "Floating PnL: {pnl} USDT | Account Unrealized: {unrealized} USDT",
},
"swing.stopLine": {
zh: "止损目标价: {stop}",
en: "Stop target: {stop}",
},
"swing.stateTitle": { zh: "策略状态", en: "Strategy State" },
"swing.armedLine": {
zh: "Armed: SE={se} SX={sx} LE={le} LX={lx}",
en: "Armed: SE={se} SX={sx} | LE={le} LX={lx}",
},
"swing.volumeLine": { zh: "累计成交量: {volume} USDT", en: "Total volume: {volume} USDT" },
"guardian.name": { zh: "Guardian 策略", en: "Guardian strategy" }, "guardian.name": { zh: "Guardian 策略", en: "Guardian strategy" },
"guardian.title": { zh: "Guardian 策略仪表盘", en: "Guardian Strategy Dashboard" }, "guardian.title": { zh: "Guardian 策略仪表盘", en: "Guardian Strategy Dashboard" },
"guardian.readyMessage": { zh: "正在等待行情/账户推送…", en: "Waiting for market/account feeds..." }, "guardian.readyMessage": { zh: "正在等待行情/账户推送…", en: "Waiting for market/account feeds..." },
@@ -199,8 +248,12 @@ const translations: Record<string, TranslationEntry> = {
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}", en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
}, },
"makerPoints.binanceLine": { "makerPoints.binanceLine": {
zh: "Binance 深度: 买10 {buy} 10 {sell} 状态: {status}", zh: "Binance 深度(±9bps): 买 {buy} 卖 {sell} 状态: {status}",
en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}", en: "Binance depth (±9bps): bid {buy} | ask {sell} | Status: {status}",
},
"makerPoints.bandDepthLine": {
zh: "StandX 档位 {band}bps 深度: 买 {buy} 卖 {sell}",
en: "StandX band {band}bps depth: buy {buy} | sell {sell}",
}, },
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" }, "makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
"makerPoints.mode.normal": { zh: "正常", en: "Normal" }, "makerPoints.mode.normal": { zh: "正常", en: "Normal" },
+5 -1
View File
@@ -41,7 +41,11 @@ function formatNotificationMessage(notification: TradeNotification, accountLabel
lines.push(``); lines.push(``);
for (const [key, value] of Object.entries(notification.details)) { for (const [key, value] of Object.entries(notification.details)) {
if (value != null) { if (value != null) {
lines.push(`${key}: ${value}`); if (Array.isArray(value)) {
lines.push(`${key}: ${value.join(", ") || "[]"}`);
} else {
lines.push(`${key}: ${value}`);
}
} }
} }
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export interface TradeNotification {
title: string; title: string;
message: string; message: string;
accountLabel?: string; accountLabel?: string;
details?: Record<string, string | number | boolean | null>; details?: Record<string, string | number | boolean | null | string[]>;
timestamp?: number; timestamp?: number;
} }
+595 -59
View File
@@ -1,12 +1,30 @@
import NodeWebSocket from "ws"; import NodeWebSocket from "ws";
import { computeDepthStats, type DepthImbalance } from "../../utils/depth"; import type { AsterDepthLevel } from "../../exchanges/types";
import type { DepthImbalance } from "../../utils/depth";
const WebSocketCtor: typeof globalThis.WebSocket = const WebSocketCtor: typeof globalThis.WebSocket =
typeof globalThis.WebSocket !== "undefined" typeof globalThis.WebSocket !== "undefined"
? globalThis.WebSocket ? globalThis.WebSocket
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket); : ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws"; const DEFAULT_WS_BASE_URL = "wss://stream.binance.com:9443/ws";
const DEFAULT_REST_BASE_URL = "https://api.binance.com";
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
const DATA_STALE_THRESHOLD_MS = 5_000;
const RECONNECT_DELAY_BASE_MS = 3000;
const RECONNECT_DELAY_MAX_MS = 60_000;
const DEFAULT_REFRESH_SYNC_INTERVAL_MS = 30_000;
const DEFAULT_DEPTH_WINDOW_BPS = 9;
const DEFAULT_IMBALANCE_RATIO = 9;
const MAX_BUFFER_SIZE = 5000;
const SYNC_SNAPSHOT_MAX_RETRIES = 5;
const REST_FAILURE_DEFENSE_THRESHOLD = 1;
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
export interface BinanceDepthSnapshot { export interface BinanceDepthSnapshot {
symbol: string; symbol: string;
@@ -16,45 +34,93 @@ export interface BinanceDepthSnapshot {
skipSellSide: boolean; skipSellSide: boolean;
imbalance: DepthImbalance; imbalance: DepthImbalance;
updatedAt: number; updatedAt: number;
windowBps: number;
localLastUpdateId: number;
}
export interface BinanceDepthHealth {
started: boolean;
connected: boolean;
orderBookReady: boolean;
restHealthy: boolean;
healthy: boolean;
reason: string | null;
lastEventAt: number;
lastSnapshotAt: number;
lastRestSyncAt: number;
localLastUpdateId: number;
}
export type BinanceConnectionListener = (state: BinanceConnectionState) => void;
interface DepthUpdateEvent {
U: number;
u: number;
bids: AsterDepthLevel[];
asks: AsterDepthLevel[];
}
interface DepthSnapshotResponse {
lastUpdateId: number;
bids: AsterDepthLevel[];
asks: AsterDepthLevel[];
} }
export class BinanceDepthTracker { export class BinanceDepthTracker {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = 3000; private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
private stopped = false; private stopped = false;
private started = false;
private snapshot: BinanceDepthSnapshot | null = null; private snapshot: BinanceDepthSnapshot | null = null;
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>(); private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
private connectionListeners = new Set<BinanceConnectionListener>();
private lastMessageTime = 0;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
private refreshSyncTimer: ReturnType<typeof setInterval> | null = null;
private connectionState: BinanceConnectionState = "disconnected";
private bidBook = new Map<string, number>();
private askBook = new Map<string, number>();
private localLastUpdateId = 0;
private orderBookReady = false;
private eventBuffer: DepthUpdateEvent[] = [];
private syncInFlight: Promise<void> | null = null;
private lastEventAt = 0;
private lastSnapshotAt = 0;
private lastRestSyncAt = 0;
private restConsecutiveFailures = 0;
private restLastError: string | null = null;
constructor( constructor(
private readonly symbol: string, private readonly symbol: string,
private readonly options?: { private readonly options?: {
baseUrl?: string; baseUrl?: string;
restBaseUrl?: string;
levels?: number; levels?: number;
ratio?: number; ratio?: number;
speedMs?: number;
depthWindowBps?: number;
refreshSyncMs?: number;
logger?: (context: string, error: unknown) => void; logger?: (context: string, error: unknown) => void;
} }
) {} ) {}
start(): void { start(): void {
this.started = true;
this.stopped = false; this.stopped = false;
this.connect(); this.connect();
this.startRefreshSyncTimer();
} }
stop(): void { stop(): void {
this.started = false;
this.stopped = true; this.stopped = true;
if (this.reconnectTimer) { this.cleanup();
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
try {
this.ws.close();
} catch {
// Ignore close errors
}
this.ws = null;
}
} }
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void { onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
@@ -65,113 +131,583 @@ export class BinanceDepthTracker {
this.listeners.delete(handler); this.listeners.delete(handler);
} }
onConnectionChange(handler: BinanceConnectionListener): void {
this.connectionListeners.add(handler);
}
offConnectionChange(handler: BinanceConnectionListener): void {
this.connectionListeners.delete(handler);
}
getSnapshot(): BinanceDepthSnapshot | null { getSnapshot(): BinanceDepthSnapshot | null {
return this.snapshot ? { ...this.snapshot } : null; return this.snapshot ? { ...this.snapshot } : null;
} }
getConnectionState(): BinanceConnectionState {
return this.connectionState;
}
isDataStale(): boolean {
if (!this.snapshot) return true;
return Date.now() - this.snapshot.updatedAt > DATA_STALE_THRESHOLD_MS;
}
isHealthy(): boolean {
return this.getHealth().healthy;
}
getHealth(): BinanceDepthHealth {
if (!this.started) {
return {
started: false,
connected: false,
orderBookReady: false,
restHealthy: true,
healthy: true,
reason: null,
lastEventAt: this.lastEventAt,
lastSnapshotAt: this.lastSnapshotAt,
lastRestSyncAt: this.lastRestSyncAt,
localLastUpdateId: this.localLastUpdateId,
};
}
const restHealthy = this.restConsecutiveFailures < REST_FAILURE_DEFENSE_THRESHOLD;
let reason: string | null = null;
if (this.connectionState !== "connected") {
reason = `ws_${this.connectionState}`;
} else if (!this.orderBookReady) {
reason = "orderbook_not_ready";
} else if (this.isDataStale()) {
reason = "orderbook_stale";
} else if (!restHealthy) {
reason = this.restLastError ? `rest_sync_failed:${this.restLastError}` : "rest_sync_failed";
}
return {
started: true,
connected: this.connectionState === "connected",
orderBookReady: this.orderBookReady,
restHealthy,
healthy: reason == null,
reason,
lastEventAt: this.lastEventAt,
lastSnapshotAt: this.lastSnapshotAt,
lastRestSyncAt: this.lastRestSyncAt,
localLastUpdateId: this.localLastUpdateId,
};
}
private cleanup(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
if (this.refreshSyncTimer) {
clearInterval(this.refreshSyncTimer);
this.refreshSyncTimer = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
try {
this.ws.close();
} catch {
// ignore close errors
}
this.ws = null;
}
this.updateConnectionState("disconnected");
}
private connect(): void { private connect(): void {
if (this.ws || this.stopped) return; if (this.ws || this.stopped) return;
const url = this.buildUrl(); const url = this.buildWsUrl();
this.ws = new WebSocketCtor(url); this.ws = new WebSocketCtor(url);
const handleOpen = () => { const handleOpen = () => {
this.reconnectDelayMs = 3000; this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
this.lastMessageTime = Date.now();
this.lastEventAt = Date.now();
this.orderBookReady = false;
this.eventBuffer = [];
this.updateConnectionState("connected");
this.startHeartbeatMonitor();
this.startMaxDurationTimer();
this.options?.logger?.("binanceDepth", "WebSocket connected");
}; };
const handleClose = () => { const handleClose = () => {
this.ws = null; this.ws = null;
this.orderBookReady = false;
this.eventBuffer = [];
this.stopHeartbeatMonitor();
this.stopMaxDurationTimer();
this.updateConnectionState("disconnected");
if (!this.stopped) { if (!this.stopped) {
this.options?.logger?.("binanceDepth", "WebSocket closed, scheduling reconnect");
this.scheduleReconnect(); this.scheduleReconnect();
} }
}; };
const handleError = (error: unknown) => { const handleError = (error: unknown) => {
this.options?.logger?.("binanceDepth", error); this.options?.logger?.("binanceDepth", error);
if (this.ws && this.connectionState === "disconnected") {
this.ws = null;
this.scheduleReconnect();
}
}; };
const handleMessage = (event: { data: unknown }) => { const handleMessage = (event: { data: unknown }) => {
this.lastMessageTime = Date.now();
this.lastEventAt = Date.now();
if (this.connectionState === "stale") {
this.updateConnectionState("connected");
}
this.handlePayload(event.data); this.handlePayload(event.data);
}; };
const handlePing = (data: unknown) => { const handlePing = (data: unknown) => {
this.lastMessageTime = Date.now();
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") { if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
this.ws.pong(data as any); try {
this.ws.pong(data as never);
} catch (error) {
this.options?.logger?.("binanceDepth pong", error);
}
} }
}; };
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") { if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
this.ws.addEventListener("open", handleOpen); this.ws.addEventListener("open", handleOpen);
this.ws.addEventListener("message", handleMessage as any); this.ws.addEventListener("message", handleMessage as never);
this.ws.addEventListener("close", handleClose); this.ws.addEventListener("close", handleClose);
this.ws.addEventListener("error", handleError as any); this.ws.addEventListener("error", handleError as never);
this.ws.addEventListener("ping", handlePing as any); this.ws.addEventListener("ping", handlePing as never);
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") { } else if ("on" in this.ws && typeof (this.ws as { on?: unknown }).on === "function") {
const nodeSocket = this.ws as any; const nodeSocket = this.ws as { on: (event: string, listener: (...args: unknown[]) => void) => void };
nodeSocket.on("open", handleOpen); nodeSocket.on("open", handleOpen);
nodeSocket.on("message", (data: unknown) => handleMessage({ data })); nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
nodeSocket.on("close", handleClose); nodeSocket.on("close", handleClose);
nodeSocket.on("error", handleError); nodeSocket.on("error", handleError);
nodeSocket.on("ping", handlePing); nodeSocket.on("ping", handlePing);
} else { } else {
(this.ws as any).onopen = handleOpen; const genericSocket = this.ws as any;
(this.ws as any).onmessage = handleMessage; genericSocket.onopen = handleOpen;
(this.ws as any).onclose = handleClose; genericSocket.onmessage = handleMessage;
(this.ws as any).onerror = handleError; genericSocket.onclose = handleClose;
genericSocket.onerror = handleError;
} }
} }
private buildUrl(): string { private buildWsUrl(): string {
const base = this.options?.baseUrl ?? DEFAULT_BASE_URL; const baseRaw = (this.options?.baseUrl ?? DEFAULT_WS_BASE_URL).replace(/\/+$/, "");
const stream = `${this.symbol.toLowerCase()}@depth10@100ms`; const base = baseRaw.endsWith("/ws") || baseRaw.includes("/stream") ? baseRaw : `${baseRaw}/ws`;
const speed = this.options?.speedMs ?? 100;
const stream = `${this.symbol.toLowerCase()}@depth@${speed}ms`;
return `${base}/${stream}`; return `${base}/${stream}`;
} }
private buildRestDepthUrl(): string {
const base = (this.options?.restBaseUrl ?? process.env.BINANCE_REST_URL ?? DEFAULT_REST_BASE_URL).replace(/\/+$/, "");
const symbol = this.symbol.toUpperCase();
return `${base}/api/v3/depth?symbol=${encodeURIComponent(symbol)}&limit=5000`;
}
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.reconnectTimer || this.stopped) return; if (this.reconnectTimer || this.stopped) return;
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null; this.reconnectTimer = null;
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000); this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
this.connect(); this.connect();
}, this.reconnectDelayMs); }, this.reconnectDelayMs);
} }
private handlePayload(data: unknown): void { private startHeartbeatMonitor(): void {
const payload = this.parsePayload(data); this.stopHeartbeatMonitor();
if (!payload) return; this.heartbeatTimer = setInterval(() => {
const bids = Array.isArray(payload.b) ? payload.b : []; const now = Date.now();
const asks = Array.isArray(payload.a) ? payload.a : []; const elapsed = now - this.lastMessageTime;
const depth = {
lastUpdateId: Number(payload.u ?? Date.now()), if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
bids, this.updateConnectionState("stale");
asks, this.options?.logger?.("binanceDepth", `Data stale: ${elapsed}ms since last message`);
}; }
const levels = this.options?.levels ?? 10;
const ratio = this.options?.ratio ?? 3; if (elapsed > HEARTBEAT_TIMEOUT_MS) {
const stats = computeDepthStats(depth, levels, ratio); this.options?.logger?.("binanceDepth", `Heartbeat timeout: ${elapsed}ms, forcing reconnect`);
this.snapshot = { this.forceReconnect("heartbeat_timeout");
symbol: this.symbol, }
buySum: stats.buySum, }, HEARTBEAT_CHECK_INTERVAL_MS);
sellSum: stats.sellSum, }
skipBuySide: stats.skipBuySide,
skipSellSide: stats.skipSellSide, private stopHeartbeatMonitor(): void {
imbalance: stats.imbalance, if (this.heartbeatTimer) {
updatedAt: Date.now(), clearInterval(this.heartbeatTimer);
}; this.heartbeatTimer = null;
for (const listener of this.listeners) {
listener({ ...this.snapshot });
} }
} }
private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null { private startMaxDurationTimer(): void {
this.stopMaxDurationTimer();
this.maxDurationTimer = setTimeout(() => {
this.options?.logger?.("binanceDepth", "Max connection duration reached (23h), reconnecting");
this.forceReconnect("max_duration");
}, MAX_CONNECTION_DURATION_MS);
}
private stopMaxDurationTimer(): void {
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
}
private startRefreshSyncTimer(): void {
if (this.refreshSyncTimer) return;
const refreshSyncMs = Math.max(5000, this.options?.refreshSyncMs ?? DEFAULT_REFRESH_SYNC_INTERVAL_MS);
this.refreshSyncTimer = setInterval(() => {
if (!this.started || this.stopped || !this.orderBookReady) return;
this.ensureSynced("periodic_refresh");
}, refreshSyncMs);
}
private forceReconnect(reason: string): void {
this.options?.logger?.("binanceDepth", `Force reconnect: ${reason}`);
this.stopHeartbeatMonitor();
this.stopMaxDurationTimer();
if (this.ws) {
try {
this.ws.close();
} catch {
// ignore
}
this.ws = null;
}
this.orderBookReady = false;
this.eventBuffer = [];
this.updateConnectionState("disconnected");
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
this.scheduleReconnect();
}
private updateConnectionState(state: BinanceConnectionState): void {
if (this.connectionState === state) return;
this.connectionState = state;
for (const listener of this.connectionListeners) {
try {
listener(state);
} catch (error) {
this.options?.logger?.("binanceDepth connectionListener", error);
}
}
}
private handlePayload(data: unknown): void {
const event = this.parseDepthEvent(data);
if (!event) return;
if (!this.orderBookReady) {
this.eventBuffer.push(event);
if (this.eventBuffer.length > MAX_BUFFER_SIZE) {
this.eventBuffer.splice(0, this.eventBuffer.length - MAX_BUFFER_SIZE);
}
this.ensureSynced("bootstrap");
return;
}
const applied = this.applyDepthEvent(event);
if (!applied) {
this.options?.logger?.(
"binanceDepth",
`Detected update gap: local=${this.localLastUpdateId}, event=[${event.U},${event.u}], resyncing`
);
this.orderBookReady = false;
this.eventBuffer = [event];
this.ensureSynced("sequence_gap");
return;
}
this.emitDepthSnapshot();
}
private ensureSynced(reason: string): void {
if (this.syncInFlight || this.stopped || !this.started) return;
this.syncInFlight = (async () => {
try {
if (!this.orderBookReady) {
await this.bootstrapOrderBookFromSnapshot(reason);
return;
}
await this.refreshOrderBookFromSnapshot(reason);
} finally {
this.syncInFlight = null;
}
})();
}
private async bootstrapOrderBookFromSnapshot(reason: string): Promise<void> {
if (this.eventBuffer.length === 0) {
return;
}
for (let attempt = 0; attempt < SYNC_SNAPSHOT_MAX_RETRIES; attempt += 1) {
const firstBuffered = this.eventBuffer[0];
if (!firstBuffered) return;
const snapshot = await this.fetchDepthSnapshot(reason);
if (!snapshot) return;
if (snapshot.lastUpdateId < firstBuffered.U) {
continue;
}
this.resetOrderBook(snapshot);
const buffered = this.eventBuffer.filter((event) => event.u > snapshot.lastUpdateId);
if (buffered.length > 0) {
const nextEvent = buffered[0];
if (!nextEvent) return;
const nextUpdateId = snapshot.lastUpdateId + 1;
if (nextEvent.U > nextUpdateId || nextEvent.u < nextUpdateId) {
continue;
}
let failed = false;
for (const event of buffered) {
if (!this.applyDepthEvent(event)) {
failed = true;
break;
}
}
if (failed) {
continue;
}
}
this.orderBookReady = true;
this.eventBuffer = [];
this.emitDepthSnapshot();
return;
}
this.options?.logger?.("binanceDepth", "Bootstrap orderbook failed after retries");
}
private async refreshOrderBookFromSnapshot(reason: string): Promise<void> {
const snapshot = await this.fetchDepthSnapshot(reason);
if (!snapshot) return;
if (snapshot.lastUpdateId < this.localLastUpdateId) {
return;
}
this.resetOrderBook(snapshot);
this.orderBookReady = true;
this.emitDepthSnapshot();
}
private resetOrderBook(snapshot: DepthSnapshotResponse): void {
this.bidBook.clear();
this.askBook.clear();
this.applyLevels(this.bidBook, snapshot.bids);
this.applyLevels(this.askBook, snapshot.asks);
this.localLastUpdateId = snapshot.lastUpdateId;
this.lastSnapshotAt = Date.now();
}
private applyDepthEvent(event: DepthUpdateEvent): boolean {
if (!this.localLastUpdateId) return false;
if (event.u < this.localLastUpdateId) {
return true;
}
if (event.U > this.localLastUpdateId + 1) {
return false;
}
this.applyLevels(this.bidBook, event.bids);
this.applyLevels(this.askBook, event.asks);
this.localLastUpdateId = event.u;
return true;
}
private applyLevels(book: Map<string, number>, levels: AsterDepthLevel[]): void {
for (const level of levels) {
const priceRaw = level?.[0];
const qtyRaw = level?.[1];
const price = Number(priceRaw);
const qty = Number(qtyRaw);
if (!priceRaw || !Number.isFinite(price) || price <= 0) continue;
if (!Number.isFinite(qty) || qty < 0) continue;
if (qty === 0) {
book.delete(priceRaw);
} else {
book.set(priceRaw, qty);
}
}
}
private emitDepthSnapshot(): void {
const bestBid = this.findBestPrice(this.bidBook, "bid");
const bestAsk = this.findBestPrice(this.askBook, "ask");
if (bestBid == null || bestAsk == null || bestBid <= 0 || bestAsk <= 0 || bestAsk < bestBid) {
return;
}
const windowBps = Math.max(1, this.options?.depthWindowBps ?? DEFAULT_DEPTH_WINDOW_BPS);
const ratio = Math.max(1.01, this.options?.ratio ?? DEFAULT_IMBALANCE_RATIO);
const bidWindowMin = bestBid * (1 - windowBps / 10_000);
const askWindowMax = bestAsk * (1 + windowBps / 10_000);
let buySum = 0;
let sellSum = 0;
for (const [priceRaw, qty] of this.bidBook.entries()) {
const price = Number(priceRaw);
if (!Number.isFinite(price) || price < bidWindowMin) continue;
buySum += qty;
}
for (const [priceRaw, qty] of this.askBook.entries()) {
const price = Number(priceRaw);
if (!Number.isFinite(price) || price > askWindowMax) continue;
sellSum += qty;
}
const skipSellSide = sellSum === 0 || buySum > sellSum * ratio;
const skipBuySide = buySum === 0 || sellSum > buySum * ratio;
let imbalance: DepthImbalance = "balanced";
if (buySum > sellSum * ratio) {
imbalance = "buy_dominant";
} else if (sellSum > buySum * ratio) {
imbalance = "sell_dominant";
}
this.snapshot = {
symbol: this.symbol,
buySum,
sellSum,
skipBuySide,
skipSellSide,
imbalance,
updatedAt: Date.now(),
windowBps,
localLastUpdateId: this.localLastUpdateId,
};
for (const listener of this.listeners) {
try {
listener({ ...this.snapshot });
} catch (error) {
this.options?.logger?.("binanceDepth listener", error);
}
}
}
private findBestPrice(book: Map<string, number>, side: "bid" | "ask"): number | null {
let best: number | null = null;
for (const [priceRaw, qty] of book.entries()) {
if (!Number.isFinite(qty) || qty <= 0) continue;
const price = Number(priceRaw);
if (!Number.isFinite(price) || price <= 0) continue;
if (best == null) {
best = price;
continue;
}
if (side === "bid") {
if (price > best) best = price;
} else if (price < best) {
best = price;
}
}
return best;
}
private async fetchDepthSnapshot(reason: string): Promise<DepthSnapshotResponse | null> {
try {
const response = await fetch(this.buildRestDepthUrl(), {
method: "GET",
headers: { "content-type": "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = (await response.json()) as {
lastUpdateId?: number;
bids?: Array<[string, string]>;
asks?: Array<[string, string]>;
};
const lastUpdateId = Number(json.lastUpdateId);
if (!Number.isFinite(lastUpdateId) || lastUpdateId <= 0) {
throw new Error("invalid lastUpdateId");
}
const bids = Array.isArray(json.bids) ? (json.bids as AsterDepthLevel[]) : [];
const asks = Array.isArray(json.asks) ? (json.asks as AsterDepthLevel[]) : [];
this.lastRestSyncAt = Date.now();
this.restConsecutiveFailures = 0;
this.restLastError = null;
return { lastUpdateId, bids, asks };
} catch (error) {
this.restConsecutiveFailures += 1;
this.restLastError = this.extractMessage(error);
this.options?.logger?.("binanceDepth", `REST sync failed (${reason}): ${this.restLastError}`);
return null;
}
}
private parseDepthEvent(data: unknown): DepthUpdateEvent | null {
try { try {
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null; const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
if (!text) return null; if (!text) return null;
const parsed = JSON.parse(text); const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object") return null; if (!parsed || typeof parsed !== "object") return null;
return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number };
const maybeCombined = parsed as { data?: unknown };
const payload =
maybeCombined.data && typeof maybeCombined.data === "object"
? (maybeCombined.data as Record<string, unknown>)
: (parsed as Record<string, unknown>);
const eventType = typeof payload.e === "string" ? payload.e : "";
if (eventType && eventType !== "depthUpdate") {
return null;
}
const U = Number(payload.U);
const u = Number(payload.u);
if (!Number.isFinite(U) || !Number.isFinite(u)) {
return null;
}
const bidsRaw = Array.isArray(payload.b) ? payload.b : [];
const asksRaw = Array.isArray(payload.a) ? payload.a : [];
const bids = bidsRaw.filter((level): level is AsterDepthLevel => Array.isArray(level)) as AsterDepthLevel[];
const asks = asksRaw.filter((level): level is AsterDepthLevel => Array.isArray(level)) as AsterDepthLevel[];
return { U, u, bids, asks };
} catch { } catch {
return null; return null;
} }
} }
}
private extractMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
}
+428
View File
@@ -0,0 +1,428 @@
import NodeWebSocket from "ws";
import { RSI } from "trading-signals";
const WebSocketCtor: typeof globalThis.WebSocket =
typeof globalThis.WebSocket !== "undefined"
? globalThis.WebSocket
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
const DEFAULT_REST_BASE_URL = "https://api.binance.com";
const DEFAULT_WS_BASE_URL = "wss://stream.binance.com:9443/ws";
// Binance sends frequent kline updates (typically 2s). We treat longer silence as stale.
const DATA_STALE_THRESHOLD_MS = 10_000;
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
const RECONNECT_DELAY_BASE_MS = 2000;
const RECONNECT_DELAY_MAX_MS = 60_000;
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
export interface BinanceRsiSnapshot {
symbol: string;
interval: string;
rsiPeriod: number;
rsi: number | null;
isStable: boolean;
lastClose: number | null;
candleOpenTime: number | null;
candleClosed: boolean | null;
updatedAt: number | null;
connectionState: BinanceConnectionState;
}
type BinanceRsiListener = (snapshot: BinanceRsiSnapshot) => void;
export class BinanceRsiTracker {
private ws: WebSocket | null = null;
private stopped = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
private lastMessageTime = 0;
private connectionState: BinanceConnectionState = "disconnected";
private rsi: RSI;
private candleOpenTime: number | null = null;
private candleClosed: boolean | null = null;
private lastClose: number | null = null;
private updatedAt: number | null = null;
private listeners = new Set<BinanceRsiListener>();
constructor(
private readonly symbol: string,
private readonly interval: string,
private readonly rsiPeriod: number,
private readonly options?: {
restBaseUrl?: string;
wsBaseUrl?: string;
limit?: number;
logger?: (context: string, error: unknown) => void;
}
) {
this.rsi = new RSI(this.rsiPeriod);
}
start(): void {
this.stopped = false;
void this.seedAndConnect("startup");
}
stop(): void {
this.stopped = true;
this.cleanup();
}
onUpdate(handler: BinanceRsiListener): void {
this.listeners.add(handler);
}
offUpdate(handler: BinanceRsiListener): void {
this.listeners.delete(handler);
}
getSnapshot(): BinanceRsiSnapshot {
return this.buildSnapshot();
}
private buildSnapshot(): BinanceRsiSnapshot {
const rsiValue = this.rsi.getResult();
const rsi = typeof rsiValue === "number" && Number.isFinite(rsiValue) ? rsiValue : null;
return {
symbol: this.symbol,
interval: this.interval,
rsiPeriod: this.rsiPeriod,
rsi,
isStable: this.rsi.isStable === true,
lastClose: this.lastClose,
candleOpenTime: this.candleOpenTime,
candleClosed: this.candleClosed,
updatedAt: this.updatedAt,
connectionState: this.connectionState,
};
}
private emitUpdate(): void {
const snapshot = this.buildSnapshot();
for (const listener of this.listeners) {
try {
listener(snapshot);
} catch (error) {
this.options?.logger?.("binanceRsi listener", error);
}
}
}
private cleanup(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
try {
this.ws.close();
} catch {
// ignore
}
this.ws = null;
}
this.updateConnectionState("disconnected");
}
private updateConnectionState(next: BinanceConnectionState): void {
if (this.connectionState === next) return;
this.connectionState = next;
this.emitUpdate();
}
private scheduleReconnect(reason: string): void {
if (this.reconnectTimer || this.stopped) return;
this.options?.logger?.("binanceRsi", `Scheduling reconnect: ${reason}`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
void this.seedAndConnect(`reconnect:${reason}`);
}, this.reconnectDelayMs);
}
private forceReconnect(reason: string): void {
if (this.stopped) return;
this.options?.logger?.("binanceRsi", `Force reconnect: ${reason}`);
// Stop timers and close WS; keep RSI state (we will reseed on reconnect).
this.stopHeartbeatMonitor();
this.stopMaxDurationTimer();
if (this.ws) {
try {
this.ws.close();
} catch {
// ignore
}
this.ws = null;
}
this.updateConnectionState("disconnected");
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
this.scheduleReconnect(reason);
}
private startHeartbeatMonitor(): void {
if (this.heartbeatTimer) return;
this.heartbeatTimer = setInterval(() => {
const elapsed = Date.now() - this.lastMessageTime;
if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
this.updateConnectionState("stale");
}
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
this.forceReconnect(`heartbeat_timeout:${elapsed}`);
}
}, HEARTBEAT_CHECK_INTERVAL_MS);
}
private stopHeartbeatMonitor(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private startMaxDurationTimer(): void {
if (this.maxDurationTimer) return;
this.maxDurationTimer = setTimeout(() => {
this.forceReconnect("max_duration");
}, MAX_CONNECTION_DURATION_MS);
}
private stopMaxDurationTimer(): void {
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
}
private buildRestUrl(): string {
const base = this.options?.restBaseUrl ?? DEFAULT_REST_BASE_URL;
return base;
}
private buildWsUrl(): string {
const base = this.options?.wsBaseUrl ?? DEFAULT_WS_BASE_URL;
const stream = `${this.symbol.toLowerCase()}@kline_${this.interval}`;
return `${base}/${stream}`;
}
private async seedAndConnect(reason: string): Promise<void> {
if (this.stopped) return;
try {
await this.seedFromRest();
} catch (error) {
this.options?.logger?.(`binanceRsi seed (${reason})`, error);
this.scheduleReconnect(`seed_failed:${reason}`);
return;
}
this.connectWs(reason);
}
private async seedFromRest(): Promise<void> {
const limit = Math.max(10, Math.floor(this.options?.limit ?? 500));
const base = this.buildRestUrl();
const url = new URL("/api/v3/klines", base);
url.searchParams.set("symbol", this.symbol.toUpperCase());
url.searchParams.set("interval", this.interval);
url.searchParams.set("limit", String(limit));
const res = await fetch(url.toString(), { method: "GET" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Binance klines HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const data = (await res.json()) as unknown;
if (!Array.isArray(data)) {
throw new Error("Binance klines response is not an array");
}
// Reset RSI from scratch for determinism.
this.rsi = new RSI(this.rsiPeriod);
this.candleOpenTime = null;
this.candleClosed = null;
this.lastClose = null;
this.updatedAt = null;
// Binance kline array format:
// [ openTime, open, high, low, close, volume, closeTime, ... ]
const rows = data
.map((row) => (Array.isArray(row) ? row : null))
.filter((row): row is any[] => Array.isArray(row) && row.length >= 7)
.map((row) => ({
openTime: Number(row[0]),
close: Number(row[4]),
closeTime: Number(row[6]),
}))
.filter((k) => Number.isFinite(k.openTime) && Number.isFinite(k.close) && Number.isFinite(k.closeTime))
.sort((a, b) => a.openTime - b.openTime);
for (const k of rows) {
this.rsi.add(k.close);
this.candleOpenTime = k.openTime;
this.candleClosed = true;
this.lastClose = k.close;
this.updatedAt = Date.now();
}
// Last bar may still be forming; we treat it as replaceable.
if (rows.length > 0) {
this.candleClosed = false;
}
this.emitUpdate();
}
private connectWs(reason: string): void {
if (this.ws || this.stopped) return;
const url = this.buildWsUrl();
this.ws = new WebSocketCtor(url);
const handleOpen = () => {
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
this.lastMessageTime = Date.now();
this.updateConnectionState("connected");
this.startHeartbeatMonitor();
this.startMaxDurationTimer();
this.options?.logger?.("binanceRsi", `WebSocket connected (${reason})`);
};
const handleClose = () => {
this.ws = null;
this.stopHeartbeatMonitor();
this.stopMaxDurationTimer();
if (!this.stopped) {
this.updateConnectionState("disconnected");
this.scheduleReconnect("ws_close");
}
};
const handleError = (error: unknown) => {
this.options?.logger?.("binanceRsi", error);
};
const handlePing = (data: unknown) => {
this.lastMessageTime = Date.now();
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
try {
this.ws.pong(data as any);
} catch (error) {
this.options?.logger?.("binanceRsi pong", error);
}
}
};
const handleMessage = (event: { data: unknown }) => {
this.lastMessageTime = Date.now();
if (this.connectionState === "stale") {
this.updateConnectionState("connected");
}
this.handlePayload(event.data);
};
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
this.ws.addEventListener("open", handleOpen);
this.ws.addEventListener("message", handleMessage as any);
this.ws.addEventListener("close", handleClose);
this.ws.addEventListener("error", handleError as any);
this.ws.addEventListener("ping", handlePing as any);
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") {
const nodeSocket = this.ws as any;
nodeSocket.on("open", handleOpen);
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
nodeSocket.on("close", handleClose);
nodeSocket.on("error", handleError);
nodeSocket.on("ping", handlePing);
} else {
(this.ws as any).onopen = handleOpen;
(this.ws as any).onmessage = handleMessage;
(this.ws as any).onclose = handleClose;
(this.ws as any).onerror = handleError;
}
}
private handlePayload(data: unknown): void {
const parsed = this.parsePayload(data);
if (!parsed) return;
const openTime = Number(parsed.openTime);
const close = Number(parsed.close);
const isClosed = Boolean(parsed.isClosed);
if (!Number.isFinite(openTime) || !Number.isFinite(close)) return;
this.applyCandleUpdate({ openTime, close, isClosed });
}
private applyCandleUpdate(params: { openTime: number; close: number; isClosed: boolean }): void {
const { openTime, close, isClosed } = params;
if (this.candleOpenTime == null) {
this.rsi.add(close);
this.candleOpenTime = openTime;
this.candleClosed = isClosed;
this.lastClose = close;
this.updatedAt = Date.now();
this.emitUpdate();
return;
}
if (openTime < this.candleOpenTime) {
// Out-of-order update; ignore.
return;
}
if (openTime === this.candleOpenTime) {
// Same candle: replace last close.
this.rsi.replace(close);
this.candleClosed = isClosed;
this.lastClose = close;
this.updatedAt = Date.now();
this.emitUpdate();
return;
}
// New candle started.
this.rsi.add(close);
this.candleOpenTime = openTime;
this.candleClosed = isClosed;
this.lastClose = close;
this.updatedAt = Date.now();
this.emitUpdate();
}
private parsePayload(
data: unknown
): { openTime?: number; close?: number; isClosed?: boolean } | null {
try {
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
if (!text) return null;
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object") return null;
// Raw stream payload shape:
// { e: "kline", s: "ETHBTC", k: { t: <openTime>, c: <close>, x: <isClosed> } }
const k = (parsed as any).k;
if (!k || typeof k !== "object") return null;
return {
openTime: Number(k.t),
close: Number(k.c),
isClosed: Boolean(k.x),
};
} catch {
return null;
}
}
}
+569 -30
View File
@@ -1,5 +1,5 @@
import type { MakerPointsConfig } from "../config"; import type { MakerPointsConfig } from "../config";
import type { ExchangeAdapter, ConnectionEventType } from "../exchanges/adapter"; import type { ExchangeAdapter } from "../exchanges/adapter";
import type { import type {
AsterAccountSnapshot, AsterAccountSnapshot,
AsterDepth, AsterDepth,
@@ -10,9 +10,9 @@ import { formatPriceToString } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { extractMessage, isInsufficientBalanceError, isPrecisionError, isRateLimitError, isUnknownOrderError } from "../utils/errors"; import { extractMessage, isInsufficientBalanceError, isPrecisionError, isRateLimitError, isUnknownOrderError } from "../utils/errors";
import { isOrderActiveStatus } from "../utils/order-status"; import { isOrderActiveStatus } from "../utils/order-status";
import { getPosition, parseSymbolParts } from "../utils/strategy"; import { getPosition, parseSymbolParts, validateAccountSnapshotForSymbol } from "../utils/strategy";
import type { PositionSnapshot } from "../utils/strategy"; import type { PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl"; import { computePositionPnl, computeStopLossPnl } from "../utils/pnl";
import { getDepthBetweenPrices, getMidOrLast, getTopPrices } from "../utils/price"; import { getDepthBetweenPrices, getMidOrLast, getTopPrices } from "../utils/price";
import { import {
marketClose, marketClose,
@@ -71,6 +71,13 @@ export interface MakerPointsSnapshot {
binance: boolean; binance: boolean;
}; };
binanceDepth: BinanceDepthSnapshot | null; binanceDepth: BinanceDepthSnapshot | null;
bandDepths: Array<{
band: "0-10" | "10-30" | "30-100";
bps: number;
buyDepth: number | null;
sellDepth: number | null;
enabled: boolean;
}>;
quoteStatus: { quoteStatus: {
closeOnly: boolean; closeOnly: boolean;
skipBuy: boolean; skipBuy: boolean;
@@ -86,6 +93,13 @@ const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
const STOP_LOSS_COOLDOWN_MS = 5_000; const STOP_LOSS_COOLDOWN_MS = 5_000;
const STOP_LOSS_CHECK_INTERVAL_MS = 250; // 止损检查最大间隔 const STOP_LOSS_CHECK_INTERVAL_MS = 250; // 止损检查最大间隔
const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔 const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔
const DATA_STALE_THRESHOLD_MS = 5_000; // 数据过时阈值(5秒)
const DEFENSE_MODE_CHECK_INTERVAL_MS = 1000; // 防御模式检查间隔
const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000; // 账户数据长期无更新阈值(会先尝试通过 REST 补拉验证,不直接进入防御模式)
const STANDX_REST_ERROR_DEFENSE_THRESHOLD = 3;
const STANDX_MARGIN_MODE_CHECK_INTERVAL_MS = 500;
const STANDX_MARGIN_MODE_MAX_ATTEMPTS = 10;
const ACCOUNT_STALE_REST_PROBE_MIN_INTERVAL_MS = 5_000;
export class MakerPointsEngine { export class MakerPointsEngine {
private accountSnapshot: AsterAccountSnapshot | null = null; private accountSnapshot: AsterAccountSnapshot | null = null;
@@ -153,12 +167,32 @@ export class MakerPointsEngine {
private lastPositionAmt = 0; private lastPositionAmt = 0;
private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT"; private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT";
// 连接保护相关状态 // 连接保护相关状态(用于断连/重连事件处理)
private connectionState: "connected" | "disconnected" = "connected"; private _standxConnectionState: "connected" | "disconnected" = "connected";
private reconnectResetPending = false; private reconnectResetPending = false;
private lastRepriceQueryTime = 0; private lastRepriceQueryTime = 0;
private readonly repriceQueryIntervalMs = 3000; // 最小查询间隔 private readonly repriceQueryIntervalMs = 3000; // 最小查询间隔
// ========== 数据过时防御模式 ==========
// 各数据源最后更新时间
private lastStandxDepthTime = 0;
private lastStandxAccountTime = 0;
private lastBinanceDepthTime = 0;
private accountStaleRestProbeInFlight: Promise<void> | null = null;
private accountStaleRestProbeLastAttempt = 0;
private accountStaleRestProbeConsecutiveFailures = 0;
// 防御模式状态
private defenseMode = false;
private defenseModeNotified = false;
private defenseModeTimer: ReturnType<typeof setInterval> | null = null;
// 防御模式下的 REST 轮询定时器
private defenseRestPollTimer: ReturnType<typeof setTimeout> | null = null;
private defenseRestPollActive = false;
private standxRestConsecutiveErrors = 0;
private standxRestUnhealthy = false;
private standxRestLastError: string | null = null;
private marginModeEnsuring: Promise<boolean> | null = null;
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) { constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries); this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
@@ -168,15 +202,33 @@ export class MakerPointsEngine {
this.priceTick = Math.max(1e-9, this.config.priceTick); this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.config.qtyStep); this.qtyStep = Math.max(1e-9, this.config.qtyStep);
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), { this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
baseUrl: process.env.BINANCE_WS_URL, baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL,
levels: 10, restBaseUrl: process.env.BINANCE_REST_URL,
ratio: 3, levels: 20,
ratio: 9,
depthWindowBps: 9,
speedMs: 100,
logger: (context, error) => { logger: (context, error) => {
this.tradeLog.push("warn", `Binance ${context} 异常: ${extractMessage(error)}`); this.tradeLog.push("warn", `Binance ${context} 异常: ${extractMessage(error)}`);
}, },
}); });
this.binanceDepth.onUpdate(() => { this.binanceDepth.onUpdate(() => {
this.feedStatus.binance = true; this.feedStatus.binance = true;
this.lastBinanceDepthTime = Date.now();
this.emitUpdate();
});
// 监听 Binance 连接状态变化
this.binanceDepth.onConnectionChange((state) => {
if (state === "disconnected") {
this.feedStatus.binance = false;
this.tradeLog.push("warn", "Binance 深度连接断开");
} else if (state === "stale") {
this.feedStatus.binance = false;
this.tradeLog.push("warn", "Binance 深度数据过时");
} else if (state === "connected") {
this.feedStatus.binance = true;
this.tradeLog.push("info", "Binance 深度连接恢复");
}
this.emitUpdate(); this.emitUpdate();
}); });
this.syncPrecision(); this.syncPrecision();
@@ -185,6 +237,12 @@ export class MakerPointsEngine {
start(): void { start(): void {
if (this.timer) return; if (this.timer) return;
// 初始化数据时间戳
const now = Date.now();
this.lastStandxDepthTime = now;
this.lastStandxAccountTime = now;
this.lastBinanceDepthTime = now;
this.timer = setInterval(() => { this.timer = setInterval(() => {
void this.tick(); void this.tick();
}, this.config.refreshIntervalMs); }, this.config.refreshIntervalMs);
@@ -193,6 +251,12 @@ export class MakerPointsEngine {
void this.checkStopLoss(); void this.checkStopLoss();
}, Math.min(STOP_LOSS_CHECK_INTERVAL_MS, this.config.refreshIntervalMs)); }, Math.min(STOP_LOSS_CHECK_INTERVAL_MS, this.config.refreshIntervalMs));
} }
// 启动防御模式检测定时器
if (!this.defenseModeTimer) {
this.defenseModeTimer = setInterval(() => {
this.checkDataStaleAndDefense();
}, DEFENSE_MODE_CHECK_INTERVAL_MS);
}
this.binanceDepth.start(); this.binanceDepth.start();
} }
@@ -205,6 +269,11 @@ export class MakerPointsEngine {
clearInterval(this.stopLossTimer); clearInterval(this.stopLossTimer);
this.stopLossTimer = null; this.stopLossTimer = null;
} }
if (this.defenseModeTimer) {
clearInterval(this.defenseModeTimer);
this.defenseModeTimer = null;
}
this.stopDefenseRestPoll();
this.binanceDepth.stop(); this.binanceDepth.stop();
} }
@@ -222,20 +291,12 @@ export class MakerPointsEngine {
private bootstrap(): void { private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
this.setupRestHealthProtection();
safeSubscribe<AsterAccountSnapshot>( safeSubscribe<AsterAccountSnapshot>(
this.exchange.watchAccount.bind(this.exchange), this.exchange.watchAccount.bind(this.exchange),
(snapshot) => { (snapshot) => {
this.accountSnapshot = snapshot; this.applyAccountSnapshot(snapshot);
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
if (Number.isFinite(totalUnrealized)) {
this.accountUnrealized = totalUnrealized;
}
const position = getPosition(snapshot, this.config.symbol);
this.sessionVolume.update(position, this.getReferencePrice());
this.detectPositionChange(position);
this.feedStatus.account = true;
this.emitUpdate();
}, },
log, log,
{ {
@@ -277,6 +338,7 @@ export class MakerPointsEngine {
this.exchange.watchDepth.bind(this.exchange, this.config.symbol), this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
(depth) => { (depth) => {
this.depthSnapshot = depth; this.depthSnapshot = depth;
this.lastStandxDepthTime = Date.now();
this.feedStatus.depth = true; this.feedStatus.depth = true;
this.emitUpdate(); this.emitUpdate();
}, },
@@ -305,6 +367,51 @@ export class MakerPointsEngine {
this.setupConnectionProtection(); this.setupConnectionProtection();
} }
private applyAccountSnapshot(snapshot: AsterAccountSnapshot): void {
this.accountSnapshot = snapshot;
// StandX: WS 推送使用本地接收时间戳;REST 快照使用响应里的 time 字段映射到 snapshot.updateTime
this.lastStandxAccountTime =
this.exchange.id === "standx" && Number.isFinite(snapshot.updateTime) && snapshot.updateTime > 0
? snapshot.updateTime
: Date.now();
this.accountStaleRestProbeConsecutiveFailures = 0;
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
if (Number.isFinite(totalUnrealized)) {
this.accountUnrealized = totalUnrealized;
}
const position = getPosition(snapshot, this.config.symbol);
this.sessionVolume.update(position, this.getReferencePrice());
this.detectPositionChange(position);
this.feedStatus.account = true;
this.emitUpdate();
}
private maybeProbeStandxAccountSnapshot(now: number): void {
if (this.exchange.id !== "standx") return;
if (!this.exchange.queryAccountSnapshot) return;
if (this.defenseMode) return;
if (this.accountStaleRestProbeInFlight) return;
if (this.accountStaleRestProbeLastAttempt > 0 && now - this.accountStaleRestProbeLastAttempt < ACCOUNT_STALE_REST_PROBE_MIN_INTERVAL_MS) {
return;
}
this.accountStaleRestProbeLastAttempt = now;
this.accountStaleRestProbeInFlight = (async () => {
try {
const next = await this.exchange.queryAccountSnapshot?.();
if (next) {
this.applyAccountSnapshot(next);
} else {
this.accountStaleRestProbeConsecutiveFailures += 1;
}
} catch {
this.accountStaleRestProbeConsecutiveFailures += 1;
} finally {
this.accountStaleRestProbeInFlight = null;
}
})();
}
/** /**
* 设置连接保护机制 * 设置连接保护机制
* 监听断连/重连事件,实现保护逻辑 * 监听断连/重连事件,实现保护逻辑
@@ -321,11 +428,43 @@ export class MakerPointsEngine {
}); });
} }
private setupRestHealthProtection(): void {
if (!this.exchange.onRestHealthEvent) return;
this.exchange.onRestHealthEvent((state, info) => {
if (state === "unhealthy") {
this.standxRestUnhealthy = true;
this.standxRestConsecutiveErrors = Math.max(this.standxRestConsecutiveErrors, info.consecutiveErrors);
this.standxRestLastError = info.error ?? this.standxRestLastError;
if (!this.defenseMode && this.standxRestConsecutiveErrors >= STANDX_REST_ERROR_DEFENSE_THRESHOLD) {
this.enterDefenseMode({
standxDepthStale: false,
binanceStale: false,
standxAccountStale: false,
accountInvalid: false,
standxRestUnhealthy: true,
standxRestConsecutiveErrors: this.standxRestConsecutiveErrors,
standxRestLastError: this.standxRestLastError,
marginModeNotIsolated: false,
marginMode: this.getStandxMarginMode(this.accountSnapshot),
standxDepthAge: 0,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: [],
});
}
} else if (state === "healthy") {
this.standxRestUnhealthy = false;
this.standxRestConsecutiveErrors = 0;
this.standxRestLastError = null;
}
});
}
/** /**
* 处理断连事件 * 处理断连事件
*/ */
private handleDisconnect(symbol: string): void { private handleDisconnect(symbol: string): void {
this.connectionState = "disconnected"; this._standxConnectionState = "disconnected";
this.tradeLog.push("warn", `WebSocket 断连 (${symbol}),启动断连保护`); this.tradeLog.push("warn", `WebSocket 断连 (${symbol}),启动断连保护`);
this.notify({ this.notify({
type: "token_expired", type: "token_expired",
@@ -342,7 +481,7 @@ export class MakerPointsEngine {
* 重连后需要重新查询挂单并取消所有挂单 * 重连后需要重新查询挂单并取消所有挂单
*/ */
private async handleReconnect(symbol: string): Promise<void> { private async handleReconnect(symbol: string): Promise<void> {
this.connectionState = "connected"; this._standxConnectionState = "connected";
this.reconnectResetPending = true; this.reconnectResetPending = true;
this.tradeLog.push("info", `WebSocket 重连成功 (${symbol}),开始重连保护流程`); this.tradeLog.push("info", `WebSocket 重连成功 (${symbol}),开始重连保护流程`);
@@ -420,6 +559,12 @@ export class MakerPointsEngine {
private async tick(): Promise<void> { private async tick(): Promise<void> {
if (this.processing) return; if (this.processing) return;
// 重连处理期间不执行主循环,避免状态竞争
if (this.reconnectResetPending) return;
// 止损执行期间不执行主循环,避免订单冲突
if (this.stopLossProcessing) return;
// 防御模式下不执行正常挂单逻辑
if (this.defenseMode) return;
this.processing = true; this.processing = true;
let hadRateLimit = false; let hadRateLimit = false;
try { try {
@@ -436,6 +581,49 @@ export class MakerPointsEngine {
this.emitUpdate(); this.emitUpdate();
return; return;
} }
if (!(await this.ensureStandxIsolatedMarginMode())) {
const current = this.getStandxMarginMode(this.accountSnapshot);
this.enterDefenseMode({
standxDepthStale: false,
binanceStale: false,
standxAccountStale: false,
accountInvalid: false,
standxRestUnhealthy: false,
standxRestConsecutiveErrors: this.standxRestConsecutiveErrors,
standxRestLastError: this.standxRestLastError,
marginModeNotIsolated: true,
marginMode: current,
standxDepthAge: 0,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: [],
});
this.emitUpdate();
return;
}
const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol);
if (!accountHealth.ok && !this.defenseMode) {
this.enterDefenseMode({
standxDepthStale: false,
binanceStale: false,
standxAccountStale: false,
accountInvalid: true,
standxRestUnhealthy: false,
standxRestConsecutiveErrors: this.standxRestConsecutiveErrors,
standxRestLastError: this.standxRestLastError,
marginModeNotIsolated: false,
marginMode: this.getStandxMarginMode(this.accountSnapshot),
standxDepthAge: 0,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: accountHealth.issues,
});
this.emitUpdate();
return;
}
this.resetReadinessFlags(); this.resetReadinessFlags();
if (!(await this.ensureStartupOrderReset())) { if (!(await this.ensureStartupOrderReset())) {
this.emitUpdate(); this.emitUpdate();
@@ -747,6 +935,11 @@ export class MakerPointsEngine {
} }
private async syncOrders(targets: DesiredOrder[], _closeOnly: boolean): Promise<void> { private async syncOrders(targets: DesiredOrder[], _closeOnly: boolean): Promise<void> {
// 止损执行期间不进行挂单操作,避免订单冲突
if (this.stopLossProcessing) return;
// 重连处理期间不进行挂单操作
if (this.reconnectResetPending) return;
// 价格变化保护:如果需要 reprice 且距上次查询已过足够时间,先查询真实挂单 // 价格变化保护:如果需要 reprice 且距上次查询已过足够时间,先查询真实挂单
const shouldVerifyOrders = await this.verifyOrdersIfNeeded(); const shouldVerifyOrders = await this.verifyOrdersIfNeeded();
if (shouldVerifyOrders) { if (shouldVerifyOrders) {
@@ -912,15 +1105,7 @@ export class MakerPointsEngine {
*/ */
private computeRealtimePnl(position: PositionSnapshot): number | null { private computeRealtimePnl(position: PositionSnapshot): number | null {
const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
// 使用实时深度计算 PnL return computeStopLossPnl(position, topBid, topAsk);
if (topBid != null && topAsk != null) {
return computePositionPnl(position, topBid, topAsk);
}
// 回退到账户推送的数据
if (Number.isFinite(position.unrealizedProfit)) {
return position.unrealizedProfit;
}
return null;
} }
private async checkStopLoss(): Promise<void> { private async checkStopLoss(): Promise<void> {
@@ -1131,6 +1316,7 @@ export class MakerPointsEngine {
const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
const spread = topBid != null && topAsk != null ? topAsk - topBid : null; const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
const pnl = computePositionPnl(position, topBid, topAsk); const pnl = computePositionPnl(position, topBid, topAsk);
const bandDepths = this.computeBandDepths(topBid, topAsk);
return { return {
ready: this.isReady(), ready: this.isReady(),
@@ -1149,6 +1335,7 @@ export class MakerPointsEngine {
lastUpdated: Date.now(), lastUpdated: Date.now(),
feedStatus: { ...this.feedStatus }, feedStatus: { ...this.feedStatus },
binanceDepth: this.binanceDepth.getSnapshot(), binanceDepth: this.binanceDepth.getSnapshot(),
bandDepths,
quoteStatus: { quoteStatus: {
closeOnly: this.lastCloseOnly, closeOnly: this.lastCloseOnly,
skipBuy: this.lastSkipBuy, skipBuy: this.lastSkipBuy,
@@ -1157,6 +1344,26 @@ export class MakerPointsEngine {
}; };
} }
private computeBandDepths(topBid: number | null, topAsk: number | null): MakerPointsSnapshot["bandDepths"] {
const bands: MakerPointsSnapshot["bandDepths"] = [
{ band: "0-10", bps: 9, buyDepth: null, sellDepth: null, enabled: this.config.enableBand0To10 },
{ band: "10-30", bps: 29, buyDepth: null, sellDepth: null, enabled: this.config.enableBand10To30 },
{ band: "30-100", bps: 99, buyDepth: null, sellDepth: null, enabled: this.config.enableBand30To100 },
];
if (!this.depthSnapshot || topBid == null || topAsk == null) {
return bands;
}
return bands.map((band) => {
const buyPrice = topBid * (1 - band.bps / 10000);
const sellPrice = topAsk * (1 + band.bps / 10000);
const buyDepth = getDepthBetweenPrices(this.depthSnapshot, "BUY", buyPrice);
const sellDepth = getDepthBetweenPrices(this.depthSnapshot, "SELL", sellPrice);
return { ...band, buyDepth, sellDepth };
});
}
private getReferencePrice(): number | null { private getReferencePrice(): number | null {
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
} }
@@ -1356,7 +1563,7 @@ export class MakerPointsEngine {
this.lastPositionSide = currentSide; this.lastPositionSide = currentSide;
} }
private async handleTokenExpiry(position: PositionSnapshot, absPosition: number): Promise<boolean> { private async handleTokenExpiry(position: PositionSnapshot, _absPosition: number): Promise<boolean> {
if (!isTokenExpiryConfigured()) { if (!isTokenExpiryConfigured()) {
return false; return false;
} }
@@ -1439,6 +1646,338 @@ export class MakerPointsEngine {
return true; return true;
} }
// ========== 数据过时防御模式方法 ==========
/**
* 检查数据是否过时,进入或退出防御模式
* StandX 账户数据在 WS 推送异常时会通过 REST 补拉;长期无更新通常意味着 WS/REST 均异常,应进入防御模式
*/
private checkDataStaleAndDefense(): void {
const now = Date.now();
const standxDepthStale = this.lastStandxDepthTime > 0 && (now - this.lastStandxDepthTime) > DATA_STALE_THRESHOLD_MS;
const binanceStale = this.lastBinanceDepthTime > 0 && (now - this.lastBinanceDepthTime) > DATA_STALE_THRESHOLD_MS;
const binanceHealth = this.binanceDepth.getHealth();
const binanceUnhealthy = !binanceHealth.healthy;
const standxAccountAge = this.lastStandxAccountTime > 0 ? now - this.lastStandxAccountTime : 0;
const standxAccountStaleByAge = this.lastStandxAccountTime > 0 && standxAccountAge > ACCOUNT_DATA_STALE_THRESHOLD_MS;
if (standxAccountStaleByAge) {
this.maybeProbeStandxAccountSnapshot(now);
}
const standxAccountStale =
standxAccountStaleByAge &&
// WS 推送间隔可能较长,先给 REST 补拉一次机会;只有补拉失败后才进入防御模式
this.accountStaleRestProbeConsecutiveFailures > 0 &&
this.accountStaleRestProbeInFlight == null;
const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol);
const accountInvalid = this.accountSnapshot != null && !accountHealth.ok;
const standxRestUnhealthy =
this.standxRestUnhealthy && this.standxRestConsecutiveErrors >= STANDX_REST_ERROR_DEFENSE_THRESHOLD;
const marginMode = this.getStandxMarginMode(this.accountSnapshot);
const marginModeNotIsolated = this.exchange.id === "standx" && marginMode != null && marginMode !== "isolated";
const shouldDefend =
standxDepthStale ||
binanceStale ||
binanceUnhealthy ||
standxAccountStale ||
accountInvalid ||
standxRestUnhealthy ||
marginModeNotIsolated;
if (shouldDefend && !this.defenseMode) {
// 进入防御模式
this.enterDefenseMode({
standxDepthStale,
binanceStale,
standxAccountStale,
accountInvalid,
standxRestUnhealthy,
standxRestConsecutiveErrors: this.standxRestConsecutiveErrors,
standxRestLastError: this.standxRestLastError,
marginModeNotIsolated,
marginMode,
binanceUnhealthy,
binanceHealthReason: binanceHealth.reason,
standxDepthAge: this.lastStandxDepthTime > 0 ? now - this.lastStandxDepthTime : 0,
binanceAge: this.lastBinanceDepthTime > 0 ? now - this.lastBinanceDepthTime : 0,
standxAccountAge,
accountIssues: accountInvalid ? accountHealth.issues : [],
});
} else if (!shouldDefend && this.defenseMode) {
// 退出防御模式
this.exitDefenseMode();
}
}
/**
* 进入防御模式
* 取消所有挂单,启动 REST 轮询保护仓位
*/
private enterDefenseMode(staleInfo: {
standxDepthStale: boolean;
binanceStale: boolean;
binanceUnhealthy?: boolean;
binanceHealthReason?: string | null;
standxAccountStale: boolean;
accountInvalid: boolean;
standxRestUnhealthy: boolean;
standxRestConsecutiveErrors: number;
standxRestLastError: string | null;
marginModeNotIsolated: boolean;
marginMode: string | null;
standxDepthAge: number;
binanceAge: number;
standxAccountAge: number;
accountIssues: string[];
}): void {
this.defenseMode = true;
// 构建过时信息描述
const staleItems: string[] = [];
if (staleInfo.standxDepthStale) {
staleItems.push(`StandX深度(${Math.round(staleInfo.standxDepthAge / 1000)}s)`);
}
if (staleInfo.standxAccountStale) {
staleItems.push(`StandX账户(${Math.round(staleInfo.standxAccountAge / 1000)}s)`);
}
if (staleInfo.accountInvalid) {
staleItems.push(`StandX仓位数据异常(${staleInfo.accountIssues.join(",") || "unknown"})`);
}
if (staleInfo.standxRestUnhealthy) {
staleItems.push(`StandX REST错误(${staleInfo.standxRestConsecutiveErrors}次)`);
}
if (staleInfo.marginModeNotIsolated) {
staleItems.push(`保证金模式(${staleInfo.marginMode ?? "unknown"})`);
}
if (staleInfo.binanceStale) {
staleItems.push(`Binance深度(${Math.round(staleInfo.binanceAge / 1000)}s)`);
}
if (staleInfo.binanceUnhealthy && staleInfo.binanceHealthReason) {
staleItems.push(`Binance簿记异常(${staleInfo.binanceHealthReason})`);
}
const staleSummary = staleItems.length > 0 ? staleItems.join(", ") : "unknown";
this.tradeLog.push("warn", `数据过时检测: ${staleSummary},进入防御模式`);
// 发送通知
if (!this.defenseModeNotified) {
this.notify({
type: "token_expired",
level: "warn",
symbol: this.config.symbol,
title: "防御模式",
message: `数据推送中断: ${staleSummary},已取消所有挂单`,
details: staleInfo,
});
this.defenseModeNotified = true;
}
// 立即取消所有挂单
void this.defenseCancelAllOrders();
// 启动 REST 轮询保护仓位
this.startDefenseRestPoll();
}
/**
* 退出防御模式
*/
private exitDefenseMode(): void {
this.defenseMode = false;
this.defenseModeNotified = false;
this.tradeLog.push("info", "数据推送恢复正常,退出防御模式");
this.notify({
type: "position_opened",
level: "info",
symbol: this.config.symbol,
title: "防御模式解除",
message: "数据推送恢复正常,恢复正常交易",
details: {},
});
// 停止 REST 轮询
this.stopDefenseRestPoll();
// 重置本地状态,强制下一轮重新计算挂单
this.desiredOrders = [];
this.lastDesiredSummary = null;
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
}
/**
* 防御模式下取消所有挂单
*/
private async defenseCancelAllOrders(): Promise<void> {
try {
if (this.exchange.forceCancelAllOrders) {
const success = await this.exchange.forceCancelAllOrders();
if (success) {
this.tradeLog.push("order", "防御模式: 已强制取消所有挂单");
} else {
this.tradeLog.push("warn", "防御模式: 取消挂单未完全成功,将继续重试");
}
} else {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
this.tradeLog.push("order", "防御模式: 已取消所有挂单");
}
// 重置本地挂单状态
this.openOrders = [];
this.pendingCancelOrders.clear();
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "防御模式: 挂单已不存在");
this.openOrders = [];
this.pendingCancelOrders.clear();
} else {
this.tradeLog.push("error", `防御模式取消挂单失败: ${extractMessage(error)}`);
}
}
}
/**
* 启动防御模式下的 REST 轮询
* 使用 REST API 拉取数据,确保止损逻辑能正常工作
*/
private startDefenseRestPoll(): void {
if (this.defenseRestPollActive) return;
this.defenseRestPollActive = true;
this.tradeLog.push("info", "防御模式: 启动 REST 数据轮询");
const poll = async () => {
if (!this.defenseRestPollActive || !this.defenseMode) return;
try {
if (this.exchange.queryAccountSnapshot) {
const nextAccount = await this.exchange.queryAccountSnapshot();
if (nextAccount) {
this.applyAccountSnapshot(nextAccount);
const health = validateAccountSnapshotForSymbol(nextAccount, this.config.symbol);
if (!health.ok) {
this.tradeLog.push("warn", `防御模式: 仓位数据仍异常: ${health.issues.join(",")}`);
}
} else {
this.tradeLog.push("warn", "防御模式: REST 获取账户快照为空");
}
}
// 防御模式下也尝试修复保证金模式(StandX)
if (this.exchange.id === "standx") {
await this.ensureStandxIsolatedMarginMode();
}
// 防御模式下持续通过 REST 刷新挂单,并尽力撤销所有挂单(避免本地状态/WS 丢失导致遗留挂单)
if (this.exchange.queryOpenOrders) {
try {
const realOrders = await this.exchange.queryOpenOrders();
this.openOrders = Array.isArray(realOrders)
? realOrders.filter(
(order) =>
order.type !== "MARKET" &&
order.symbol === this.config.symbol &&
isOrderActiveStatus(order.status)
)
: [];
this.pendingCancelOrders.clear();
this.feedStatus.orders = true;
if (realOrders.length > 0) {
this.tradeLog.push("warn", `防御模式: 发现 ${realOrders.length} 个挂单,执行取消`);
await this.defenseCancelAllOrders();
}
} catch (error) {
this.tradeLog.push("error", `防御模式查询挂单失败: ${extractMessage(error)}`);
// 查询失败时仍然尝试撤销所有挂单(宁可多撤,也不遗留)
await this.defenseCancelAllOrders();
}
} else {
await this.defenseCancelAllOrders();
}
// 检查止损条件(使用当前账户快照中的数据)
// checkStopLoss 会继续运行,使用最后收到的数据进行止损判断
} catch (error) {
this.tradeLog.push("error", `防御模式 REST 轮询失败: ${extractMessage(error)}`);
}
// 继续下一次轮询
if (this.defenseRestPollActive && this.defenseMode) {
this.defenseRestPollTimer = setTimeout(() => void poll(), 2000);
}
};
void poll();
}
private getStandxMarginMode(snapshot: AsterAccountSnapshot | null): string | null {
if (this.exchange.id !== "standx") return null;
const positions = snapshot?.positions ?? [];
const match = positions.find((pos) => pos.symbol === this.config.symbol);
const raw = (match as any)?.marginType ?? (match as any)?.margin_mode;
const mode = typeof raw === "string" ? raw.trim().toLowerCase() : "";
return mode ? mode : null;
}
private async ensureStandxIsolatedMarginMode(): Promise<boolean> {
if (this.exchange.id !== "standx") return true;
const currentMode = this.getStandxMarginMode(this.accountSnapshot);
if (currentMode === "isolated") return true;
const change = this.exchange.changeMarginMode?.bind(this.exchange);
const queryAccount = this.exchange.queryAccountSnapshot?.bind(this.exchange);
if (!change || !queryAccount) return false;
if (this.marginModeEnsuring) {
return false;
}
this.marginModeEnsuring = (async () => {
try {
await change({ symbol: this.config.symbol, marginMode: "isolated" });
for (let attempt = 0; attempt < STANDX_MARGIN_MODE_MAX_ATTEMPTS; attempt++) {
const next = await queryAccount();
if (next) {
this.applyAccountSnapshot(next);
}
const mode = this.getStandxMarginMode(this.accountSnapshot);
if (mode === "isolated") {
this.tradeLog.push("info", "已切换为逐仓模式 (isolated),恢复策略运行");
return true;
}
await this.sleep(STANDX_MARGIN_MODE_CHECK_INTERVAL_MS);
}
this.tradeLog.push("warn", `逐仓模式切换未确认,当前模式: ${this.getStandxMarginMode(this.accountSnapshot) ?? "unknown"}`);
return false;
} catch (error) {
this.tradeLog.push("error", `切换逐仓模式失败: ${extractMessage(error)}`);
return false;
} finally {
this.marginModeEnsuring = null;
}
})();
return await this.marginModeEnsuring;
}
/**
* 停止防御模式下的 REST 轮询
*/
private stopDefenseRestPoll(): void {
if (!this.defenseRestPollActive) return;
this.defenseRestPollActive = false;
if (this.defenseRestPollTimer) {
clearTimeout(this.defenseRestPollTimer);
this.defenseRestPollTimer = null;
}
this.tradeLog.push("info", "防御模式: 停止 REST 数据轮询");
}
} }
function resolveBinanceSymbol(symbol: string): string { function resolveBinanceSymbol(symbol: string): string {
+624
View File
@@ -0,0 +1,624 @@
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
import { getPosition, type PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl";
import { getMidOrLast, getTopPrices } from "../utils/price";
import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { t } from "../i18n";
import { BinanceRsiTracker, type BinanceRsiSnapshot } from "./common/binance-rsi";
import { createInitialSwingState, stepSwing, type SwingState } from "./swing-logic";
import { isOrderActiveStatus } from "../utils/order-status";
import type { SwingConfig } from "../config";
export type SwingRsiZone = "overbought" | "oversold" | "neutral" | "unknown";
export type SwingPhase =
| "disabled"
| "initializing"
| "observing"
| "waiting_open_short"
| "waiting_open_long"
| "waiting_close_short"
| "waiting_close_long";
export interface SwingEngineSnapshot {
ready: boolean;
disabled: boolean;
symbol: string;
direction: SwingConfig["direction"];
lastPrice: number | null;
phase: SwingPhase;
binancePrice: number | null;
rsi: number | null;
rsiStable: boolean;
rsiZone: SwingRsiZone;
binanceConnection: BinanceRsiSnapshot["connectionState"];
binanceUpdatedAt: number | null;
armed: Pick<
SwingState,
"armedShortEntry" | "armedShortExit" | "armedLongEntry" | "armedLongExit"
>;
position: PositionSnapshot;
pnl: number;
unrealized: number;
sessionVolume: number;
stopLossTarget: number | null;
stopLossKillSwitch: boolean;
openOrders: AsterOrder[];
depth: AsterDepth | null;
ticker: AsterTicker | null;
tradeLog: TradeLogEntry[];
lastUpdated: number | null;
error: string | null;
}
type SwingEvent = "update";
type SwingListener = (snapshot: SwingEngineSnapshot) => void;
const EPS = 1e-5;
export class SwingEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
private openOrders: AsterOrder[] = [];
private depthSnapshot: AsterDepth | null = null;
private tickerSnapshot: AsterTicker | null = null;
private readonly locks: OrderLockMap = {};
private readonly timers: OrderTimerMap = {};
private readonly pending: OrderPendingMap = {};
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<SwingEvent, SwingEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private readonly rateLimit: RateLimitController;
private readonly binanceRsi: BinanceRsiTracker;
private binanceSnapshot: BinanceRsiSnapshot;
private timer: ReturnType<typeof setInterval> | null = null;
private processing = false;
private disabled = false;
private lastError: string | null = null;
private ordersSnapshotReady = false;
private precisionSync: Promise<void> | null = null;
private swingState: SwingState = createInitialSwingState();
// Stop-loss placement de-bounce
private lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
side: null,
price: null,
at: 0,
};
constructor(private readonly config: SwingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.binanceRsi = new BinanceRsiTracker(
this.config.signalSymbol,
this.config.signalInterval,
this.config.rsiPeriod,
{
limit: 500,
logger: (context, error) => {
// Keep Binance errors visible but non-fatal.
this.tradeLog.push("warn", `[Binance] ${context}: ${String(error)}`);
},
}
);
this.binanceSnapshot = this.binanceRsi.getSnapshot();
this.binanceRsi.onUpdate((snapshot) => {
this.binanceSnapshot = snapshot;
this.emitUpdate();
});
this.binanceRsi.start();
this.syncPrecision();
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.config.pollIntervalMs);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
// Binance tracker is external IO; stop it too.
this.binanceRsi.stop();
}
on(event: SwingEvent, handler: SwingListener): void {
this.events.on(event, handler);
}
off(event: SwingEvent, handler: SwingListener): void {
this.events.off(event, handler);
}
getSnapshot(): SwingEngineSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
safeSubscribe<AsterAccountSnapshot>(
this.exchange.watchAccount.bind(this.exchange),
(snapshot) => {
this.accountSnapshot = snapshot;
const position = getPosition(snapshot, this.config.symbol);
const reference = this.getReferencePrice();
this.sessionVolume.update(position, reference);
// Safe-by-default: refuse short mode on spot accounts.
if (
snapshot.marketType === "spot" &&
(this.config.direction === "short" || this.config.direction === "both")
) {
if (!this.disabled) {
this.disabled = true;
this.lastError = "Swing strategy requires perp/margin for shorting; spot accounts cannot short.";
this.tradeLog.push("error", this.lastError);
}
}
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }),
}
);
safeSubscribe<AsterOrder[]>(
this.exchange.watchOrders.bind(this.exchange),
(orders) => {
this.synchronizeLocks(orders);
this.openOrders = Array.isArray(orders)
? orders.filter(
(order) =>
order.type !== "MARKET" &&
order.symbol === this.config.symbol &&
isOrderActiveStatus(order.status)
)
: [];
this.ordersSnapshotReady = true;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }),
}
);
safeSubscribe<AsterDepth>(
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
(depth) => {
this.depthSnapshot = depth;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }),
}
);
safeSubscribe<AsterTicker>(
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
(ticker) => {
this.tickerSnapshot = ticker;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
processFail: (error) => t("log.process.tickerError", { error: extractMessage(error) }),
}
);
}
private synchronizeLocks(orders: AsterOrder[] | null | undefined): void {
const list = Array.isArray(orders) ? orders : [];
Object.keys(this.pending).forEach((type) => {
const pendingId = this.pending[type];
if (!pendingId) return;
const match = list.find((order) => String(order.orderId) === pendingId);
if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) {
unlockOperating(this.locks, this.timers, this.pending, type);
}
});
}
private isReady(): boolean {
return Boolean(
this.accountSnapshot &&
this.tickerSnapshot &&
this.depthSnapshot &&
this.ordersSnapshotReady &&
this.binanceSnapshot.isStable &&
this.binanceSnapshot.rsi != null
);
}
private async tick(): Promise<void> {
if (this.processing) return;
this.processing = true;
let hadRateLimit = false;
try {
const decision = this.rateLimit.beforeCycle();
if (decision === "paused") {
this.emitUpdate();
return;
}
if (decision === "skip") {
return;
}
if (this.disabled) {
this.emitUpdate();
return;
}
if (!this.isReady()) {
this.emitUpdate();
return;
}
const account = this.accountSnapshot!;
const position = getPosition(account, this.config.symbol);
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
const bid = topBid ?? Number(this.tickerSnapshot?.lastPrice);
const ask = topAsk ?? Number(this.tickerSnapshot?.lastPrice);
const pnl = computePositionPnl(position, bid, ask);
const price = this.getReferencePrice();
const decisionOut = stepSwing(
this.swingState,
{ direction: this.config.direction, rsiHigh: this.config.rsiHigh, rsiLow: this.config.rsiLow },
{ rsi: this.binanceSnapshot.rsi, positionAmt: position.positionAmt, pnl }
);
this.swingState = decisionOut.nextState;
for (const action of decisionOut.actions) {
if (action.type === "OPEN_SHORT") {
await this.tryOpen("SELL", action.reason);
} else if (action.type === "OPEN_LONG") {
await this.tryOpen("BUY", action.reason);
} else if (action.type === "CLOSE_POSITION") {
await this.tryClose(position, action.reason);
}
}
// Stop-loss management / kill-switch for any open position.
await this.handleStopLoss(position, price);
this.sessionVolume.update(position, price);
this.emitUpdate();
} catch (error) {
if (isRateLimitError(error)) {
hadRateLimit = true;
this.rateLimit.registerRateLimit("swing");
this.tradeLog.push("warn", `SwingEngine 429: ${String(error)}`);
} else {
this.lastError = extractMessage(error);
this.tradeLog.push("error", `SwingEngine error: ${this.lastError}`);
}
this.emitUpdate();
} finally {
try {
this.rateLimit.onCycleComplete(hadRateLimit);
} finally {
this.processing = false;
}
}
}
private async tryOpen(side: "BUY" | "SELL", reason: string): Promise<void> {
try {
// Ensure flat before opening.
const position = getPosition(this.accountSnapshot, this.config.symbol);
if (Math.abs(position.positionAmt) > EPS) {
return;
}
await placeMarketOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
this.config.tradeAmount,
(type, detail) => this.tradeLog.push(type, detail),
false,
{
markPrice: position.markPrice,
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.config.qtyStep }
);
this.tradeLog.push("open", `${reason}: ${side} (market)`);
} catch (err) {
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
}
}
private async tryClose(position: PositionSnapshot, reason: string): Promise<void> {
try {
if (Math.abs(position.positionAmt) <= EPS) return;
const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
const expected =
side === "SELL"
? Number(this.depthSnapshot?.bids?.[0]?.[0])
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
Math.abs(position.positionAmt),
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.config.qtyStep }
);
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
} catch (err) {
if (isUnknownOrderError(err)) {
this.tradeLog.push("order", "Close skipped: order missing");
} else {
this.tradeLog.push("error", `Close failed: ${extractMessage(err)}`);
}
}
}
private async handleStopLoss(position: PositionSnapshot, referencePrice: number | null): Promise<void> {
const hasPosition = Math.abs(position.positionAmt) > EPS;
if (!hasPosition) {
this.lastStopAttempt = { side: null, price: null, at: 0 };
return;
}
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
if (!hasEntryPrice) {
return;
}
const direction = position.positionAmt > 0 ? "long" : "short";
const stopSide: "BUY" | "SELL" = direction === "long" ? "SELL" : "BUY";
const stopPrice =
direction === "long"
? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct))
: position.entryPrice * (1 + Math.max(0, this.config.stopLossPct));
const tick = Math.max(1e-9, this.config.priceTick);
const lastPrice = referencePrice ?? Number(this.tickerSnapshot?.lastPrice) ?? null;
// Kill-switch (always-on).
const triggerKill =
direction === "long"
? lastPrice != null && Number.isFinite(lastPrice) && lastPrice <= stopPrice + tick
: lastPrice != null && Number.isFinite(lastPrice) && lastPrice >= stopPrice - tick;
if (triggerKill) {
await this.tryClose(position, "Stop-loss kill-switch");
return;
}
// If exchange supports stop orders, keep one active.
const currentStop = this.openOrders.find((o) => {
const hasStopPrice = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0;
return o.side === stopSide && (o.type === "STOP_MARKET" || hasStopPrice);
});
if (currentStop) return;
// De-bounce: avoid repeated submissions of same stop.
const now = Date.now();
if (
this.lastStopAttempt.side === stopSide &&
this.lastStopAttempt.price != null &&
Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick &&
now - this.lastStopAttempt.at < 5000
) {
return;
}
try {
const qty = Math.abs(position.positionAmt);
await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
stopSide,
stopPrice,
qty,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
} catch (err) {
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
this.tradeLog.push("error", `Failed to place stop-loss order: ${extractMessage(err)}`);
}
}
private emitUpdate(): void {
try {
const snapshot = this.buildSnapshot();
this.events.emit("update", snapshot, (error) => {
this.tradeLog.push("error", `SwingEngine update handler error: ${String(error)}`);
});
} catch (err) {
this.tradeLog.push("error", `SwingEngine snapshot error: ${String(err)}`);
}
}
private buildSnapshot(): SwingEngineSnapshot {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
const pnl = computePositionPnl(position, topBid ?? price, topAsk ?? price);
const reference = this.getReferencePrice();
const hasPosition = Math.abs(position.positionAmt) > EPS;
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
const stopLossTarget = hasPosition && hasEntryPrice
? (position.positionAmt > 0
? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct))
: position.entryPrice * (1 + Math.max(0, this.config.stopLossPct)))
: null;
const tick = Math.max(1e-9, this.config.priceTick);
const stopLossKillSwitch =
stopLossTarget != null && reference != null && Number.isFinite(reference)
? (position.positionAmt > 0 ? reference <= stopLossTarget + tick : reference >= stopLossTarget - tick)
: false;
const zone: SwingRsiZone =
this.binanceSnapshot.rsi == null || !Number.isFinite(this.binanceSnapshot.rsi)
? "unknown"
: this.binanceSnapshot.rsi > this.config.rsiHigh
? "overbought"
: this.binanceSnapshot.rsi < this.config.rsiLow
? "oversold"
: "neutral";
const posAmt = Number(position.positionAmt);
const phase: SwingPhase = this.disabled
? "disabled"
: !this.isReady()
? "initializing"
: Math.abs(posAmt) <= EPS
? this.swingState.armedShortEntry
? "waiting_open_short"
: this.swingState.armedLongEntry
? "waiting_open_long"
: "observing"
: posAmt < -EPS
? this.swingState.armedShortExit
? "waiting_close_short"
: "observing"
: this.swingState.armedLongExit
? "waiting_close_long"
: "observing";
return {
ready: this.isReady() && !this.disabled,
disabled: this.disabled,
symbol: this.config.symbol,
direction: this.config.direction,
lastPrice: reference,
phase,
binancePrice: this.binanceSnapshot.lastClose,
rsi: this.binanceSnapshot.rsi,
rsiStable: this.binanceSnapshot.isStable,
rsiZone: zone,
binanceConnection: this.binanceSnapshot.connectionState,
binanceUpdatedAt: this.binanceSnapshot.updatedAt,
armed: {
armedShortEntry: this.swingState.armedShortEntry,
armedShortExit: this.swingState.armedShortExit,
armedLongEntry: this.swingState.armedLongEntry,
armedLongExit: this.swingState.armedLongExit,
},
position,
pnl,
unrealized: position.unrealizedProfit,
sessionVolume: this.sessionVolume.value,
stopLossTarget,
stopLossKillSwitch,
openOrders: this.openOrders,
depth: this.depthSnapshot,
ticker: this.tickerSnapshot,
tradeLog: this.tradeLog.all(),
lastUpdated: Date.now(),
error: this.lastError,
};
}
private getReferencePrice(): number | null {
return (
getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ??
(this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null)
);
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
const delta = Math.abs(precision.priceTick - this.config.priceTick);
if (delta > 1e-12) {
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
if (delta > 1e-12) {
this.config.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
`Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { createInitialSwingState, stepSwing, type SwingLogicConfig } from "./swing-logic";
const baseConfig: SwingLogicConfig = {
direction: "both",
rsiHigh: 70,
rsiLow: 30,
};
describe("swing logic", () => {
it("arms short entry on RSI cross up 70, then opens on cross down 70", () => {
let state = createInitialSwingState();
// First observation sets prevRsi, no cross yet.
({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 }));
const a1 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 71, positionAmt: 0, pnl: 0 });
expect(a1.actions).toEqual([]);
expect(a1.nextState.armedShortEntry).toBe(true);
state = a1.nextState;
const a2 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 });
expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_SHORT"]);
expect(a2.nextState.armedShortEntry).toBe(false);
});
it("arms long entry on RSI cross down 30, then opens on cross up 30", () => {
let state = createInitialSwingState();
({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 }));
const a1 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 29, positionAmt: 0, pnl: 0 });
expect(a1.actions).toEqual([]);
expect(a1.nextState.armedLongEntry).toBe(true);
state = a1.nextState;
const a2 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 });
expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_LONG"]);
expect(a2.nextState.armedLongEntry).toBe(false);
});
it("short exit requires profit: arms on cross down 30, closes on cross up 30 if pnl > 0", () => {
let state = createInitialSwingState();
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: -1 }));
const a1 = stepSwing(state, baseConfig, { rsi: 29, positionAmt: -1, pnl: -1 });
expect(a1.actions).toEqual([]);
expect(a1.nextState.armedShortExit).toBe(true);
state = a1.nextState;
const a2 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0 });
expect(a2.actions).toEqual([]); // pnl not strictly positive
expect(a2.nextState.armedShortExit).toBe(true);
state = a2.nextState;
const a3 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 });
// No cross (prev=31 -> 31), still armed.
expect(a3.actions).toEqual([]);
// Cross down then up to trigger close with profit
const a4 = stepSwing(a3.nextState, baseConfig, { rsi: 29, positionAmt: -1, pnl: 0.01 });
const a5 = stepSwing(a4.nextState, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 });
expect(a5.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]);
expect(a5.nextState.armedShortExit).toBe(false);
});
it("long exit requires profit: arms on cross up 70, closes on cross down 70 if pnl > 0", () => {
let state = createInitialSwingState();
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0 }));
const a1 = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 1, pnl: 0 });
expect(a1.actions).toEqual([]);
expect(a1.nextState.armedLongExit).toBe(true);
state = a1.nextState;
const a2 = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0.01 });
expect(a2.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]);
expect(a2.nextState.armedLongExit).toBe(false);
});
it("clears entry arms when a position is present", () => {
let state = createInitialSwingState();
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 0, pnl: 0 }));
// Arm short entry.
state = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 0, pnl: 0 }).nextState;
expect(state.armedShortEntry).toBe(true);
// Now position appears: entry arms should be reset.
const next = stepSwing(state, baseConfig, { rsi: 71, positionAmt: -1, pnl: 0 });
expect(next.nextState.armedShortEntry).toBe(false);
expect(next.nextState.armedLongEntry).toBe(false);
});
});
+151
View File
@@ -0,0 +1,151 @@
export type SwingDirection = "long" | "short" | "both";
export interface SwingLogicConfig {
direction: SwingDirection;
rsiHigh: number; // e.g. 70
rsiLow: number; // e.g. 30
}
export interface SwingState {
prevRsi: number | null;
armedShortEntry: boolean;
armedShortExit: boolean;
armedLongEntry: boolean;
armedLongExit: boolean;
}
export type SwingAction =
| { type: "OPEN_SHORT"; reason: string }
| { type: "OPEN_LONG"; reason: string }
| { type: "CLOSE_POSITION"; reason: string };
export interface SwingStepInput {
rsi: number | null;
positionAmt: number;
pnl: number;
}
export function createInitialSwingState(): SwingState {
return {
prevRsi: null,
armedShortEntry: false,
armedShortExit: false,
armedLongEntry: false,
armedLongExit: false,
};
}
const EPS = 1e-8;
function crossUp(prev: number | null, next: number, threshold: number): boolean {
if (prev == null) return false;
return prev <= threshold && next > threshold;
}
function crossDown(prev: number | null, next: number, threshold: number): boolean {
if (prev == null) return false;
return prev >= threshold && next < threshold;
}
export function stepSwing(
state: SwingState,
config: SwingLogicConfig,
input: SwingStepInput
): { nextState: SwingState; actions: SwingAction[] } {
const nextState: SwingState = { ...state };
const actions: SwingAction[] = [];
const rsi = input.rsi;
const hasRsi = typeof rsi === "number" && Number.isFinite(rsi);
const prevRsi = nextState.prevRsi;
const direction = config.direction;
const allowLong = direction === "long" || direction === "both";
const allowShort = direction === "short" || direction === "both";
const positionAmt = Number(input.positionAmt);
const pnl = Number(input.pnl);
const isFlat = !Number.isFinite(positionAmt) || Math.abs(positionAmt) <= EPS;
const isLong = Number.isFinite(positionAmt) && positionAmt > EPS;
const isShort = Number.isFinite(positionAmt) && positionAmt < -EPS;
// If RSI is missing/invalid, avoid mutating state.
if (!hasRsi) {
return { nextState, actions };
}
// Keep prevRsi updated once we have a valid reading.
nextState.prevRsi = rsi;
if (isFlat) {
// When flat, exit arms are irrelevant.
nextState.armedShortExit = false;
nextState.armedLongExit = false;
if (!allowShort) {
nextState.armedShortEntry = false;
} else {
if (crossUp(prevRsi, rsi, config.rsiHigh)) {
nextState.armedShortEntry = true;
}
if (nextState.armedShortEntry && crossDown(prevRsi, rsi, config.rsiHigh)) {
actions.push({ type: "OPEN_SHORT", reason: "RSI armed above high, then crossed below high" });
nextState.armedShortEntry = false;
// Avoid impossible dual-entries.
nextState.armedLongEntry = false;
}
}
if (!allowLong) {
nextState.armedLongEntry = false;
} else {
if (crossDown(prevRsi, rsi, config.rsiLow)) {
nextState.armedLongEntry = true;
}
if (nextState.armedLongEntry && crossUp(prevRsi, rsi, config.rsiLow)) {
actions.push({ type: "OPEN_LONG", reason: "RSI armed below low, then crossed above low" });
nextState.armedLongEntry = false;
nextState.armedShortEntry = false;
}
}
if (actions.length > 1) {
// Defensive: avoid opening both directions in one step.
return { nextState: { ...nextState, armedShortEntry: false, armedLongEntry: false }, actions: [] };
}
return { nextState, actions };
}
// When exposed, entry arms are irrelevant (strategy does not pyramid).
nextState.armedShortEntry = false;
nextState.armedLongEntry = false;
// Exits are always allowed (even if direction config changes) to avoid trapping positions.
if (isShort) {
nextState.armedLongExit = false;
if (crossDown(prevRsi, rsi, config.rsiLow)) {
nextState.armedShortExit = true;
}
if (nextState.armedShortExit && crossUp(prevRsi, rsi, config.rsiLow) && pnl > 0) {
actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed below low, then crossed above low with profit" });
nextState.armedShortExit = false;
}
return { nextState, actions };
}
if (isLong) {
nextState.armedShortExit = false;
if (crossUp(prevRsi, rsi, config.rsiHigh)) {
nextState.armedLongExit = true;
}
if (nextState.armedLongExit && crossDown(prevRsi, rsi, config.rsiHigh) && pnl > 0) {
actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed above high, then crossed below high with profit" });
nextState.armedLongExit = false;
}
return { nextState, actions };
}
return { nextState, actions };
}
+11 -2
View File
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Box, Text, useInput } from "ink"; import { Box, Text, useInput } from "ink";
import { TrendApp } from "./TrendApp"; import { TrendApp } from "./TrendApp";
import { SwingApp } from "./SwingApp";
import { GuardianApp } from "./GuardianApp"; import { GuardianApp } from "./GuardianApp";
import { MakerApp } from "./MakerApp"; import { MakerApp } from "./MakerApp";
import { MakerPointsApp } from "./MakerPointsApp"; import { MakerPointsApp } from "./MakerPointsApp";
@@ -14,7 +15,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
import { t } from "../i18n"; import { t } from "../i18n";
interface StrategyOption { interface StrategyOption {
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
label: string; label: string;
description: string; description: string;
component: React.ComponentType<{ onExit: () => void }>; component: React.ComponentType<{ onExit: () => void }>;
@@ -27,6 +28,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
description: t("app.strategy.trend.desc"), description: t("app.strategy.trend.desc"),
component: TrendApp, component: TrendApp,
}, },
{
id: "swing",
label: t("app.strategy.swing.label"),
description: t("app.strategy.swing.desc"),
component: SwingApp,
},
{ {
id: "guardian", id: "guardian",
label: t("app.strategy.guardian.label"), label: t("app.strategy.guardian.label"),
@@ -70,7 +77,9 @@ export function App() {
const strategies = useMemo(() => { const strategies = useMemo(() => {
const next: StrategyOption[] = [...BASE_STRATEGIES]; const next: StrategyOption[] = [...BASE_STRATEGIES];
if (exchangeId === "standx") { if (exchangeId === "standx") {
next.splice(3, 0, { const gridIndex = next.findIndex((s) => s.id === "grid");
const insertAt = gridIndex === -1 ? next.length : gridIndex;
next.splice(insertAt, 0, {
id: "maker-points" as const, id: "maker-points" as const,
label: t("app.strategy.makerPoints.label"), label: t("app.strategy.makerPoints.label"),
description: t("app.strategy.makerPoints.desc"), description: t("app.strategy.makerPoints.desc"),
+10
View File
@@ -135,6 +135,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
? t("offset.imbalance.sell") ? t("offset.imbalance.sell")
: t("offset.imbalance.balanced"); : t("offset.imbalance.balanced");
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal"); const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
return ( return (
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
@@ -164,6 +165,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
status: imbalanceLabel, status: imbalanceLabel,
})} })}
</Text> </Text>
{snapshot.bandDepths.map((band) => (
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
{t("makerPoints.bandDepthLine", {
band: band.band,
buy: formatDepth(band.buyDepth),
sell: formatDepth(band.sellDepth),
})}
</Text>
))}
<Text> <Text>
{t("maker.dataStatus")} {t("maker.dataStatus")}
{feedEntries.map((entry, index) => ( {feedEntries.map((entry, index) => (
+217
View File
@@ -0,0 +1,217 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { swingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
import { formatNumber } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
import { t } from "../i18n";
const READY_MESSAGE = t("swing.readyMessage");
interface SwingAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function SwingApp({ onExit }: SwingAppProps) {
const [snapshot, setSnapshot] = useState<SwingEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<SwingEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(_input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: swingConfig.symbol });
const engine = new SwingEngine(swingConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: SwingEngineSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
if (error) {
return (
<Box flexDirection="column" padding={1}>
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
<Text color="gray">{t("common.checkEnv")}</Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text>{t("common.initializing", { target: t("swing.name") })}</Text>
</Box>
);
}
const zoneLabel =
snapshot.rsiZone === "overbought"
? t("swing.zone.overbought")
: snapshot.rsiZone === "oversold"
? t("swing.zone.oversold")
: snapshot.rsiZone === "neutral"
? t("swing.zone.neutral")
: t("swing.zone.unknown");
const phaseLabel =
snapshot.phase === "disabled"
? t("swing.phase.disabled")
: snapshot.phase === "initializing"
? t("swing.phase.initializing")
: snapshot.phase === "waiting_open_short"
? t("swing.phase.waitingOpenShort")
: snapshot.phase === "waiting_close_short"
? t("swing.phase.waitingCloseShort")
: snapshot.phase === "waiting_open_long"
? t("swing.phase.waitingOpenLong")
: snapshot.phase === "waiting_close_long"
? t("swing.phase.waitingCloseLong")
: t("swing.phase.observing");
const lastLogs = snapshot.tradeLog.slice(-5);
const sortedOrders = [...snapshot.openOrders].sort(
(a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
);
const orderRows = sortedOrders.slice(0, 8).map((order) => ({
id: order.orderId,
side: order.side,
type: order.type,
price: order.price,
qty: order.origQty,
filled: order.executedQty,
status: order.status,
}));
const orderColumns: TableColumn[] = [
{ key: "id", header: "ID", align: "right", minWidth: 6 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "type", header: "Type", minWidth: 10 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
{ key: "status", header: "Status", minWidth: 10 },
];
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
return (
<Box flexDirection="column" paddingX={1} paddingY={0}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">{t("swing.title")}</Text>
<Text>
{t("swing.headerLine", {
exchange: exchangeName,
symbol: snapshot.symbol,
direction: snapshot.direction,
lastPrice: formatNumber(snapshot.lastPrice, 6),
phase: phaseLabel,
})}
</Text>
<Text color="gray">
{t("swing.signalLine", {
binanceSymbol: "ETHBTC",
binancePrice: formatNumber(snapshot.binancePrice, 8),
rsi: formatNumber(snapshot.rsi, 2),
zone: zoneLabel,
connection: snapshot.binanceConnection,
})}
</Text>
<Text color={snapshot.disabled ? "red" : "gray"}>
{t("swing.statusLine", {
status: snapshot.disabled
? t("status.paused")
: snapshot.ready
? t("status.live")
: READY_MESSAGE,
})}
</Text>
{snapshot.error ? <Text color="red">{snapshot.error}</Text> : null}
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright">{t("common.section.position")}</Text>
{hasPosition ? (
<>
<Text>
{t("swing.positionLine", {
direction: snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
entry: formatNumber(snapshot.position.entryPrice, 6),
})}
</Text>
<Text>
{t("swing.pnlLine", {
pnl: formatNumber(snapshot.pnl, 4),
unrealized: formatNumber(snapshot.unrealized, 4),
})}
</Text>
<Text color={snapshot.stopLossKillSwitch ? "red" : "gray"}>
{t("swing.stopLine", { stop: formatNumber(snapshot.stopLossTarget, 6) })}
</Text>
</>
) : (
<Text color="gray">{t("common.noPosition")}</Text>
)}
</Box>
<Box flexDirection="column">
<Text color="greenBright">{t("swing.stateTitle")}</Text>
<Text color="gray">
{t("swing.armedLine", {
se: snapshot.armed.armedShortEntry ? "Y" : "N",
sx: snapshot.armed.armedShortExit ? "Y" : "N",
le: snapshot.armed.armedLongEntry ? "Y" : "N",
lx: snapshot.armed.armedLongExit ? "Y" : "N",
})}
</Text>
<Text color="gray">{t("swing.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}</Text>
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow">{t("common.section.orders")}</Text>
{orderRows.length > 0 ? <DataTable columns={orderColumns} rows={orderRows} /> : <Text color="gray">{t("common.noOrders")}</Text>}
</Box>
<Box flexDirection="column">
<Text color="yellow">{t("common.section.recentTrades")}</Text>
{lastLogs.length > 0 ? (
lastLogs.map((item, index) => (
<Text key={`${item.time}-${index}`}>
[{item.time}] [{item.type}] {item.detail}
</Text>
))
) : (
<Text color="gray">{t("common.noLogs")}</Text>
)}
</Box>
</Box>
);
}
+20
View File
@@ -13,4 +13,24 @@ export function computePositionPnl(
: (position.entryPrice - (priceForPnl as number)) * absAmt; : (position.entryPrice - (priceForPnl as number)) * absAmt;
} }
export function computeStopLossPnl(
position: PositionSnapshot,
bestBid?: number | null,
bestAsk?: number | null
): number | null {
const absAmt = Math.abs(position.positionAmt);
if (!Number.isFinite(absAmt) || absAmt <= 0) return 0;
// If entry price is missing, prefer the exchange-provided unrealized PnL.
if (!Number.isFinite(position.entryPrice) || position.entryPrice <= 0) {
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
}
const priceForPnl = position.positionAmt > 0 ? bestBid : bestAsk;
if (!Number.isFinite(priceForPnl as number) || (priceForPnl as number) <= 0) {
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
}
return computePositionPnl(position, bestBid, bestAsk);
}
+41
View File
@@ -47,6 +47,47 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
}; };
} }
export function validateAccountSnapshotForSymbol(
snapshot: AsterAccountSnapshot | null,
symbol: string
): { ok: true } | { ok: false; issues: string[] } {
if (!snapshot) return { ok: true };
const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? [];
if (positions.length === 0) return { ok: true };
const NON_ZERO_EPS = 1e-8;
const issues: string[] = [];
for (const position of positions) {
const amt = Number(position.positionAmt);
if (!Number.isFinite(amt)) {
issues.push("invalid_positionAmt");
continue;
}
if (Math.abs(amt) <= NON_ZERO_EPS) {
continue;
}
const entryPrice = Number(position.entryPrice);
if (!Number.isFinite(entryPrice) || entryPrice <= 0) {
issues.push("invalid_entryPrice");
}
const unrealizedProfit = Number(position.unrealizedProfit);
if (!Number.isFinite(unrealizedProfit)) {
issues.push("invalid_unrealizedProfit");
}
const rawMark = Number(position.markPrice);
if (position.markPrice != null && position.markPrice !== "" && (!Number.isFinite(rawMark) || rawMark <= 0)) {
issues.push("invalid_markPrice");
}
}
if (issues.length === 0) return { ok: true };
return { ok: false, issues: Array.from(new Set(issues)) };
}
export function getSMA(values: AsterKline[], length: number): number | null { export function getSMA(values: AsterKline[], length: number): number | null {
if (!Array.isArray(values) || values.length < length) return null; if (!Array.isArray(values) || values.length < length) return null;
const window = values.slice(-length); const window = values.slice(-length);
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type { AsterAccountSnapshot } from "../src/exchanges/types";
import { validateAccountSnapshotForSymbol } from "../src/utils/strategy";
function baseSnapshot(positions: AsterAccountSnapshot["positions"]): AsterAccountSnapshot {
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions,
assets: [],
marketType: "perp",
};
}
describe("validateAccountSnapshotForSymbol", () => {
it("accepts empty positions", () => {
const result = validateAccountSnapshotForSymbol(baseSnapshot([]), "BTC-USD");
expect(result.ok).toBe(true);
});
it("accepts zero-sized positions even if entry price is zero", () => {
const result = validateAccountSnapshotForSymbol(
baseSnapshot([
{
symbol: "BTC-USD",
positionAmt: "0",
entryPrice: "0",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
markPrice: "0",
},
]),
"BTC-USD"
);
expect(result.ok).toBe(true);
});
it("flags invalid numeric fields for non-zero positions", () => {
const result = validateAccountSnapshotForSymbol(
baseSnapshot([
{
symbol: "BTC-USD",
positionAmt: "1",
entryPrice: "NaN",
unrealizedProfit: "oops",
positionSide: "BOTH",
updateTime: Date.now(),
markPrice: "-1",
},
]),
"BTC-USD"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues).toEqual(
expect.arrayContaining(["invalid_entryPrice", "invalid_unrealizedProfit", "invalid_markPrice"])
);
}
});
it("flags invalid positionAmt", () => {
const result = validateAccountSnapshotForSymbol(
baseSnapshot([
{
symbol: "BTC-USD",
positionAmt: "abc",
entryPrice: "100",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
markPrice: "101",
},
]),
"BTC-USD"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues).toContain("invalid_positionAmt");
}
});
});
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
const ORIGINAL_FETCH = globalThis.fetch;
class StubAdapter implements ExchangeAdapter {
id = "standx";
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions: [],
assets: [],
marketType: "perp",
};
}
}
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("MakerPointsEngine Binance depth health defense", () => {
it("enters defense mode when Binance depth tracker is unhealthy", () => {
vi.useFakeTimers();
globalThis.fetch = vi.fn(async () => {
throw new Error("network blocked in test");
}) as any;
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 20,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: true,
filterMinDepth: 0,
},
new StubAdapter()
);
const now = Date.now();
(engine as any).lastStandxDepthTime = now;
(engine as any).lastStandxAccountTime = now;
(engine as any).lastBinanceDepthTime = now;
(engine as any).binanceDepth = {
getHealth: () => ({
started: true,
connected: true,
orderBookReady: false,
restHealthy: false,
healthy: false,
reason: "orderbook_not_ready",
lastEventAt: now,
lastSnapshotAt: 0,
lastRestSyncAt: 0,
localLastUpdateId: 0,
}),
stop: () => {},
};
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(true);
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
expect(logs.some((detail) => detail.includes("Binance簿记异常(orderbook_not_ready)"))).toBe(true);
engine.stop();
});
it("exits defense mode after Binance depth health recovers", () => {
vi.useFakeTimers();
globalThis.fetch = vi.fn(async () => {
throw new Error("network blocked in test");
}) as any;
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 20,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: true,
filterMinDepth: 0,
},
new StubAdapter()
);
const now = Date.now();
(engine as any).lastStandxDepthTime = now;
(engine as any).lastStandxAccountTime = now;
(engine as any).lastBinanceDepthTime = now;
let unhealthy = true;
(engine as any).binanceDepth = {
getHealth: () => ({
started: true,
connected: true,
orderBookReady: !unhealthy,
restHealthy: !unhealthy,
healthy: !unhealthy,
reason: unhealthy ? "orderbook_not_ready" : null,
lastEventAt: now,
lastSnapshotAt: now,
lastRestSyncAt: now,
localLastUpdateId: unhealthy ? 0 : 100,
}),
stop: () => {},
};
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(true);
unhealthy = false;
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(false);
engine.stop();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
async function loadConfig() {
vi.resetModules();
return await import("../src/config");
}
describe("config env parsing", () => {
it("strips shell-style inline comments from symbol values", async () => {
process.env.EXCHANGE = "standx";
process.env.STANDX_SYMBOL = "BTC-USD # comment";
const { resolveSymbolFromEnv } = await loadConfig();
expect(resolveSymbolFromEnv()).toBe("BTC-USD");
});
it("parses numeric maker-points env values with inline comments", async () => {
process.env.EXCHANGE = "standx";
process.env.MAKER_POINTS_STOP_LOSS_USD = "1 # comment";
process.env.MAKER_POINTS_CLOSE_THRESHOLD = "2 ; comment";
const { makerPointsConfig } = await loadConfig();
expect(makerPointsConfig.stopLossUsd).toBe(1);
expect(makerPointsConfig.closeThreshold).toBe(2);
});
it("parses boolean maker-points env values with inline comments", async () => {
process.env.EXCHANGE = "standx";
process.env.MAKER_POINTS_BAND_10_30 = "false # comment";
const { makerPointsConfig } = await loadConfig();
expect(makerPointsConfig.enableBand10To30).toBe(false);
});
});
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
class StubAdapter implements ExchangeAdapter {
id = "standx";
accountSnapshot: AsterAccountSnapshot | null = null;
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return this.accountSnapshot;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine defense-mode account staleness", () => {
it("does not enter defense mode for ~21s StandX account gap (REST probe succeeds)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
const adapter = new StubAdapter();
adapter.accountSnapshot = {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions: [],
assets: [],
marketType: "perp",
};
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
const now = Date.now();
(engine as any).lastStandxDepthTime = now;
(engine as any).lastBinanceDepthTime = now;
(engine as any).lastStandxAccountTime = now - 21_000;
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(false);
await vi.runAllTimersAsync();
engine.stop();
});
it("enters defense mode if StandX account REST probe fails", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
const adapter = new StubAdapter();
adapter.accountSnapshot = null;
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
const now = Date.now();
(engine as any).lastStandxDepthTime = now;
(engine as any).lastBinanceDepthTime = now;
(engine as any).lastStandxAccountTime = now - 121_000;
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(false);
await vi.runAllTimersAsync();
vi.advanceTimersByTime(1000);
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(true);
engine.stop();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
class StubAdapter implements ExchangeAdapter {
id = "standx";
cancelAllCount = 0;
openOrders: AsterOrder[] | Error = [];
accountSnapshot: AsterAccountSnapshot | null = null;
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {
this.cancelAllCount += 1;
this.openOrders = [];
}
async queryOpenOrders(): Promise<AsterOrder[]> {
if (this.openOrders instanceof Error) throw this.openOrders;
return this.openOrders;
}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return this.accountSnapshot;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine defense-mode REST polling", () => {
it("keeps trying to fetch open orders and cancel all when open orders exist", async () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
adapter.openOrders = [
{
orderId: "1",
clientOrderId: "c1",
symbol: "BTC-USD",
side: "BUY",
type: "LIMIT",
status: "NEW",
price: "100",
origQty: "1",
executedQty: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: "false",
closePosition: "false",
},
];
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).enterDefenseMode({
standxDepthStale: true,
binanceStale: false,
binanceUnhealthy: false,
binanceHealthReason: null,
standxAccountStale: false,
accountInvalid: false,
standxRestUnhealthy: false,
standxRestConsecutiveErrors: 0,
standxRestLastError: null,
marginModeNotIsolated: false,
marginMode: "isolated",
standxDepthAge: 6000,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: [],
});
await vi.waitFor(() => {
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
});
engine.stop();
});
it("attempts cancel-all even if open-order query fails", async () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
adapter.openOrders = new Error("boom");
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).enterDefenseMode({
standxDepthStale: true,
binanceStale: false,
binanceUnhealthy: false,
binanceHealthReason: null,
standxAccountStale: false,
accountInvalid: false,
standxRestUnhealthy: false,
standxRestConsecutiveErrors: 0,
standxRestLastError: null,
marginModeNotIsolated: false,
marginMode: "isolated",
standxDepthAge: 6000,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: [],
});
await vi.waitFor(() => {
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
});
engine.stop();
});
});
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
class StandxStubAdapter implements ExchangeAdapter {
id = "standx";
marginMode: "cross" | "isolated" = "cross";
changeCalls: Array<{ symbol: string; marginMode: "isolated" | "cross" }> = [];
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
marketType: "perp",
positions: [
{
symbol: "BTC-USD",
positionAmt: "0",
entryPrice: "0",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
marginType: this.marginMode,
},
],
assets: [],
};
}
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
this.changeCalls.push(params);
this.marginMode = params.marginMode;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine StandX isolated margin guard", () => {
it("switches to isolated before placing orders", async () => {
vi.useFakeTimers();
const adapter = new StandxStubAdapter();
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
// Seed engine state to pass readiness checks without WS.
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
(engine as any).initialOrderSnapshotReady = true;
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
(engine as any).depthSnapshot = {
lastUpdateId: 1,
bids: [["100", "1"]],
asks: [["101", "1"]],
eventTime: Date.now(),
symbol: "BTC-USD",
} as AsterDepth;
(engine as any).tickerSnapshot = {
symbol: "BTC-USD",
lastPrice: "100",
openPrice: "0",
highPrice: "0",
lowPrice: "0",
volume: "0",
quoteVolume: "0",
eventTime: Date.now(),
} as AsterTicker;
const syncSpy = vi.fn().mockResolvedValue(undefined);
(engine as any).syncOrders = syncSpy;
// First tick should force margin mode to isolated and then proceed to sync orders.
await (engine as any).tick();
expect(adapter.changeCalls).toEqual([{ symbol: "BTC-USD", marginMode: "isolated" }]);
expect(syncSpy).toHaveBeenCalledTimes(1);
engine.stop();
});
it("enters defense mode if it cannot switch to isolated", async () => {
vi.useFakeTimers();
const adapter = new StandxStubAdapter();
adapter.changeMarginMode = vi.fn(async () => {
throw new Error("change failed");
}) as any;
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
(engine as any).initialOrderSnapshotReady = true;
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
(engine as any).depthSnapshot = {
lastUpdateId: 1,
bids: [["100", "1"]],
asks: [["101", "1"]],
eventTime: Date.now(),
symbol: "BTC-USD",
} as AsterDepth;
(engine as any).tickerSnapshot = {
symbol: "BTC-USD",
lastPrice: "100",
openPrice: "0",
highPrice: "0",
lowPrice: "0",
volume: "0",
quoteVolume: "0",
eventTime: Date.now(),
} as AsterTicker;
const syncSpy = vi.fn().mockResolvedValue(undefined);
(engine as any).syncOrders = syncSpy;
await (engine as any).tick();
expect(syncSpy).not.toHaveBeenCalled();
expect((engine as any).defenseMode).toBe(true);
engine.stop();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { ParadexGateway } from "../src/exchanges/paradex/gateway";
describe("ParadexGateway createOrder", () => {
it("sets `size` for closePosition MARKET orders even when amount is omitted", async () => {
const createOrder = vi.fn(async (symbol, type, side, amount, price, params) => ({
id: "1",
symbol,
type,
side,
status: "open",
price: price ?? 0,
amount: amount ?? Number(params?.size ?? 0),
filled: 0,
stopPrice: undefined,
timestamp: Date.now(),
lastUpdateTimestamp: Date.now(),
info: {
reduceOnly: params?.reduceOnly,
closePosition: params?.closePosition,
},
}));
const gateway = new ParadexGateway({
symbol: "BTC/USDC",
displaySymbol: "BTC/USDC",
privateKey: "test",
walletAddress: "test",
logger: () => {},
usePro: false,
}) as any;
gateway.exchange = { createOrder, markets: {}, symbols: [] };
gateway.initialized = true;
gateway.marketSymbol = "BTC/USDC";
await gateway.createOrder({
symbol: "BTC/USDC",
side: "SELL",
type: "MARKET",
quantity: 1.23,
reduceOnly: "true",
closePosition: "true",
});
expect(createOrder).toHaveBeenCalledTimes(1);
const [_symbol, _type, _side, amountArg, _price, extraParams] = createOrder.mock.calls[0]!;
expect(amountArg).toBeUndefined();
expect(extraParams).toMatchObject({
closePosition: true,
reduceOnly: true,
size: "1.23",
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { computeStopLossPnl } from "../src/utils/pnl";
describe("computeStopLossPnl", () => {
it("falls back to exchange-provided unrealized PnL when entryPrice is missing", () => {
const pnl = computeStopLossPnl(
{ positionAmt: 1, entryPrice: 0, unrealizedProfit: -2000, markPrice: null },
40000,
40010
);
expect(pnl).toBe(-2000);
});
it("uses best bid/ask when entryPrice is available", () => {
const longPnl = computeStopLossPnl(
{ positionAmt: 1, entryPrice: 100, unrealizedProfit: -5, markPrice: null },
90,
91
);
expect(longPnl).toBe(-10);
const shortPnl = computeStopLossPnl(
{ positionAmt: -2, entryPrice: 100, unrealizedProfit: -5, markPrice: null },
95,
105
);
expect(shortPnl).toBe(-10);
});
it("falls back to exchange-provided unrealized PnL when best prices are unavailable", () => {
const pnl = computeStopLossPnl(
{ positionAmt: 1, entryPrice: 100, unrealizedProfit: -123, markPrice: null },
null,
null
);
expect(pnl).toBe(-123);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { StandxGateway } from "../src/exchanges/standx/gateway";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
vi.restoreAllMocks();
});
describe("StandxGateway REST health", () => {
it("emits unhealthy after 3 consecutive REST failures, then healthy after a success", async () => {
const gateway = new StandxGateway({
token: "test-token",
symbol: "BTC-USD",
baseUrl: "https://example.com",
wsUrl: "wss://example.com/ws",
logger: () => {},
});
const events: Array<{ state: string; consecutiveErrors: number }> = [];
gateway.onRestHealthEvent((state, info) => {
events.push({ state, consecutiveErrors: info.consecutiveErrors });
});
globalThis.fetch = vi.fn(async () => {
return {
ok: false,
status: 500,
text: async () => "server error",
} as any;
}) as any;
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
expect(events).toEqual([{ state: "unhealthy", consecutiveErrors: 3 }]);
globalThis.fetch = vi.fn(async () => {
return {
ok: true,
status: 200,
text: async () => "[]",
} as any;
}) as any;
await expect(gateway.queryOpenOrders("BTC-USD")).resolves.toEqual([]);
expect(events).toEqual([
{ state: "unhealthy", consecutiveErrors: 3 },
{ state: "healthy", consecutiveErrors: 0 },
]);
});
});